Saved sessions and prompt history now live in ~/.config/chats/<root> instead of .codey/ inside the checkout; an older build's files are moved there on startup. The event log and scratch space stay in .codey/. Codey's MCP servers come from its own config only. Trusting a project now covers just the allow rules in its .claude/settings.json. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 27 changed files with 564 additions and 894 deletions.
| @@ -1,97 +1,97 @@ | ||
| 1 | - # Codey | |
| 2 | - | |
| 3 | - Codey is a coding agent harness in Go. Point it at a repository, tell it to make the failing test pass, and measure how often it actually does. | |
| 4 | - | |
| 5 | - It is one static binary: a full-screen TUI on a terminal, a drop-in for `claude -p` in scripts and CI, and an [Agent Client Protocol](https://agentclientprotocol.com) agent that other UIs can drive, [T3 Code](https://t3.codes) included. | |
| 6 | - | |
| 7 | - It works with an Anthropic API key, or with any gateway, proxy or local server that speaks the Messages API. If you already use Claude Code, Codey picks up its MCP servers, its permission rules, its CLAUDE.md files and, if you ask, its skills. | |
| 8 | - | |
| 9 | - ## "Wait, what is this for?" | |
| 10 | - | |
| 11 | - Measuring. The code is the byproduct. The deliverable is [METRICS.md](METRICS.md): pass rate, edit success, turns and cost across an eval suite, with one row for each change that could move them. [PLAN.md](PLAN.md) is the build plan this follows. | |
| 12 | - | |
| 13 | - Codey is still a full agent, with subagents, tabs and git worktrees, skills, MCP, web search, language-server diagnostics on every edit, and hooks. It is built so you can measure it. | |
| 14 | - | |
| 15 | - ## Installation | |
| 16 | - | |
| 17 | - > ```bash | |
| 18 | - > export ANTHROPIC_API_KEY=sk-ant-... | |
| 19 | - > ``` | |
| 20 | - > | |
| 21 | - > - A gateway or local server: `-base-url` with `-api-key` or `-auth-token`, or `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` | |
| 22 | - > - A Claude Code login: off unless you opt in with `-claude-code-auth`. That credential was issued to Claude Code, and using it from another client is outside Anthropic's terms for it. [Read this first.](docs/user/install.md#quick-start) | |
| 23 | - | |
| 24 | - ### Release binary | |
| 25 | - | |
| 26 | - Linux and macOS, amd64 and arm64, with `checksums.txt` on the [releases page](https://github.com/johannSo/codey/releases): | |
| 27 | - | |
| 28 | - ```bash | |
| 29 | - # linux or darwin, amd64 or arm64 | |
| 30 | - curl -L https://github.com/johannSo/codey/releases/latest/download/codey_linux_amd64.tar.gz | tar xz codey | |
| 31 | - ``` | |
| 32 | - | |
| 33 | - Put `codey` somewhere on your `PATH`. Release binaries are not signed. On macOS, a copy downloaded with a browser needs `xattr -d com.apple.quarantine codey` before it will run. | |
| 34 | - | |
| 35 | - ### With Go | |
| 36 | - | |
| 37 | - ```bash | |
| 38 | - go install github.com/johannSo/codey/cmd/codey@latest | |
| 39 | - ``` | |
| 40 | - | |
| 41 | - ### From source | |
| 42 | - | |
| 43 | - ```bash | |
| 44 | - git clone https://github.com/johannSo/codey | |
| 45 | - cd codey | |
| 46 | - go build -o codey ./cmd/codey | |
| 47 | - ``` | |
| 48 | - | |
| 49 | - ### First run | |
| 50 | - | |
| 51 | - ```bash | |
| 52 | - codey -C /path/to/a/throwaway/clone # full-screen TUI | |
| 53 | - codey -C /path/to/repo -p "why does TestFoo fail?" # one prompt, then exit | |
| 54 | - ``` | |
| 55 | - | |
| 56 | - The first run on a terminal opens a short setup wizard that asks for the model, effort, approvals, web search and theme. `/setup` opens it again later, and `/settings` changes one setting at a time. | |
| 57 | - | |
| 58 | - ## Some notes | |
| 59 | - | |
| 60 | - Codey is early. Expect rough edges. | |
| 61 | - | |
| 62 | - Approval prompts are not a sandbox. `-yolo` belongs inside a container or against a throwaway clone. Read [Safety](docs/user/safety.md) before you point Codey at anything you care about. | |
| 63 | - | |
| 64 | - ## Documentation | |
| 65 | - | |
| 66 | - Full docs live in [docs/](docs). There's no docs site. | |
| 67 | - | |
| 68 | - - [Install and first run](docs/user/install.md): credentials, gateways, and the Claude Code login | |
| 69 | - - [Flags](docs/user/flags.md): including compaction thresholds and turn caps | |
| 70 | - - [The TUI](docs/user/tui.md): keys, images, questions, the mouse, `!` shell, `/model`, `/effort` | |
| 71 | - - [Tabs, worktrees and sessions](docs/user/tabs.md) | |
| 72 | - - [Subagents](docs/user/subagents.md) and the agents sidebar | |
| 73 | - - [Setup, `/settings` and where Codey keeps things](docs/user/setup.md) | |
| 74 | - - [Tools](docs/user/tools.md) and diagnostics after every edit | |
| 75 | - - [Skills](docs/user/skills.md), Claude Code's included | |
| 76 | - - [Web search](docs/user/web-search.md) | |
| 77 | - - [MCP servers](docs/user/mcp.md), and trusting a project's `.mcp.json` | |
| 78 | - - [Claude Code's settings](docs/user/claude-code-settings.md): permissions and CLAUDE.md | |
| 79 | - - [Hooks](docs/user/hooks.md) and [herdr](docs/user/hooks.md#herdr) | |
| 80 | - - [Using it in place of `claude`](docs/user/in-place-of-claude.md): `-p`, `--output-format`, `--allowedTools` | |
| 81 | - - [Inside another editor](docs/user/editors.md): ACP, and Codey as a T3 Code provider | |
| 82 | - - [Safety](docs/user/safety.md) | |
| 83 | - | |
| 84 | - Working on Codey itself? Start at [docs/internals/overview.md](docs/internals/overview.md). | |
| 85 | - | |
| 86 | - ## Evals | |
| 87 | - | |
| 88 | - ```bash | |
| 89 | - ./evals/selftest.sh # no API calls: checks the tasks measure something | |
| 90 | - go run ./evals -n 3 # the real thing | |
| 91 | - ``` | |
| 92 | - | |
| 93 | - Ten tasks, a fresh temp directory per run, full transcripts under `evals/runs/<timestamp>/`. The results are in [METRICS.md](METRICS.md). | |
| 94 | - | |
| 95 | - ## License | |
| 96 | - | |
| 97 | - MIT; see [LICENSE](LICENSE). | |
| 1 | + # Codey | |
| 2 | + | |
| 3 | + Codey is a coding agent harness in Go. Point it at a repository, tell it to make the failing test pass, and measure how often it actually does. | |
| 4 | + | |
| 5 | + It is one static binary: a full-screen TUI on a terminal, a drop-in for `claude -p` in scripts and CI, and an [Agent Client Protocol](https://agentclientprotocol.com) agent that other UIs can drive, [T3 Code](https://t3.codes) included. | |
| 6 | + | |
| 7 | + It works with an Anthropic API key, or with any gateway, proxy or local server that speaks the Messages API. If you already use Claude Code, Codey picks up its permission rules, its CLAUDE.md files and, if you ask, its skills. | |
| 8 | + | |
| 9 | + ## "Wait, what is this for?" | |
| 10 | + | |
| 11 | + Measuring. The code is the byproduct. The deliverable is [METRICS.md](METRICS.md): pass rate, edit success, turns and cost across an eval suite, with one row for each change that could move them. [PLAN.md](PLAN.md) is the build plan this follows. | |
| 12 | + | |
| 13 | + Codey is still a full agent, with subagents, tabs and git worktrees, skills, MCP, web search, language-server diagnostics on every edit, and hooks. It is built so you can measure it. | |
| 14 | + | |
| 15 | + ## Installation | |
| 16 | + | |
| 17 | + > ```bash | |
| 18 | + > export ANTHROPIC_API_KEY=sk-ant-... | |
| 19 | + > ``` | |
| 20 | + > | |
| 21 | + > - A gateway or local server: `-base-url` with `-api-key` or `-auth-token`, or `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` | |
| 22 | + > - A Claude Code login: off unless you opt in with `-claude-code-auth`. That credential was issued to Claude Code, and using it from another client is outside Anthropic's terms for it. [Read this first.](docs/user/install.md#quick-start) | |
| 23 | + | |
| 24 | + ### Release binary | |
| 25 | + | |
| 26 | + Linux and macOS, amd64 and arm64, with `checksums.txt` on the [releases page](https://github.com/johannSo/codey/releases): | |
| 27 | + | |
| 28 | + ```bash | |
| 29 | + # linux or darwin, amd64 or arm64 | |
| 30 | + curl -L https://github.com/johannSo/codey/releases/latest/download/codey_linux_amd64.tar.gz | tar xz codey | |
| 31 | + ``` | |
| 32 | + | |
| 33 | + Put `codey` somewhere on your `PATH`. Release binaries are not signed. On macOS, a copy downloaded with a browser needs `xattr -d com.apple.quarantine codey` before it will run. | |
| 34 | + | |
| 35 | + ### With Go | |
| 36 | + | |
| 37 | + ```bash | |
| 38 | + go install github.com/johannSo/codey/cmd/codey@latest | |
| 39 | + ``` | |
| 40 | + | |
| 41 | + ### From source | |
| 42 | + | |
| 43 | + ```bash | |
| 44 | + git clone https://github.com/johannSo/codey | |
| 45 | + cd codey | |
| 46 | + go build -o codey ./cmd/codey | |
| 47 | + ``` | |
| 48 | + | |
| 49 | + ### First run | |
| 50 | + | |
| 51 | + ```bash | |
| 52 | + codey -C /path/to/a/throwaway/clone # full-screen TUI | |
| 53 | + codey -C /path/to/repo -p "why does TestFoo fail?" # one prompt, then exit | |
| 54 | + ``` | |
| 55 | + | |
| 56 | + The first run on a terminal opens a short setup wizard that asks for the model, effort, approvals, web search and theme. `/setup` opens it again later, and `/settings` changes one setting at a time. | |
| 57 | + | |
| 58 | + ## Some notes | |
| 59 | + | |
| 60 | + Codey is early. Expect rough edges. | |
| 61 | + | |
| 62 | + Approval prompts are not a sandbox. `-yolo` belongs inside a container or against a throwaway clone. Read [Safety](docs/user/safety.md) before you point Codey at anything you care about. | |
| 63 | + | |
| 64 | + ## Documentation | |
| 65 | + | |
| 66 | + Full docs live in [docs/](docs). There's no docs site. | |
| 67 | + | |
| 68 | + - [Install and first run](docs/user/install.md): credentials, gateways, and the Claude Code login | |
| 69 | + - [Flags](docs/user/flags.md): including compaction thresholds and turn caps | |
| 70 | + - [The TUI](docs/user/tui.md): keys, images, questions, the mouse, `!` shell, `/model`, `/effort` | |
| 71 | + - [Tabs, worktrees and sessions](docs/user/tabs.md) | |
| 72 | + - [Subagents](docs/user/subagents.md) and the agents sidebar | |
| 73 | + - [Setup, `/settings` and where Codey keeps things](docs/user/setup.md) | |
| 74 | + - [Tools](docs/user/tools.md) and diagnostics after every edit | |
| 75 | + - [Skills](docs/user/skills.md), Claude Code's included | |
| 76 | + - [Web search](docs/user/web-search.md) | |
| 77 | + - [MCP servers](docs/user/mcp.md) | |
| 78 | + - [Claude Code's settings](docs/user/claude-code-settings.md): permissions, CLAUDE.md, and trusting a project | |
| 79 | + - [Hooks](docs/user/hooks.md) and [herdr](docs/user/hooks.md#herdr) | |
| 80 | + - [Using it in place of `claude`](docs/user/in-place-of-claude.md): `-p`, `--output-format`, `--allowedTools` | |
| 81 | + - [Inside another editor](docs/user/editors.md): ACP, and Codey as a T3 Code provider | |
| 82 | + - [Safety](docs/user/safety.md) | |
| 83 | + | |
| 84 | + Working on Codey itself? Start at [docs/internals/overview.md](docs/internals/overview.md). | |
| 85 | + | |
| 86 | + ## Evals | |
| 87 | + | |
| 88 | + ```bash | |
| 89 | + ./evals/selftest.sh # no API calls: checks the tasks measure something | |
| 90 | + go run ./evals -n 3 # the real thing | |
| 91 | + ``` | |
| 92 | + | |
| 93 | + Ten tasks, a fresh temp directory per run, full transcripts under `evals/runs/<timestamp>/`. The results are in [METRICS.md](METRICS.md). | |
| 94 | + | |
| 95 | + ## License | |
| 96 | + | |
| 97 | + MIT; see [LICENSE](LICENSE). | |
| @@ -1,22 +1,19 @@ | ||
| 1 | 1 | package main |
| 2 | 2 | |
| 3 | 3 | // What a Claude Code user's existing configuration contributes to a run: the |
| 4 | - // MCP servers it knows about, the permission rules in its settings files, and | |
| 5 | - // its CLAUDE.md files. The reading and the rules about what is honoured live in | |
| 4 | + // permission rules in its settings files, and its CLAUDE.md files. Its MCP | |
| 5 | + // servers are not among them; Codey starts only the ones in its own config. The reading and the rules about what is honoured live in | |
| 6 | 6 | // internal/ccsettings; this file is the wiring — when to ask about trust, how, |
| 7 | 7 | // and what to say when the answer is no. |
| 8 | 8 | |
| 9 | 9 | import ( |
| 10 | 10 | "fmt" |
| 11 | 11 | "io" |
| 12 | - "os" | |
| 13 | - "sort" | |
| 14 | 12 | "strings" |
| 15 | 13 | "sync" |
| 16 | 14 | |
| 17 | 15 | "github.com/johannSo/codey/internal/approval" |
| 18 | 16 | "github.com/johannSo/codey/internal/ccsettings" |
| 19 | - "github.com/johannSo/codey/internal/config" | |
| 20 | 17 | ) |
| 21 | 18 | |
| 22 | 19 | // trustFlag is spelled once, because three messages name it. |
| @@ -25,15 +22,9 @@ const trustFlag = "-trust-project" | ||
| 25 | 22 | // ccOptions is what loading Claude Code's configuration needs from main. |
| 26 | 23 | type ccOptions struct { |
| 27 | 24 | Home, Root string |
| 28 | - // Own is Codey's own mcp_servers, which win any clash. | |
| 29 | - Own map[string]config.MCPServer | |
| 30 | - // Trust is -trust-project: use the project's servers and allow rules this | |
| 31 | - // run, whatever was decided before, without asking. | |
| 25 | + // Trust is -trust-project: use the project's allow rules this run, | |
| 26 | + // whatever was decided before, without asking. | |
| 32 | 27 | Trust bool |
| 33 | - // NoMCP is -no-mcp. The servers are still part of the trust question — | |
| 34 | - // the question is about the repository, not about this run — but the | |
| 35 | - // prompt says they will not start. | |
| 36 | - NoMCP bool | |
| 37 | 28 | // Ask puts the trust question to a person and reports the answer, and |
| 38 | 29 | // whether there was one. Nil where nobody can answer: -p, agent stdio, |
| 39 | 30 | // a piped stdin. |
| @@ -41,14 +32,10 @@ type ccOptions struct { | ||
| 41 | 32 | Store ccsettings.TrustStore |
| 42 | 33 | // Notice reports something to the user, wherever this run's notices go. |
| 43 | 34 | Notice func(format string, args ...any) |
| 44 | - // Lookup is the environment ${VAR} expands from. | |
| 45 | - Lookup func(string) (string, bool) | |
| 46 | 35 | } |
| 47 | 36 | |
| 48 | 37 | // ccSetup is what came of it. |
| 49 | 38 | type ccSetup struct { |
| 50 | - // MCP is every server to start, Codey's own included. | |
| 51 | - MCP map[string]config.MCPServer | |
| 52 | 39 | // Rules is nil when no settings file had any rules, so the policy is left |
| 53 | 40 | // exactly as it would be on a machine without Claude Code. |
| 54 | 41 | Rules approval.RuleSet |
| @@ -58,34 +45,17 @@ type ccSetup struct { | ||
| 58 | 45 | |
| 59 | 46 | // loadClaudeCode reads Claude Code's files for the startup root, settles |
| 60 | 47 | // whether the project's own files are trusted — asking, if there is someone |
| 61 | - // to ask and nothing has been decided about these exact definitions — and | |
| 62 | - // returns what this run should use. | |
| 48 | + // to ask and nothing has been decided about these exact rules — and returns | |
| 49 | + // what this run should use. | |
| 63 | 50 | func loadClaudeCode(o ccOptions) ccSetup { |
| 64 | - if o.Lookup == nil { | |
| 65 | - o.Lookup = os.LookupEnv | |
| 66 | - } | |
| 67 | - mcpFiles, errs := ccsettings.LoadMCP(o.Home, o.Root) | |
| 68 | - for _, err := range errs { | |
| 69 | - o.Notice("%v", err) | |
| 70 | - } | |
| 71 | 51 | perms, errs := ccsettings.LoadPermissions(o.Home, o.Root) |
| 72 | 52 | for _, err := range errs { |
| 73 | 53 | o.Notice("%v — its deny rules are not in force", err) |
| 74 | 54 | } |
| 75 | 55 | |
| 76 | - grant := ccsettings.NewGrant(mcpFiles, perms) | |
| 77 | - trusted := settleTrust(o, grant) | |
| 56 | + trusted := settleTrust(o, ccsettings.NewGrant(perms)) | |
| 78 | 57 | |
| 79 | 58 | out := ccSetup{Trusted: trusted} |
| 80 | - merged, notes := ccsettings.MergeMCP(o.Own, mcpFiles, trusted, o.Lookup) | |
| 81 | - out.MCP = merged | |
| 82 | - if !o.NoMCP { | |
| 83 | - // A clash or a missing variable is only news if the servers were | |
| 84 | - // going to start. | |
| 85 | - for _, n := range notes { | |
| 86 | - o.Notice("%s", n) | |
| 87 | - } | |
| 88 | - } | |
| 89 | 59 | rules, notes := ccsettings.NewRuleset(o.Home, o.Root, perms, trusted) |
| 90 | 60 | for _, n := range notes { |
| 91 | 61 | o.Notice("%s", n) |
| @@ -123,11 +93,7 @@ func settleTrust(o ccOptions, grant ccsettings.Grant) bool { | ||
| 123 | 93 | o.Notice("%s — they come from the repository, and nobody has said this checkout is trusted. Run once interactively to be asked, or pass %s", untrustedWhat(grant), trustFlag) |
| 124 | 94 | return false |
| 125 | 95 | } |
| 126 | - lines := grant.Describe() | |
| 127 | - if o.NoMCP && len(grant.Servers) > 0 { | |
| 128 | - lines = append(lines, "(the servers will not start this run: -no-mcp)") | |
| 129 | - } | |
| 130 | - trusted, answered := o.Ask(o.Root, lines) | |
| 96 | + trusted, answered := o.Ask(o.Root, grant.Describe()) | |
| 131 | 97 | if !answered { |
| 132 | 98 | // Interrupted, or stdin closed: no for now, and the question stays |
| 133 | 99 | // open for the next start. |
| @@ -144,19 +110,7 @@ func settleTrust(o ccOptions, grant ccsettings.Grant) bool { | ||
| 144 | 110 | |
| 145 | 111 | // untrustedWhat names what is being left out, for a notice. |
| 146 | 112 | func untrustedWhat(g ccsettings.Grant) string { |
| 147 | - var parts []string | |
| 148 | - if len(g.Servers) > 0 { | |
| 149 | - names := make([]string, 0, len(g.Servers)) | |
| 150 | - for n := range g.Servers { | |
| 151 | - names = append(names, n) | |
| 152 | - } | |
| 153 | - sort.Strings(names) | |
| 154 | - parts = append(parts, fmt.Sprintf("the MCP servers in %s (%s) were not started", ccsettings.ProjectMCP, strings.Join(names, ", "))) | |
| 155 | - } | |
| 156 | - if len(g.Allow) > 0 { | |
| 157 | - parts = append(parts, fmt.Sprintf("the allow rules in %s are ignored", strings.Join(g.AllowFrom, " and "))) | |
| 158 | - } | |
| 159 | - return strings.Join(parts, ", and ") | |
| 113 | + return fmt.Sprintf("the allow rules in %s are ignored", strings.Join(g.AllowFrom, " and ")) | |
| 160 | 114 | } |
| 161 | 115 | |
| 162 | 116 | // askTrustOnTerminal is the trust question, asked on the terminal before the |
| @@ -224,12 +178,11 @@ func rulesFor(o ccOptions, startRoot string, start ccSetup) func(root string) ap | ||
| 224 | 178 | } |
| 225 | 179 | ro := o |
| 226 | 180 | ro.Root, ro.Ask = root, nil |
| 227 | - mcpFiles, _ := ccsettings.LoadMCP(o.Home, root) | |
| 228 | 181 | perms, errs := ccsettings.LoadPermissions(o.Home, root) |
| 229 | 182 | for _, err := range errs { |
| 230 | 183 | o.Notice("%v — its deny rules are not in force", err) |
| 231 | 184 | } |
| 232 | - trusted := settleTrust(ro, ccsettings.NewGrant(mcpFiles, perms)) | |
| 185 | + trusted := settleTrust(ro, ccsettings.NewGrant(perms)) | |
| 233 | 186 | rules, notes := ccsettings.NewRuleset(o.Home, root, perms, trusted) |
| 234 | 187 | for _, n := range notes { |
| 235 | 188 | o.Notice("%s", n) |
| @@ -9,12 +9,11 @@ import ( | ||
| 9 | 9 | |
| 10 | 10 | "github.com/johannSo/codey/internal/approval" |
| 11 | 11 | "github.com/johannSo/codey/internal/ccsettings" |
| 12 | - "github.com/johannSo/codey/internal/config" | |
| 13 | 12 | ) |
| 14 | 13 | |
| 15 | 14 | // ccFixture is a home and a project under a temp directory — never the real |
| 16 | - // ~/.claude* — with a project .mcp.json, a shared allow rule, and a server of | |
| 17 | - // the user's own. | |
| 15 | + // ~/.claude* — with a shared allow rule, and MCP servers in both of Claude | |
| 16 | + // Code's places for them, which nothing here should read. | |
| 18 | 17 | type ccFixture struct { |
| 19 | 18 | home, root string |
| 20 | 19 | store ccsettings.TrustStore |
| @@ -50,9 +49,7 @@ func (f *ccFixture) opts(answer *bool) ccOptions { | ||
| 50 | 49 | o := ccOptions{ |
| 51 | 50 | Home: f.home, |
| 52 | 51 | Root: f.root, |
| 53 | - Own: map[string]config.MCPServer{"codey-own": {Command: "own"}}, | |
| 54 | 52 | Store: f.store, |
| 55 | - Lookup: func(string) (string, bool) { return "", false }, | |
| 56 | 53 | Notice: func(format string, args ...any) { f.notices = append(f.notices, fmt.Sprintf(format, args...)) }, |
| 57 | 54 | } |
| 58 | 55 | if answer != nil { |
| @@ -80,28 +77,23 @@ func allowed(rs approval.RuleSet, cmd string) approval.Verdict { | ||
| 80 | 77 | func yes() *bool { v := true; return &v } |
| 81 | 78 | func no() *bool { v := false; return &v } |
| 82 | 79 | |
| 83 | - // Headless — -p, agent stdio — nothing from the repository is used, the user's | |
| 84 | - // own servers still start, the repository's deny rules still hold, and the | |
| 85 | - // notice names the flag that says otherwise. | |
| 80 | + // Headless — -p, agent stdio — nothing from the repository is used, the | |
| 81 | + // repository's deny rules still hold, and the notice names the flag that says | |
| 82 | + // otherwise. | |
| 86 | 83 | func TestUntrustedHeadless(t *testing.T) { |
| 87 | 84 | f := newCCFixture(t) |
| 88 | 85 | got := loadClaudeCode(f.opts(nil)) |
| 89 | - if _, ok := got.MCP["repo-tool"]; ok { | |
| 90 | - t.Error("an untrusted project server would start") | |
| 91 | - } | |
| 92 | - for _, name := range []string{"mine", "codey-own"} { | |
| 93 | - if _, ok := got.MCP[name]; !ok { | |
| 94 | - t.Errorf("%s should start: it is the user's own", name) | |
| 95 | - } | |
| 96 | - } | |
| 97 | 86 | if v := allowed(got.Rules, "make build"); v != approval.NoVerdict { |
| 98 | 87 | t.Errorf("an untrusted project allow rule was honoured: %v", v) |
| 99 | 88 | } |
| 100 | 89 | if v := allowed(got.Rules, "rm -rf x"); v != approval.VerdictDeny { |
| 101 | 90 | t.Errorf("the project's deny rule should hold untrusted: %v", v) |
| 102 | 91 | } |
| 103 | - if !f.said(trustFlag) || !f.said("repo-tool") { | |
| 104 | - t.Errorf("notices %q should name the server and %s", f.notices, trustFlag) | |
| 92 | + if !f.said(trustFlag) || !f.said(".claude/settings.json") { | |
| 93 | + t.Errorf("notices %q should name the file and %s", f.notices, trustFlag) | |
| 94 | + } | |
| 95 | + if f.said("repo-tool") || f.said("mcp") { | |
| 96 | + t.Errorf("notices %q mention Claude Code's MCP servers, which are not read", f.notices) | |
| 105 | 97 | } |
| 106 | 98 | } |
| 107 | 99 | |
| @@ -110,14 +102,14 @@ func TestTrustFlag(t *testing.T) { | ||
| 110 | 102 | o := f.opts(no()) |
| 111 | 103 | o.Trust = true |
| 112 | 104 | got := loadClaudeCode(o) |
| 113 | - if _, ok := got.MCP["repo-tool"]; !ok || f.asked != 0 { | |
| 114 | - t.Errorf("-trust-project should start the project's servers without asking (asked %d)", f.asked) | |
| 105 | + if f.asked != 0 { | |
| 106 | + t.Errorf("-trust-project should not ask (asked %d)", f.asked) | |
| 115 | 107 | } |
| 116 | 108 | if v := allowed(got.Rules, "make build"); v != approval.VerdictAllow { |
| 117 | 109 | t.Errorf("-trust-project should honour the project's allow rules: %v", v) |
| 118 | 110 | } |
| 119 | 111 | // The flag is for one run: nothing is recorded. |
| 120 | - if decided, _, _ := f.store.Lookup(f.root, ccsettings.NewGrant(mustMCP(t, f), mustPerms(t, f)).Fingerprint()); decided { | |
| 112 | + if decided, _, _ := f.store.Lookup(f.root, ccsettings.NewGrant(mustPerms(t, f)).Fingerprint()); decided { | |
| 121 | 113 | t.Error("the flag should not record a decision") |
| 122 | 114 | } |
| 123 | 115 | } |
| @@ -128,9 +120,6 @@ func TestTrustIsAskedOnceAndRemembered(t *testing.T) { | ||
| 128 | 120 | if f.asked != 1 { |
| 129 | 121 | t.Fatalf("asked %d times, want 1", f.asked) |
| 130 | 122 | } |
| 131 | - if _, ok := got.MCP["repo-tool"]; !ok { | |
| 132 | - t.Error("a trusted project server should start") | |
| 133 | - } | |
| 134 | 123 | if v := allowed(got.Rules, "make build"); v != approval.VerdictAllow { |
| 135 | 124 | t.Errorf("a trusted project allow rule should be honoured: %v", v) |
| 136 | 125 | } |
| @@ -141,22 +130,18 @@ func TestTrustIsAskedOnceAndRemembered(t *testing.T) { | ||
| 141 | 130 | t.Errorf("asked again for the same definitions") |
| 142 | 131 | } |
| 143 | 132 | got = loadClaudeCode(f.opts(nil)) |
| 144 | - if _, ok := got.MCP["repo-tool"]; !ok { | |
| 145 | - t.Error("a recorded trust should hold for a headless run") | |
| 133 | + if v := allowed(got.Rules, "make build"); v != approval.VerdictAllow { | |
| 134 | + t.Errorf("a recorded trust should hold for a headless run: %v", v) | |
| 146 | 135 | } |
| 147 | 136 | |
| 148 | - // A changed .mcp.json is a new question. | |
| 137 | + // A .mcp.json is not part of what is trusted: changing it asks nothing. | |
| 149 | 138 | f.write(t, f.root, ".mcp.json", `{"mcpServers":{"repo-tool":{"command":"curl","args":["evil.example"]}}}`) |
| 150 | - got = loadClaudeCode(f.opts(nil)) | |
| 151 | - if _, ok := got.MCP["repo-tool"]; ok { | |
| 152 | - t.Error("a changed server started on the old trust") | |
| 153 | - } | |
| 154 | 139 | loadClaudeCode(f.opts(yes())) |
| 155 | - if f.asked != 2 { | |
| 156 | - t.Errorf("a changed .mcp.json should ask again (asked %d)", f.asked) | |
| 140 | + if f.asked != 1 { | |
| 141 | + t.Errorf("a changed .mcp.json asked again (asked %d)", f.asked) | |
| 157 | 142 | } |
| 158 | 143 | |
| 159 | - // So is a changed allow list. | |
| 144 | + // A changed allow list is a new question. | |
| 160 | 145 | f.write(t, f.root, ".claude/settings.json", `{"permissions":{"allow":["Bash(make:*)","Bash"]}}`) |
| 161 | 146 | got = loadClaudeCode(f.opts(nil)) |
| 162 | 147 | if v := allowed(got.Rules, "make build"); v != approval.NoVerdict { |
| @@ -167,8 +152,8 @@ func TestTrustIsAskedOnceAndRemembered(t *testing.T) { | ||
| 167 | 152 | func TestDeclinedTrustIsRemembered(t *testing.T) { |
| 168 | 153 | f := newCCFixture(t) |
| 169 | 154 | got := loadClaudeCode(f.opts(no())) |
| 170 | - if _, ok := got.MCP["repo-tool"]; ok { | |
| 171 | - t.Error("a declined project server would start") | |
| 155 | + if v := allowed(got.Rules, "make build"); v != approval.NoVerdict { | |
| 156 | + t.Errorf("a declined project allow rule was honoured: %v", v) | |
| 172 | 157 | } |
| 173 | 158 | f.notices = nil |
| 174 | 159 | loadClaudeCode(f.opts(yes())) |
| @@ -180,10 +165,10 @@ func TestDeclinedTrustIsRemembered(t *testing.T) { | ||
| 180 | 165 | } |
| 181 | 166 | } |
| 182 | 167 | |
| 183 | - // Nothing to trust is nothing to ask. | |
| 168 | + // Nothing to trust is nothing to ask — and a .mcp.json is nothing to trust, | |
| 169 | + // since its servers are never started. | |
| 184 | 170 | func TestNoProjectFilesNoQuestion(t *testing.T) { |
| 185 | 171 | f := newCCFixture(t) |
| 186 | - os.Remove(filepath.Join(f.root, ".mcp.json")) | |
| 187 | 172 | f.write(t, f.root, ".claude/settings.json", `{"permissions":{"deny":["Bash(rm:*)"]}}`) |
| 188 | 173 | got := loadClaudeCode(f.opts(yes())) |
| 189 | 174 | if f.asked != 0 { |
| @@ -197,7 +182,7 @@ func TestNoProjectFilesNoQuestion(t *testing.T) { | ||
| 197 | 182 | // A machine without Claude Code leaves the policy exactly as it was. |
| 198 | 183 | func TestNoClaudeCodeNoRules(t *testing.T) { |
| 199 | 184 | base := t.TempDir() |
| 200 | - o := ccOptions{Home: filepath.Join(base, "h"), Root: filepath.Join(base, "r"), Notice: func(string, ...any) {}, Lookup: func(string) (string, bool) { return "", false }} | |
| 185 | + o := ccOptions{Home: filepath.Join(base, "h"), Root: filepath.Join(base, "r"), Notice: func(string, ...any) {}} | |
| 201 | 186 | got := loadClaudeCode(o) |
| 202 | 187 | if got.Rules != nil { |
| 203 | 188 | t.Errorf("rules = %#v, want nil", got.Rules) |
| @@ -216,7 +201,7 @@ func TestRulesForAnotherRoot(t *testing.T) { | ||
| 216 | 201 | if v := allowed(lookup(other), "make"); v != approval.NoVerdict || f.asked != 0 { |
| 217 | 202 | t.Errorf("verdict %v, asked %d: another root must not be asked about", v, f.asked) |
| 218 | 203 | } |
| 219 | - g := ccsettings.NewGrant(nil, []ccsettings.Permissions{{Scope: ccsettings.ScopeProject, Allow: []string{"Bash(make:*)"}}}) | |
| 204 | + g := ccsettings.NewGrant([]ccsettings.Permissions{{Scope: ccsettings.ScopeProject, Allow: []string{"Bash(make:*)"}}}) | |
| 220 | 205 | f.store.Record(other, g.Fingerprint(), true) |
| 221 | 206 | lookup = rulesFor(o, f.root, start) |
| 222 | 207 | if v := allowed(lookup(other), "make"); v != approval.VerdictAllow { |
| @@ -238,11 +223,11 @@ func TestAskTrustOnTerminal(t *testing.T) { | ||
| 238 | 223 | for _, tc := range tests { |
| 239 | 224 | var out strings.Builder |
| 240 | 225 | ask := askTrustOnTerminal(strings.NewReader(tc.in), &out) |
| 241 | - trusted, answered := ask("/src/proj", []string{"start these MCP servers, from .mcp.json:", " db: db-mcp"}) | |
| 226 | + trusted, answered := ask("/src/proj", []string{"run these without asking, from .claude/settings.json:", " Bash(make:*)"}) | |
| 242 | 227 | if trusted != tc.trusted || answered != tc.answered { |
| 243 | 228 | t.Errorf("%q: got %v %v, want %v %v", tc.in, trusted, answered, tc.trusted, tc.answered) |
| 244 | 229 | } |
| 245 | - if !strings.Contains(out.String(), "db: db-mcp") || !strings.Contains(out.String(), "[y/N]") { | |
| 230 | + if !strings.Contains(out.String(), "Bash(make:*)") || !strings.Contains(out.String(), "[y/N]") { | |
| 246 | 231 | t.Errorf("prompt did not show the definitions:\n%s", out.String()) |
| 247 | 232 | } |
| 248 | 233 | } |
| @@ -263,11 +248,6 @@ func TestReadLineStopsAtTheNewline(t *testing.T) { | ||
| 263 | 248 | } |
| 264 | 249 | } |
| 265 | 250 | |
| 266 | - func mustMCP(t *testing.T, f *ccFixture) []ccsettings.MCPFile { | |
| 267 | - files, _ := ccsettings.LoadMCP(f.home, f.root) | |
| 268 | - return files | |
| 269 | - } | |
| 270 | - | |
| 271 | 251 | func mustPerms(t *testing.T, f *ccFixture) []ccsettings.Permissions { |
| 272 | 252 | p, _ := ccsettings.LoadPermissions(f.home, f.root) |
| 273 | 253 | return p |
| @@ -97,7 +97,7 @@ func run() error { | ||
| 97 | 97 | noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr") |
| 98 | 98 | noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it") |
| 99 | 99 | noMCP := flag.Bool("no-mcp", false, "do not start the MCP servers in the config, so their tools are not offered") |
| 100 | - trustProject := flag.Bool(strings.TrimPrefix(trustFlag, "-"), false, "use this project's .mcp.json servers and the allow rules in its .claude/settings.json for one run, without asking") | |
| 100 | + trustProject := flag.Bool(strings.TrimPrefix(trustFlag, "-"), false, "use the allow rules in this project's .claude/settings.json for one run, without asking") | |
| 101 | 101 | // The flags a script written for `claude -p` passes — see claudecli.go. |
| 102 | 102 | claudeFlags := registerClaudeFlags(flag.CommandLine) |
| 103 | 103 | |
| @@ -140,6 +140,18 @@ func run() error { | ||
| 140 | 140 | return err |
| 141 | 141 | } |
| 142 | 142 | |
| 143 | + // Conversations live under ~/.config/chats rather than in the checkout, | |
| 144 | + // and whatever an older build left in .codey/ is moved there first. With | |
| 145 | + // no config directory to put them in they stay where they always were: | |
| 146 | + // a session that cannot be saved is worse than one saved in the tree. | |
| 147 | + sessionDir, chatsErr := statedir.Chats(root) | |
| 148 | + chatsMoved := 0 | |
| 149 | + if chatsErr != nil { | |
| 150 | + sessionDir = filepath.Join(root, statedir.Dir, statedir.SessionsSubdir) | |
| 151 | + } else { | |
| 152 | + chatsMoved, chatsErr = statedir.MigrateChats(root, sessionDir) | |
| 153 | + } | |
| 154 | + | |
| 143 | 155 | // Saved preferences sit under the flags and over the built-in defaults. A |
| 144 | 156 | // config that cannot be read is reported and then ignored: Codey |
| 145 | 157 | // still starts, and the wizard can write a good one over it. |
| @@ -265,6 +277,12 @@ func run() error { | ||
| 265 | 277 | if stateErr != nil { |
| 266 | 278 | notice("%v — starting a fresh %s; the old one is untouched", stateErr, statedir.Dir) |
| 267 | 279 | } |
| 280 | + if chatsMoved > 0 { | |
| 281 | + notice("moved %d saved chat files from %s to %s", chatsMoved, statedir.Dir, sessionDir) | |
| 282 | + } | |
| 283 | + if chatsErr != nil { | |
| 284 | + notice("%v — chats are kept in %s", chatsErr, sessionDir) | |
| 285 | + } | |
| 268 | 286 | |
| 269 | 287 | if !client.IsDefaultEndpoint() { |
| 270 | 288 | // Worth saying out loud: the prompt and every file the agent reads go |
| @@ -395,25 +413,15 @@ func run() error { | ||
| 395 | 413 | procs := tools.NewProcesses() |
| 396 | 414 | defer procs.Close() |
| 397 | 415 | |
| 398 | - // The MCP servers the config names, started once for the session and | |
| 399 | - // shared by every agent in it, subagents included, as Claude Code shares | |
| 400 | - // them: a server is a process, and one per agent would be four copies of | |
| 401 | - // the same database connection. They run in the root Codey was started | |
| 402 | - // in and stay there across /worktree, since a server's working directory | |
| 403 | - // is fixed when it starts. A server that will not start is reported and | |
| 404 | - // left out; the session goes on without its tools. | |
| 405 | - // Claude Code's configuration for this root: its MCP servers merged under | |
| 406 | - // Codey's own, and its permission rules. The project's committed files | |
| 407 | - // are used only if this checkout is trusted, which is asked here — on the | |
| 408 | - // terminal, before the TUI owns it — when there is a person to ask and | |
| 409 | - // nothing has been decided about these exact definitions. -p and agent | |
| 410 | - // runs are not asked; see ccconfig.go. | |
| 416 | + // Claude Code's permission rules for this root. The project's committed | |
| 417 | + // allow rules are used only if this checkout is trusted, which is asked | |
| 418 | + // here — on the terminal, before the TUI owns it — when there is a person | |
| 419 | + // to ask and nothing has been decided about these exact rules. -p and | |
| 420 | + // agent runs are not asked; see ccconfig.go. | |
| 411 | 421 | ccOpts := ccOptions{ |
| 412 | 422 | Home: home, |
| 413 | 423 | Root: root, |
| 414 | - Own: cfg.MCPServers, | |
| 415 | 424 | Trust: *trustProject, |
| 416 | - NoMCP: *noMCP, | |
| 417 | 425 | Notice: notice, |
| 418 | 426 | } |
| 419 | 427 | if cfgPath, err := config.Path(); err == nil { |
| @@ -425,11 +433,17 @@ func run() error { | ||
| 425 | 433 | cc := loadClaudeCode(ccOpts) |
| 426 | 434 | instructions := instructionsFor(home, notice) |
| 427 | 435 | |
| 436 | + // The MCP servers Codey's config names — Claude Code's are not read — | |
| 437 | + // started once for the session and shared by every agent in it, | |
| 438 | + // subagents included, as Claude Code shares them: a server is a process, | |
| 439 | + // and one per agent would be four copies of the same database connection. | |
| 440 | + // They run in the root Codey was started in and stay there across | |
| 441 | + // /worktree, since a server's working directory is fixed when it starts. | |
| 442 | + // A server that will not start is reported and left out; the session goes | |
| 443 | + // on without its tools. | |
| 428 | 444 | var mcpTools []tools.Tool |
| 429 | - if len(cc.MCP) > 0 && !*noMCP { | |
| 430 | - mcpCfg := cfg | |
| 431 | - mcpCfg.MCPServers = cc.MCP | |
| 432 | - ts, closer, errs := mcp.Connect(context.Background(), root, mcpCfg) | |
| 445 | + if len(cfg.MCPServers) > 0 && !*noMCP { | |
| 446 | + ts, closer, errs := mcp.Connect(context.Background(), root, cfg) | |
| 433 | 447 | defer closer.Close() |
| 434 | 448 | for _, err := range errs { |
| 435 | 449 | notice("%v", err) |
| @@ -646,8 +660,6 @@ func run() error { | ||
| 646 | 660 | return built |
| 647 | 661 | } |
| 648 | 662 | |
| 649 | - sessionDir := filepath.Join(root, agent.SessionDir) | |
| 650 | - | |
| 651 | 663 | // workRoot is where the tools work; root stays where the state lives. The |
| 652 | 664 | // two are the same until /worktree moves the work, or until a session that |
| 653 | 665 | // had moved is resumed below. |
| @@ -283,7 +283,7 @@ func TestPrintOnceStreamsARealRun(t *testing.T) { | ||
| 283 | 283 | p, out, _, _ := fixedPrinter(formatStreamJSON, false) |
| 284 | 284 | a.Observe = p.observe |
| 285 | 285 | p.begin(root, client.Model(), "default", []string{"bash"}) |
| 286 | - sessionDir := filepath.Join(root, agent.SessionDir) | |
| 286 | + sessionDir := filepath.Join(t.TempDir(), "chats") | |
| 287 | 287 | if err := printOnce(context.Background(), a, p, sessionDir, "s1", root, client, "clean up"); err != nil { |
| 288 | 288 | t.Fatalf("printOnce: %v", err) |
| 289 | 289 | } |
| @@ -13,7 +13,7 @@ internal/websearch the search command, the URL jail, HTML to text | ||
| 13 | 13 | internal/mcp MCP client: starts the configured servers, offers their tools |
| 14 | 14 | internal/config saved defaults: model, effort, approvals, web search, theme, diagnostics |
| 15 | 15 | internal/approval allow / deny / ask, with shell analysis and Claude Code's permission rules |
| 16 | - internal/ccsettings Claude Code's MCP servers, permission rules and CLAUDE.md, and who may grant what | |
| 16 | + internal/ccsettings Claude Code's permission rules and CLAUDE.md, and who may grant what | |
| 17 | 17 | internal/ask questions the agent puts to you mid-task, and the tool for it |
| 18 | 18 | internal/hooks the lifecycle, published: fan-out, and the config's commands |
| 19 | 19 | internal/herdr reporting agent state to the herdr pane Codey is running in |
| @@ -1,10 +1,10 @@ | ||
| 1 | 1 | # Claude Code's settings |
| 2 | 2 | |
| 3 | 3 | If you already use Claude Code, a project set up for it should not need setting |
| 4 | - up a second time. Codey reads three kinds of thing from Claude Code's files — | |
| 5 | - [its MCP servers](mcp.md#claude-codes-servers), its permission rules, and its | |
| 6 | - CLAUDE.md files — and none of it is copied anywhere or written back: change it | |
| 7 | - in Claude Code and the next start here sees the change. | |
| 4 | + up a second time. Codey reads two kinds of thing from Claude Code's files — its | |
| 5 | + permission rules and its CLAUDE.md files — and none of it is copied anywhere or | |
| 6 | + written back: change it in Claude Code and the next start here sees the change. | |
| 7 | + Its MCP servers are [not read](mcp.md#claude-codes-servers-are-not-read). | |
| 8 | 8 | |
| 9 | 9 | **Permissions.** The `permissions` block of three settings files, in the order |
| 10 | 10 | Claude Code reads them: |
| @@ -12,7 +12,7 @@ Claude Code reads them: | ||
| 12 | 12 | | File | Whose | `deny` / `ask` | `allow` | `defaultMode` | |
| 13 | 13 | | --- | --- | --- | --- | --- | |
| 14 | 14 | | `~/.claude/settings.json` | yours, every project | honoured | honoured | `acceptEdits` only | |
| 15 | - | `.claude/settings.json` | the repository's, committed | honoured | only once [trusted](mcp.md#trusting-a-project) | ignored | | |
| 15 | + | `.claude/settings.json` | the repository's, committed | honoured | only once [trusted](#trusting-a-project) | ignored | | |
| 16 | 16 | | `.claude/settings.local.json` | yours, this project, git-ignored | honoured | honoured | `acceptEdits` only | |
| 17 | 17 | |
| 18 | 18 | The rule underneath that table is who wrote the file. A deny or an ask rule can |
| @@ -20,8 +20,8 @@ only make the agent do less or ask more, and nobody comes to harm from that, so | ||
| 20 | 20 | a repository may add as many as it likes. An allow rule is the opposite: it is |
| 21 | 21 | a decision that something runs without you being asked, and a decision written |
| 22 | 22 | into a repository was made by whoever wrote the repository. The shared file's |
| 23 | - allow rules therefore wait for the same trust question as its `.mcp.json`, and | |
| 24 | - until you answer it they are listed and ignored. A `settings.local.json` that | |
| 23 | + allow rules therefore wait for you to [trust the project](#trusting-a-project), | |
| 24 | + and until you do they are listed and ignored. A `settings.local.json` that | |
| 25 | 25 | git turns out to be tracking arrived with the clone, whatever its name says, |
| 26 | 26 | and is treated as shared. |
| 27 | 27 | |
| @@ -98,10 +98,47 @@ it in. | ||
| 98 | 98 | it has an equivalent of. |
| 99 | 99 | - `permissions.additionalDirectories`: the file tools keep the working root as |
| 100 | 100 | their only root. |
| 101 | - - Claude Code's own record of which `.mcp.json` servers you approved | |
| 102 | - (`enabledMcpjsonServers`, `enableAllProjectMcpServers`). It approves by name, | |
| 103 | - so a server whose command changed under the same name would still count; | |
| 104 | - Codey asks its own question about the exact definitions instead. | |
| 101 | + - MCP servers, from `~/.claude.json` or `.mcp.json`, and with them Claude | |
| 102 | + Code's record of which ones you approved (`enabledMcpjsonServers`, | |
| 103 | + `enableAllProjectMcpServers`). Codey starts only the servers in its own | |
| 104 | + [config](mcp.md). | |
| 105 | 105 | - Managed (enterprise) settings, subdirectory CLAUDE.md files loaded on demand, |
| 106 | 106 | and `.claude/rules/`. |
| 107 | - - Remote MCP servers, which Codey's client does not speak. | |
| 107 | + | |
| 108 | + ## Trusting a project | |
| 109 | + | |
| 110 | + A shared `.claude/settings.json` comes from the repository, and an allow rule | |
| 111 | + in it is whoever wrote the repository deciding what runs on your machine | |
| 112 | + without asking. So, like Claude Code, Codey asks first. The first interactive | |
| 113 | + start in a checkout whose shared settings have allow rules prints, before the | |
| 114 | + TUI opens: | |
| 115 | + | |
| 116 | + ``` | |
| 117 | + Claude Code's configuration for /home/you/src/thing would: | |
| 118 | + | |
| 119 | + run these without asking, from .claude/settings.json: | |
| 120 | + Bash(make:*) | |
| 121 | + | |
| 122 | + These files come from the repository, not from you: trusting them lets it | |
| 123 | + choose commands this machine runs. Your answer is remembered until they change. | |
| 124 | + Trust them for this checkout? [y/N] | |
| 125 | + ``` | |
| 126 | + | |
| 127 | + The answer — yes or no — is remembered for this checkout against a fingerprint | |
| 128 | + of exactly those rules. One more allow rule is a different fingerprint, and the | |
| 129 | + question comes back; nothing else does. A refusal is remembered too, so saying | |
| 130 | + no once is not a question at every start. | |
| 131 | + | |
| 132 | + The record is kept in `~/.config/codey/trust.json`, not in the project's | |
| 133 | + `.codey/` directory with the rest of the per-repository state. That is the | |
| 134 | + point of it: anything inside the working tree can arrive with a clone, and a | |
| 135 | + repository that shipped its own record of being trusted would be approving its | |
| 136 | + own rules. The fingerprint would be no defence there — whoever wrote the | |
| 137 | + settings file can compute it. Only a file the repository cannot reach can say | |
| 138 | + what you decided. | |
| 139 | + | |
| 140 | + Where nobody can be asked — `-p`, `codey agent stdio`, a piped stdin — an | |
| 141 | + undecided project's allow rules are ignored, with a line on stderr saying so. | |
| 142 | + A checkout you have already trusted needs nothing more. `-trust-project` says | |
| 143 | + yes for one run without asking and without recording anything, which is what a | |
| 144 | + CI job against a repository you control wants. | |
| @@ -26,82 +26,14 @@ Only tools are used: not resources, prompts or sampling, and no HTTP | ||
| 26 | 26 | transport. Pictures a tool returns are described to the model rather than |
| 27 | 27 | shown. A server's directory is fixed when it starts, so after `/worktree` it |
| 28 | 28 | is still serving the root the session began in. `-no-mcp` starts none of them |
| 29 | - for one run — Codey's own and Claude Code's alike. | |
| 29 | + for one run. | |
| 30 | 30 | |
| 31 | - ## Claude Code's servers | |
| 31 | + ## Claude Code's servers are not read | |
| 32 | 32 | |
| 33 | - The servers Claude Code already knows about are started as well, read from | |
| 34 | - where Claude Code keeps them rather than copied into Codey's config: | |
| 33 | + Only the servers in Codey's own `mcp_servers` are started. The ones Claude Code | |
| 34 | + keeps — in `~/.claude.json` (`claude mcp add`, either scope) and in a | |
| 35 | + repository's `.mcp.json` — are ignored, so a server is never started because a | |
| 36 | + clone or another program put it there. To use one here, copy its entry into | |
| 37 | + `mcp_servers`; the shape is the same. | |
| 35 | 38 | |
| 36 | - | Where | Whose | Started | | |
| 37 | - | --- | --- | --- | | |
| 38 | - | `~/.claude.json`, under `projects["<root>"].mcpServers` | yours, for this project — what `claude mcp add` writes | always | | |
| 39 | - | `.mcp.json` at the working root | the repository's, committed | once you [trust it](#trusting-a-project) | | |
| 40 | - | `~/.claude.json`, top-level `mcpServers` | yours, for every project — `claude mcp add -s user` | always | | |
| 41 | 39 | |
| 42 | - The shape is the one already shown above, `{"mcpServers": {name: {command, | |
| 43 | - args, env}}}`, with `"type": "stdio"` accepted and meaning the same thing. A | |
| 44 | - server with `"type": "http"` or `"sse"`, or with a `url`, is a remote server; | |
| 45 | - Codey's client speaks only stdio, so it is reported at startup and skipped. | |
| 46 | - `${VAR}` and `${VAR:-default}` are expanded in the command, the arguments and | |
| 47 | - the env values, as Claude Code expands them — so a `.mcp.json` can say | |
| 48 | - `"${DB_URL}"` and leave the secret in your environment. A variable that is | |
| 49 | - unset and has no default skips that server with a notice naming it, rather | |
| 50 | - than starting it with a hole in its command line. | |
| 51 | - | |
| 52 | - When two places define a server of the same name, Codey's own `mcp_servers` | |
| 53 | - win, and after that the table is read top to bottom: your per-project servers, | |
| 54 | - then the repository's, then your global ones. That is Claude Code's own order, | |
| 55 | - so a name means the same program in both. Each clash is reported once, naming | |
| 56 | - the one that won, because a server that quietly turned out to be a different | |
| 57 | - program is not something anyone thinks to debug. | |
| 58 | - | |
| 59 | - ### Trusting a project | |
| 60 | - | |
| 61 | - A `.mcp.json` comes from the repository. Starting a server listed there means | |
| 62 | - running a command — with your environment, your credentials, your network — | |
| 63 | - that whoever wrote the repository chose, before the agent has done anything | |
| 64 | - and outside every approval prompt. So, like Claude Code, Codey asks first. The | |
| 65 | - first interactive start in a checkout whose `.mcp.json` or shared | |
| 66 | - `.claude/settings.json` [allow rules](claude-code-settings.md) would grant | |
| 67 | - something prints, before the TUI opens: | |
| 68 | - | |
| 69 | - ``` | |
| 70 | - Claude Code's configuration for /home/you/src/thing would: | |
| 71 | - | |
| 72 | - start these MCP servers, from .mcp.json: | |
| 73 | - db: npx -y @acme/db-mcp --url ${DB_URL} (env: DB_TOKEN) | |
| 74 | - run these without asking, from .claude/settings.json: | |
| 75 | - Bash(make:*) | |
| 76 | - | |
| 77 | - These files come from the repository, not from you: trusting them lets it | |
| 78 | - choose commands this machine runs. Your answer is remembered until they change. | |
| 79 | - Trust them for this checkout? [y/N] | |
| 80 | - ``` | |
| 81 | - | |
| 82 | - One question covers both files, because it is one question: do you trust what | |
| 83 | - this repository says about itself. The full command line is shown because that | |
| 84 | - is the thing being agreed to. The answer — yes or no — is remembered for this | |
| 85 | - checkout against a fingerprint of exactly those definitions, as written, before | |
| 86 | - any `${VAR}` is expanded. A new server, a changed argument or one more allow | |
| 87 | - rule is a different fingerprint, and the question comes back; nothing else | |
| 88 | - does. A refusal is remembered too, so saying no once is not a question at every | |
| 89 | - start. | |
| 90 | - | |
| 91 | - The record is kept in `~/.config/codey/trust.json`, not in the project's | |
| 92 | - `.codey/` directory with the rest of the per-repository state. That is the | |
| 93 | - point of it: anything inside the working tree can arrive with a clone, and a | |
| 94 | - repository that shipped its own record of being trusted would be approving its | |
| 95 | - own servers. The fingerprint would be no defence there — whoever wrote the | |
| 96 | - `.mcp.json` can compute it. Only a file the repository cannot reach can say what | |
| 97 | - you decided. | |
| 98 | - | |
| 99 | - Where nobody can be asked — `-p`, `codey agent stdio`, a piped stdin — an | |
| 100 | - undecided project's servers are not started and its allow rules are ignored, | |
| 101 | - with a line on stderr saying so. A checkout you have already trusted needs | |
| 102 | - nothing more. `-trust-project` says yes for one run without asking and without | |
| 103 | - recording anything, which is what a CI job against a repository you control | |
| 104 | - wants. Servers in `~/.claude.json` never ask: you put them there, as you put | |
| 105 | - Codey's own in its config. Once started, every server's tool calls go through | |
| 106 | - the approval prompts like any other, unless an allow rule such as `mcp__db` | |
| 107 | - says otherwise. | |
| @@ -40,9 +40,8 @@ working root you would sensibly point Codey at — but the flip side is that | ||
| 40 | 40 | anything able to write that file has arranged to run commands here. `-no-hooks` |
| 41 | 41 | is the switch that says no for one run. MCP servers are the same bargain: each |
| 42 | 42 | is a program started with your environment when the session opens, and only |
| 43 | - its tool calls go through the prompts. `-no-mcp` is theirs. The one kind of | |
| 44 | - server that does not come from a file you wrote — a repository's `.mcp.json` — | |
| 45 | - waits for you to [trust it](mcp.md#trusting-a-project) first. | |
| 43 | + its tool calls go through the prompts. `-no-mcp` is theirs. Only the servers | |
| 44 | + in Codey's own config are started; a repository's `.mcp.json` is never read. | |
| 46 | 45 | |
| 47 | 46 | **A sandbox.** This is the layer that actually contains a mistake, and the |
| 48 | 47 | Codey cannot provide it for you. The honest recipe on Linux: |
| @@ -138,17 +138,24 @@ on"; they do not write to the file. The two screens that do are the wizard and | ||
| 138 | 138 | |
| 139 | 139 | ## Where Codey keeps things |
| 140 | 140 | |
| 141 | - Two directories: one global, one per repository. The second sits in your | |
| 142 | - working tree and is git-ignored, because sessions and an event log are yours | |
| 143 | - rather than the project's. | |
| 141 | + Two directories: one global, one per repository. Conversations are kept in the | |
| 142 | + global one, so they are not scattered across every checkout you have opened | |
| 143 | + and do not vanish with a `git clean`. The per-repository one sits in your | |
| 144 | + working tree and is git-ignored, because an event log is yours rather than the | |
| 145 | + project's. | |
| 144 | 146 | |
| 145 | 147 | | Path | What | |
| 146 | 148 | | --- | --- | |
| 147 | 149 | | `~/.config/codey/config.json` | the answers, written by the wizard and `/settings`; [hooks](hooks.md) go here by hand | |
| 148 | - | `~/.config/codey/trust.json` | which checkouts' [Claude Code files](mcp.md#trusting-a-project) you trusted, and which version of them | | |
| 149 | - | `<repo>/.codey/sessions/` | saved conversations, which is what `-resume` and `/resume` read | | |
| 150 | + | `~/.config/codey/trust.json` | which checkouts' [Claude Code settings](claude-code-settings.md#trusting-a-project) you trusted, and which version of them | | |
| 151 | + | `~/.config/chats/<repo>/` | saved conversations for that checkout, which is what `-resume` and `/resume` read; `<repo>` is its path with `/` turned into `-` | | |
| 152 | + | `~/.config/chats/<repo>/history` | what has been typed at the prompt there, which `↑` walks back through | | |
| 150 | 153 | | `<repo>/.codey/events.jsonl` | the event log METRICS.md is built from | |
| 151 | - | `<repo>/.codey/history` | what has been typed at the prompt here, which `↑` walks back through | | |
| 154 | + | |
| 155 | + Older builds kept the conversations in `<repo>/.codey/sessions/` and the prompt | |
| 156 | + history in `<repo>/.codey/history`. The first run in such a checkout moves them | |
| 157 | + to `~/.config/chats/` and says so; a file already at the new path is never | |
| 158 | + overwritten, and the old copy is left where it was. | |
| 152 | 159 | |
| 153 | 160 | Codey was called `harness` until recently, and both of those used to live under |
| 154 | 161 | that name. Neither rename costs you anything. The config is read from |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | `ctrl+t` — or `/new` — opens a second conversation in a tab. A tab is a whole |
| 4 | 4 | session: its own agent, its own history, its own approvals, its own subagents |
| 5 | - and its own file under `.codey/sessions`. Nothing crosses between them but the | |
| 5 | + and its own file under `~/.config/chats/<repo>/`. Nothing crosses between them but the | |
| 6 | 6 | model you are on and the terminal they share, so a turn running in one tab is |
| 7 | 7 | not interrupted, approved or compacted by anything you do in another. |
| 8 | 8 | |
| @@ -290,13 +290,12 @@ A `!` or `$` line and a prompt are different kinds of thing, so they are | ||
| 290 | 290 | offered back separately: `↑` at the shell prompt walks shell commands, and the |
| 291 | 291 | prompt aimed at the model never hands you a `go test` to send as prose. |
| 292 | 292 | |
| 293 | - The last few hundred entries are kept in `.codey/history` inside the checkout | |
| 294 | - Codey was started in — not per session, which would forget the thing you are | |
| 293 | + The last few hundred entries are kept in `~/.config/chats/<repo>/history`, | |
| 294 | + beside the sessions of the checkout Codey was started in — not per session, which would forget the thing you are | |
| 295 | 295 | most likely to want back, and not one file per machine, which would offer you |
| 296 | 296 | the test command of a project you are not in. Two tabs append to it rather than |
| 297 | - rewriting it, so neither loses the other's lines. It is inside the state | |
| 298 | - directory, which `.gitignore` already covers; delete the file and the prompt | |
| 299 | - box simply stops remembering. | |
| 297 | + rewriting it, so neither loses the other's lines. Delete the file and the | |
| 298 | + prompt box simply stops remembering. | |
| 300 | 299 | |
| 301 | 300 | ## Running something yourself |
| 302 | 301 | |
| @@ -11,7 +11,6 @@ import ( | ||
| 11 | 11 | "time" |
| 12 | 12 | |
| 13 | 13 | "github.com/johannSo/codey/internal/llm" |
| 14 | - "github.com/johannSo/codey/internal/statedir" | |
| 15 | 14 | ) |
| 16 | 15 | |
| 17 | 16 | // Session is a conversation saved to disk so --resume can pick it up. |
| @@ -32,9 +31,6 @@ type Session struct { | ||
| 32 | 31 | Messages []llm.Message `json:"messages"` |
| 33 | 32 | } |
| 34 | 33 | |
| 35 | - // SessionDir is where sessions live, relative to the working root. | |
| 36 | - const SessionDir = statedir.Dir + "/sessions" | |
| 37 | - | |
| 38 | 34 | // Save writes the session for id, overwriting any previous copy. root is the |
| 39 | 35 | // directory the session is filed under and worktree the tree the work has been |
| 40 | 36 | // moved into, which is usually empty and never replaces root. |
| @@ -105,8 +101,9 @@ func ResolveSession(dir, id string) (Session, error) { | ||
| 105 | 101 | } |
| 106 | 102 | |
| 107 | 103 | // BelongsTo reports whether a session was recorded in this working root. The |
| 108 | - // session directory already lives inside the root, so this is mostly belt and | |
| 109 | - // braces — but a file copied, symlinked or resumed from elsewhere would | |
| 104 | + // session directory is already named after the root, so this is mostly belt | |
| 105 | + // and braces — but two roots whose paths flatten to the same directory name, | |
| 106 | + // or a file copied, symlinked or resumed from elsewhere, would | |
| 110 | 107 | // otherwise let one checkout reopen another's conversation, which is exactly |
| 111 | 108 | // what a directory-scoped chooser must not do. A session with no root recorded |
| 112 | 109 | // is taken to belong where it is filed: it predates the field, and the |
| @@ -1,21 +1,20 @@ | ||
| 1 | 1 | // Package ccsettings reads the configuration Claude Code keeps for a user and |
| 2 | 2 | // for a project, so that someone who already set Claude Code up does not have |
| 3 | - // to set Codey up a second time: the MCP servers it knows about, the | |
| 4 | - // permission rules in its settings files, and the CLAUDE.md files it loads | |
| 5 | - // into every conversation. | |
| 3 | + // to set Codey up a second time: the permission rules in its settings files, | |
| 4 | + // and the CLAUDE.md files it loads into every conversation. Its MCP servers | |
| 5 | + // are not read: Codey starts only the servers in its own config. | |
| 6 | 6 | // |
| 7 | 7 | // Reading is the easy half. The half that needs care is what is honoured, and |
| 8 | - // it turns on who wrote each file. ~/.claude.json and ~/.claude/settings.json | |
| 9 | - // are the user's own, written by the user or by `claude` on their behalf, and | |
| 10 | - // they carry the same weight as Codey's own config. A project's .mcp.json and | |
| 11 | - // .claude/settings.json are committed to the repository, which means they were | |
| 12 | - // written by whoever wrote the repository: starting a server listed there is | |
| 13 | - // running a command a stranger chose, and an allow rule there is a stranger | |
| 14 | - // deciding what runs without asking. Those two are honoured only once the user | |
| 15 | - // has said, for this checkout and these exact definitions, that they trust | |
| 16 | - // them — the same bargain Claude Code's own trust dialog strikes. Anything | |
| 17 | - // that can only take power away — a deny rule, an ask rule — is honoured from | |
| 18 | - // every file, because nobody is harmed by an agent that asks more often. | |
| 8 | + // it turns on who wrote each file. ~/.claude/settings.json is the user's own, | |
| 9 | + // written by the user or by `claude` on their behalf, and it carries the same | |
| 10 | + // weight as Codey's own config. A project's .claude/settings.json is committed | |
| 11 | + // to the repository, which means it was written by whoever wrote the | |
| 12 | + // repository: an allow rule there is a stranger deciding what runs without | |
| 13 | + // asking. Those rules are honoured only once the user has said, for this | |
| 14 | + // checkout and these exact rules, that they trust them — the same bargain | |
| 15 | + // Claude Code's own trust dialog strikes. Anything that can only take power | |
| 16 | + // away — a deny rule, an ask rule — is honoured from every file, because | |
| 17 | + // nobody is harmed by an agent that asks more often. | |
| 19 | 18 | // |
| 20 | 19 | // Nothing here writes to Claude Code's files. The one file this package |
| 21 | 20 | // writes is Codey's own record of those trust decisions. |
| @@ -36,26 +35,22 @@ import ( | ||
| 36 | 35 | type Scope string |
| 37 | 36 | |
| 38 | 37 | const ( |
| 39 | - // ScopeUser is the user's own, for every project: ~/.claude/settings.json, | |
| 40 | - // and the top-level servers in ~/.claude.json. | |
| 38 | + // ScopeUser is the user's own, for every project: ~/.claude/settings.json. | |
| 41 | 39 | ScopeUser Scope = "user" |
| 42 | - // ScopeProject is committed to the repository: .mcp.json and | |
| 43 | - // .claude/settings.json at the working root. Written by whoever wrote the | |
| 44 | - // repository, so what it grants waits for trust. | |
| 40 | + // ScopeProject is committed to the repository: .claude/settings.json at | |
| 41 | + // the working root. Written by whoever wrote the repository, so what it | |
| 42 | + // grants waits for trust. | |
| 45 | 43 | ScopeProject Scope = "project" |
| 46 | 44 | // ScopeLocal is the user's own, for this project only: |
| 47 | - // .claude/settings.local.json, which Claude Code keeps out of git, and the | |
| 48 | - // per-project servers in ~/.claude.json, which `claude mcp add` writes. | |
| 45 | + // .claude/settings.local.json, which Claude Code keeps out of git. | |
| 49 | 46 | ScopeLocal Scope = "local" |
| 50 | 47 | ) |
| 51 | 48 | |
| 52 | 49 | // File names, relative to the home directory or the working root. |
| 53 | 50 | const ( |
| 54 | - ClaudeJSON = ".claude.json" | |
| 55 | 51 | UserSettings = ".claude/settings.json" |
| 56 | 52 | ProjectSettings = ".claude/settings.json" |
| 57 | 53 | LocalSettings = ".claude/settings.local.json" |
| 58 | - ProjectMCP = ".mcp.json" | |
| 59 | 54 | UserInstructions = ".claude/CLAUDE.md" |
| 60 | 55 | instructionsName = "CLAUDE.md" |
| 61 | 56 | localInstructions = "CLAUDE.local.md" |
| @@ -79,8 +74,8 @@ func readJSON(path string, v any) (bool, error) { | ||
| 79 | 74 | |
| 80 | 75 | // shown is how a path is written in a message: under ~ when it is in the home |
| 81 | 76 | // directory, relative when it is in the working root, absolute otherwise. A |
| 82 | - // prompt that says ".mcp.json" is one a person reads; one that says | |
| 83 | - // /home/someone/src/thing/.mcp.json is one they skim. | |
| 77 | + // prompt that says ".claude/settings.json" is one a person reads; one that | |
| 78 | + // says /home/someone/src/thing/.claude/settings.json is one they skim. | |
| 84 | 79 | func shown(home, root, path string) string { |
| 85 | 80 | if root != "" { |
| 86 | 81 | if rel, err := filepath.Rel(root, path); err == nil && !strings.HasPrefix(rel, "..") { |
| @@ -5,10 +5,7 @@ import ( | ||
| 5 | 5 | "os/exec" |
| 6 | 6 | "path/filepath" |
| 7 | 7 | "reflect" |
| 8 | - "strings" | |
| 9 | 8 | "testing" |
| 10 | - | |
| 11 | - "github.com/johannSo/codey/internal/config" | |
| 12 | 9 | ) |
| 13 | 10 | |
| 14 | 11 | // write puts a file under dir, creating its parents. Every test here builds |
| @@ -123,171 +120,3 @@ func TestTrackedLocalSettingsAreProjectScope(t *testing.T) { | ||
| 123 | 120 | t.Errorf("an untrusted, committed local file's allow rule was honoured: %+v", v) |
| 124 | 121 | } |
| 125 | 122 | } |
| 126 | - | |
| 127 | - func TestLoadMCPShapes(t *testing.T) { | |
| 128 | - home, root := dirs(t) | |
| 129 | - write(t, root, ProjectMCP, `{"mcpServers":{ | |
| 130 | - "plain": {"command":"plain-mcp","args":["--x"]}, | |
| 131 | - "typed": {"type":"stdio","command":"typed-mcp","env":{"K":"v"}}, | |
| 132 | - "web": {"type":"http","url":"https://example.com/mcp"}, | |
| 133 | - "old": {"type":"sse","url":"https://example.com/sse"}, | |
| 134 | - "bare": {"url":"https://example.com/u"} | |
| 135 | - }}`) | |
| 136 | - write(t, home, ClaudeJSON, `{ | |
| 137 | - "numStartups": 12, | |
| 138 | - "mcpServers": {"mine": {"command":"mine-mcp"}}, | |
| 139 | - "projects": { | |
| 140 | - "`+root+`": {"mcpServers": {"here": {"command":"here-mcp"}}, "allowedTools": []}, | |
| 141 | - "/elsewhere": {"mcpServers": {"there": {"command":"there-mcp"}}} | |
| 142 | - } | |
| 143 | - }`) | |
| 144 | - | |
| 145 | - files, errs := LoadMCP(home, root) | |
| 146 | - if len(errs) != 3 { | |
| 147 | - t.Errorf("want one error per remote server, got %v", errs) | |
| 148 | - } | |
| 149 | - for _, err := range errs { | |
| 150 | - if !strings.Contains(err.Error(), "stdio") { | |
| 151 | - t.Errorf("error %q should say Codey speaks only stdio", err) | |
| 152 | - } | |
| 153 | - } | |
| 154 | - var got []string | |
| 155 | - for _, f := range files { | |
| 156 | - got = append(got, string(f.Scope)+":"+strings.Join(sortedNames(f.Servers), ",")) | |
| 157 | - } | |
| 158 | - want := []string{"local:here", "project:plain,typed", "user:mine"} | |
| 159 | - if !reflect.DeepEqual(got, want) { | |
| 160 | - t.Errorf("files %v, want %v (most specific first)", got, want) | |
| 161 | - } | |
| 162 | - } | |
| 163 | - | |
| 164 | - // Claude Code keys a project by the path it was started in; a root reached | |
| 165 | - // through a symlink is still that project. | |
| 166 | - func TestLoadMCPFindsProjectThroughSymlink(t *testing.T) { | |
| 167 | - home, root := dirs(t) | |
| 168 | - link := filepath.Join(filepath.Dir(home), "link") | |
| 169 | - if err := os.Symlink(root, link); err != nil { | |
| 170 | - t.Skip(err) | |
| 171 | - } | |
| 172 | - write(t, home, ClaudeJSON, `{"projects":{"`+root+`":{"mcpServers":{"here":{"command":"x"}}}}}`) | |
| 173 | - files, _ := LoadMCP(home, link) | |
| 174 | - if len(files) != 1 || files[0].Scope != ScopeLocal { | |
| 175 | - t.Errorf("got %+v", files) | |
| 176 | - } | |
| 177 | - } | |
| 178 | - | |
| 179 | - func TestLoadMCPBrokenFileIsReported(t *testing.T) { | |
| 180 | - home, root := dirs(t) | |
| 181 | - write(t, root, ProjectMCP, `{"mcpServers": [`) | |
| 182 | - files, errs := LoadMCP(home, root) | |
| 183 | - if len(files) != 0 || len(errs) != 1 { | |
| 184 | - t.Errorf("files %v errs %v", files, errs) | |
| 185 | - } | |
| 186 | - } | |
| 187 | - | |
| 188 | - func env(m map[string]string) func(string) (string, bool) { | |
| 189 | - return func(k string) (string, bool) { v, ok := m[k]; return v, ok } | |
| 190 | - } | |
| 191 | - | |
| 192 | - func TestMergeMCPPrecedence(t *testing.T) { | |
| 193 | - files := []MCPFile{ | |
| 194 | - {Scope: ScopeLocal, Shown: "~/.claude.json (this project)", Servers: map[string]ServerDef{ | |
| 195 | - "db": {Command: "local-db"}, "only-local": {Command: "l"}}}, | |
| 196 | - {Scope: ScopeProject, Shown: ".mcp.json", Servers: map[string]ServerDef{ | |
| 197 | - "db": {Command: "project-db"}, "fs": {Command: "project-fs"}, "lint": {Command: "project-lint"}}}, | |
| 198 | - {Scope: ScopeUser, Shown: "~/.claude.json", Servers: map[string]ServerDef{ | |
| 199 | - "fs": {Command: "user-fs"}, "lint": {Command: "user-lint"}, "only-user": {Command: "u"}}}, | |
| 200 | - } | |
| 201 | - own := map[string]config.MCPServer{"lint": {Command: "codey-lint"}} | |
| 202 | - | |
| 203 | - tests := []struct { | |
| 204 | - name string | |
| 205 | - trusted bool | |
| 206 | - want map[string]string | |
| 207 | - wantNotes int | |
| 208 | - }{ | |
| 209 | - { | |
| 210 | - name: "trusted", | |
| 211 | - trusted: true, | |
| 212 | - want: map[string]string{ | |
| 213 | - "lint": "codey-lint", "db": "local-db", "fs": "project-fs", | |
| 214 | - "only-local": "l", "only-user": "u", | |
| 215 | - }, | |
| 216 | - // db: local over project; lint: codey over project and user; | |
| 217 | - // fs: project over user. | |
| 218 | - wantNotes: 4, | |
| 219 | - }, | |
| 220 | - { | |
| 221 | - name: "untrusted project servers give way to the user's own", | |
| 222 | - trusted: false, | |
| 223 | - want: map[string]string{ | |
| 224 | - "lint": "codey-lint", "db": "local-db", "fs": "user-fs", | |
| 225 | - "only-local": "l", "only-user": "u", | |
| 226 | - }, | |
| 227 | - wantNotes: 1, | |
| 228 | - }, | |
| 229 | - } | |
| 230 | - for _, tc := range tests { | |
| 231 | - t.Run(tc.name, func(t *testing.T) { | |
| 232 | - got, notes := MergeMCP(own, files, tc.trusted, env(nil)) | |
| 233 | - commands := map[string]string{} | |
| 234 | - for n, s := range got { | |
| 235 | - commands[n] = s.Command | |
| 236 | - } | |
| 237 | - if !reflect.DeepEqual(commands, tc.want) { | |
| 238 | - t.Errorf("got %v, want %v", commands, tc.want) | |
| 239 | - } | |
| 240 | - if len(notes) != tc.wantNotes { | |
| 241 | - t.Errorf("notes %q, want %d", notes, tc.wantNotes) | |
| 242 | - } | |
| 243 | - for _, n := range notes { | |
| 244 | - if !strings.Contains(n, "using the one in") { | |
| 245 | - t.Errorf("clash note %q should name the winner", n) | |
| 246 | - } | |
| 247 | - } | |
| 248 | - }) | |
| 249 | - } | |
| 250 | - } | |
| 251 | - | |
| 252 | - func TestExpand(t *testing.T) { | |
| 253 | - vars := env(map[string]string{"HOME": "/h", "EMPTY": "", "PORT": "5432"}) | |
| 254 | - tests := []struct { | |
| 255 | - in, want string | |
| 256 | - wantErr bool | |
| 257 | - }{ | |
| 258 | - {"plain", "plain", false}, | |
| 259 | - {"${HOME}/bin/tool", "/h/bin/tool", false}, | |
| 260 | - {"--port=${PORT}", "--port=5432", false}, | |
| 261 | - {"${MISSING:-fallback}", "fallback", false}, | |
| 262 | - {"${EMPTY:-fallback}", "fallback", false}, | |
| 263 | - {"${HOME:-unused}", "/h", false}, | |
| 264 | - {"${MISSING:-}", "", false}, | |
| 265 | - {"${A:-x}${B:-y}", "xy", false}, | |
| 266 | - {"$HOME stays", "$HOME stays", false}, | |
| 267 | - {"${MISSING}", "", true}, | |
| 268 | - } | |
| 269 | - for _, tc := range tests { | |
| 270 | - got, err := expand(tc.in, vars) | |
| 271 | - if (err != nil) != tc.wantErr || got != tc.want { | |
| 272 | - t.Errorf("expand(%q) = %q, %v; want %q, error %v", tc.in, got, err, tc.want, tc.wantErr) | |
| 273 | - } | |
| 274 | - } | |
| 275 | - } | |
| 276 | - | |
| 277 | - func TestMergeMCPExpandsEverywhere(t *testing.T) { | |
| 278 | - files := []MCPFile{{Scope: ScopeUser, Shown: "~/.claude.json", Servers: map[string]ServerDef{ | |
| 279 | - "db": {Command: "${BIN:-/usr/bin}/db", Args: []string{"--url", "${DB_URL}"}, Env: map[string]string{"TOKEN": "${TOKEN}"}}, | |
| 280 | - "broken": {Command: "x", Args: []string{"${NOPE}"}}, | |
| 281 | - }}} | |
| 282 | - got, notes := MergeMCP(nil, files, false, env(map[string]string{"DB_URL": "pg://x", "TOKEN": "t"})) | |
| 283 | - want := config.MCPServer{Command: "/usr/bin/db", Args: []string{"--url", "pg://x"}, Env: map[string]string{"TOKEN": "t"}} | |
| 284 | - if !reflect.DeepEqual(got["db"], want) { | |
| 285 | - t.Errorf("db = %+v, want %+v", got["db"], want) | |
| 286 | - } | |
| 287 | - if _, ok := got["broken"]; ok { | |
| 288 | - t.Error("a server with an unset variable and no default should not start") | |
| 289 | - } | |
| 290 | - if len(notes) != 1 || !strings.Contains(notes[0], "NOPE") { | |
| 291 | - t.Errorf("notes %q should name the missing variable", notes) | |
| 292 | - } | |
| 293 | - } | |
| @@ -1,249 +0,0 @@ | ||
| 1 | - package ccsettings | |
| 2 | - | |
| 3 | - import ( | |
| 4 | - "fmt" | |
| 5 | - "path/filepath" | |
| 6 | - "regexp" | |
| 7 | - "sort" | |
| 8 | - "strings" | |
| 9 | - | |
| 10 | - "github.com/johannSo/codey/internal/config" | |
| 11 | - ) | |
| 12 | - | |
| 13 | - // ServerDef is one MCP server as Claude Code writes it, before any ${VAR} in | |
| 14 | - // it is expanded. It is kept unexpanded because that is the form the trust | |
| 15 | - // decision is about: what the repository wrote, not what this shell happened | |
| 16 | - // to substitute into it. | |
| 17 | - type ServerDef struct { | |
| 18 | - // Type is "stdio", or empty, which means the same thing. "http", "sse" | |
| 19 | - // and "ws" are remote transports Codey's client does not speak. | |
| 20 | - Type string `json:"type,omitempty"` | |
| 21 | - Command string `json:"command,omitempty"` | |
| 22 | - Args []string `json:"args,omitempty"` | |
| 23 | - Env map[string]string `json:"env,omitempty"` | |
| 24 | - URL string `json:"url,omitempty"` | |
| 25 | - } | |
| 26 | - | |
| 27 | - // stdio reports whether Codey can start this server. A url is a remote | |
| 28 | - // server whatever the type says. | |
| 29 | - func (d ServerDef) stdio() bool { | |
| 30 | - return (d.Type == "" || d.Type == "stdio") && d.URL == "" | |
| 31 | - } | |
| 32 | - | |
| 33 | - // MCPFile is the servers one Claude Code file defines, the remote ones | |
| 34 | - // already removed. | |
| 35 | - type MCPFile struct { | |
| 36 | - Scope Scope | |
| 37 | - // Shown names the file in messages; for ~/.claude.json's per-project | |
| 38 | - // servers it says which project. | |
| 39 | - Shown string | |
| 40 | - Servers map[string]ServerDef | |
| 41 | - } | |
| 42 | - | |
| 43 | - // mcpJSON is the shape of .mcp.json, and of the parts of ~/.claude.json read | |
| 44 | - // here. ~/.claude.json carries a great deal more — login state, tips shown, | |
| 45 | - // per-project history — none of which is any of Codey's business. | |
| 46 | - type mcpJSON struct { | |
| 47 | - MCPServers map[string]ServerDef `json:"mcpServers"` | |
| 48 | - Projects map[string]struct { | |
| 49 | - MCPServers map[string]ServerDef `json:"mcpServers"` | |
| 50 | - } `json:"projects"` | |
| 51 | - } | |
| 52 | - | |
| 53 | - // LoadMCP reads the MCP servers Claude Code would offer in root, from the | |
| 54 | - // three places it keeps them, most specific first: | |
| 55 | - // | |
| 56 | - // - ~/.claude.json, under projects[root] — "local" scope, the user's own | |
| 57 | - // servers for this one project, which is what `claude mcp add` writes; | |
| 58 | - // - .mcp.json at the root — "project" scope, committed to the repository; | |
| 59 | - // - ~/.claude.json, top-level — "user" scope, for every project. | |
| 60 | - // | |
| 61 | - // That is Claude Code's own order of precedence, and the order MergeMCP | |
| 62 | - // resolves name clashes in. Servers Codey cannot start — a url, or a type | |
| 63 | - // other than stdio — are reported and left out here, so nothing downstream | |
| 64 | - // has to know they existed. | |
| 65 | - func LoadMCP(home, root string) ([]MCPFile, []error) { | |
| 66 | - var errs []error | |
| 67 | - var claude mcpJSON | |
| 68 | - claudePath := "" | |
| 69 | - if home != "" { | |
| 70 | - claudePath = filepath.Join(home, ClaudeJSON) | |
| 71 | - if _, err := readJSON(claudePath, &claude); err != nil { | |
| 72 | - errs = append(errs, fmt.Errorf("claude code mcp: %w", err)) | |
| 73 | - } | |
| 74 | - } | |
| 75 | - | |
| 76 | - var files []MCPFile | |
| 77 | - add := func(scope Scope, label string, defs map[string]ServerDef) { | |
| 78 | - if len(defs) == 0 { | |
| 79 | - return | |
| 80 | - } | |
| 81 | - kept := map[string]ServerDef{} | |
| 82 | - for _, name := range sortedNames(defs) { | |
| 83 | - d := defs[name] | |
| 84 | - if !d.stdio() { | |
| 85 | - transport := d.Type | |
| 86 | - if transport == "" || transport == "stdio" { | |
| 87 | - transport = "url" | |
| 88 | - } | |
| 89 | - errs = append(errs, fmt.Errorf("mcp: %s in %s is a remote server (%s), and Codey's client speaks only stdio — skipped", name, label, transport)) | |
| 90 | - continue | |
| 91 | - } | |
| 92 | - kept[name] = d | |
| 93 | - } | |
| 94 | - if len(kept) > 0 { | |
| 95 | - files = append(files, MCPFile{Scope: scope, Shown: label, Servers: kept}) | |
| 96 | - } | |
| 97 | - } | |
| 98 | - | |
| 99 | - if claudePath != "" && root != "" { | |
| 100 | - // Claude Code keys a project by the directory it was started in. A | |
| 101 | - // root reached through a symlink is the same project under another | |
| 102 | - // name, so both spellings are tried. | |
| 103 | - for _, key := range rootKeys(root) { | |
| 104 | - if p, ok := claude.Projects[key]; ok { | |
| 105 | - add(ScopeLocal, "~/"+ClaudeJSON+" (this project)", p.MCPServers) | |
| 106 | - break | |
| 107 | - } | |
| 108 | - } | |
| 109 | - } | |
| 110 | - if root != "" { | |
| 111 | - var project mcpJSON | |
| 112 | - path := filepath.Join(root, ProjectMCP) | |
| 113 | - if _, err := readJSON(path, &project); err != nil { | |
| 114 | - errs = append(errs, fmt.Errorf("claude code mcp: %w", err)) | |
| 115 | - } else { | |
| 116 | - add(ScopeProject, ProjectMCP, project.MCPServers) | |
| 117 | - } | |
| 118 | - } | |
| 119 | - if claudePath != "" { | |
| 120 | - add(ScopeUser, "~/"+ClaudeJSON, claude.MCPServers) | |
| 121 | - } | |
| 122 | - return files, errs | |
| 123 | - } | |
| 124 | - | |
| 125 | - func rootKeys(root string) []string { | |
| 126 | - keys := []string{filepath.Clean(root)} | |
| 127 | - if real, err := filepath.EvalSymlinks(root); err == nil && real != keys[0] { | |
| 128 | - keys = append(keys, real) | |
| 129 | - } | |
| 130 | - return keys | |
| 131 | - } | |
| 132 | - | |
| 133 | - // Project is the project-scope file in files, or nil. | |
| 134 | - func Project(files []MCPFile) *MCPFile { | |
| 135 | - for i := range files { | |
| 136 | - if files[i].Scope == ScopeProject { | |
| 137 | - return &files[i] | |
| 138 | - } | |
| 139 | - } | |
| 140 | - return nil | |
| 141 | - } | |
| 142 | - | |
| 143 | - // MergeMCP combines Codey's own servers with Claude Code's into the set to | |
| 144 | - // start, resolving each name to one definition and expanding ${VAR} in it. | |
| 145 | - // | |
| 146 | - // Codey's own config wins a clash, because it is the file written for this | |
| 147 | - // program; after it, files are taken in the order LoadMCP returned them. Each | |
| 148 | - // clash is reported once, naming the winner, because a server that silently | |
| 149 | - // turned out to be a different program is the kind of surprise nobody thinks | |
| 150 | - // to debug. | |
| 151 | - // | |
| 152 | - // trusted says whether the project's .mcp.json may be started. When it may | |
| 153 | - // not, its servers are dropped before anything else is decided — so a user's | |
| 154 | - // own server with the same name is the one that runs, rather than nothing. | |
| 155 | - // | |
| 156 | - // lookup is the environment ${VAR} is expanded from: os.LookupEnv in | |
| 157 | - // production, a map in the tests. | |
| 158 | - func MergeMCP(own map[string]config.MCPServer, files []MCPFile, trusted bool, lookup func(string) (string, bool)) (map[string]config.MCPServer, []string) { | |
| 159 | - out := map[string]config.MCPServer{} | |
| 160 | - from := map[string]string{} | |
| 161 | - for name, s := range own { | |
| 162 | - out[name] = s | |
| 163 | - from[name] = "Codey's config" | |
| 164 | - } | |
| 165 | - var notes []string | |
| 166 | - for _, f := range files { | |
| 167 | - if f.Scope == ScopeProject && !trusted { | |
| 168 | - continue | |
| 169 | - } | |
| 170 | - for _, name := range sortedNames(f.Servers) { | |
| 171 | - if winner, taken := from[name]; taken { | |
| 172 | - notes = append(notes, fmt.Sprintf("mcp: %s is defined in both %s and %s — using the one in %s", name, winner, f.Shown, winner)) | |
| 173 | - continue | |
| 174 | - } | |
| 175 | - spec, err := expandServer(f.Servers[name], lookup) | |
| 176 | - if err != nil { | |
| 177 | - notes = append(notes, fmt.Sprintf("mcp: %s in %s: %v — skipped", name, f.Shown, err)) | |
| 178 | - // Claimed all the same: a broken definition in a more specific | |
| 179 | - // file should not quietly hand the name to a less specific one. | |
| 180 | - from[name] = f.Shown | |
| 181 | - continue | |
| 182 | - } | |
| 183 | - out[name] = spec | |
| 184 | - from[name] = f.Shown | |
| 185 | - } | |
| 186 | - } | |
| 187 | - return out, notes | |
| 188 | - } | |
| 189 | - | |
| 190 | - // envRef is ${VAR} or ${VAR:-default}, the two forms Claude Code expands in a | |
| 191 | - // server definition. | |
| 192 | - var envRef = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}`) | |
| 193 | - | |
| 194 | - // expand substitutes every ${VAR} in s. A variable that is unset, or set to | |
| 195 | - // nothing, takes its default when it has one — the shell's reading of :- — | |
| 196 | - // and is an error when it has none: Claude Code refuses such a definition | |
| 197 | - // rather than starting a server with a hole in its command line, and so does | |
| 198 | - // this. | |
| 199 | - func expand(s string, lookup func(string) (string, bool)) (string, error) { | |
| 200 | - var missing []string | |
| 201 | - out := envRef.ReplaceAllStringFunc(s, func(ref string) string { | |
| 202 | - m := envRef.FindStringSubmatch(ref) | |
| 203 | - if v, ok := lookup(m[1]); ok && v != "" { | |
| 204 | - return v | |
| 205 | - } | |
| 206 | - if m[2] != "" { | |
| 207 | - return m[3] | |
| 208 | - } | |
| 209 | - missing = append(missing, m[1]) | |
| 210 | - return "" | |
| 211 | - }) | |
| 212 | - if len(missing) > 0 { | |
| 213 | - return "", fmt.Errorf("%s is not set and has no default", strings.Join(missing, ", ")) | |
| 214 | - } | |
| 215 | - return out, nil | |
| 216 | - } | |
| 217 | - | |
| 218 | - func expandServer(d ServerDef, lookup func(string) (string, bool)) (config.MCPServer, error) { | |
| 219 | - var err error | |
| 220 | - s := config.MCPServer{} | |
| 221 | - if s.Command, err = expand(d.Command, lookup); err != nil { | |
| 222 | - return s, err | |
| 223 | - } | |
| 224 | - for _, a := range d.Args { | |
| 225 | - v, err := expand(a, lookup) | |
| 226 | - if err != nil { | |
| 227 | - return s, err | |
| 228 | - } | |
| 229 | - s.Args = append(s.Args, v) | |
| 230 | - } | |
| 231 | - if len(d.Env) > 0 { | |
| 232 | - s.Env = map[string]string{} | |
| 233 | - for k, v := range d.Env { | |
| 234 | - if s.Env[k], err = expand(v, lookup); err != nil { | |
| 235 | - return s, err | |
| 236 | - } | |
| 237 | - } | |
| 238 | - } | |
| 239 | - return s, nil | |
| 240 | - } | |
| 241 | - | |
| 242 | - func sortedNames[V any](m map[string]V) []string { | |
| 243 | - names := make([]string, 0, len(m)) | |
| 244 | - for n := range m { | |
| 245 | - names = append(names, n) | |
| 246 | - } | |
| 247 | - sort.Strings(names) | |
| 248 | - return names | |
| 249 | - } | |
| @@ -15,13 +15,8 @@ import ( | ||
| 15 | 15 | ) |
| 16 | 16 | |
| 17 | 17 | // Grant is everything a project's committed Claude Code files would grant if |
| 18 | - // trusted: the servers its .mcp.json would start, and the allow rules its | |
| 19 | - // shared settings would honour. One question covers both, because both are | |
| 20 | - // the same question — do you trust what this repository says about itself — | |
| 21 | - // and two prompts at startup for one decision is one too many. | |
| 18 | + // trusted: the allow rules its shared settings would honour. | |
| 22 | 19 | type Grant struct { |
| 23 | - // Servers are the stdio servers .mcp.json defines, unexpanded. | |
| 24 | - Servers map[string]ServerDef | |
| 25 | 20 | // Allow is the allow rules from every project-scope settings file, and |
| 26 | 21 | // AllowFrom names those files. |
| 27 | 22 | Allow []string |
| @@ -29,11 +24,8 @@ type Grant struct { | ||
| 29 | 24 | } |
| 30 | 25 | |
| 31 | 26 | // NewGrant collects what the project-scope files would grant. |
| 32 | - func NewGrant(mcp []MCPFile, perms []Permissions) Grant { | |
| 27 | + func NewGrant(perms []Permissions) Grant { | |
| 33 | 28 | var g Grant |
| 34 | - if p := Project(mcp); p != nil { | |
| 35 | - g.Servers = p.Servers | |
| 36 | - } | |
| 37 | 29 | for _, p := range perms { |
| 38 | 30 | if p.Scope == ScopeProject && len(p.Allow) > 0 { |
| 39 | 31 | g.Allow = append(g.Allow, p.Allow...) |
| @@ -44,49 +36,30 @@ func NewGrant(mcp []MCPFile, perms []Permissions) Grant { | ||
| 44 | 36 | } |
| 45 | 37 | |
| 46 | 38 | // Empty reports whether there is nothing to ask about. |
| 47 | - func (g Grant) Empty() bool { return len(g.Servers) == 0 && len(g.Allow) == 0 } | |
| 39 | + func (g Grant) Empty() bool { return len(g.Allow) == 0 } | |
| 48 | 40 | |
| 49 | - // Fingerprint identifies exactly these definitions. A trust decision is | |
| 50 | - // recorded against it, so a .mcp.json that changes — a new server, a | |
| 51 | - // different command for an old one, one more allow rule — is a new question | |
| 52 | - // rather than one answered last month about something else. | |
| 53 | - // | |
| 54 | - // It covers the definitions as written, before ${VAR} is expanded: what the | |
| 55 | - // repository says is what is being trusted, and the user's environment is the | |
| 56 | - // user's own. | |
| 41 | + // Fingerprint identifies exactly these rules. A trust decision is recorded | |
| 42 | + // against it, so a settings file that changes — one more allow rule — is a | |
| 43 | + // new question rather than one answered last month about something else. | |
| 57 | 44 | func (g Grant) Fingerprint() string { |
| 58 | 45 | allow := slices.Clone(g.Allow) |
| 59 | 46 | slices.Sort(allow) |
| 60 | - // encoding/json writes map keys sorted, so equal definitions always | |
| 61 | - // encode to equal bytes. | |
| 62 | 47 | body, _ := json.Marshal(struct { |
| 63 | - Servers map[string]ServerDef `json:"servers"` | |
| 64 | - Allow []string `json:"allow"` | |
| 65 | - }{g.Servers, allow}) | |
| 48 | + Allow []string `json:"allow"` | |
| 49 | + }{allow}) | |
| 66 | 50 | sum := sha256.Sum256(body) |
| 67 | 51 | return "sha256:" + hex.EncodeToString(sum[:]) |
| 68 | 52 | } |
| 69 | 53 | |
| 70 | - // Describe is the grant as lines for a trust prompt: each server's command | |
| 71 | - // line in full, since that is the thing being agreed to, and each rule. | |
| 54 | + // Describe is the grant as lines for a trust prompt: each rule, since that is | |
| 55 | + // the thing being agreed to. | |
| 72 | 56 | func (g Grant) Describe() []string { |
| 73 | - var lines []string | |
| 74 | - if len(g.Servers) > 0 { | |
| 75 | - lines = append(lines, "start these MCP servers, from "+ProjectMCP+":") | |
| 76 | - for _, name := range sortedNames(g.Servers) { | |
| 77 | - d := g.Servers[name] | |
| 78 | - line := " " + name + ": " + strings.Join(append([]string{d.Command}, d.Args...), " ") | |
| 79 | - if len(d.Env) > 0 { | |
| 80 | - line += " (env: " + strings.Join(sortedNames(d.Env), ", ") + ")" | |
| 81 | - } | |
| 82 | - lines = append(lines, line) | |
| 83 | - } | |
| 57 | + if len(g.Allow) == 0 { | |
| 58 | + return nil | |
| 84 | 59 | } |
| 85 | - if len(g.Allow) > 0 { | |
| 86 | - lines = append(lines, "run these without asking, from "+strings.Join(g.AllowFrom, " and ")+":") | |
| 87 | - for _, r := range g.Allow { | |
| 88 | - lines = append(lines, " "+r) | |
| 89 | - } | |
| 60 | + lines := []string{"run these without asking, from " + strings.Join(g.AllowFrom, " and ") + ":"} | |
| 61 | + for _, r := range g.Allow { | |
| 62 | + lines = append(lines, " "+r) | |
| 90 | 63 | } |
| 91 | 64 | return lines |
| 92 | 65 | } |
| @@ -101,8 +74,8 @@ const TrustFilename = "trust.json" | ||
| 101 | 74 | // directory with the rest of the per-repository state. That is the whole |
| 102 | 75 | // point of it: anything inside the working tree can arrive with a clone, and a |
| 103 | 76 | // repository that shipped its own .codey/trust.json would be approving its own |
| 104 | - // servers. The fingerprint is no protection there, because whoever wrote the | |
| 105 | - // .mcp.json can compute it. Only a file the repository cannot reach can say | |
| 77 | + // rules. The fingerprint is no protection there, because whoever wrote the | |
| 78 | + // settings file can compute it. Only a file the repository cannot reach can say | |
| 106 | 79 | // what the user decided. |
| 107 | 80 | type TrustStore struct { |
| 108 | 81 | Path string |
| @@ -7,30 +7,16 @@ import ( | ||
| 7 | 7 | ) |
| 8 | 8 | |
| 9 | 9 | func TestFingerprint(t *testing.T) { |
| 10 | - base := Grant{ | |
| 11 | - Servers: map[string]ServerDef{"a": {Command: "x", Env: map[string]string{"K": "1", "J": "2"}}, "b": {Command: "y"}}, | |
| 12 | - Allow: []string{"Bash(make:*)", "Edit"}, | |
| 13 | - } | |
| 14 | - same := Grant{ | |
| 15 | - Servers: map[string]ServerDef{"b": {Command: "y"}, "a": {Command: "x", Env: map[string]string{"J": "2", "K": "1"}}}, | |
| 16 | - Allow: []string{"Edit", "Bash(make:*)"}, | |
| 17 | - } | |
| 10 | + base := Grant{Allow: []string{"Bash(make:*)", "Edit"}} | |
| 11 | + same := Grant{Allow: []string{"Edit", "Bash(make:*)"}} | |
| 18 | 12 | if base.Fingerprint() != same.Fingerprint() { |
| 19 | - t.Error("the same definitions in another order should be the same fingerprint") | |
| 20 | - } | |
| 21 | - changed := []Grant{ | |
| 22 | - {Servers: map[string]ServerDef{"a": {Command: "x", Env: map[string]string{"K": "1", "J": "2"}}, "b": {Command: "y", Args: []string{"--evil"}}}, Allow: base.Allow}, | |
| 23 | - {Servers: map[string]ServerDef{"a": {Command: "x", Env: map[string]string{"K": "1", "J": "2"}}}, Allow: base.Allow}, | |
| 24 | - {Servers: base.Servers, Allow: []string{"Bash(make:*)", "Edit", "Bash"}}, | |
| 25 | - {Servers: base.Servers, Allow: []string{"Bash(make:*)", "Edit"}, AllowFrom: []string{"elsewhere"}}, | |
| 13 | + t.Error("the same rules in another order should be the same fingerprint") | |
| 26 | 14 | } |
| 27 | - for i, g := range changed[:3] { | |
| 28 | - if g.Fingerprint() == base.Fingerprint() { | |
| 29 | - t.Errorf("change %d did not change the fingerprint", i) | |
| 30 | - } | |
| 15 | + if (Grant{Allow: []string{"Bash(make:*)", "Edit", "Bash"}}).Fingerprint() == base.Fingerprint() { | |
| 16 | + t.Error("one more rule did not change the fingerprint") | |
| 31 | 17 | } |
| 32 | 18 | // Which file a rule came from is not part of what is trusted. |
| 33 | - if changed[3].Fingerprint() != base.Fingerprint() { | |
| 19 | + if (Grant{Allow: base.Allow, AllowFrom: []string{"elsewhere"}}).Fingerprint() != base.Fingerprint() { | |
| 34 | 20 | t.Error("AllowFrom should not affect the fingerprint") |
| 35 | 21 | } |
| 36 | 22 | if !(Grant{}).Empty() || base.Empty() { |
| @@ -39,24 +25,16 @@ func TestFingerprint(t *testing.T) { | ||
| 39 | 25 | } |
| 40 | 26 | |
| 41 | 27 | func TestNewGrantTakesOnlyProjectScope(t *testing.T) { |
| 42 | - mcp := []MCPFile{ | |
| 43 | - {Scope: ScopeLocal, Servers: map[string]ServerDef{"l": {Command: "l"}}}, | |
| 44 | - {Scope: ScopeProject, Servers: map[string]ServerDef{"p": {Command: "p"}}}, | |
| 45 | - {Scope: ScopeUser, Servers: map[string]ServerDef{"u": {Command: "u"}}}, | |
| 46 | - } | |
| 47 | 28 | perms := []Permissions{ |
| 48 | 29 | {Scope: ScopeUser, Allow: []string{"Bash"}}, |
| 49 | 30 | {Scope: ScopeProject, Shown: ".claude/settings.json", Allow: []string{"Edit"}, Deny: []string{"Read(.env)"}}, |
| 50 | 31 | {Scope: ScopeLocal, Allow: []string{"Write"}}, |
| 51 | 32 | } |
| 52 | - g := NewGrant(mcp, perms) | |
| 53 | - if len(g.Servers) != 1 || g.Servers["p"].Command != "p" { | |
| 54 | - t.Errorf("servers %v", g.Servers) | |
| 55 | - } | |
| 33 | + g := NewGrant(perms) | |
| 56 | 34 | if len(g.Allow) != 1 || g.Allow[0] != "Edit" || g.AllowFrom[0] != ".claude/settings.json" { |
| 57 | 35 | t.Errorf("allow %v from %v", g.Allow, g.AllowFrom) |
| 58 | 36 | } |
| 59 | - if len(g.Describe()) != 4 { | |
| 37 | + if len(g.Describe()) != 2 { | |
| 60 | 38 | t.Errorf("describe: %q", g.Describe()) |
| 61 | 39 | } |
| 62 | 40 | } |
| @@ -0,0 +1,132 @@ | ||
| 1 | + package statedir | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "errors" | |
| 5 | + "fmt" | |
| 6 | + "io" | |
| 7 | + "os" | |
| 8 | + "path/filepath" | |
| 9 | + "strings" | |
| 10 | + ) | |
| 11 | + | |
| 12 | + // Conversations are kept out of the repository, in one directory per working | |
| 13 | + // root under ~/.config/chats (or wherever XDG_CONFIG_HOME puts it). A chat is | |
| 14 | + // yours rather than the project's: it should not be scattered across every | |
| 15 | + // checkout you have ever opened, should not vanish with a `git clean -dfx`, | |
| 16 | + // and should not sit inside a tree that might be copied, archived or shared. | |
| 17 | + // The event log and the agent's scratch space stay in Dir, because those are | |
| 18 | + // about the repository. | |
| 19 | + const ( | |
| 20 | + ChatsDir = "chats" | |
| 21 | + // SessionsSubdir and HistoryFile are what the chats used to be called | |
| 22 | + // inside Dir, before they moved out. They are named here only so the | |
| 23 | + // move can find them. | |
| 24 | + SessionsSubdir = "sessions" | |
| 25 | + HistoryFile = "history" | |
| 26 | + ) | |
| 27 | + | |
| 28 | + // Chats is the directory a working root's saved sessions and prompt history | |
| 29 | + // live in: ~/.config/chats/<root>, with the root's path flattened into one | |
| 30 | + // directory name the way Claude Code names its projects, so the directory | |
| 31 | + // can be found by eye. Two roots that flatten alike would share it, which is | |
| 32 | + // why every session also records its root and is checked against it when it | |
| 33 | + // is listed or resumed. | |
| 34 | + func Chats(root string) (string, error) { | |
| 35 | + dir, err := os.UserConfigDir() | |
| 36 | + if err != nil { | |
| 37 | + return "", fmt.Errorf("chats: no config directory: %w", err) | |
| 38 | + } | |
| 39 | + return filepath.Join(dir, ChatsDir, chatKey(root)), nil | |
| 40 | + } | |
| 41 | + | |
| 42 | + func chatKey(root string) string { | |
| 43 | + return strings.Map(func(r rune) rune { | |
| 44 | + if r == '/' || r == '\\' || r == ':' { | |
| 45 | + return '-' | |
| 46 | + } | |
| 47 | + return r | |
| 48 | + }, filepath.Clean(root)) | |
| 49 | + } | |
| 50 | + | |
| 51 | + // MigrateChats moves the sessions and the prompt history an older build left | |
| 52 | + // in <root>/.codey into dir, reporting how many files it moved. It is called | |
| 53 | + // at startup, after Migrate and before anything opens a session. | |
| 54 | + // | |
| 55 | + // A file already present at the destination wins and the old copy is left | |
| 56 | + // where it was: both existing means both places have been written to, and | |
| 57 | + // overwriting either could only lose a conversation. Nothing to move is the | |
| 58 | + // common case, and must not create dir. | |
| 59 | + func MigrateChats(root, dir string) (int, error) { | |
| 60 | + moved := 0 | |
| 61 | + oldSessions := filepath.Join(root, Dir, SessionsSubdir) | |
| 62 | + entries, err := os.ReadDir(oldSessions) | |
| 63 | + if err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 64 | + return 0, fmt.Errorf("chats: %w", err) | |
| 65 | + } | |
| 66 | + var errs []error | |
| 67 | + for _, e := range entries { | |
| 68 | + if e.IsDir() || filepath.Ext(e.Name()) != ".json" { | |
| 69 | + continue | |
| 70 | + } | |
| 71 | + ok, err := moveFile(filepath.Join(oldSessions, e.Name()), filepath.Join(dir, e.Name())) | |
| 72 | + if err != nil { | |
| 73 | + errs = append(errs, err) | |
| 74 | + } | |
| 75 | + if ok { | |
| 76 | + moved++ | |
| 77 | + } | |
| 78 | + } | |
| 79 | + // Removed only once empty, so anything left behind stays visible. | |
| 80 | + if len(entries) > 0 { | |
| 81 | + os.Remove(oldSessions) | |
| 82 | + } | |
| 83 | + | |
| 84 | + oldHistory := filepath.Join(root, Dir, HistoryFile) | |
| 85 | + if info, err := os.Lstat(oldHistory); err == nil && info.Mode().IsRegular() { | |
| 86 | + ok, err := moveFile(oldHistory, filepath.Join(dir, HistoryFile)) | |
| 87 | + if err != nil { | |
| 88 | + errs = append(errs, err) | |
| 89 | + } | |
| 90 | + if ok { | |
| 91 | + moved++ | |
| 92 | + } | |
| 93 | + } | |
| 94 | + if err := errors.Join(errs...); err != nil { | |
| 95 | + return moved, fmt.Errorf("chats: %w", err) | |
| 96 | + } | |
| 97 | + return moved, nil | |
| 98 | + } | |
| 99 | + | |
| 100 | + // moveFile renames src to dst unless dst exists, falling back to copy and | |
| 101 | + // remove when the two are on different filesystems — the config directory | |
| 102 | + // and a checkout often are. | |
| 103 | + func moveFile(src, dst string) (bool, error) { | |
| 104 | + if _, err := os.Lstat(dst); err == nil { | |
| 105 | + return false, nil | |
| 106 | + } | |
| 107 | + if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { | |
| 108 | + return false, err | |
| 109 | + } | |
| 110 | + if err := os.Rename(src, dst); err == nil { | |
| 111 | + return true, nil | |
| 112 | + } | |
| 113 | + in, err := os.Open(src) | |
| 114 | + if err != nil { | |
| 115 | + return false, err | |
| 116 | + } | |
| 117 | + defer in.Close() | |
| 118 | + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) | |
| 119 | + if err != nil { | |
| 120 | + return false, err | |
| 121 | + } | |
| 122 | + if _, err := io.Copy(out, in); err != nil { | |
| 123 | + out.Close() | |
| 124 | + os.Remove(dst) | |
| 125 | + return false, err | |
| 126 | + } | |
| 127 | + if err := out.Close(); err != nil { | |
| 128 | + os.Remove(dst) | |
| 129 | + return false, err | |
| 130 | + } | |
| 131 | + return true, os.Remove(src) | |
| 132 | + } | |
| @@ -0,0 +1,93 @@ | ||
| 1 | + package statedir | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "os" | |
| 5 | + "path/filepath" | |
| 6 | + "testing" | |
| 7 | + ) | |
| 8 | + | |
| 9 | + // Chats are filed under the config directory, one directory per root, and | |
| 10 | + // never inside the root itself. | |
| 11 | + func TestChatsLivesUnderTheConfigDirectory(t *testing.T) { | |
| 12 | + cfg := t.TempDir() | |
| 13 | + t.Setenv("XDG_CONFIG_HOME", cfg) | |
| 14 | + | |
| 15 | + got, err := Chats("/home/someone/src/app") | |
| 16 | + if err != nil { | |
| 17 | + t.Fatal(err) | |
| 18 | + } | |
| 19 | + if want := filepath.Join(cfg, ChatsDir, "-home-someone-src-app"); got != want { | |
| 20 | + t.Errorf("Chats = %q, want %q", got, want) | |
| 21 | + } | |
| 22 | + other, _ := Chats("/home/someone/src/other") | |
| 23 | + if other == got { | |
| 24 | + t.Error("two roots share one chats directory") | |
| 25 | + } | |
| 26 | + } | |
| 27 | + | |
| 28 | + // What an older build left in .codey/ is carried over, so -resume still finds | |
| 29 | + // yesterday's conversation and ↑ still finds yesterday's command. | |
| 30 | + func TestMigrateChatsMovesSessionsAndHistory(t *testing.T) { | |
| 31 | + root := t.TempDir() | |
| 32 | + dir := filepath.Join(t.TempDir(), "chats") | |
| 33 | + mkdirWith(t, root, filepath.Join(Dir, SessionsSubdir), "s1.json", `{"id":"s1"}`) | |
| 34 | + mkdirWith(t, root, filepath.Join(Dir, SessionsSubdir), "s2.json", `{"id":"s2"}`) | |
| 35 | + mkdirWith(t, root, Dir, HistoryFile, "\"go test\"\n") | |
| 36 | + mkdirWith(t, root, Dir, "events.jsonl", "{}\n") | |
| 37 | + | |
| 38 | + moved, err := MigrateChats(root, dir) | |
| 39 | + if err != nil { | |
| 40 | + t.Fatalf("MigrateChats: %v", err) | |
| 41 | + } | |
| 42 | + if moved != 3 { | |
| 43 | + t.Errorf("moved = %d, want 3", moved) | |
| 44 | + } | |
| 45 | + if got := read(t, filepath.Join(dir, "s1.json")); got != `{"id":"s1"}` { | |
| 46 | + t.Errorf("s1.json = %q", got) | |
| 47 | + } | |
| 48 | + if got := read(t, filepath.Join(dir, HistoryFile)); got != "\"go test\"\n" { | |
| 49 | + t.Errorf("history = %q", got) | |
| 50 | + } | |
| 51 | + if _, err := os.Lstat(filepath.Join(root, Dir, SessionsSubdir)); err == nil { | |
| 52 | + t.Error("the emptied sessions directory was left behind") | |
| 53 | + } | |
| 54 | + // The event log is about the repository and stays with it. | |
| 55 | + if _, err := os.Lstat(filepath.Join(root, Dir, "events.jsonl")); err != nil { | |
| 56 | + t.Errorf("events.jsonl moved or lost: %v", err) | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + // A session at both ends has been written in both places, and neither copy may | |
| 61 | + // be overwritten. | |
| 62 | + func TestMigrateChatsLeavesAClashWhereItIs(t *testing.T) { | |
| 63 | + root := t.TempDir() | |
| 64 | + dir := filepath.Join(t.TempDir(), "chats") | |
| 65 | + mkdirWith(t, root, filepath.Join(Dir, SessionsSubdir), "s1.json", "old") | |
| 66 | + mkdirWith(t, dir, ".", "s1.json", "new") | |
| 67 | + | |
| 68 | + moved, err := MigrateChats(root, dir) | |
| 69 | + if err != nil { | |
| 70 | + t.Fatalf("MigrateChats: %v", err) | |
| 71 | + } | |
| 72 | + if moved != 0 { | |
| 73 | + t.Errorf("moved = %d, want 0", moved) | |
| 74 | + } | |
| 75 | + if got := read(t, filepath.Join(dir, "s1.json")); got != "new" { | |
| 76 | + t.Errorf("destination = %q, want it untouched", got) | |
| 77 | + } | |
| 78 | + if got := read(t, filepath.Join(root, Dir, SessionsSubdir, "s1.json")); got != "old" { | |
| 79 | + t.Errorf("source = %q, want it left in place", got) | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + // The common case: nothing to move, and nothing created. | |
| 84 | + func TestMigrateChatsWithNothingToMove(t *testing.T) { | |
| 85 | + dir := filepath.Join(t.TempDir(), "chats") | |
| 86 | + moved, err := MigrateChats(t.TempDir(), dir) | |
| 87 | + if err != nil || moved != 0 { | |
| 88 | + t.Fatalf("MigrateChats = %d, %v; want 0, nil", moved, err) | |
| 89 | + } | |
| 90 | + if _, err := os.Lstat(dir); err == nil { | |
| 91 | + t.Error("created a chats directory with nothing to put in it") | |
| 92 | + } | |
| 93 | + } | |
| @@ -13,7 +13,8 @@ import ( | ||
| 13 | 13 | "path/filepath" |
| 14 | 14 | ) |
| 15 | 15 | |
| 16 | - // Dir is where sessions and the event log live, relative to the working root. | |
| 16 | + // Dir is where the event log and the agent's scratch space live, relative to | |
| 17 | + // the working root. Sessions and prompt history used to as well; see Chats. | |
| 17 | 18 | // LegacyDir is what it was called before the rename. |
| 18 | 19 | const ( |
| 19 | 20 | Dir = ".codey" |
| @@ -49,8 +50,8 @@ func Migrate(root string) (bool, error) { | ||
| 49 | 50 | } |
| 50 | 51 | |
| 51 | 52 | // Ensure creates the state directory and makes it ignore itself. The |
| 52 | - // directory lives inside the user's repository — session files, an event log, | |
| 53 | - // scratch space for the agent — and none of that belongs in their commits. | |
| 53 | + // directory lives inside the user's repository — an event log, scratch space | |
| 54 | + // for the agent — and none of that belongs in their commits. | |
| 54 | 55 | // A .gitignore inside the directory that ignores everything does it without |
| 55 | 56 | // touching the project's own .gitignore, and git honours it from any depth. |
| 56 | 57 | func Ensure(root string) error { |
| @@ -19,15 +19,16 @@ import ( | ||
| 19 | 19 | // It is kept per checkout rather than per session or per machine. Per session |
| 20 | 20 | // would forget the thing you are most likely to want back, which is what you |
| 21 | 21 | // typed just before the last restart; one file for the whole machine would |
| 22 | - // offer you the test command of a project you are not in. | |
| 22 | + // offer you the test command of a project you are not in. It lives beside the | |
| 23 | + // checkout's saved sessions, under ~/.config/chats, not in the checkout itself. | |
| 23 | 24 | |
| 24 | 25 | // historyLimit is how many entries are kept. A few hundred is more than the |
| 25 | 26 | // arrow key can practically reach and still a file small enough to read whole |
| 26 | 27 | // at startup without anyone noticing. |
| 27 | 28 | const historyLimit = 500 |
| 28 | 29 | |
| 29 | - // historyFilename is what the file is called inside the state directory. | |
| 30 | - const historyFilename = "history" | |
| 30 | + // historyFilename is what the file is called inside the chats directory. | |
| 31 | + const historyFilename = statedir.HistoryFile | |
| 31 | 32 | |
| 32 | 33 | // compactAtMultiple is how far the file may grow past the limit before it is |
| 33 | 34 | // rewritten. Entries are appended rather than rewritten so two tabs typing at |
| @@ -55,31 +56,29 @@ type history struct { | ||
| 55 | 56 | walking bool |
| 56 | 57 | } |
| 57 | 58 | |
| 58 | - // newHistory loads the history for a checkout, or returns an empty one. | |
| 59 | + // newHistory loads the history kept in a checkout's chats directory, or | |
| 60 | + // returns an empty one. | |
| 59 | 61 | // |
| 60 | 62 | // Every failure is silent. A history that cannot be read is a convenience that |
| 61 | 63 | // is missing, and refusing to open the prompt box because of a stray file in |
| 62 | - // the state directory would be a much larger failure than the one being | |
| 64 | + // the chats directory would be a much larger failure than the one being | |
| 63 | 65 | // reported. |
| 64 | - func newHistory(stateRoot string) *history { | |
| 66 | + func newHistory(chatsDir string) *history { | |
| 65 | 67 | h := &history{} |
| 66 | - // A root that is not a directory that exists gets a history that lives | |
| 67 | - // only as long as the process. That is the honest reading of "I do not | |
| 68 | - // know where to keep this", and it keeps a mistyped -C from creating a | |
| 69 | - // state directory somewhere nobody meant to have one. | |
| 70 | - if fi, err := os.Stat(stateRoot); stateRoot == "" || err != nil || !fi.IsDir() { | |
| 68 | + // No directory gets a history that lives only as long as the process. | |
| 69 | + // That is the honest reading of "I do not know where to keep this". | |
| 70 | + if chatsDir == "" { | |
| 71 | 71 | return h |
| 72 | 72 | } |
| 73 | - h.path = historyPath(stateRoot) | |
| 73 | + h.path = historyPath(chatsDir) | |
| 74 | 74 | h.load() |
| 75 | 75 | h.idx = len(h.entries) |
| 76 | 76 | return h |
| 77 | 77 | } |
| 78 | 78 | |
| 79 | - // historyPath is where a checkout's history lives. It sits beside the sessions | |
| 80 | - // and the event log, in the directory that is already ignored by git. | |
| 81 | - func historyPath(stateRoot string) string { | |
| 82 | - return filepath.Join(stateRoot, statedir.Dir, historyFilename) | |
| 79 | + // historyPath is where a checkout's history lives: beside its sessions. | |
| 80 | + func historyPath(chatsDir string) string { | |
| 81 | + return filepath.Join(chatsDir, historyFilename) | |
| 83 | 82 | } |
| 84 | 83 | |
| 85 | 84 | // load reads the file, dropping anything it cannot parse, and compacts it if |
| @@ -140,7 +139,7 @@ func (h *history) record(line string) { | ||
| 140 | 139 | h.append(line) |
| 141 | 140 | } |
| 142 | 141 | |
| 143 | - // append adds one line to the file, creating the state directory if this is | |
| 142 | + // append adds one line to the file, creating the chats directory if this is | |
| 144 | 143 | // the first thing ever written there. Quoted, so that a prompt with newlines |
| 145 | 144 | // in it stays one entry. |
| 146 | 145 | func (h *history) append(line string) { |
| @@ -18,17 +18,20 @@ import ( | ||
| 18 | 18 | |
| 19 | 19 | // newHistoryModel builds a model whose history has a real directory to live |
| 20 | 20 | // in, so a test can look at what was written as well as at what was recalled. |
| 21 | + // What it returns is that directory: the chats directory, not the checkout. | |
| 21 | 22 | func newHistoryModel(t *testing.T) (*model, string) { |
| 22 | 23 | t.Helper() |
| 23 | 24 | root := t.TempDir() |
| 25 | + dir := filepath.Join(t.TempDir(), "chats") | |
| 24 | 26 | m := newModel(context.Background(), Options{ |
| 25 | - Client: llm.New(llm.WithAPIKey("x")), | |
| 26 | - Root: root, | |
| 27 | - HomeRoot: root, | |
| 28 | - Policy: approval.New(nil), | |
| 27 | + Client: llm.New(llm.WithAPIKey("x")), | |
| 28 | + Root: root, | |
| 29 | + HomeRoot: root, | |
| 30 | + SessionDir: dir, | |
| 31 | + Policy: approval.New(nil), | |
| 29 | 32 | }) |
| 30 | 33 | m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) |
| 31 | - return m, root | |
| 34 | + return m, dir | |
| 32 | 35 | } |
| 33 | 36 | |
| 34 | 37 | // The whole point of the feature: what was sent comes back. |
| @@ -236,9 +239,9 @@ func TestHistoryIsCappedAndCompacted(t *testing.T) { | ||
| 236 | 239 | // Nothing here may be a reason the prompt box does not work. A history with |
| 237 | 240 | // nowhere to live still walks; it just forgets at the end of the session. |
| 238 | 241 | func TestAHistoryWithNoDirectoryStillWorks(t *testing.T) { |
| 239 | - h := newHistory(filepath.Join(t.TempDir(), "does-not-exist")) | |
| 242 | + h := newHistory("") | |
| 240 | 243 | if h.path != "" { |
| 241 | - t.Errorf("path = %q, want no file for a root that is not there", h.path) | |
| 244 | + t.Errorf("path = %q, want no file without a chats directory", h.path) | |
| 242 | 245 | } |
| 243 | 246 | h.record("kept in memory") |
| 244 | 247 | if text, ok := h.prev("", ""); !ok || text != "kept in memory" { |
| @@ -263,12 +266,11 @@ func TestGarbageLinesAreSkippedRatherThanFatal(t *testing.T) { | ||
| 263 | 266 | } |
| 264 | 267 | } |
| 265 | 268 | |
| 266 | - // The file belongs where the sessions and the event log are, so it is covered | |
| 267 | - // by the ignore rule that is already there. | |
| 268 | - func TestTheFileLivesInTheStateDirectory(t *testing.T) { | |
| 269 | - root := t.TempDir() | |
| 270 | - want := filepath.Join(root, statedir.Dir, historyFilename) | |
| 271 | - if got := historyPath(root); got != want { | |
| 269 | + // The file belongs beside the sessions, out of the checkout. | |
| 270 | + func TestTheFileLivesBesideTheSessions(t *testing.T) { | |
| 271 | + dir := t.TempDir() | |
| 272 | + want := filepath.Join(dir, statedir.HistoryFile) | |
| 273 | + if got := historyPath(dir); got != want { | |
| 272 | 274 | t.Errorf("historyPath = %q, want %q", got, want) |
| 273 | 275 | } |
| 274 | 276 | } |
| @@ -474,11 +474,12 @@ func newModel(ctx context.Context, opts Options) *model { | ||
| 474 | 474 | parent: ctx, |
| 475 | 475 | mouseOn: true, |
| 476 | 476 | copyFn: copyToClipboard, |
| 477 | - // The history belongs to the checkout Codey was started in, not to | |
| 478 | - // the directory the work has since moved to: a worktree is a place | |
| 479 | - // this conversation is happening, and the commands you type in it are | |
| 480 | - // the same commands you will want back in the next one. | |
| 481 | - hist: newHistory(opts.HomeRoot), | |
| 477 | + // The history sits beside the sessions, which belong to the checkout | |
| 478 | + // Codey was started in rather than the directory the work has since | |
| 479 | + // moved to: a worktree is a place this conversation is happening, and | |
| 480 | + // the commands you type in it are the same commands you will want | |
| 481 | + // back in the next one. | |
| 482 | + hist: newHistory(opts.SessionDir), | |
| 482 | 483 | } |
| 483 | 484 | m.tr.setVerbose(opts.Verbose) |
| 484 | 485 | m.tr.addRaw(splash(opts, 80)) |
| @@ -15,7 +15,7 @@ import ( | ||
| 15 | 15 | ) |
| 16 | 16 | |
| 17 | 17 | // The session chooser is deliberately scoped to the working root. Sessions are |
| 18 | - // already filed under <root>/.codey/sessions, and every row is checked against | |
| 18 | + // already filed under ~/.config/chats/<root>, and every row is checked against | |
| 19 | 19 | // the root a second time on the way out of the agent package — a conversation |
| 20 | 20 | // about one checkout has no business being reopened in another, where none of |
| 21 | 21 | // the paths it discusses exist. |
| @@ -48,7 +48,7 @@ func assistant(text string) llm.Message { | ||
| 48 | 48 | func resumeModel(t *testing.T) *model { |
| 49 | 49 | t.Helper() |
| 50 | 50 | root := t.TempDir() |
| 51 | - dir := filepath.Join(root, agent.SessionDir) | |
| 51 | + dir := filepath.Join(t.TempDir(), "chats") | |
| 52 | 52 | |
| 53 | 53 | saveSession(t, dir, agent.Session{ |
| 54 | 54 | ID: "20260101T090000Z", Root: root, Model: "claude-opus-5", Turns: 3, |
| @@ -33,7 +33,7 @@ func pickerModel(t *testing.T, trees ...worktreeInfo) (*model, *string) { | ||
| 33 | 33 | Policy: approval.New(nil), |
| 34 | 34 | Root: root, |
| 35 | 35 | HomeRoot: root, |
| 36 | - SessionDir: filepath.Join(root, agent.SessionDir), | |
| 36 | + SessionDir: filepath.Join(t.TempDir(), "chats"), | |
| 37 | 37 | SessionID: "20260101T090000Z", |
| 38 | 38 | NewTools: func(r string) *tools.Registry { |
| 39 | 39 | *built = r |
| @@ -424,7 +424,7 @@ func TestWorktreeResumeFindsATreeThatWasMade(t *testing.T) { | ||
| 424 | 424 | |
| 425 | 425 | m, built := pickerModel(t) |
| 426 | 426 | m.opts.Root, m.opts.HomeRoot = repo, repo |
| 427 | - m.opts.SessionDir = filepath.Join(repo, agent.SessionDir) | |
| 427 | + m.opts.SessionDir = filepath.Join(t.TempDir(), "chats") | |
| 428 | 428 | |
| 429 | 429 | path, err := runWorktreeScript(context.Background(), repo, "dark-mode") |
| 430 | 430 | if err != nil { |