diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c1c11b..dd66972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- Profiles (#117): `~/.lich/profiles/.json` (plus an optional `.md` + used as the system prompt) is merged between the global and project config. + Select one with `--profile`, `LICH_PROFILE`, a project `profile` key, or the + default set by `lich profile use`. New `lich profile list|show|create|use`. + File tools now refuse `.lich/profiles/`. See `docs/user-guide/profiles.md`. +- `lich tui`, `chat`, `serve` and `gateway` now show the real config error + (for example `profile not found`) instead of always saying "no model + configured". - `lich init --global` writes the starter config to `~/.lich/config.json` (never overwrites). The setup wizard ends with "Save as the global default?" (default no); yes writes the answers to `~/.lich/config.json` and keeps diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index e2446f4..44dd445 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -26,6 +26,7 @@ export default defineConfig({ { text: 'Introduction', link: '/' }, { text: 'Getting started', link: '/getting-started' }, { text: 'CLI reference', link: '/user-guide/cli' }, + { text: 'Profiles', link: '/user-guide/profiles' }, { text: 'TUI guide', link: '/user-guide/tui' }, { text: 'Ossuary guide', link: '/user-guide/ossuary' }, { text: 'Gateway guide', link: '/user-guide/gateway' }, diff --git a/docs/user-guide/cli.md b/docs/user-guide/cli.md index 334b856..a471b25 100644 --- a/docs/user-guide/cli.md +++ b/docs/user-guide/cli.md @@ -8,6 +8,7 @@ lich # open the TUI; first run on a TTY starts the setup wizard lich init # write .lich/config.json without the wizard (flags apply; never overwrites) lich init --global # write ~/.lich/config.json, the defaults every project inherits +lich profile list # named configs in ~/.lich/profiles (also show/create/use) lich "one shot task" # run a single task and print the reply lich chat # interactive chat (commands: /exit, /quit) lich tui # interactive terminal UI (ink) @@ -91,6 +92,7 @@ Per-kind defaults: - **Per-project keys:** `work_dir` and `session_dir` in the global file are ignored. - **Paths:** relative `plugins` paths in the global file resolve against `~/.lich/`. MCP `command` and `args` are used as written. - **Legacy location:** `~/.config/lich/config.json` is still read when `~/.lich/config.json` is absent, with a one-line hint to move it. Lich never writes there. +- **Profiles:** a selected profile (`--profile`, `LICH_PROFILE`, or `lich profile use`) is merged between the global and project files. See [Profiles](profiles.md). - **`--config `** replaces the whole chain; nothing is merged. `lich init` and `lich mcp` write only the project file. `lich init --global` and the wizard's "save as global" answer write `~/.lich/config.json`. diff --git a/docs/user-guide/profiles.md b/docs/user-guide/profiles.md new file mode 100644 index 0000000..f769574 --- /dev/null +++ b/docs/user-guide/profiles.md @@ -0,0 +1,59 @@ +# Profiles + +A profile is a named config, such as `coder` or `bard`, that you can switch between without editing project files. Profiles live under `~/.lich/profiles/`, next to the global config. + +``` +~/.lich/ +├── config.json # global defaults (see the CLI reference: Global config) +└── profiles/ + ├── coder.json # any config keys: model, agent_name, max_turns, plugins, ... + └── coder.md # optional "soul": becomes the system prompt +``` + +## Layers + +A run merges up to three files, each overriding the one before it key by key: + +1. `~/.lich/config.json` (global) +2. `~/.lich/profiles/.json` (the selected profile) +3. `/.lich/config.json` (project) + +The merge rules are the same as for the global config. It is a shallow merge. A later `providers` array replaces the earlier one and drops the earlier `models` unless the later layer sets its own. `work_dir` and `session_dir` in a profile are ignored. Relative `plugins` paths in a profile resolve against `~/.lich/profiles/`. + +A profile only needs the keys that differ from your global config. For example, a profile that just changes the model: + +```json +{ "agent_name": "coder", "providers": [{ "kind": "ollama", "name": "main", "model": "qwen3:8b" }] } +``` + +`--config ` still replaces every layer. It cannot be combined with `--profile`, and `LICH_PROFILE` is ignored when it is given. + +## The soul file + +When `.md` exists and is not blank, its contents become the profile's `system_prompt`, replacing any `system_prompt` in `.json`. A project `system_prompt` still wins over it. A profile can be only a soul file, with no JSON. + +## Choosing a profile + +The first of these that is set wins: + +1. `--profile ` on the command line +2. the `LICH_PROFILE` environment variable +3. a `profile` key in the project `.lich/config.json` +4. the default profile: a `profile` key in `~/.lich/config.json`, set by `lich profile use` + +A named profile that does not exist is an error (`profile not found: `). Profile names use lowercase letters, digits, `-` and `_`. When `--profile` or `LICH_PROFILE` is set, bare `lich` skips the setup wizard. + +## Commands + +| Command | Effect | +| --- | --- | +| `lich profile list` | List profiles. `*` marks the default; `(soul)` marks a profile with a `.md` file. | +| `lich profile show ` | Print the profile's JSON and its soul file's path and length. | +| `lich profile create ` | Run the setup wizard and write `~/.lich/profiles/.json`. Needs a TTY; never overwrites. | +| `lich profile use ` | Make `` the default by setting `profile` in `~/.lich/config.json` (other keys are kept). | + +To clear the default, remove the `profile` key from `~/.lich/config.json`. + +## Safety + +File tools cannot read or write `.lich/profiles/` under the working directory, just as they cannot touch `.lich/config.json`. When the working directory is your home directory, these are your global identity files. Profiles hold env var *names* for keys (`api_key_env`, `token_envs`), never the secrets themselves. diff --git a/src/cli.ts b/src/cli.ts index d3a0310..6665f50 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,6 +25,7 @@ import { type ProviderKind, } from "./cli_config.js"; import { run_mcp } from "./cli_mcp.js"; +import { run_profile } from "./cli_profile.js"; import { empty_mcp_flags, take_mcp_flag, type McpCliFlags } from "./cli_mcp_flags.js"; import { package_root_from_module_url, run_ossuary } from "./cli_ossuary.js"; import { run_update } from "./cli_update.js"; @@ -62,6 +63,7 @@ const FLAG_KEYS: Record = { "--session-dir": "session_dir", "--log-level": "log_level", "--theme": "theme", + "--profile": "profile", }; function usage_text(): string { @@ -83,11 +85,14 @@ function usage_text(): string { " lich mcp list list mcp servers in .lich/config.json", " lich mcp add add a catalog or --command/--url server (disabled)", " lich mcp enable / disable / remove ", + " lich profile list list profiles in ~/.lich/profiles (* = default)", + " lich profile create / show / use ", " lich --help show this help", " lich --version print version", "", "Flags (before or after the subcommand):", " --config JSON config file parsed by parse_agent_config", + " --profile merge ~/.lich/profiles/.json between global and project config (or LICH_PROFILE)", " --work-dir working directory for tools", " --max-turns loop turn budget (default 25)", " --model model name (default from LICH_MODEL)", @@ -129,6 +134,7 @@ function non_tui_resume_mode(first: string | undefined): string | undefined { first === "init" || first === "config" || first === "mcp" || + first === "profile" || first === "update" || first === "chat" || first === "gateway" || @@ -142,6 +148,11 @@ function non_tui_resume_mode(first: string | undefined): string | undefined { /** Mode-aware config failure message; one-shot keeps the generic variant. */ function error_for_mode(mode: string, base_message: string): string { + const modes = ["tui", "gateway", "serve", "chat"]; + // Only the missing-model case gets the hint; other errors (bad file, unknown profile) pass through. + if (modes.includes(mode) === true && base_message.startsWith("no model configured") === false) { + return `lich ${mode}: ${base_message}`; + } if (mode === "tui") { return "lich tui: no model configured — set LICH_MODEL (e.g. glm-5.3-flash:cloud), pass --model, or create .lich/config.json (`lich config` prints a template)"; } @@ -395,9 +406,12 @@ function apply_overrides(config: Record, overrides: Record | undefined { - const layered = load_layered_config(work_dir); +/** + * Project `.lich/config.json` (under `--work-dir`, else cwd) merged over the + * global `~/.lich/config.json`, with the selected profile in between. + */ +function load_discovered_config(work_dir: string, profile?: string): Record | undefined { + const layered = load_layered_config(work_dir, profile); for (const note of layered?.notes ?? []) { process.stderr.write(`${note}\n`); } @@ -405,9 +419,14 @@ function load_discovered_config(work_dir: string): Record | und } function build_config(options: CliOptions): AgentConfig { + if (options.config_path !== undefined && options.overrides["profile"] !== undefined) { + throw new Error("--profile cannot be combined with --config (--config replaces every layer)"); + } const base = options.config_path === undefined - ? load_discovered_config(work_dir_of(options)) ?? { providers: [env_provider(options.overrides)] } + ? load_discovered_config(work_dir_of(options), options.overrides["profile"]) ?? { + providers: [env_provider(options.overrides)], + } : load_config_file(options.config_path); apply_overrides(base, options.overrides); const providers = base["providers"]; @@ -631,7 +650,8 @@ async function offer_wizard(work_dir: string, hint?: string): Promise<"written" } async function maybe_first_run(options: CliOptions, work_dir: string): Promise { - if (options.config_path !== undefined) { + // An explicit file or a requested profile is the setup; build_config reports a missing one. + if (options.config_path !== undefined || options.overrides["profile"] !== undefined || (process.env.LICH_PROFILE ?? "").length > 0) { return undefined; } const existing = existing_config_path(work_dir); @@ -798,6 +818,9 @@ export async function run_cli(argv: string[]): Promise { if (first === "mcp") { return run_mcp(work_dir_of(options), options.positionals, options.mcp_flags); } + if (first === "profile") { + return run_profile(options.positionals); + } if (first === "update") { if (options.positionals.length > 1) { throw new Error("update takes no arguments"); diff --git a/src/cli_config.ts b/src/cli_config.ts index ad044ef..a22bfb5 100644 --- a/src/cli_config.ts +++ b/src/cli_config.ts @@ -13,6 +13,7 @@ const CONFIG_RELPATH = `${LICH_DIRNAME}/config.json`; const LEGACY_USER_CONFIG = ".config/lich/config.json"; /** Keys that stay per project: a global layer never sets them. */ const PROJECT_ONLY_KEYS = ["work_dir", "session_dir"] as const; +const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]*$/; export type ProviderKind = "openai_compat" | "anthropic" | "ollama"; @@ -80,13 +81,50 @@ export interface LayeredConfig { sources: string[]; /** One-line notices for the user (for example, the legacy location in use). */ notes: string[]; + /** The profile merged in, when one was selected. */ + profile?: string; +} + +/** `~/.lich/profiles`: one `.json` (and optional `.md` soul) per profile. */ +export function profiles_dir(): string { + return path.resolve(homedir(), LICH_DIRNAME, "profiles"); +} + +/** The JSON and soul paths for a profile name; throws on a name that is not a plain slug. */ +export function profile_paths(name: string): { json: string; soul: string } { + if (PROFILE_NAME.test(name) === false) { + throw new Error(`invalid profile name "${name}" (use lowercase letters, digits, - and _)`); + } + return { json: path.join(profiles_dir(), `${name}.json`), soul: path.join(profiles_dir(), `${name}.md`) }; +} + +/** + * A profile as a layer: its JSON (paths resolved against `~/.lich/profiles`) with + * a non-empty soul file as `system_prompt`. Throws when neither file exists. + */ +function profile_layer(name: string): { layer: Record; sources: string[] } { + const paths = profile_paths(name); + const has_json = existsSync(paths.json); + const has_soul = existsSync(paths.soul); + if (has_json === false && has_soul === false) { + throw new Error(`profile not found: ${name}`); + } + const layer = has_json === true ? global_layer(read_config_object(paths.json), profiles_dir()) : {}; + delete layer["profile"]; + const soul = has_soul === true ? readFileSync(paths.soul, "utf8").trim() : ""; + if (soul.length > 0) { + layer["system_prompt"] = soul; + } + return { layer, sources: [paths.json, paths.soul].filter((file) => existsSync(file) === true) }; } /** * Project `.lich/config.json` merged over the global base (`~/.lich/config.json`, - * else the legacy `~/.config/lich/config.json`). Undefined when neither exists. + * else the legacy `~/.config/lich/config.json`), with a selected profile in + * between. The profile is `profile_flag`, else `LICH_PROFILE`, else a `profile` + * key in the project file, else one in the global file. Undefined when no file exists. */ -export function load_layered_config(work_dir: string): LayeredConfig | undefined { +export function load_layered_config(work_dir: string, profile_flag?: string): LayeredConfig | undefined { const project_path = project_config_path(work_dir); const global_path = global_config_path(); const legacy = path.resolve(homedir(), LEGACY_USER_CONFIG); @@ -101,13 +139,32 @@ export function load_layered_config(work_dir: string): LayeredConfig | undefined } const project = existsSync(project_path) === true ? read_config_object(project_path) : undefined; const base = base_path === undefined ? undefined : global_layer(read_config_object(base_path), path.dirname(base_path)); - if (project === undefined && base === undefined) { + const selected = first_profile_name([profile_flag, process.env.LICH_PROFILE, project?.["profile"], base?.["profile"]]); + const profile = selected === undefined ? undefined : profile_layer(selected); + if (project === undefined && base === undefined && profile === undefined) { return undefined; } - const sources = [base_path, project === undefined ? undefined : project_path].filter( - (entry): entry is string => entry !== undefined, - ); - return { config: merge_config_layers(base ?? {}, project ?? {}), sources, notes }; + const sources = [ + ...(base_path === undefined ? [] : [base_path]), + ...(profile?.sources ?? []), + ...(project === undefined ? [] : [project_path]), + ]; + // With work_dir = home the project file is the global file, so the profile goes over it. + const config = + project_path === global_path && project !== undefined + ? merge_config_layers(project, profile?.layer ?? {}) + : merge_config_layers(merge_config_layers(base ?? {}, profile?.layer ?? {}), project ?? {}); + delete config["profile"]; + return { config, sources, notes, ...(selected === undefined ? {} : { profile: selected }) }; +} + +function first_profile_name(candidates: readonly unknown[]): string | undefined { + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.length > 0) { + return candidate; + } + } + return undefined; } /** @@ -150,7 +207,7 @@ function global_layer(config: Record, home: string): Record { +export function read_config_object(found: string): Record { const raw = readFileSync(found, "utf8"); const parsed = safe_json_parse(raw); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { diff --git a/src/cli_profile.ts b/src/cli_profile.ts new file mode 100644 index 0000000..64890dd --- /dev/null +++ b/src/cli_profile.ts @@ -0,0 +1,140 @@ +/** + * `lich profile`: named configs under ~/.lich/profiles. A profile is merged + * 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 { 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 { ask_line, build_setup_config, collect_setup_answers } from "./setup_wizard.js"; + +function write_line(line: string): void { + process.stdout.write(`${line}\n`); +} + +function require_name(name: string | undefined, action: string): string { + if (name === undefined || name.length === 0) { + throw new Error(`profile ${action} requires a name`); + } + profile_paths(name); + return name; +} + +function read_global(): Record { + return existsSync(global_config_path()) === true ? read_config_object(global_config_path()) : {}; +} + +function sticky_profile(): string | undefined { + const value = read_global()["profile"]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function profile_names(): string[] { + let entries: string[]; + try { + entries = readdirSync(profiles_dir()); + } catch { + return []; + } + const names = new Set(); + for (const entry of entries) { + const match = /^([a-z0-9][a-z0-9_-]*)\.(json|md)$/.exec(entry); + if (match?.[1] !== undefined) { + names.add(match[1]); + } + } + return [...names].sort(); +} + +function list_profiles(): void { + const names = profile_names(); + if (names.length === 0) { + write_line(`no profiles in ${profiles_dir()}`); + return; + } + const active = sticky_profile(); + for (const name of names) { + const soul = existsSync(profile_paths(name).soul) === true ? " (soul)" : ""; + write_line(`${name === active ? "*" : " "} ${name}${soul}`); + } +} + +function show_profile(name: string): void { + const paths = profile_paths(name); + if (existsSync(paths.json) === false && existsSync(paths.soul) === false) { + throw new Error(`profile not found: ${name}`); + } + if (existsSync(paths.json) === true) { + write_line(paths.json); + write_line(readFileSync(paths.json, "utf8").trimEnd()); + } + if (existsSync(paths.soul) === true) { + write_line(`${paths.soul} (system prompt, ${readFileSync(paths.soul, "utf8").trim().length} chars)`); + } +} + +/** Run the setup wizard and write `~/.lich/profiles/.json`; never overwrites. */ +async function create_profile(name: string): Promise { + const paths = profile_paths(name); + if (existsSync(paths.json) === true) { + throw new Error(`profile ${name} already exists`); + } + if (process.stdin.isTTY !== true) { + throw new Error("profile create needs a TTY; write the JSON by hand instead"); + } + const rl = createInterface({ input: process.stdin, output: process.stdout }); + let config: Record; + try { + // A directory with no .lich/plugins: project plugins do not belong in a profile. + const answers = await collect_setup_answers(profiles_dir(), (prompt) => ask_line(rl, prompt)); + if (answers === undefined) { + process.stderr.write("lich: profile create cancelled; nothing written\n"); + return 1; + } + config = build_setup_config(answers); + } finally { + rl.close(); + } + mkdirSync(profiles_dir(), { recursive: true }); + // "wx": a profile created meanwhile is never overwritten. + writeFileSync(paths.json, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", flag: "wx" }); + write_line(`wrote ${paths.json}`); + return 0; +} + +/** Record `name` as the sticky default (`profile` key in ~/.lich/config.json). */ +function use_profile(name: string): void { + const paths = profile_paths(name); + 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); + write_line(`default profile: ${name} (in ${global_config_path()})`); +} + +export async function run_profile(positionals: readonly string[]): Promise { + const action = positionals[1]; + if (positionals.length > 3) { + throw new Error("profile takes one name"); + } + if (action === "list") { + if (positionals[2] !== undefined) { + throw new Error("profile list takes no name"); + } + list_profiles(); + return 0; + } + if (action === "show") { + show_profile(require_name(positionals[2], "show")); + return 0; + } + if (action === "create") { + return create_profile(require_name(positionals[2], "create")); + } + if (action === "use") { + use_profile(require_name(positionals[2], "use")); + return 0; + } + throw new Error("profile requires list, show, create, or use"); +} diff --git a/src/tools/builtin/disk_usage.ts b/src/tools/builtin/disk_usage.ts index 45c5869..48b90e7 100644 --- a/src/tools/builtin/disk_usage.ts +++ b/src/tools/builtin/disk_usage.ts @@ -3,7 +3,14 @@ import { readdir } from "node:fs/promises"; import type { Dirent } from "node:fs"; import path from "node:path"; import type { JsonSchemaObject } from "../../util/json_schema.js"; -import { capture_errors, optional_number_arg, optional_string_arg, resolve_safe_path } from "../guard.js"; +import { + assert_file_tool_access, + capture_errors, + file_tool_denied, + optional_number_arg, + optional_string_arg, + resolve_safe_path, +} from "../guard.js"; import type { Tool, ToolContext } from "../types.js"; import { clamp_int_arg } from "./fetch_url.js"; @@ -38,12 +45,19 @@ function run_du(entry_path: string): Promise { }); } -/** Measure every depth-1 entry iteratively; null signals du itself is missing. */ -async function measure_entries(root: string): Promise { +/** + * Measure every depth-1 entry iteratively, skipping what file tools may not read + * (.lich/config.json, .lich/profiles); null signals du itself is missing. + */ +async function measure_entries(root: string, work_dir: string): Promise { const entries = await readdir(root, { withFileTypes: true }); const measured: DirEntry[] = []; for (const entry of entries as Dirent[]) { - const bytes = await run_du(path.join(root, entry.name)); + const full = path.join(root, entry.name); + if (file_tool_denied(work_dir, full, "read") === true) { + continue; + } + const bytes = await run_du(full); if (bytes < 0) { return null; } @@ -68,7 +82,8 @@ async function run_disk_usage(args: Record, work_dir: string): const target = optional_string_arg(args, "path", "."); const max_entries = clamp_int_arg(args, "max_entries", DEFAULT_MAX_ENTRIES, MAX_MAX_ENTRIES); const root = resolve_safe_path(work_dir, target); - const usage = await measure_entries(root); + assert_file_tool_access(work_dir, root, "read"); + const usage = await measure_entries(root, work_dir); if (usage === null) { throw new Error("du_unavailable"); } diff --git a/src/tools/builtin/list_dir.ts b/src/tools/builtin/list_dir.ts index f581c7b..d984917 100644 --- a/src/tools/builtin/list_dir.ts +++ b/src/tools/builtin/list_dir.ts @@ -2,7 +2,14 @@ import { readdir, stat } from "node:fs/promises"; import type { Dirent } from "node:fs"; import path from "node:path"; import type { JsonSchemaObject } from "../../util/json_schema.js"; -import { capture_errors, optional_number_arg, optional_string_arg, resolve_safe_path } from "../guard.js"; +import { + assert_file_tool_access, + capture_errors, + file_tool_denied, + optional_number_arg, + optional_string_arg, + resolve_safe_path, +} from "../guard.js"; import type { Tool } from "../types.js"; const MAX_ENTRIES = 500; @@ -53,12 +60,17 @@ async function push_entries( queue: Array<{ dir: string; remaining: number }>, entries: Dirent[], current: { dir: string; remaining: number }, + work_dir: string, ): Promise { for (const entry of sort_entries(entries)) { if (lines.length >= MAX_ENTRIES) { return true; } const full = path.join(current.dir, entry.name); + // Never list what file tools may not read (.lich/config.json, .lich/profiles). + if (file_tool_denied(work_dir, full, "read") === true) { + continue; + } if (entry.isDirectory() === true) { lines.push(`d ${entry.name}/`); if (current.remaining > 1) { @@ -71,7 +83,7 @@ async function push_entries( return false; } -async function collect_lines(root: string, max_depth: number): Promise { +async function collect_lines(root: string, max_depth: number, work_dir: string): Promise { const lines: string[] = []; const queue: Array<{ dir: string; remaining: number }> = [{ dir: root, remaining: max_depth }]; let truncated = false; @@ -84,7 +96,7 @@ async function collect_lines(root: string, max_depth: number): Promise if (entries === undefined) { continue; } - truncated = await push_entries(lines, queue, entries, current); + truncated = await push_entries(lines, queue, entries, current, work_dir); } if (truncated === true) { lines.push(`(... truncated at ${MAX_ENTRIES} entries)`); @@ -105,7 +117,8 @@ export const list_dir_tool: Tool = { const target = optional_string_arg(args, "path", "."); const depth = clamp_depth(optional_number_arg(args, "depth", 1)); const root = resolve_safe_path(context.work_dir, target); - const lines = await collect_lines(root, depth); + assert_file_tool_access(context.work_dir, root, "read"); + const lines = await collect_lines(root, depth, context.work_dir); return { ok: true, output: lines.join("\n") }; }), }; \ No newline at end of file diff --git a/src/tools/guard.ts b/src/tools/guard.ts index 438d73e..f7ddb43 100644 --- a/src/tools/guard.ts +++ b/src/tools/guard.ts @@ -68,7 +68,8 @@ function reject_symlink_leaf(resolved: string, target: string, base_dir: string) } /** - * Deny `.lich/config.json` to file tools; allow `.lich/` writes only under + * Deny `.lich/config.json` and `.lich/profiles/` to file tools (with work_dir = + * home these are the global identity files); allow `.lich/` writes only under * `skills/` and `plugins/`; deny `.env*` basenames on writes. */ export function assert_file_tool_access(work_dir: string, resolved: string, mode: "read" | "write"): void { @@ -78,6 +79,9 @@ export function assert_file_tool_access(work_dir: string, resolved: string, mode if (parts[0] === ".lich" && parts[1] === "config.json" && parts.length === 2) { throw new Error("forbidden_path: .lich/config.json"); } + if (parts[0] === ".lich" && parts[1] === "profiles") { + throw new Error("forbidden_path: .lich/profiles"); + } if (mode === "write" && parts[0] === ".lich") { const allowed = parts[1] === "skills" || parts[1] === "plugins"; if (allowed === false) { @@ -92,6 +96,16 @@ export function assert_file_tool_access(work_dir: string, resolved: string, mode } } +/** True when `assert_file_tool_access` would refuse `resolved`. */ +export function file_tool_denied(work_dir: string, resolved: string, mode: "read" | "write"): boolean { + try { + assert_file_tool_access(work_dir, resolved, mode); + return false; + } catch { + return true; + } +} + /** Read a required non-empty string argument, or throw `missing_arg`. */ export function require_string_arg(args: Record, key: string): string { const value = args[key]; diff --git a/test/cli_config.test.ts b/test/cli_config.test.ts index 2dbcccd..6b7844e 100644 --- a/test/cli_config.test.ts +++ b/test/cli_config.test.ts @@ -202,17 +202,23 @@ describe("load_layered_config", () => { let saved_home: string | undefined; let home: string; + let saved_profile: string | undefined; + beforeEach(() => { saved_home = process.env.HOME; + saved_profile = process.env.LICH_PROFILE; home = make_temp_dir("home"); process.env.HOME = home; + delete process.env.LICH_PROFILE; }); afterEach(() => { - if (saved_home === undefined) { - delete process.env.HOME; - } else { - process.env.HOME = saved_home; + for (const [key, value] of [["HOME", saved_home], ["LICH_PROFILE", saved_profile]] as const) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } } }); @@ -281,6 +287,87 @@ describe("load_layered_config", () => { }); }); +describe("load_layered_config profiles", () => { + let saved: { home?: string; profile?: string }; + let home: string; + + beforeEach(() => { + saved = { home: process.env.HOME, profile: process.env.LICH_PROFILE }; + home = make_temp_dir("home"); + process.env.HOME = home; + delete process.env.LICH_PROFILE; + }); + + afterEach(() => { + for (const [key, value] of [["HOME", saved.home], ["LICH_PROFILE", saved.profile]] as const) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + function write_file(file: string, body: string): void { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, body); + } + + const profiles = (): string => path.join(home, ".lich", "profiles"); + + it("merges global < profile < project, resolving profile plugin paths against ~/.lich/profiles", () => { + const work = make_temp_dir("work"); + write_file(global_config_path(), JSON.stringify({ agent_name: "global", max_turns: 9, theme: "lich" })); + write_file(path.join(profiles(), "coder.json"), JSON.stringify({ agent_name: "coder", max_turns: 5, plugins: ["p.mjs"] })); + write_file(project_config_path(work), JSON.stringify({ max_turns: 2 })); + const layered = load_layered_config(work, "coder"); + expect(layered?.config).toEqual({ agent_name: "coder", max_turns: 2, theme: "lich", plugins: [path.join(profiles(), "p.mjs")] }); + expect(layered?.profile).toBe("coder"); + expect(layered?.sources).toEqual([global_config_path(), path.join(profiles(), "coder.json"), project_config_path(work)]); + }); + + it("uses a non-empty soul as the system prompt, which a project system_prompt still overrides", () => { + const work = make_temp_dir("work"); + write_file(path.join(profiles(), "bard.json"), JSON.stringify({ system_prompt: "from json" })); + write_file(path.join(profiles(), "bard.md"), " You are a bard.\n"); + expect(load_layered_config(work, "bard")?.config["system_prompt"]).toBe("You are a bard."); + write_file(path.join(profiles(), "bard.md"), " \n"); + expect(load_layered_config(work, "bard")?.config["system_prompt"]).toBe("from json"); + write_file(project_config_path(work), JSON.stringify({ system_prompt: "project" })); + write_file(path.join(profiles(), "bard.md"), "You are a bard."); + expect(load_layered_config(work, "bard")?.config["system_prompt"]).toBe("project"); + }); + + it("picks the flag, then LICH_PROFILE, then the project profile key, then the global one, and drops the key", () => { + const work = make_temp_dir("work"); + for (const name of ["a", "b", "c", "d"]) { + write_file(path.join(profiles(), `${name}.json`), JSON.stringify({ agent_name: name })); + } + write_file(global_config_path(), JSON.stringify({ profile: "d" })); + expect(load_layered_config(work)?.config).toEqual({ agent_name: "d" }); + write_file(project_config_path(work), JSON.stringify({ profile: "c" })); + expect(load_layered_config(work)?.config).toEqual({ agent_name: "c" }); + process.env.LICH_PROFILE = "b"; + expect(load_layered_config(work)?.config).toEqual({ agent_name: "b" }); + expect(load_layered_config(work, "a")?.config).toEqual({ agent_name: "a" }); + }); + + it("puts the profile over ~/.lich/config.json when the work_dir is home", () => { + write_file(path.join(profiles(), "coder.json"), JSON.stringify({ agent_name: "coder" })); + // Without ~/.lich/config.json the legacy file is still the base. + write_file(path.join(home, ".config", "lich", "config.json"), JSON.stringify({ agent_name: "legacy", theme: "lich" })); + expect(load_layered_config(home, "coder")?.config).toEqual({ agent_name: "coder", theme: "lich" }); + write_file(global_config_path(), JSON.stringify({ agent_name: "home", max_turns: 9 })); + expect(load_layered_config(home, "coder")?.config).toEqual({ agent_name: "coder", max_turns: 9 }); + }); + + it("throws for a missing profile or a name that is not a plain slug, without absolute paths", () => { + const work = make_temp_dir("work"); + expect(() => load_layered_config(work, "ghost")).toThrow(/^profile not found: ghost$/); + expect(() => load_layered_config(work, "../x")).toThrow("invalid profile name"); + }); +}); + describe("merge_config_layers", () => { const base_providers = [{ kind: "ollama", name: "a", model: "m" }]; diff --git a/test/first_run.test.ts b/test/first_run.test.ts index 264b9b8..e81a1df 100644 --- a/test/first_run.test.ts +++ b/test/first_run.test.ts @@ -17,9 +17,9 @@ vi.mock("../src/cli_config.js", async (import_original) => { const actual = await import_original(); return { ...actual, - write_lich_config: (work_dir: string, config: Record) => { + write_lich_config: (work_dir: string, config: Record, update?: boolean) => { writes.calls.push({ work_dir, config }); - return actual.write_lich_config(work_dir, config); + return actual.write_lich_config(work_dir, config, update); }, }; }); @@ -111,7 +111,7 @@ beforeEach(() => { wizard.lines = []; tui_run.configs = []; gateway_run.fn.mockClear(); - for (const key of ["LICH_MODEL", "LICH_PROVIDER_KIND"] as const) { + for (const key of ["LICH_MODEL", "LICH_PROVIDER_KIND", "LICH_PROFILE"] as const) { saved_env[key] = process.env[key]; delete process.env[key]; } @@ -511,6 +511,68 @@ describe("lich init and bare lich", () => { }); }); + 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); + try { + wizard.lines = ["coder", "", "code-model", "", "", ""]; + expect(await run_cli(["profile", "create", "coder"])).toBe(0); + const file = path.join(String(process.env.HOME), ".lich", "profiles", "coder.json"); + const written = JSON.parse(readFileSync(file, "utf8")) as Record; + expect(written["agent_name"]).toBe("coder"); + expect(written["plugins"]).toEqual([]); + await expect(run_cli(["profile", "create", "coder"])).rejects.toThrow("already exists"); + } finally { + stdout.mockRestore(); + restore_tty(); + } + }); + + it("lich profile use records the default in ~/.lich/config.json, keeping its other keys, and list marks 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" })); + writeFileSync(path.join(home, ".lich", "profiles", "bard.md"), "You are a bard."); + writeFileSync(global_config_path(), JSON.stringify({ max_turns: 4 })); + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + try { + expect(await run_cli(["profile", "use", "coder"])).toBe(0); + expect(JSON.parse(readFileSync(global_config_path(), "utf8"))).toEqual({ max_turns: 4, profile: "coder" }); + stdout.mockClear(); + expect(await run_cli(["profile", "list"])).toBe(0); + expect(stdout.mock.calls.map((call) => String(call[0])).join("")).toBe(" bard (soul)\n* coder\n"); + await expect(run_cli(["profile", "use", "ghost"])).rejects.toThrow("profile not found: ghost"); + } finally { + 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 }); + writeFileSync(path.join(home, ".lich", "profiles", "coder.json"), JSON.stringify({ + agent_name: "coder", + providers: [{ kind: "ollama", name: "main", model: "code-model" }], + })); + const dir = make_temp_dir("profile-run"); + const restore_tty = set_tty(true); + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + try { + wizard.lines = ["should-not-be-read"]; + expect(await run_cli(["--work-dir", dir, "--profile", "coder"])).toBe(0); + expect(writes.calls).toHaveLength(0); + expect(tui_run.configs[0]?.agent_name).toBe("coder"); + expect(tui_run.configs[0]?.providers?.[0]?.model).toBe("code-model"); + await expect(run_cli(["tui", "--profile", "coder", "--config", global_config_path()])).rejects.toThrow( + "--profile cannot be combined with --config", + ); + await expect(run_cli(["tui", "--profile", "ghost"])).rejects.toThrow("lich tui: profile not found: ghost"); + } finally { + stdout.mockRestore(); + restore_tty(); + } + }); + it("lets lich tui pick up the file lich init wrote", async () => { await without_model_env(async () => { const dir = make_temp_dir("tui-pickup"); diff --git a/test/tools.test.ts b/test/tools.test.ts index 1618561..31fbee2 100644 --- a/test/tools.test.ts +++ b/test/tools.test.ts @@ -454,6 +454,31 @@ describe("forbidden file paths", () => { expect(write.error?.startsWith("forbidden_path")).toBe(true); }); + it("denies .lich/profiles to read and write (global identity files when work_dir is home)", async () => { + await mkdir(path.join(tmp_root, ".lich", "profiles"), { recursive: true }); + await writeFile(path.join(tmp_root, ".lich", "profiles", "coder.md"), "soul\n", "utf8"); + const registry = new ToolRegistry(); + register_builtin_tools(registry); + const executor = make_executor(registry); + const read = await executor.execute("read_file", { path: ".lich/profiles/coder.md" }); + expect(read.error?.startsWith("forbidden_path: .lich/profiles")).toBe(true); + const write = await executor.execute("write_file", { path: ".lich/profiles/coder.json", content: "{}\n" }); + expect(write.error?.startsWith("forbidden_path")).toBe(true); + const listed = await executor.execute("list_dir", { path: ".lich/profiles" }); + expect(listed.error?.startsWith("forbidden_path: .lich/profiles")).toBe(true); + const walked = await executor.execute("list_dir", { path: ".lich", depth: 3 }); + expect(walked.ok).toBe(true); + expect(walked.output).not.toContain("profiles"); + expect(walked.output).not.toContain("coder.md"); + const usage = await executor.execute("disk_usage", { path: ".lich/profiles" }); + expect(usage.error?.startsWith("forbidden_path: .lich/profiles")).toBe(true); + const parent = await executor.execute("disk_usage", { path: ".lich" }); + if (parent.error !== "du_unavailable") { + expect(parent.ok).toBe(true); + expect(parent.output).not.toContain("profiles"); + } + }); + it("allows writes under .lich/skills and .lich/plugins; denies other .lich writes and .env*", async () => { const registry = new ToolRegistry(); register_builtin_tools(registry);