Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
c540421
wiki: log merges of #115 #127 #137 #139 #141 #142 #143
claude Oct 2, 2026
5a6efcd
wiki: decision models and Ollama model research (#148)
claude Oct 2, 2026
aaa033c
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 2, 2026
97992d7
wiki: Hermes model roles, memory embedders and Jev plugins (#148, #149)
claude Oct 2, 2026
f1a3427
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 2, 2026
af2d08f
docs: default local Ollama to qwen3:8b and document Ollama cloud (#148)
claude Oct 2, 2026
9c6f11b
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 2, 2026
45f9ef1
wiki: fix Hermes Jev plugin count and update providers after #152
claude Oct 2, 2026
84e91ab
feat(config): per-role provider chains via models.chat and models.com…
claude Oct 2, 2026
9fd71b7
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 2, 2026
c00a4c7
test, docs: cover models.compress through Agent; clarify omitted-role…
claude Oct 2, 2026
b5a20f1
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 3, 2026
b054cc7
feat(plugins): entry settings, host model access and before_llm_call …
claude Oct 3, 2026
d6b712a
fix(plugins): deep-copy hook messages, honor run abort in models.chat…
claude Oct 3, 2026
ab5df46
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 3, 2026
2b01b6d
wiki: ingest Ollama System One/Clef check; record model roles and plu…
claude Oct 3, 2026
e010ef8
wiki: index one-liners match model roles and plugin host features
claude Oct 3, 2026
4640db2
wiki: bump index updated date
claude Oct 3, 2026
98a2c2b
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 3, 2026
b8266bc
feat(examples): decision_lane plugin for game_bridge action selection
claude Oct 3, 2026
bab4850
fix(decision_lane): zero off-criteria answers, require numeric thresh…
claude Oct 3, 2026
e0237cc
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 4, 2026
c59f5d1
wiki: record the decision_lane prototype (#159)
claude Oct 4, 2026
ca1c05f
wiki: decision_lane egress mentions the Bearer key
claude Oct 4, 2026
dcbb9e6
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 4, 2026
2074964
fix(agent): tools_enabled filters plugin tools; guard hooks fail clos…
claude Oct 4, 2026
4f97ea2
fix(loop): emit tool_call_end for last-turn skipped calls so transcri…
claude Oct 4, 2026
1b2d4d9
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 4, 2026
4840336
wiki: record #161 (plugin-tool allowlist, fail-closed guard hooks, la…
claude Oct 4, 2026
0b3d485
wiki: move the #161 fixed note out of the open-holes list
claude Oct 4, 2026
d7e1e95
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 4, 2026
0930c7d
fix: http_request status, terminal_timeout_ms, .. path guard, OpenAI …
claude Oct 4, 2026
09fc444
fix(openai): treat a null tool-call id as missing; correct http_reque…
claude Oct 4, 2026
1294e6a
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 4, 2026
07d0d3b
docs: webhook binds loopback by default; drop stale version from over…
claude Oct 4, 2026
0608068
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 4, 2026
16c4bdb
refactor(providers): share HTTP/error helpers in providers/http.ts (#…
claude Oct 4, 2026
53962b9
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 4, 2026
fe394bd
fix: Ctrl+C/Esc cancel, atomic config update, test tmp cleanup (#133)
claude Oct 4, 2026
84f62a4
fix: on cancel, drop the unanswered user line and do not reprint the …
claude Oct 4, 2026
3cfddb1
fix: keep this turn's reply when a cancel lands during tools
claude Oct 4, 2026
8c5fe08
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 4, 2026
b941191
wiki: map #113 phase children and related issues
claude Oct 4, 2026
39aade9
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 4, 2026
4c8b180
feat(config): global ~/.lich/config.json merged under the project con…
claude Oct 4, 2026
dc815f3
fix(config): read the legacy user config only when ~/.lich/config.jso…
claude Oct 4, 2026
36f8f87
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 5, 2026
146619b
feat(config): lich init --global and a "save as global" wizard step (…
claude Oct 5, 2026
2faf90c
fix(wizard): write the config once, plugins included, when work_dir i…
claude Oct 5, 2026
eff0ea1
fix(wizard): store absolute plugin paths when writing ~/.lich/config.…
claude Oct 5, 2026
558e3d1
Merge remote-tracking branch 'origin/main' into claude/open-issues-pr…
claude Oct 5, 2026
eaadb01
feat(config): named profiles in ~/.lich/profiles (#117)
claude Oct 5, 2026
ae7e9d8
fix(profiles): profile over the home config, stable errors, guard lis…
claude Oct 5, 2026
2d64bbf
fix(profiles): keep the legacy base at home; disk_usage skips denied …
claude Oct 5, 2026
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

- Profiles (#117): `~/.lich/profiles/<name>.json` (plus an optional `<name>.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
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
2 changes: 2 additions & 0 deletions docs/user-guide/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <path>`** 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`.
Expand Down
59 changes: 59 additions & 0 deletions docs/user-guide/profiles.md
Original file line number Diff line number Diff line change
@@ -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/<name>.json` (the selected profile)
3. `<work_dir>/.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 <path>` still replaces every layer. It cannot be combined with `--profile`, and `LICH_PROFILE` is ignored when it is given.

## The soul file

When `<name>.md` exists and is not blank, its contents become the profile's `system_prompt`, replacing any `system_prompt` in `<name>.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 <name>` 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: <name>`). 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 <name>` | Print the profile's JSON and its soul file's path and length. |
| `lich profile create <name>` | Run the setup wizard and write `~/.lich/profiles/<name>.json`. Needs a TTY; never overwrites. |
| `lich profile use <name>` | Make `<name>` 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.
33 changes: 28 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -62,6 +63,7 @@ const FLAG_KEYS: Record<string, string> = {
"--session-dir": "session_dir",
"--log-level": "log_level",
"--theme": "theme",
"--profile": "profile",
};

function usage_text(): string {
Expand All @@ -83,11 +85,14 @@ function usage_text(): string {
" lich mcp list list mcp servers in .lich/config.json",
" lich mcp add <name> add a catalog or --command/--url server (disabled)",
" lich mcp enable <name> / disable <name> / remove <name>",
" lich profile list list profiles in ~/.lich/profiles (* = default)",
" lich profile create <name> / show <name> / use <name>",
" lich --help show this help",
" lich --version print version",
"",
"Flags (before or after the subcommand):",
" --config <path> JSON config file parsed by parse_agent_config",
" --profile <name> merge ~/.lich/profiles/<name>.json between global and project config (or LICH_PROFILE)",
" --work-dir <path> working directory for tools",
" --max-turns <n> loop turn budget (default 25)",
" --model <m> model name (default from LICH_MODEL)",
Expand Down Expand Up @@ -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" ||
Expand All @@ -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)";
}
Expand Down Expand Up @@ -395,19 +406,27 @@ function apply_overrides(config: Record<string, unknown>, overrides: Record<stri
apply_provider_override(config, overrides);
}

/** Project `.lich/config.json` (under `--work-dir`, else cwd) merged over the global `~/.lich/config.json`. */
function load_discovered_config(work_dir: string): Record<string, unknown> | 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<string, unknown> | undefined {
const layered = load_layered_config(work_dir, profile);
for (const note of layered?.notes ?? []) {
process.stderr.write(`${note}\n`);
}
return layered?.config;
}

function build_config(options: CliOptions): AgentConfig {
if (options.config_path !== undefined && options.overrides["profile"] !== undefined) {
Comment thread
cursor[bot] marked this conversation as resolved.
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"];
Expand Down Expand Up @@ -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<number | undefined> {
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);
Expand Down Expand Up @@ -798,6 +818,9 @@ export async function run_cli(argv: string[]): Promise<number> {
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");
Expand Down
73 changes: 65 additions & 8 deletions src/cli_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 `<name>.json` (and optional `<name>.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<string, unknown>; 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);
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -150,7 +207,7 @@ function global_layer(config: Record<string, unknown>, home: string): Record<str
return layer;
}

function read_config_object(found: string): Record<string, unknown> {
export function read_config_object(found: string): Record<string, unknown> {
const raw = readFileSync(found, "utf8");
const parsed = safe_json_parse<unknown>(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
Expand Down
Loading
Loading