Vectrum
Sign In

Chats move to ~/.config/chats, and Claude Code's .mcp.json is no longer read

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>
Jjonicommitted 1 parent dc95726commitbfa9218

Showing 27 changed files with 564 additions and 894 deletions.

+97−97README.mdView File
@@ -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).
+10−57cmd/codey/ccconfig.goView File
@@ -1,22 +1,19 @@
11 package main
22
33 // 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
66 // internal/ccsettings; this file is the wiring — when to ask about trust, how,
77 // and what to say when the answer is no.
88
99 import (
1010 "fmt"
1111 "io"
12- "os"
13- "sort"
1412 "strings"
1513 "sync"
1614
1715 "github.com/johannSo/codey/internal/approval"
1816 "github.com/johannSo/codey/internal/ccsettings"
19- "github.com/johannSo/codey/internal/config"
2017 )
2118
2219 // trustFlag is spelled once, because three messages name it.
@@ -25,15 +22,9 @@ const trustFlag = "-trust-project"
2522 // ccOptions is what loading Claude Code's configuration needs from main.
2623 type ccOptions struct {
2724 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.
3227 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
3728 // Ask puts the trust question to a person and reports the answer, and
3829 // whether there was one. Nil where nobody can answer: -p, agent stdio,
3930 // a piped stdin.
@@ -41,14 +32,10 @@ type ccOptions struct {
4132 Store ccsettings.TrustStore
4233 // Notice reports something to the user, wherever this run's notices go.
4334 Notice func(format string, args ...any)
44- // Lookup is the environment ${VAR} expands from.
45- Lookup func(string) (string, bool)
4635 }
4736
4837 // ccSetup is what came of it.
4938 type ccSetup struct {
50- // MCP is every server to start, Codey's own included.
51- MCP map[string]config.MCPServer
5239 // Rules is nil when no settings file had any rules, so the policy is left
5340 // exactly as it would be on a machine without Claude Code.
5441 Rules approval.RuleSet
@@ -58,34 +45,17 @@ type ccSetup struct {
5845
5946 // loadClaudeCode reads Claude Code's files for the startup root, settles
6047 // 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.
6350 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- }
7151 perms, errs := ccsettings.LoadPermissions(o.Home, o.Root)
7252 for _, err := range errs {
7353 o.Notice("%v — its deny rules are not in force", err)
7454 }
7555
76- grant := ccsettings.NewGrant(mcpFiles, perms)
77- trusted := settleTrust(o, grant)
56+ trusted := settleTrust(o, ccsettings.NewGrant(perms))
7857
7958 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- }
8959 rules, notes := ccsettings.NewRuleset(o.Home, o.Root, perms, trusted)
9060 for _, n := range notes {
9161 o.Notice("%s", n)
@@ -123,11 +93,7 @@ func settleTrust(o ccOptions, grant ccsettings.Grant) bool {
12393 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)
12494 return false
12595 }
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())
13197 if !answered {
13298 // Interrupted, or stdin closed: no for now, and the question stays
13399 // open for the next start.
@@ -144,19 +110,7 @@ func settleTrust(o ccOptions, grant ccsettings.Grant) bool {
144110
145111 // untrustedWhat names what is being left out, for a notice.
146112 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 "))
160114 }
161115
162116 // 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
224178 }
225179 ro := o
226180 ro.Root, ro.Ask = root, nil
227- mcpFiles, _ := ccsettings.LoadMCP(o.Home, root)
228181 perms, errs := ccsettings.LoadPermissions(o.Home, root)
229182 for _, err := range errs {
230183 o.Notice("%v — its deny rules are not in force", err)
231184 }
232- trusted := settleTrust(ro, ccsettings.NewGrant(mcpFiles, perms))
185+ trusted := settleTrust(ro, ccsettings.NewGrant(perms))
233186 rules, notes := ccsettings.NewRuleset(o.Home, root, perms, trusted)
234187 for _, n := range notes {
235188 o.Notice("%s", n)
+27−47cmd/codey/ccconfig_test.goView File
@@ -9,12 +9,11 @@ import (
99
1010 "github.com/johannSo/codey/internal/approval"
1111 "github.com/johannSo/codey/internal/ccsettings"
12- "github.com/johannSo/codey/internal/config"
1312 )
1413
1514 // 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.
1817 type ccFixture struct {
1918 home, root string
2019 store ccsettings.TrustStore
@@ -50,9 +49,7 @@ func (f *ccFixture) opts(answer *bool) ccOptions {
5049 o := ccOptions{
5150 Home: f.home,
5251 Root: f.root,
53- Own: map[string]config.MCPServer{"codey-own": {Command: "own"}},
5452 Store: f.store,
55- Lookup: func(string) (string, bool) { return "", false },
5653 Notice: func(format string, args ...any) { f.notices = append(f.notices, fmt.Sprintf(format, args...)) },
5754 }
5855 if answer != nil {
@@ -80,28 +77,23 @@ func allowed(rs approval.RuleSet, cmd string) approval.Verdict {
8077 func yes() *bool { v := true; return &v }
8178 func no() *bool { v := false; return &v }
8279
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.
8683 func TestUntrustedHeadless(t *testing.T) {
8784 f := newCCFixture(t)
8885 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- }
9786 if v := allowed(got.Rules, "make build"); v != approval.NoVerdict {
9887 t.Errorf("an untrusted project allow rule was honoured: %v", v)
9988 }
10089 if v := allowed(got.Rules, "rm -rf x"); v != approval.VerdictDeny {
10190 t.Errorf("the project's deny rule should hold untrusted: %v", v)
10291 }
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)
10597 }
10698 }
10799
@@ -110,14 +102,14 @@ func TestTrustFlag(t *testing.T) {
110102 o := f.opts(no())
111103 o.Trust = true
112104 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)
115107 }
116108 if v := allowed(got.Rules, "make build"); v != approval.VerdictAllow {
117109 t.Errorf("-trust-project should honour the project's allow rules: %v", v)
118110 }
119111 // 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 {
121113 t.Error("the flag should not record a decision")
122114 }
123115 }
@@ -128,9 +120,6 @@ func TestTrustIsAskedOnceAndRemembered(t *testing.T) {
128120 if f.asked != 1 {
129121 t.Fatalf("asked %d times, want 1", f.asked)
130122 }
131- if _, ok := got.MCP["repo-tool"]; !ok {
132- t.Error("a trusted project server should start")
133- }
134123 if v := allowed(got.Rules, "make build"); v != approval.VerdictAllow {
135124 t.Errorf("a trusted project allow rule should be honoured: %v", v)
136125 }
@@ -141,22 +130,18 @@ func TestTrustIsAskedOnceAndRemembered(t *testing.T) {
141130 t.Errorf("asked again for the same definitions")
142131 }
143132 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)
146135 }
147136
148- // A changed .mcp.json is a new question.
137+ // A .mcp.json is not part of what is trusted: changing it asks nothing.
149138 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- }
154139 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)
157142 }
158143
159- // So is a changed allow list.
144+ // A changed allow list is a new question.
160145 f.write(t, f.root, ".claude/settings.json", `{"permissions":{"allow":["Bash(make:*)","Bash"]}}`)
161146 got = loadClaudeCode(f.opts(nil))
162147 if v := allowed(got.Rules, "make build"); v != approval.NoVerdict {
@@ -167,8 +152,8 @@ func TestTrustIsAskedOnceAndRemembered(t *testing.T) {
167152 func TestDeclinedTrustIsRemembered(t *testing.T) {
168153 f := newCCFixture(t)
169154 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)
172157 }
173158 f.notices = nil
174159 loadClaudeCode(f.opts(yes()))
@@ -180,10 +165,10 @@ func TestDeclinedTrustIsRemembered(t *testing.T) {
180165 }
181166 }
182167
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.
184170 func TestNoProjectFilesNoQuestion(t *testing.T) {
185171 f := newCCFixture(t)
186- os.Remove(filepath.Join(f.root, ".mcp.json"))
187172 f.write(t, f.root, ".claude/settings.json", `{"permissions":{"deny":["Bash(rm:*)"]}}`)
188173 got := loadClaudeCode(f.opts(yes()))
189174 if f.asked != 0 {
@@ -197,7 +182,7 @@ func TestNoProjectFilesNoQuestion(t *testing.T) {
197182 // A machine without Claude Code leaves the policy exactly as it was.
198183 func TestNoClaudeCodeNoRules(t *testing.T) {
199184 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) {}}
201186 got := loadClaudeCode(o)
202187 if got.Rules != nil {
203188 t.Errorf("rules = %#v, want nil", got.Rules)
@@ -216,7 +201,7 @@ func TestRulesForAnotherRoot(t *testing.T) {
216201 if v := allowed(lookup(other), "make"); v != approval.NoVerdict || f.asked != 0 {
217202 t.Errorf("verdict %v, asked %d: another root must not be asked about", v, f.asked)
218203 }
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:*)"}}})
220205 f.store.Record(other, g.Fingerprint(), true)
221206 lookup = rulesFor(o, f.root, start)
222207 if v := allowed(lookup(other), "make"); v != approval.VerdictAllow {
@@ -238,11 +223,11 @@ func TestAskTrustOnTerminal(t *testing.T) {
238223 for _, tc := range tests {
239224 var out strings.Builder
240225 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:*)"})
242227 if trusted != tc.trusted || answered != tc.answered {
243228 t.Errorf("%q: got %v %v, want %v %v", tc.in, trusted, answered, tc.trusted, tc.answered)
244229 }
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]") {
246231 t.Errorf("prompt did not show the definitions:\n%s", out.String())
247232 }
248233 }
@@ -263,11 +248,6 @@ func TestReadLineStopsAtTheNewline(t *testing.T) {
263248 }
264249 }
265250
266- func mustMCP(t *testing.T, f *ccFixture) []ccsettings.MCPFile {
267- files, _ := ccsettings.LoadMCP(f.home, f.root)
268- return files
269- }
270-
271251 func mustPerms(t *testing.T, f *ccFixture) []ccsettings.Permissions {
272252 p, _ := ccsettings.LoadPermissions(f.home, f.root)
273253 return p
+34−22cmd/codey/main.goView File
@@ -97,7 +97,7 @@ func run() error {
9797 noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr")
9898 noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it")
9999 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")
101101 // The flags a script written for `claude -p` passes — see claudecli.go.
102102 claudeFlags := registerClaudeFlags(flag.CommandLine)
103103
@@ -140,6 +140,18 @@ func run() error {
140140 return err
141141 }
142142
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+
143155 // Saved preferences sit under the flags and over the built-in defaults. A
144156 // config that cannot be read is reported and then ignored: Codey
145157 // still starts, and the wizard can write a good one over it.
@@ -265,6 +277,12 @@ func run() error {
265277 if stateErr != nil {
266278 notice("%v — starting a fresh %s; the old one is untouched", stateErr, statedir.Dir)
267279 }
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+ }
268286
269287 if !client.IsDefaultEndpoint() {
270288 // Worth saying out loud: the prompt and every file the agent reads go
@@ -395,25 +413,15 @@ func run() error {
395413 procs := tools.NewProcesses()
396414 defer procs.Close()
397415
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.
411421 ccOpts := ccOptions{
412422 Home: home,
413423 Root: root,
414- Own: cfg.MCPServers,
415424 Trust: *trustProject,
416- NoMCP: *noMCP,
417425 Notice: notice,
418426 }
419427 if cfgPath, err := config.Path(); err == nil {
@@ -425,11 +433,17 @@ func run() error {
425433 cc := loadClaudeCode(ccOpts)
426434 instructions := instructionsFor(home, notice)
427435
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.
428444 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)
433447 defer closer.Close()
434448 for _, err := range errs {
435449 notice("%v", err)
@@ -646,8 +660,6 @@ func run() error {
646660 return built
647661 }
648662
649- sessionDir := filepath.Join(root, agent.SessionDir)
650-
651663 // workRoot is where the tools work; root stays where the state lives. The
652664 // two are the same until /worktree moves the work, or until a session that
653665 // had moved is resumed below.
+1−1cmd/codey/printout_test.goView File
@@ -283,7 +283,7 @@ func TestPrintOnceStreamsARealRun(t *testing.T) {
283283 p, out, _, _ := fixedPrinter(formatStreamJSON, false)
284284 a.Observe = p.observe
285285 p.begin(root, client.Model(), "default", []string{"bash"})
286- sessionDir := filepath.Join(root, agent.SessionDir)
286+ sessionDir := filepath.Join(t.TempDir(), "chats")
287287 if err := printOnce(context.Background(), a, p, sessionDir, "s1", root, client, "clean up"); err != nil {
288288 t.Fatalf("printOnce: %v", err)
289289 }
+1−1docs/internals/overview.mdView File
@@ -13,7 +13,7 @@ internal/websearch the search command, the URL jail, HTML to text
1313 internal/mcp MCP client: starts the configured servers, offers their tools
1414 internal/config saved defaults: model, effort, approvals, web search, theme, diagnostics
1515 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
1717 internal/ask questions the agent puts to you mid-task, and the tool for it
1818 internal/hooks the lifecycle, published: fan-out, and the config's commands
1919 internal/herdr reporting agent state to the herdr pane Codey is running in
+49−12docs/user/claude-code-settings.mdView File
@@ -1,10 +1,10 @@
11 # Claude Code's settings
22
33 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).
88
99 **Permissions.** The `permissions` block of three settings files, in the order
1010 Claude Code reads them:
@@ -12,7 +12,7 @@ Claude Code reads them:
1212 | File | Whose | `deny` / `ask` | `allow` | `defaultMode` |
1313 | --- | --- | --- | --- | --- |
1414 | `~/.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 |
1616 | `.claude/settings.local.json` | yours, this project, git-ignored | honoured | honoured | `acceptEdits` only |
1717
1818 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
2020 a repository may add as many as it likes. An allow rule is the opposite: it is
2121 a decision that something runs without you being asked, and a decision written
2222 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
2525 git turns out to be tracking arrived with the clone, whatever its name says,
2626 and is treated as shared.
2727
@@ -98,10 +98,47 @@ it in.
9898 it has an equivalent of.
9999 - `permissions.additionalDirectories`: the file tools keep the working root as
100100 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).
105105 - Managed (enterprise) settings, subdirectory CLAUDE.md files loaded on demand,
106106 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.
+7−75docs/user/mcp.mdView File
@@ -26,82 +26,14 @@ Only tools are used: not resources, prompts or sampling, and no HTTP
2626 transport. Pictures a tool returns are described to the model rather than
2727 shown. A server's directory is fixed when it starts, so after `/worktree` it
2828 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.
3030
31- ## Claude Code's servers
31+ ## Claude Code's servers are not read
3232
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.
3538
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 |
4139
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.
+2−3docs/user/safety.mdView File
@@ -40,9 +40,8 @@ working root you would sensibly point Codey at — but the flip side is that
4040 anything able to write that file has arranged to run commands here. `-no-hooks`
4141 is the switch that says no for one run. MCP servers are the same bargain: each
4242 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.
4645
4746 **A sandbox.** This is the layer that actually contains a mistake, and the
4847 Codey cannot provide it for you. The honest recipe on Linux:
+13−6docs/user/setup.mdView File
@@ -138,17 +138,24 @@ on"; they do not write to the file. The two screens that do are the wizard and
138138
139139 ## Where Codey keeps things
140140
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.
144146
145147 | Path | What |
146148 | --- | --- |
147149 | `~/.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 |
150153 | `<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.
152159
153160 Codey was called `harness` until recently, and both of those used to live under
154161 that name. Neither rename costs you anything. The config is read from
+1−1docs/user/tabs.mdView File
@@ -2,7 +2,7 @@
22
33 `ctrl+t` — or `/new` — opens a second conversation in a tab. A tab is a whole
44 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
66 model you are on and the terminal they share, so a turn running in one tab is
77 not interrupted, approved or compacted by anything you do in another.
88
+4−5docs/user/tui.mdView File
@@ -290,13 +290,12 @@ A `!` or `$` line and a prompt are different kinds of thing, so they are
290290 offered back separately: `↑` at the shell prompt walks shell commands, and the
291291 prompt aimed at the model never hands you a `go test` to send as prose.
292292
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
295295 most likely to want back, and not one file per machine, which would offer you
296296 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.
300299
301300 ## Running something yourself
302301
+3−6internal/agent/session.goView File
@@ -11,7 +11,6 @@ import (
1111 "time"
1212
1313 "github.com/johannSo/codey/internal/llm"
14- "github.com/johannSo/codey/internal/statedir"
1514 )
1615
1716 // Session is a conversation saved to disk so --resume can pick it up.
@@ -32,9 +31,6 @@ type Session struct {
3231 Messages []llm.Message `json:"messages"`
3332 }
3433
35- // SessionDir is where sessions live, relative to the working root.
36- const SessionDir = statedir.Dir + "/sessions"
37-
3834 // Save writes the session for id, overwriting any previous copy. root is the
3935 // directory the session is filed under and worktree the tree the work has been
4036 // moved into, which is usually empty and never replaces root.
@@ -105,8 +101,9 @@ func ResolveSession(dir, id string) (Session, error) {
105101 }
106102
107103 // 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
110107 // otherwise let one checkout reopen another's conversation, which is exactly
111108 // what a directory-scoped chooser must not do. A session with no root recorded
112109 // is taken to belong where it is filed: it predates the field, and the
+20−25internal/ccsettings/ccsettings.goView File
@@ -1,21 +1,20 @@
11 // Package ccsettings reads the configuration Claude Code keeps for a user and
22 // 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.
66 //
77 // 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.
1918 //
2019 // Nothing here writes to Claude Code's files. The one file this package
2120 // writes is Codey's own record of those trust decisions.
@@ -36,26 +35,22 @@ import (
3635 type Scope string
3736
3837 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.
4139 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.
4543 ScopeProject Scope = "project"
4644 // 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.
4946 ScopeLocal Scope = "local"
5047 )
5148
5249 // File names, relative to the home directory or the working root.
5350 const (
54- ClaudeJSON = ".claude.json"
5551 UserSettings = ".claude/settings.json"
5652 ProjectSettings = ".claude/settings.json"
5753 LocalSettings = ".claude/settings.local.json"
58- ProjectMCP = ".mcp.json"
5954 UserInstructions = ".claude/CLAUDE.md"
6055 instructionsName = "CLAUDE.md"
6156 localInstructions = "CLAUDE.local.md"
@@ -79,8 +74,8 @@ func readJSON(path string, v any) (bool, error) {
7974
8075 // shown is how a path is written in a message: under ~ when it is in the home
8176 // 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.
8479 func shown(home, root, path string) string {
8580 if root != "" {
8681 if rel, err := filepath.Rel(root, path); err == nil && !strings.HasPrefix(rel, "..") {
+0−171internal/ccsettings/load_test.goView File
@@ -5,10 +5,7 @@ import (
55 "os/exec"
66 "path/filepath"
77 "reflect"
8- "strings"
98 "testing"
10-
11- "github.com/johannSo/codey/internal/config"
129 )
1310
1411 // write puts a file under dir, creating its parents. Every test here builds
@@ -123,171 +120,3 @@ func TestTrackedLocalSettingsAreProjectScope(t *testing.T) {
123120 t.Errorf("an untrusted, committed local file's allow rule was honoured: %+v", v)
124121 }
125122 }
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- }
+0−249internal/ccsettings/mcp.godeleted
@@ -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- }
+17−44internal/ccsettings/trust.goView File
@@ -15,13 +15,8 @@ import (
1515 )
1616
1717 // 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.
2219 type Grant struct {
23- // Servers are the stdio servers .mcp.json defines, unexpanded.
24- Servers map[string]ServerDef
2520 // Allow is the allow rules from every project-scope settings file, and
2621 // AllowFrom names those files.
2722 Allow []string
@@ -29,11 +24,8 @@ type Grant struct {
2924 }
3025
3126 // NewGrant collects what the project-scope files would grant.
32- func NewGrant(mcp []MCPFile, perms []Permissions) Grant {
27+ func NewGrant(perms []Permissions) Grant {
3328 var g Grant
34- if p := Project(mcp); p != nil {
35- g.Servers = p.Servers
36- }
3729 for _, p := range perms {
3830 if p.Scope == ScopeProject && len(p.Allow) > 0 {
3931 g.Allow = append(g.Allow, p.Allow...)
@@ -44,49 +36,30 @@ func NewGrant(mcp []MCPFile, perms []Permissions) Grant {
4436 }
4537
4638 // 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 }
4840
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.
5744 func (g Grant) Fingerprint() string {
5845 allow := slices.Clone(g.Allow)
5946 slices.Sort(allow)
60- // encoding/json writes map keys sorted, so equal definitions always
61- // encode to equal bytes.
6247 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})
6650 sum := sha256.Sum256(body)
6751 return "sha256:" + hex.EncodeToString(sum[:])
6852 }
6953
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.
7256 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
8459 }
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)
9063 }
9164 return lines
9265 }
@@ -101,8 +74,8 @@ const TrustFilename = "trust.json"
10174 // directory with the rest of the per-repository state. That is the whole
10275 // point of it: anything inside the working tree can arrive with a clone, and a
10376 // 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
10679 // what the user decided.
10780 type TrustStore struct {
10881 Path string
+8−30internal/ccsettings/trust_test.goView File
@@ -7,30 +7,16 @@ import (
77 )
88
99 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:*)"}}
1812 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")
2614 }
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")
3117 }
3218 // 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() {
3420 t.Error("AllowFrom should not affect the fingerprint")
3521 }
3622 if !(Grant{}).Empty() || base.Empty() {
@@ -39,24 +25,16 @@ func TestFingerprint(t *testing.T) {
3925 }
4026
4127 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- }
4728 perms := []Permissions{
4829 {Scope: ScopeUser, Allow: []string{"Bash"}},
4930 {Scope: ScopeProject, Shown: ".claude/settings.json", Allow: []string{"Edit"}, Deny: []string{"Read(.env)"}},
5031 {Scope: ScopeLocal, Allow: []string{"Write"}},
5132 }
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)
5634 if len(g.Allow) != 1 || g.Allow[0] != "Edit" || g.AllowFrom[0] != ".claude/settings.json" {
5735 t.Errorf("allow %v from %v", g.Allow, g.AllowFrom)
5836 }
59- if len(g.Describe()) != 4 {
37+ if len(g.Describe()) != 2 {
6038 t.Errorf("describe: %q", g.Describe())
6139 }
6240 }
+132−0internal/statedir/chats.goaddedView File
@@ -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+ }
+93−0internal/statedir/chats_test.goaddedView File
@@ -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+ }
+4−3internal/statedir/dir.goView File
@@ -13,7 +13,8 @@ import (
1313 "path/filepath"
1414 )
1515
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.
1718 // LegacyDir is what it was called before the rename.
1819 const (
1920 Dir = ".codey"
@@ -49,8 +50,8 @@ func Migrate(root string) (bool, error) {
4950 }
5051
5152 // 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.
5455 // A .gitignore inside the directory that ignores everything does it without
5556 // touching the project's own .gitignore, and git honours it from any depth.
5657 func Ensure(root string) error {
+16−17internal/tui/history.goView File
@@ -19,15 +19,16 @@ import (
1919 // It is kept per checkout rather than per session or per machine. Per session
2020 // would forget the thing you are most likely to want back, which is what you
2121 // 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.
2324
2425 // historyLimit is how many entries are kept. A few hundred is more than the
2526 // arrow key can practically reach and still a file small enough to read whole
2627 // at startup without anyone noticing.
2728 const historyLimit = 500
2829
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
3132
3233 // compactAtMultiple is how far the file may grow past the limit before it is
3334 // rewritten. Entries are appended rather than rewritten so two tabs typing at
@@ -55,31 +56,29 @@ type history struct {
5556 walking bool
5657 }
5758
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.
5961 //
6062 // Every failure is silent. A history that cannot be read is a convenience that
6163 // 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
6365 // reported.
64- func newHistory(stateRoot string) *history {
66+ func newHistory(chatsDir string) *history {
6567 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 == "" {
7171 return h
7272 }
73- h.path = historyPath(stateRoot)
73+ h.path = historyPath(chatsDir)
7474 h.load()
7575 h.idx = len(h.entries)
7676 return h
7777 }
7878
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)
8382 }
8483
8584 // load reads the file, dropping anything it cannot parse, and compacts it if
@@ -140,7 +139,7 @@ func (h *history) record(line string) {
140139 h.append(line)
141140 }
142141
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
144143 // the first thing ever written there. Quoted, so that a prompt with newlines
145144 // in it stays one entry.
146145 func (h *history) append(line string) {
+15−13internal/tui/history_test.goView File
@@ -18,17 +18,20 @@ import (
1818
1919 // newHistoryModel builds a model whose history has a real directory to live
2020 // 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.
2122 func newHistoryModel(t *testing.T) (*model, string) {
2223 t.Helper()
2324 root := t.TempDir()
25+ dir := filepath.Join(t.TempDir(), "chats")
2426 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),
2932 })
3033 m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
31- return m, root
34+ return m, dir
3235 }
3336
3437 // The whole point of the feature: what was sent comes back.
@@ -236,9 +239,9 @@ func TestHistoryIsCappedAndCompacted(t *testing.T) {
236239 // Nothing here may be a reason the prompt box does not work. A history with
237240 // nowhere to live still walks; it just forgets at the end of the session.
238241 func TestAHistoryWithNoDirectoryStillWorks(t *testing.T) {
239- h := newHistory(filepath.Join(t.TempDir(), "does-not-exist"))
242+ h := newHistory("")
240243 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)
242245 }
243246 h.record("kept in memory")
244247 if text, ok := h.prev("", ""); !ok || text != "kept in memory" {
@@ -263,12 +266,11 @@ func TestGarbageLinesAreSkippedRatherThanFatal(t *testing.T) {
263266 }
264267 }
265268
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 {
272274 t.Errorf("historyPath = %q, want %q", got, want)
273275 }
274276 }
+6−5internal/tui/model.goView File
@@ -474,11 +474,12 @@ func newModel(ctx context.Context, opts Options) *model {
474474 parent: ctx,
475475 mouseOn: true,
476476 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),
482483 }
483484 m.tr.setVerbose(opts.Verbose)
484485 m.tr.addRaw(splash(opts, 80))
+1−1internal/tui/session_picker.goView File
@@ -15,7 +15,7 @@ import (
1515 )
1616
1717 // 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
1919 // the root a second time on the way out of the agent package — a conversation
2020 // about one checkout has no business being reopened in another, where none of
2121 // the paths it discusses exist.
+1−1internal/tui/session_picker_test.goView File
@@ -48,7 +48,7 @@ func assistant(text string) llm.Message {
4848 func resumeModel(t *testing.T) *model {
4949 t.Helper()
5050 root := t.TempDir()
51- dir := filepath.Join(root, agent.SessionDir)
51+ dir := filepath.Join(t.TempDir(), "chats")
5252
5353 saveSession(t, dir, agent.Session{
5454 ID: "20260101T090000Z", Root: root, Model: "claude-opus-5", Turns: 3,
+2−2internal/tui/worktree_picker_test.goView File
@@ -33,7 +33,7 @@ func pickerModel(t *testing.T, trees ...worktreeInfo) (*model, *string) {
3333 Policy: approval.New(nil),
3434 Root: root,
3535 HomeRoot: root,
36- SessionDir: filepath.Join(root, agent.SessionDir),
36+ SessionDir: filepath.Join(t.TempDir(), "chats"),
3737 SessionID: "20260101T090000Z",
3838 NewTools: func(r string) *tools.Registry {
3939 *built = r
@@ -424,7 +424,7 @@ func TestWorktreeResumeFindsATreeThatWasMade(t *testing.T) {
424424
425425 m, built := pickerModel(t)
426426 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")
428428
429429 path, err := runWorktreeScript(context.Background(), repo, "dark-mode")
430430 if err != nil {