diff --git a/CHANGELOG.md b/CHANGELOG.md index 602c43f..8f58c5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/src/cli.ts b/src/cli.ts index 6665f50..1c52f50 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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, @@ -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); diff --git a/src/cli_config.ts b/src/cli_config.ts index a22bfb5..7fd932f 100644 --- a/src/cli_config.ts +++ b/src/cli_config.ts @@ -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; + /** 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`. diff --git a/src/cli_profile.ts b/src/cli_profile.ts index 64890dd..8de0e0b 100644 --- a/src/cli_profile.ts +++ b/src/cli_profile.ts @@ -3,10 +3,17 @@ * between the global ~/.lich/config.json and the project config; `.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 { @@ -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()})`); } diff --git a/test/first_run.test.ts b/test/first_run.test.ts index e81a1df..abc03a5 100644 --- a/test/first_run.test.ts +++ b/test/first_run.test.ts @@ -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"; @@ -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"; @@ -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/.json and never overwrites", async () => { const restore_tty = set_tty(true); const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); @@ -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; + 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 });