Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

- `lich profile use` copies `~/.config/lich/config.json` into `~/.lich/config.json`
when the global file does not exist yet, so legacy settings (including
`tools_enabled`) stay in effect. `lich init --global` refuses to create
`~/.lich/config.json` while that legacy file is still the live config.
- Gateway (#144): conversations queue on the shared `SessionManager` instead of
their own promise chains. Beyond `max_conversations`, the least recently used
conversation is evicted (it was the oldest inserted). Only Telegram's `/start`
Expand Down
7 changes: 7 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
config_template,
existing_config_path,
global_config_path,
legacy_user_config_active,
project_config_path,
provider_kind_defaults,
starter_config_object,
Expand Down Expand Up @@ -701,6 +702,12 @@ function run_init(options: CliOptions): number {
// The global layer ignores these, so do not write them there.
delete config["work_dir"];
delete config["session_dir"];
// A new ~/.lich/config.json stops the legacy file from being read at all.
if (legacy_user_config_active() === true) {
throw new Error(
"legacy config at ~/.config/lich/config.json is still in use; move it to ~/.lich/config.json before init --global",
);
}
}
// write_lich_config(home) targets ~/.lich/config.json, the global file.
const result = write_lich_config(options.global === true ? homedir() : work_dir_of(options), config);
Expand Down
36 changes: 36 additions & 0 deletions src/cli_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,42 @@ export function global_config_path(): string {
return path.resolve(homedir(), CONFIG_RELPATH);
}

/** True when the legacy file is the live user config (no `~/.lich/config.json` yet). */
export function legacy_user_config_active(): boolean {
if (existsSync(global_config_path()) === true) {
return false;
}
return existsSync(path.resolve(homedir(), LEGACY_USER_CONFIG));
}

export interface GlobalConfigSeed {
config: Record<string, unknown>;
/** True when `config` was read from the legacy file, not `~/.lich/config.json`. */
from_legacy: boolean;
/** Permission bits of the legacy file, when it seeded `config`. */
mode?: number;
}

/**
* Object to write into `~/.lich/config.json`. An existing global file is
* returned unchanged. Otherwise the legacy file seeds the write, with plugin
* paths already resolved, so creating the global file does not drop it.
*/
export function global_config_seed(): GlobalConfigSeed {
if (existsSync(global_config_path()) === true) {
return { config: read_config_object(global_config_path()), from_legacy: false };
}
const legacy = path.resolve(homedir(), LEGACY_USER_CONFIG);
if (existsSync(legacy) === false) {
return { config: {}, from_legacy: false };
}
return {
config: global_layer(read_config_object(legacy), path.dirname(legacy)),
from_legacy: true,
mode: statSync(legacy).mode & 0o777,
};
}

/**
* Ordered absolute chain: the work_dir (default cwd) project config, the
* global `~/.lich/config.json`, then the legacy `~/.config/lich/config.json`.
Expand Down
19 changes: 16 additions & 3 deletions src/cli_profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@
* between the global ~/.lich/config.json and the project config; `<name>.md`
* next to it, when present, becomes its system prompt.
*/
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { createInterface } from "node:readline";
import { global_config_path, profile_paths, profiles_dir, read_config_object, write_lich_config } from "./cli_config.js";
import {
global_config_path,
global_config_seed,
profile_paths,
profiles_dir,
read_config_object,
write_lich_config,
} from "./cli_config.js";
import { ask_line, build_setup_config, collect_setup_answers } from "./setup_wizard.js";

function write_line(line: string): void {
Expand Down Expand Up @@ -109,7 +116,13 @@ function use_profile(name: string): void {
if (existsSync(paths.json) === false && existsSync(paths.soul) === false) {
throw new Error(`profile not found: ${name}`);
}
write_lich_config(homedir(), { ...read_global(), profile: name }, true);
// A new global file hides the legacy one, so copy that file's settings first.
const seed = global_config_seed();
const result = write_lich_config(homedir(), { ...seed.config, profile: name }, true);
if (seed.from_legacy === true && seed.mode !== undefined && result.written === true) {
chmodSync(result.path, seed.mode);
process.stderr.write("lich: copied the legacy config into ~/.lich/config.json so it stays in effect\n");
}
write_line(`default profile: ${name} (in ${global_config_path()})`);
}

Expand Down
60 changes: 58 additions & 2 deletions test/first_run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* First-run setup: one writer for `lich init` and the bare-`lich` wizard.
* No network. Config discovery ignores the real user-home file.
*/
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TMP_BASE } from "./helpers/tmp_base.js";
Expand Down Expand Up @@ -58,7 +58,7 @@ vi.mock("../src/gateway.js", () => ({
}));

import { parse_agent_config } from "../src/agent/config.js";
import { config_search_paths, existing_config_path, global_config_path, project_config_path, write_lich_config } from "../src/cli_config.js";
import { config_search_paths, existing_config_path, global_config_path, load_layered_config, project_config_path, write_lich_config } from "../src/cli_config.js";
import { read_platform_token } from "../src/gateway/token_env.js";
import { build_setup_config, collect_setup_answers } from "../src/setup_wizard.js";
import { run_cli } from "../src/cli.js";
Expand Down Expand Up @@ -511,6 +511,19 @@ describe("lich init and bare lich", () => {
});
});

it("lich init --global refuses to hide a legacy config", async () => {
const home = String(process.env.HOME);
const legacy = path.join(home, ".config", "lich", "config.json");
mkdirSync(path.dirname(legacy), { recursive: true });
const body = JSON.stringify({ agent_name: "legacy", tools_enabled: ["read_file"] });
writeFileSync(legacy, body);
await expect(run_cli(["init", "--global", "--model", "starter"])).rejects.toThrow(
"legacy config at ~/.config/lich/config.json is still in use",
);
expect(existsSync(global_config_path())).toBe(false);
expect(readFileSync(legacy, "utf8")).toBe(body);
});

it("lich profile create runs the wizard into ~/.lich/profiles/<name>.json and never overwrites", async () => {
const restore_tty = set_tty(true);
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
Expand Down Expand Up @@ -547,6 +560,49 @@ describe("lich init and bare lich", () => {
}
});

it("lich profile use copies a legacy config into ~/.lich/config.json instead of hiding it", async () => {
const home = String(process.env.HOME);
mkdirSync(path.join(home, ".lich", "profiles"), { recursive: true });
writeFileSync(path.join(home, ".lich", "profiles", "coder.json"), JSON.stringify({ agent_name: "coder" }));
const legacy = path.join(home, ".config", "lich", "config.json");
mkdirSync(path.dirname(legacy), { recursive: true });
const legacy_body = {
max_turns: 4,
tools_enabled: ["read_file"],
work_dir: "/should-not-stick",
session_dir: "/also-not",
plugins: ["plugin.mjs", { path: "obj.mjs", settings: { a: 1 } }],
};
writeFileSync(legacy, JSON.stringify(legacy_body));
chmodSync(legacy, 0o600);
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
try {
expect(await run_cli(["profile", "use", "coder"])).toBe(0);
expect(readFileSync(legacy, "utf8")).toBe(JSON.stringify(legacy_body));
const global = JSON.parse(readFileSync(global_config_path(), "utf8")) as Record<string, unknown>;
expect(global["max_turns"]).toBe(4);
expect(global["tools_enabled"]).toEqual(["read_file"]);
expect(global["profile"]).toBe("coder");
expect(global["work_dir"]).toBeUndefined();
expect(global["session_dir"]).toBeUndefined();
expect(global["plugins"]).toEqual([
path.join(home, ".config", "lich", "plugin.mjs"),
{ path: path.join(home, ".config", "lich", "obj.mjs"), settings: { a: 1 } },
]);
expect(statSync(global_config_path()).mode & 0o777).toBe(0o600);
const work = make_temp_dir("after-profile-use");
const layered = load_layered_config(work);
expect(layered?.config["agent_name"]).toBe("coder");
expect(layered?.config["tools_enabled"]).toEqual(["read_file"]);
expect(layered?.sources[0]).toBe(global_config_path());
expect(String(stderr.mock.calls[0]?.[0])).toContain("copied the legacy config");
} finally {
stderr.mockRestore();
stdout.mockRestore();
}
});

it("bare lich --profile skips the wizard and runs with the profile; --profile with --config is rejected", async () => {
const home = String(process.env.HOME);
mkdirSync(path.join(home, ".lich", "profiles"), { recursive: true });
Expand Down
Loading