Vectrum
Sign In

Merge auth opt-in: the Claude Code login, only when asked for

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Jjonicommitted 2 parents 53650d4 502664dcommit5843884

Showing 17 changed files with 1083 additions and 175 deletions.

Merge commit — showing changes against the first parent.

+68−22README.mdView File
@@ -15,14 +15,17 @@ go build -o codey ./cmd/codey
1515 ./codey -C /path/to/repo -p "why does TestFoo fail?" # one prompt
1616 ```
1717
18- If you are logged into Claude Code, that is all the setup there is: Codey
19- borrows the OAuth credential from `~/.claude/.credentials.json` when nothing
20- else supplied one. Otherwise, or to use a different account:
18+ Codey needs a credential, and the ordinary one is an Anthropic API key, which
19+ you create at
20+ [console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys):
2121
2222 ```bash
2323 export ANTHROPIC_API_KEY=sk-ant-...
2424 ```
2525
26+ Started with nothing at all, Codey stops and says how to get one rather than
27+ failing on the first request.
28+
2629 A different endpoint — a gateway, a proxy, a local server speaking the Messages
2730 API:
2831
@@ -38,13 +41,28 @@ what [the setup wizard](#setup) writes on a first run. `-auth-token` sends
3841 stderr, because your prompt and every file the agent reads go wherever that URL
3942 points.
4043
41- The Claude Code login is the last resort, tried only when no flag and no
42- environment variable supplied a credential, and only against Anthropic's own
44+ On your own machine, if you are logged into Claude Code, Codey can borrow the
45+ OAuth credential Claude Code keeps in `~/.claude/.credentials.json` instead of
46+ needing a key. It does this only when asked: `-claude-code-auth` for one run, or
47+ `"claude_code_auth": "on"` in [the config](#setup) — which the setup wizard and
48+ `/settings` both offer — for every run. Be aware of what that is: the
49+ credential is issued to Claude Code, and using it from another client is
50+ outside Anthropic's terms for it, which API keys and gateway tokens do not
51+ carry. That is why it is off until you turn it on, and why it is not something
52+ to set up on a machine other people use.
53+
54+ Even when asked for, it is the last resort. It is tried only when no flag and
55+ no environment variable supplied a credential, and only against Anthropic's own
4356 endpoint — a credential found on disk is not one you chose to send, so it is
4457 never forwarded to a `-base-url` you pointed Codey at. `-no-claude-code-auth`
45- skips it entirely. Note that the credential is issued to Claude Code; using it
46- from another client is outside Anthropic's terms for it, which API keys and
47- gateway tokens do not carry.
58+ refuses it for one run, whatever `-claude-code-auth` and the config say.
59+
60+ Earlier builds borrowed the login silently whenever nothing else was set. If
61+ you relied on that, the first start after upgrading asks once, on the terminal,
62+ whether to keep doing so, and saves the answer as `claude_code_auth` — yes or
63+ no, so it is not asked again. A `-p` run, a pipe, and `codey models` have
64+ nobody to ask, so they just report that there is no credential and how to get
65+ one.
4866
4967 On macOS Claude Code keeps this in the Keychain rather than a file, so there is
5068 nothing to borrow there. An expired access token is refreshed automatically; the
@@ -55,7 +73,7 @@ would break your Claude Code login.
5573 Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`,
5674 `-resume latest`, `-no-stream`, `-no-cache`, `-no-auto-compact`, `-v`, `-yolo`,
5775 `-no-tui`, `-no-skills`, `-no-agents`, `-no-web`, `-no-hooks`,
58- `-no-diagnostics`, `-no-mcp`, `-no-claude-code-auth`.
76+ `-no-diagnostics`, `-no-mcp`, `-claude-code-auth`, `-no-claude-code-auth`.
5977
6078 ### Flags and thresholds
6179
@@ -758,8 +776,9 @@ agents at once. `-p` and `-no-tui` runs are not offered the tools at all, and
758776
759777 The first run on a terminal opens a full-screen wizard instead of the chat: the
760778 mark assembles, the name lights up, and it asks five questions one at a time,
761- plus the two that are only sometimes there — the Claude skills question on a
762- machine that has some, and the herdr one on a machine that has
779+ plus the three that are only sometimes there — the Claude Code login question
780+ where there is no API key and Claude Code is logged in, the Claude skills
781+ question on a machine that has some, and the herdr one on a machine that has
763782 [herdr](#herdr).
764783 "First run" means the config file does not say `"setup": "done"` — the marker
765784 the wizard stamps when the last question is answered — rather than that no file
@@ -789,13 +808,22 @@ still gets asked the ones it is missing.
789808 | Approvals | ask before every mutating call, or accept them all |
790809 | Web search | `none`, Claude's hosted search, or a command on this machine |
791810 | Theme | `ember` `mint` `iris` `mono` |
811+ | Claude Code login | no, or borrow it when no key is set — asked only where no key or token was given, the endpoint is Anthropic's, and Claude Code is logged in |
792812 | Claude skills | leave them alone, use them in place, or copy them once — asked only where there are some |
793813 | herdr skill | install it, or not — asked only where herdr is |
794814
795- The last two are the steps that are sometimes not there. The rail knows how
815+ The last three are the steps that are sometimes not there. The rail knows how
796816 many questions this run is actually asking rather than how many the build has,
797- so a machine with neither gets a five-node rail with no gaps in it, and
798- `step 6 of 7` on a machine with both counts the questions you are being asked.
817+ so a machine with none of them gets a five-node rail with no gaps in it, and
818+ `step 7 of 8` on a machine with all three counts the questions you are being
819+ asked. The Claude Code login question opens on no, and both answers carry the
820+ same sentence about the terms: the login is issued to Claude Code, and using it
821+ from another client is outside Anthropic's terms for it — so it is a choice
822+ for your own machine, described [under Quick start](#quick-start). Someone with
823+ an API key set is not asked at all, because there is nothing for them to
824+ decide. The answer is `claude_code_auth`; changing it mid-session says it takes
825+ effect on the next start, because the client was built, credential and all,
826+ before the wizard opened.
799827 The herdr answer is also the one that is not a preference: it is an act,
800828 carried out once, so nothing about it is written to the config. It appears when
801829 `HERDR_ENV=1` or `herdr` is on `PATH`. What it installs, and what happens when
@@ -829,12 +857,19 @@ honoured):
829857 "search_command": "ddgs text -q {{query}} -m {{count}} -nc",
830858 "claude_skills": "use",
831859 "diagnostics": "on",
860+ "claude_code_auth": "off",
832861 "theme": "ember",
833862 "auto_compact": "on",
834863 "setup": "done"
835864 }
836865 ```
837866
867+ `claude_code_auth` is `"on"` or `"off"`, and a missing key — or any other word
868+ — means off: the Claude Code login is borrowed only on an explicit `"on"`, or
869+ for one run with `-claude-code-auth`, and `-no-claude-code-auth` beats both.
870+ A missing key is also how Codey knows it has never asked, which is what the
871+ one-time question for someone upgrading reads.
872+
838873 `auto_compact` is the one key neither the wizard nor `/settings` offers a row
839874 for — set it by hand, or pass `-no-auto-compact` for a single run.
840875 `diagnostics` is the one `/settings` has a row for and the wizard does not
@@ -906,13 +941,14 @@ in place, applied as you move.
906941
907942 ```
908943 Settings ↑↓ row · ←→ change · esc done
909- ❯ Model Opus 5 $5 / $25 per Mtok
910- Effort high default
911- Approvals Ask me first recommended
912- Web search No web access default
913- Diagnostics On default
914- Claude skills Leave them alone default
915- Theme ember
944+ ❯ Model Opus 5 $5 / $25 per Mtok
945+ Effort high default
946+ Approvals Ask me first recommended
947+ Web search No web access default
948+ Diagnostics On default
949+ Claude Code login No, I'll use an API key default
950+ Claude skills Leave them alone default
951+ Theme ember
916952 claude-opus-5 · enter to pick from anthropic
917953 saved to ~/.config/codey/config.json
918954 ```
@@ -929,6 +965,13 @@ level the other does not use. The model row is the exception: its choices come
929965 from the endpoint rather than from this build, so `enter` hands over to the
930966 `/model` picker and lands back here once you have chosen.
931967
968+ The Claude Code login row is there whether or not the wizard asked, so the
969+ opt-in [described under Quick start](#quick-start) can be turned on or back off
970+ without rerunning setup. Like web search, diagnostics and Claude's skills, it
971+ is decided when the session starts, so a change says it takes effect on the
972+ next start rather than pretending to swap the credential under a conversation
973+ already in progress.
974+
932975 What the panel shows is the *session*, not the file — `/effort`, `shift+tab`
933976 and `-yolo` all change a setting without the file hearing about it, and a panel
934977 that disagreed with the status bar two lines below it would be worse than
@@ -1403,7 +1446,10 @@ instead of adding one.
14031446 Two invocations exist only for that provider card: `codey --version`, and
14041447 `codey models`, which prints whether there is a credential and what the
14051448 endpoint serves. Both are shaped for the regular expressions on the other end
1406- rather than for a person.
1449+ rather than for a person. The probe follows the same opt-in as everything else:
1450+ a Claude Code login counts as logged in only when the config says
1451+ `"claude_code_auth": "on"` — the probe is run with no flags, so
1452+ `-claude-code-auth` in the launch arguments reaches the agent but not the card.
14071453
14081454 `docs/superpowers/specs/2026-09-19-t3code-provider-design.md` is the research
14091455 this is built on: what every T3 Code provider requires of a binary, and why the
+12−2cmd/codey/agentcmd.goView File
@@ -151,9 +151,19 @@ const modelsTimeout = 8 * time.Second
151151 // marked. It exits zero even when it has nothing good to say — a non-zero exit
152152 // makes the client discard the whole listing, including the part that was
153153 // true.
154- func printModels(w io.Writer, client *llm.Client) {
154+ //
155+ // The client is the one startup built, so a Claude Code login counts as being
156+ // logged in exactly when the session would use it: when the config says
157+ // "claude_code_auth": "on". claudeCode says there is such a login on this
158+ // machine going unused, which is worth naming in the logged-out line — the
159+ // probe runs with no flags, so the config is the way to turn it on here.
160+ func printModels(w io.Writer, client *llm.Client, claudeCode bool) {
155161 if !client.HasCredentials() {
156- fmt.Fprintln(w, "You are not logged in. Set ANTHROPIC_API_KEY, or log in with Claude Code.")
162+ if claudeCode {
163+ fmt.Fprintln(w, `You are not logged in. Set ANTHROPIC_API_KEY, or set "claude_code_auth": "on" in Codey's config to use your Claude Code login.`)
164+ return
165+ }
166+ fmt.Fprintln(w, "You are not logged in. Set ANTHROPIC_API_KEY.")
157167 return
158168 }
159169 fmt.Fprintf(w, "You are logged in to %s.\n", client.BaseURL)
+2−2cmd/codey/agentcmd_test.goView File
@@ -161,7 +161,7 @@ func TestModelsOutputParsesTheWayTheClientParsesIt(t *testing.T) {
161161 // No endpoint is reachable here, so this exercises the fallback: the one
162162 // model we were configured with, and a login state that is still true.
163163 var out bytes.Buffer
164- printModels(&out, testClient(t))
164+ printModels(&out, testClient(t), false)
165165
166166 loggedIn, models, def := parseLikeT3(out.String())
167167 if !loggedIn {
@@ -174,7 +174,7 @@ func TestModelsOutputParsesTheWayTheClientParsesIt(t *testing.T) {
174174
175175 func TestLoggedOutSaysSoRatherThanFailing(t *testing.T) {
176176 var out bytes.Buffer
177- printModels(&out, noCredentialClient(t))
177+ printModels(&out, noCredentialClient(t), false)
178178
179179 if loggedIn, _, _ := parseLikeT3(out.String()); loggedIn {
180180 t.Errorf("a client with no credential reported itself logged in:\n%s", out.String())
+218−0cmd/codey/auth.goaddedView File
@@ -0,0 +1,218 @@
1+ package main
2+
3+ // Where the credential comes from.
4+ //
5+ // An API key or a gateway token is the ordinary answer, and nothing here
6+ // touches one: the llm package reads them from the flags and the environment.
7+ // What this file decides is the other answer — the OAuth login Claude Code
8+ // keeps in ~/.claude/.credentials.json — and the rule for it is that it is
9+ // borrowed only when someone asked. That login is issued to Claude Code, and
10+ // using it from another client is outside Anthropic's terms for it. It used to
11+ // be borrowed silently whenever nothing else was set; that was tolerable on
12+ // the one machine Codey was written on and is not for a binary other people
13+ // install, where "a file happened to exist" is not consent.
14+
15+ import (
16+ "bufio"
17+ "errors"
18+ "fmt"
19+ "io"
20+ "path/filepath"
21+ "strings"
22+
23+ "github.com/johannSo/codey/internal/claudecode"
24+ "github.com/johannSo/codey/internal/config"
25+ "github.com/johannSo/codey/internal/llm"
26+ )
27+
28+ // claudeCodeAllowed is whether this run may borrow Claude Code's login, read
29+ // in order of how deliberate each source is: -no-claude-code-auth, then
30+ // -claude-code-auth, then the config, then off.
31+ //
32+ // The refusal outranks the request so that a launch line which has collected
33+ // both — a wrapper script that adds one, a settings box that adds the other —
34+ // comes out on the side that sends nothing it was not sure about. And the
35+ // default is off, so that only a person can turn it on.
36+ func claudeCodeAllowed(refuse, request bool, cfg config.Config) bool {
37+ switch {
38+ case refuse:
39+ return false
40+ case request:
41+ return true
42+ }
43+ return cfg.ClaudeCodeAuthEnabled()
44+ }
45+
46+ // claudeCodeAskable reports whether the question of borrowing Claude Code's
47+ // login arises at all for this client: nothing else was supplied, the
48+ // endpoint is Anthropic's, and there is a login on disk to borrow. Anywhere
49+ // else the answer would change nothing, and a question whose answer changes
50+ // nothing is one the wizard should not ask and an error should not suggest.
51+ //
52+ // It looks for the file without reading it. Whether what is in there is a
53+ // usable token is a question for after someone has said yes.
54+ func claudeCodeAskable(client *llm.Client, home string) bool {
55+ return !client.HasCredentials() && client.IsDefaultEndpoint() && claudecode.Present(home)
56+ }
57+
58+ // resolveClaudeCode returns a token source borrowed from Claude Code's login,
59+ // or nil when one should not be used. The second return is a line for the
60+ // user: a borrowed credential is worth announcing, the same way a non-default
61+ // endpoint is.
62+ //
63+ // Three conditions gate it. It has to have been asked for, which is what
64+ // allowed carries — see claudeCodeAllowed. The client must have no credential
65+ // already, because we never override what the user typed. And the endpoint
66+ // must be Anthropic's, because a credential we found on disk is not one the
67+ // user chose to send — forwarding it to a -base-url someone else operates
68+ // would leak it to a third party because a file happened to exist. All three
69+ // are checked before the file is opened.
70+ func resolveClaudeCode(client *llm.Client, home string, allowed bool) (*claudecode.Source, string, error) {
71+ if !allowed || client.HasCredentials() || !client.IsDefaultEndpoint() {
72+ return nil, "", nil
73+ }
74+ creds, err := claudecode.Load(home)
75+ if errors.Is(err, claudecode.ErrNotFound) {
76+ return nil, "", nil
77+ }
78+ if err != nil {
79+ return nil, "", err
80+ }
81+ note := fmt.Sprintf("using your Claude Code login (%s plan) from ~/%s", creds.SubscriptionType, claudecode.Filename)
82+ // home is handed on so a rotated refresh token can be written back. That
83+ // is the only circumstance in which this process writes to Claude Code's
84+ // file, and not writing then is what would break its login.
85+ //
86+ // The refresh client is deliberately not Codey's: that one allows
87+ // ten minutes for a model to answer, which is the wrong budget for a token
88+ // exchange running under a lock. NewSource supplies a short-timeout client.
89+ return claudecode.NewSource(creds, nil, home), note, nil
90+ }
91+
92+ // borrowed is the client startup ends up with, and what it took to get it.
93+ type borrowed struct {
94+ client *llm.Client
95+ // opts is the list the client was built from, kept so a second client on
96+ // another model can be built the same way.
97+ opts []llm.Option
98+ // source is set only when the credential is Claude Code's, so its
99+ // write-back failure hook can be wired once there is somewhere to report.
100+ source *claudecode.Source
101+ note string
102+ }
103+
104+ // borrowClaudeCode builds the client from opts, and rebuilds it on Claude
105+ // Code's login when that is allowed and nothing else supplied a credential.
106+ //
107+ // The options are a slice rather than a client because the rebuild has to
108+ // replay them: replaying the same list with one more option is how the rebuilt
109+ // client is guaranteed to be the one that passed the endpoint check. It is
110+ // the one place both the session and the `codey models` probe get their
111+ // client from, so the probe cannot report a login the session would refuse.
112+ func borrowClaudeCode(opts []llm.Option, home string, allowed bool) (borrowed, error) {
113+ b := borrowed{client: llm.New(opts...), opts: opts}
114+ if home == "" {
115+ // No home directory is not fatal: an explicit credential still works.
116+ return b, nil
117+ }
118+ src, note, err := resolveClaudeCode(b.client, home, allowed)
119+ if err != nil || src == nil {
120+ return b, err
121+ }
122+ b.opts = append(append([]llm.Option(nil), opts...), llm.WithTokenSource(src.Token))
123+ b.client = llm.New(b.opts...)
124+ // Not expected to ever trigger: resolveClaudeCode already checked the
125+ // endpoint before we agreed to borrow this credential, but that check ran
126+ // against the client we had before this rebuild. This is a guard against
127+ // a future refactor reordering things so the two clients quietly diverge,
128+ // not a condition anyone should see fire.
129+ if !b.client.IsDefaultEndpoint() {
130+ return borrowed{}, errors.New("internal: discovered credential on a non-default endpoint")
131+ }
132+ b.source, b.note = src, note
133+ return b, nil
134+ }
135+
136+ // shouldAskClaudeCode is whether startup, having found no credential, stops to
137+ // ask once about the Claude Code login instead of only failing.
138+ //
139+ // It exists for the people who relied on the silent fallback: after upgrading,
140+ // their next start would otherwise end in "no credentials" for no reason they
141+ // can see. So it asks — but only of a person at a terminal, since a -p run, a
142+ // pipe and a protocol client have nobody to answer; only when the config has
143+ // never recorded an answer, so it is asked once rather than on every start;
144+ // only when the config was read, since the answer is saved over it; and never
145+ // when -no-claude-code-auth already said no.
146+ func shouldAskClaudeCode(cfg config.Config, cfgErr error, askable, refused, tty bool) bool {
147+ return askable && tty && !refused && cfgErr == nil && cfg.ClaudeCodeAuth == ""
148+ }
149+
150+ // askClaudeCode is that question, on the plain terminal before anything takes
151+ // the screen over. The answer is saved either way: yes so the next start does
152+ // not ask again, and no for the same reason — someone who said no should be
153+ // told how to get a key, not asked the same thing every morning. Anything but
154+ // a yes is a no, an empty line included, because the cautious answer is the
155+ // one enter should give.
156+ func askClaudeCode(in io.Reader, out io.Writer, cfg config.Config, save func(config.Config) error) (bool, config.Config) {
157+ fmt.Fprintf(out, "codey: no API key is set, but Claude Code is logged in on this machine (~/%s).\n", claudecode.Filename)
158+ fmt.Fprintln(out, "codey: that login is issued to Claude Code; using it from another client is outside Anthropic's terms for it.")
159+ fmt.Fprint(out, "codey: use it for Codey on this machine? [y/N] ")
160+
161+ line, _ := bufio.NewReader(in).ReadString('\n')
162+ yes := false
163+ switch strings.ToLower(strings.TrimSpace(line)) {
164+ case "y", "yes":
165+ yes = true
166+ }
167+
168+ cfg.ClaudeCodeAuth = config.ClaudeCodeAuthOff
169+ if yes {
170+ cfg.ClaudeCodeAuth = config.ClaudeCodeAuthOn
171+ }
172+ if save != nil {
173+ if err := save(cfg); err != nil {
174+ // The answer still holds for this run; only remembering it failed,
175+ // and the next start will ask again, which is the honest outcome.
176+ fmt.Fprintf(out, "codey: could not save the answer: %v\n", err)
177+ }
178+ }
179+ return yes, cfg
180+ }
181+
182+ // apiKeysURL is where an Anthropic API key is created.
183+ const apiKeysURL = "https://console.anthropic.com/settings/keys"
184+
185+ // noCredentialsError says how to get a credential, because "unauthorized"
186+ // against a gateway that ignores the header is a confusing failure and "no
187+ // credentials" on its own is a dead end.
188+ //
189+ // The Claude Code login is mentioned only where it could be borrowed — there
190+ // is a login on disk and the endpoint is Anthropic's — and with the terms
191+ // attached, because it is a way to start that is yours to choose on your own
192+ // machine rather than the first thing to try.
193+ func noCredentialsError(claudeCode bool, cfgPath string) error {
194+ var b strings.Builder
195+ b.WriteString("no credentials. Set one of:\n")
196+ b.WriteString(" export ANTHROPIC_API_KEY=sk-ant-... create one at " + apiKeysURL + "\n")
197+ b.WriteString(" -base-url URL with ANTHROPIC_AUTH_TOKEN for a gateway (or -api-key / -auth-token)")
198+ if claudeCode {
199+ b.WriteString("\nClaude Code is logged in here: -claude-code-auth, or \"claude_code_auth\": \"on\" in " + cfgPath + ", borrows that login.")
200+ b.WriteString("\nIt is issued to Claude Code; using it from another client is outside Anthropic's terms for it.")
201+ }
202+ return errors.New(b.String())
203+ }
204+
205+ // configPathLabel is where the config lives, with the home directory spelled
206+ // as ~ so the message reads the way the README does.
207+ func configPathLabel(home string) string {
208+ p, err := config.Path()
209+ if err != nil {
210+ return "Codey's config"
211+ }
212+ if home != "" {
213+ if rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, "..") {
214+ return "~/" + filepath.ToSlash(rel)
215+ }
216+ }
217+ return p
218+ }
+314−58cmd/codey/auth_test.goView File
@@ -1,11 +1,17 @@
11 package main
22
33 import (
4+ "bytes"
45 "context"
6+ "errors"
7+ "io"
8+ "net/http"
59 "os"
610 "path/filepath"
11+ "strings"
712 "testing"
813
14+ "github.com/johannSo/codey/internal/config"
915 "github.com/johannSo/codey/internal/llm"
1016 )
1117
@@ -27,34 +33,12 @@ func fakeHome(t *testing.T) string {
2733 return home
2834 }
2935
30- func TestClaudeCodeFillsInAMissingCredential(t *testing.T) {
31- c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL))
32- c.ClearCredentialsForTest()
33- src, note, err := resolveClaudeCode(c, fakeHome(t), false)
34- if err != nil {
35- t.Fatalf("resolveClaudeCode: %v", err)
36- }
37- if src == nil {
38- t.Fatal("expected a token source")
39- }
40- tok, err := src.Token(context.Background())
41- if err != nil {
42- t.Fatalf("token: %v", err)
43- }
44- if tok != "file-token" {
45- t.Errorf("token = %q, want file-token", tok)
46- }
47- if note == "" {
48- t.Error("using a discovered credential should be announced")
49- }
50- }
51-
5236 // tripwireHome writes a credentials file that is malformed JSON: a
5337 // deliberate landmine, not a fixture. If resolveClaudeCode ever reads it
54- // before deciding the endpoint disqualifies discovery, claudecode.Load
55- // returns a parse error and the test surfaces that loudly, rather than
56- // passing on the unobservable (and here false) assumption that the file
57- // went untouched.
38+ // when it should not have — the login was not asked for, or the endpoint
39+ // disqualifies it — claudecode.Load returns a parse error and the test
40+ // surfaces that loudly, rather than passing on the unobservable (and here
41+ // false) assumption that the file went untouched.
5842 func tripwireHome(t *testing.T) string {
5943 t.Helper()
6044 home := t.TempDir()
@@ -68,54 +52,121 @@ func tripwireHome(t *testing.T) string {
6852 return home
6953 }
7054
71- // An explicit credential wins. We never override what the user typed.
72- func TestClaudeCodeIsSkippedWhenACredentialExists(t *testing.T) {
73- c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL), llm.WithAPIKey("sk-ant-explicit"))
74- src, _, err := resolveClaudeCode(c, fakeHome(t), false)
75- if err != nil {
76- t.Fatalf("resolveClaudeCode: %v", err)
55+ // cleanEnv keeps the developer's own shell out of a test about where the
56+ // credential comes from: an exported ANTHROPIC_API_KEY would otherwise be
57+ // the credential every one of these tests found.
58+ func cleanEnv(t *testing.T) {
59+ t.Helper()
60+ t.Setenv("ANTHROPIC_API_KEY", "")
61+ t.Setenv("ANTHROPIC_AUTH_TOKEN", "")
62+ t.Setenv("ANTHROPIC_BASE_URL", "")
63+ }
64+
65+ // The order the switches are read in. The refusal outranks the request, and
66+ // with neither flag the config decides, and with no config the answer is no.
67+ func TestClaudeCodeAuthPrecedence(t *testing.T) {
68+ on := config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOn}
69+ off := config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOff}
70+ cases := []struct {
71+ name string
72+ refuse, request bool
73+ cfg config.Config
74+ want bool
75+ }{
76+ {"nothing said: off", false, false, config.Config{}, false},
77+ {"config on", false, false, on, true},
78+ {"config off", false, false, off, false},
79+ {"flag on, no config", false, true, config.Config{}, true},
80+ {"flag on beats config off", false, true, off, true},
81+ {"-no beats config on", true, false, on, false},
82+ {"-no beats the flag", true, true, config.Config{}, false},
83+ {"-no beats the flag and config on", true, true, on, false},
84+ {"an unknown word is not on", false, false, config.Config{ClaudeCodeAuth: "yes"}, false},
7785 }
78- if src != nil {
79- t.Error("an explicit credential must not be replaced by the file")
86+ for _, tc := range cases {
87+ t.Run(tc.name, func(t *testing.T) {
88+ if got := claudeCodeAllowed(tc.refuse, tc.request, tc.cfg); got != tc.want {
89+ t.Errorf("claudeCodeAllowed(-no=%v, -claude-code-auth=%v, %q) = %v, want %v",
90+ tc.refuse, tc.request, tc.cfg.ClaudeCodeAuth, got, tc.want)
91+ }
92+ })
8093 }
8194 }
8295
83- // The security property: a credential we found is never sent to an endpoint
84- // the user pointed us at. "We did not send it" is not observable from outside,
85- // so it is a test rather than a comment. The home directory here contains
86- // malformed JSON rather than a valid credential: if resolveClaudeCode ever
87- // reads the file before rejecting the non-default endpoint, claudecode.Load
88- // fails to parse it and err comes back non-nil, which the assertion below
89- // catches. A nil err is what proves the file was never opened.
90- func TestClaudeCodeIsNeverSentToANonDefaultEndpoint(t *testing.T) {
91- c := llm.New(llm.WithBaseURL("http://gateway.example"))
96+ func noCredentials(baseURL string) *llm.Client {
97+ c := llm.New(llm.WithBaseURL(baseURL))
9298 c.ClearCredentialsForTest()
93- src, _, err := resolveClaudeCode(c, tripwireHome(t), false)
94- if err != nil {
95- t.Fatalf("resolveClaudeCode read the credentials file (err = %v); it must not touch disk once the endpoint disqualifies discovery", err)
99+ return c
100+ }
101+
102+ // The gate, end to end: what the flags and the config say goes through
103+ // claudeCodeAllowed into resolveClaudeCode, as it does in run. Every case
104+ // that must not borrow is run against the tripwire, so "it did not use the
105+ // login" also proves "it did not open the file".
106+ func TestClaudeCodeIsBorrowedOnlyWhenAskedFor(t *testing.T) {
107+ on := config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOn}
108+ cases := []struct {
109+ name string
110+ refuse, request bool
111+ cfg config.Config
112+ base string
113+ borrow bool
114+ }{
115+ {"off by default", false, false, config.Config{}, llm.DefaultBaseURL, false},
116+ {"off when the config says off", false, false, config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOff}, llm.DefaultBaseURL, false},
117+ {"on with the config", false, false, on, llm.DefaultBaseURL, true},
118+ {"on with the flag", false, true, config.Config{}, llm.DefaultBaseURL, true},
119+ {"off with -no-claude-code-auth", true, false, on, llm.DefaultBaseURL, false},
120+ {"never to a gateway, config on", false, false, on, "http://gateway.example", false},
121+ {"never to a gateway, flag on", false, true, config.Config{}, "http://gateway.example", false},
96122 }
97- if src != nil {
98- t.Fatal("a discovered credential must never go to a non-default endpoint")
123+ for _, tc := range cases {
124+ t.Run(tc.name, func(t *testing.T) {
125+ home := tripwireHome(t)
126+ if tc.borrow {
127+ home = fakeHome(t)
128+ }
129+ src, note, err := resolveClaudeCode(noCredentials(tc.base), home, claudeCodeAllowed(tc.refuse, tc.request, tc.cfg))
130+ if err != nil {
131+ t.Fatalf("resolveClaudeCode read the credentials file (err = %v); it must not touch disk unless the login was asked for and may be sent", err)
132+ }
133+ if got := src != nil; got != tc.borrow {
134+ t.Fatalf("borrowed = %v, want %v", got, tc.borrow)
135+ }
136+ if !tc.borrow {
137+ return
138+ }
139+ tok, err := src.Token(context.Background())
140+ if err != nil {
141+ t.Fatalf("token: %v", err)
142+ }
143+ if tok != "file-token" {
144+ t.Errorf("token = %q, want file-token", tok)
145+ }
146+ if note == "" {
147+ t.Error("using a borrowed credential should be announced")
148+ }
149+ })
99150 }
100151 }
101152
102- func TestClaudeCodeCanBeDisabled(t *testing.T) {
103- c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL))
104- c.ClearCredentialsForTest()
105- src, _, err := resolveClaudeCode(c, fakeHome(t), true)
153+ // An explicit credential wins, even over a login that was asked for. We never
154+ // override what the user typed.
155+ func TestClaudeCodeIsSkippedWhenACredentialExists(t *testing.T) {
156+ c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL), llm.WithAPIKey("sk-ant-explicit"))
157+ src, _, err := resolveClaudeCode(c, tripwireHome(t), true)
106158 if err != nil {
107- t.Fatalf("resolveClaudeCode: %v", err)
159+ t.Fatalf("resolveClaudeCode read the file despite an explicit credential: %v", err)
108160 }
109161 if src != nil {
110- t.Error("-no-claude-code-auth must skip discovery entirely")
162+ t.Error("an explicit credential must not be replaced by the file")
111163 }
112164 }
113165
114- // Not logged in is not an error. It falls through to errNoCredentials.
166+ // Asked for and not logged in is not an error. It falls through to the
167+ // no-credentials message.
115168 func TestClaudeCodeMissingFileIsNotAnError(t *testing.T) {
116- c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL))
117- c.ClearCredentialsForTest()
118- src, _, err := resolveClaudeCode(c, t.TempDir(), false)
169+ src, _, err := resolveClaudeCode(noCredentials(llm.DefaultBaseURL), t.TempDir(), true)
119170 if err != nil {
120171 t.Fatalf("a missing file is not an error, got %v", err)
121172 }
@@ -123,3 +174,208 @@ func TestClaudeCodeMissingFileIsNotAnError(t *testing.T) {
123174 t.Error("no file means no source")
124175 }
125176 }
177+
178+ // A broken file the user asked us to use is worth stopping for, with the
179+ // path, rather than a "no credentials" that hides why.
180+ func TestClaudeCodeBrokenFileIsAnErrorOnceAskedFor(t *testing.T) {
181+ _, _, err := resolveClaudeCode(noCredentials(llm.DefaultBaseURL), tripwireHome(t), true)
182+ if err == nil || !strings.Contains(err.Error(), ".credentials.json") {
183+ t.Errorf("err = %v, want a parse error naming the file", err)
184+ }
185+ }
186+
187+ // borrowClaudeCode is what run builds its client with. A borrowed login has
188+ // to end up on the client and in the options a second client is built from;
189+ // one that was not asked for leaves both alone.
190+ func TestBorrowClaudeCodeRebuildsTheClient(t *testing.T) {
191+ cleanEnv(t)
192+ opts := []llm.Option{llm.WithModel("codey-test-model")}
193+
194+ b, err := borrowClaudeCode(opts, tripwireHome(t), false)
195+ if err != nil {
196+ t.Fatalf("not asked for: %v", err)
197+ }
198+ if b.client.HasCredentials() || b.source != nil || len(b.opts) != len(opts) {
199+ t.Fatalf("a login nobody asked for was borrowed: %+v", b)
200+ }
201+
202+ b, err = borrowClaudeCode(opts, fakeHome(t), true)
203+ if err != nil {
204+ t.Fatal(err)
205+ }
206+ if !b.client.HasCredentials() || b.source == nil || b.note == "" {
207+ t.Fatalf("asked for, but not borrowed: %+v", b)
208+ }
209+ if len(b.opts) != len(opts)+1 {
210+ t.Errorf("the options a second client is built from do not carry the login: %d, want %d", len(b.opts), len(opts)+1)
211+ }
212+ if again := llm.New(b.opts...); !again.HasCredentials() || again.Model() != "codey-test-model" {
213+ t.Error("a client rebuilt from the options differs from the one in use")
214+ }
215+ }
216+
217+ // The question only arises where its answer changes something.
218+ func TestClaudeCodeAskable(t *testing.T) {
219+ cases := []struct {
220+ name string
221+ client *llm.Client
222+ home string
223+ want bool
224+ }{
225+ {"no key, Anthropic, logged in", noCredentials(llm.DefaultBaseURL), fakeHome(t), true},
226+ {"an API key is set", llm.New(llm.WithBaseURL(llm.DefaultBaseURL), llm.WithAPIKey("k")), fakeHome(t), false},
227+ {"a gateway", noCredentials("http://gateway.example"), fakeHome(t), false},
228+ {"Claude Code not logged in", noCredentials(llm.DefaultBaseURL), t.TempDir(), false},
229+ {"no home directory", noCredentials(llm.DefaultBaseURL), "", false},
230+ }
231+ for _, tc := range cases {
232+ if got := claudeCodeAskable(tc.client, tc.home); got != tc.want {
233+ t.Errorf("%s: askable = %v, want %v", tc.name, got, tc.want)
234+ }
235+ }
236+ }
237+
238+ // The message says how to get a key, and mentions the Claude Code login —
239+ // with the terms — only on a machine where it could be borrowed.
240+ func TestNoCredentialsSaysHowToGetOne(t *testing.T) {
241+ plain := noCredentialsError(false, "~/.config/codey/config.json").Error()
242+ for _, want := range []string{"ANTHROPIC_API_KEY", apiKeysURL, "-base-url", "ANTHROPIC_AUTH_TOKEN"} {
243+ if !strings.Contains(plain, want) {
244+ t.Errorf("the message does not mention %q:\n%s", want, plain)
245+ }
246+ }
247+ if strings.Contains(plain, "Claude Code") {
248+ t.Errorf("the message offers a Claude Code login there is none of:\n%s", plain)
249+ }
250+
251+ cc := noCredentialsError(true, "~/.config/codey/config.json").Error()
252+ for _, want := range []string{"-claude-code-auth", `"claude_code_auth": "on"`, "~/.config/codey/config.json", "terms"} {
253+ if !strings.Contains(cc, want) {
254+ t.Errorf("the message does not mention %q:\n%s", want, cc)
255+ }
256+ }
257+ if n := strings.Count(cc, "\n") + 1; n > 6 {
258+ t.Errorf("the message is %d lines; keep it short:\n%s", n, cc)
259+ }
260+ }
261+
262+ // Asked once, at a terminal, of someone the config has never recorded an
263+ // answer for — and nowhere else.
264+ func TestShouldAskClaudeCode(t *testing.T) {
265+ unset := config.Config{}
266+ cases := []struct {
267+ name string
268+ cfg config.Config
269+ cfgErr error
270+ askable bool
271+ refused bool
272+ tty bool
273+ want bool
274+ }{
275+ {"upgrading, at a terminal", unset, nil, true, false, true, true},
276+ {"-p or a pipe", unset, nil, true, false, false, false},
277+ {"already answered on", config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOn}, nil, true, false, true, false},
278+ {"already answered off", config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOff}, nil, true, false, true, false},
279+ {"-no-claude-code-auth", unset, nil, true, true, true, false},
280+ {"nothing to borrow", unset, nil, false, false, true, false},
281+ {"an unreadable config", unset, errors.New("bad json"), true, false, true, false},
282+ }
283+ for _, tc := range cases {
284+ if got := shouldAskClaudeCode(tc.cfg, tc.cfgErr, tc.askable, tc.refused, tc.tty); got != tc.want {
285+ t.Errorf("%s: ask = %v, want %v", tc.name, got, tc.want)
286+ }
287+ }
288+ }
289+
290+ // The answer is saved either way, so it is asked once; enter is no; and the
291+ // terms are on screen before the question is.
292+ func TestAskClaudeCodeSavesTheAnswer(t *testing.T) {
293+ cases := []struct {
294+ in string
295+ yes bool
296+ save string
297+ }{
298+ {"y\n", true, config.ClaudeCodeAuthOn},
299+ {"YES\n", true, config.ClaudeCodeAuthOn},
300+ {"\n", false, config.ClaudeCodeAuthOff},
301+ {"n\n", false, config.ClaudeCodeAuthOff},
302+ {"", false, config.ClaudeCodeAuthOff}, // ctrl-d
303+ }
304+ for _, tc := range cases {
305+ var out bytes.Buffer
306+ var saved *config.Config
307+ yes, cfg := askClaudeCode(strings.NewReader(tc.in), &out, config.Config{Model: "kept"}, func(c config.Config) error {
308+ saved = &c
309+ return nil
310+ })
311+ if yes != tc.yes {
312+ t.Errorf("%q: yes = %v, want %v", tc.in, yes, tc.yes)
313+ }
314+ if saved == nil || saved.ClaudeCodeAuth != tc.save || cfg.ClaudeCodeAuth != tc.save {
315+ t.Errorf("%q: saved %+v, want claude_code_auth %q", tc.in, saved, tc.save)
316+ }
317+ if saved != nil && saved.Model != "kept" {
318+ t.Errorf("%q: the rest of the config was not kept: %+v", tc.in, saved)
319+ }
320+ if !strings.Contains(out.String(), "terms") {
321+ t.Errorf("the question does not carry the terms caveat:\n%s", out.String())
322+ }
323+ }
324+ }
325+
326+ // refusingTransport answers every request with a 500 and never touches the
327+ // network, so a client on Anthropic's default endpoint can be exercised.
328+ type refusingTransport struct{}
329+
330+ func (refusingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
331+ return &http.Response{
332+ StatusCode: http.StatusInternalServerError,
333+ Body: io.NopCloser(strings.NewReader(`{}`)),
334+ Header: http.Header{},
335+ Request: req,
336+ }, nil
337+ }
338+
339+ // `codey models` reads its client off the same path run does, so a Claude
340+ // Code login counts as logged in exactly when the config turned it on — and
341+ // not merely because the file is there.
342+ func TestModelsProbeFollowsTheOptIn(t *testing.T) {
343+ cleanEnv(t)
344+ home := fakeHome(t)
345+
346+ probe := func(cfg config.Config) string {
347+ rt := refusingTransport{}
348+ opts := []llm.Option{
349+ llm.WithModel("codey-test-model"),
350+ llm.WithHTTPClient(&http.Client{Transport: rt}),
351+ }
352+ askable := claudeCodeAskable(llm.New(opts...), home)
353+ b, err := borrowClaudeCode(opts, home, claudeCodeAllowed(false, false, cfg))
354+ if err != nil {
355+ t.Fatal(err)
356+ }
357+ var out bytes.Buffer
358+ printModels(&out, b.client, askable)
359+ return out.String()
360+ }
361+
362+ out := probe(config.Config{})
363+ if loggedIn, _, _ := parseLikeT3(out); loggedIn {
364+ t.Errorf("a Claude Code login nobody turned on counted as logged in:\n%s", out)
365+ }
366+ if !t3LoggedOut.MatchString(out) {
367+ t.Errorf("the client would read this as unknown rather than logged out:\n%s", out)
368+ }
369+ if !strings.Contains(out, `"claude_code_auth": "on"`) {
370+ t.Errorf("the logged-out line does not say how to turn the login on:\n%s", out)
371+ }
372+
373+ out = probe(config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOn})
374+ loggedIn, models, def := parseLikeT3(out)
375+ if !loggedIn {
376+ t.Errorf("config on, and the client would read this as logged out:\n%s", out)
377+ }
378+ if len(models) != 1 || def != "codey-test-model" {
379+ t.Errorf("models = %v, default = %q, from:\n%s", models, def, out)
380+ }
381+ }
+62−85cmd/codey/main.goView File
@@ -89,7 +89,8 @@ func run() error {
8989 noWeb := flag.Bool("no-web", false, "do not offer web search, whatever the config says")
9090 noSkills := flag.Bool("no-skills", false, "skip discovering skills in ~/"+skills.Dirname+", ./"+skills.Dirname+" and Claude Code's own")
9191 noAgents := flag.Bool("no-agents", false, "do not offer the subagent tools, so the agent works alone")
92- noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "do not fall back to the OAuth login in ~/"+claudecode.Filename)
92+ claudeCodeAuth := flag.Bool("claude-code-auth", false, "with no API key or token set, borrow the OAuth login in ~/"+claudecode.Filename+" for this run — your own machine only; it is issued to Claude Code (config: \"claude_code_auth\": \"on\")")
93+ noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "never borrow the OAuth login in ~/"+claudecode.Filename+", whatever -claude-code-auth or the config say")
9394 noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr")
9495 noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it")
9596 noMCP := flag.Bool("no-mcp", false, "do not start the MCP servers in the config, so their tools are not offered")
@@ -125,69 +126,80 @@ func run() error {
125126 // still starts, and the wizard can write a good one over it.
126127 cfg, _, cfgErr := config.Load()
127128
128- // Held as a slice because the Claude Code fallback below may have to
129- // rebuild the client with one more option, and replaying the same list is
130- // how the rebuilt client is guaranteed to be the one that passed the
131- // endpoint check.
129+ // Held as a slice because the Claude Code login may have to rebuild the
130+ // client with one more option — see borrowClaudeCode in auth.go.
132131 opts := []llm.Option{
133132 llm.WithModel(config.Or(*model, cfg.Model)),
134133 llm.WithBaseURL(*baseURL),
135134 llm.WithAPIKey(*apiKey),
136135 llm.WithAuthToken(*authToken),
137136 }
138- client := llm.New(opts...)
139- // clientOpts is the list the live client was actually built from, kept so
140- // a second client on another model can be built the same way. The Claude
141- // Code branch below is the only thing that adds to it.
142- clientOpts := opts
143137
144138 home, err := os.UserHomeDir()
145139 if err != nil {
146140 // No home directory is not fatal: an explicit credential still works.
147141 home = ""
148142 }
149- var authNote string
150- // systemPrefix carries the Claude Code identity block. It is set only when
151- // the credential was borrowed from Claude Code's own login: an OAuth
152- // request that omits it is rejected (as a 429, not a 401 — it reads like a
153- // quota problem and is not one).
154- var systemPrefix string
155- // ccSource is kept so its write-back failure hook can be wired once the
156- // places a warning can go — the notice channel, the event log — exist.
157- var ccSource *claudecode.Source
158- if home != "" {
159- src, note, err := resolveClaudeCode(client, home, *noClaudeCodeAuth)
160- if err != nil {
161- return err
162- }
163- if src != nil {
164- ccSource = src
165- clientOpts = append(opts, llm.WithTokenSource(src.Token))
166- client = llm.New(clientOpts...)
167- // Not expected to ever trigger: resolveClaudeCode already checked
168- // the endpoint before we agreed to borrow this credential, but
169- // that check ran against the client we had before this rebuild.
170- // This is a guard against a future refactor reordering things so
171- // the two clients quietly diverge, not a condition anyone should
172- // see fire.
173- if !client.IsDefaultEndpoint() {
174- return errors.New("internal: discovered credential on a non-default endpoint")
175- }
176- authNote = note
177- systemPrefix = agent.ClaudeCodeIdentity
178- }
143+ // Whether borrowing Claude Code's login is a question for this run at all,
144+ // decided before anything is borrowed: the wizard asks it, and the errors
145+ // mention it, only where the answer would change something.
146+ ccAskable := claudeCodeAskable(llm.New(opts...), home)
147+ // The login is borrowed only when asked for, by flag or config; see
148+ // auth.go for why that is not the default.
149+ b, err := borrowClaudeCode(opts, home, claudeCodeAllowed(*noClaudeCodeAuth, *claudeCodeAuth, cfg))
150+ if err != nil {
151+ return err
179152 }
180153 // The login-and-models probe answers here, where a borrowed Claude Code
181154 // credential already counts as being logged in, and before the check
182155 // below: "not logged in" is the useful answer to that question, not an
183- // error exit that tells the asker nothing.
156+ // error exit that tells the asker nothing. It reads the same flags and
157+ // config the session does, so it cannot report a login the agent run
158+ // would then refuse to use.
184159 if inv.Kind == invModels {
185- printModels(os.Stdout, client)
160+ printModels(os.Stdout, b.client, ccAskable)
186161 return nil
187162 }
188163
189- if !client.HasCredentials() {
190- return errNoCredentials
164+ // Someone upgrading who relied on the old silent fallback is asked once,
165+ // at a terminal, rather than met with an error they have no context for.
166+ tty := inv.Kind == invNormal && *prompt == "" && interactive()
167+ if !b.client.HasCredentials() && shouldAskClaudeCode(cfg, cfgErr, ccAskable, *noClaudeCodeAuth, tty) {
168+ var yes bool
169+ yes, cfg = askClaudeCode(os.Stdin, os.Stderr, cfg, config.Save)
170+ if yes {
171+ if b, err = borrowClaudeCode(opts, home, true); err != nil {
172+ return err
173+ }
174+ }
175+ }
176+ if !b.client.HasCredentials() {
177+ return noCredentialsError(ccAskable, configPathLabel(home))
178+ }
179+
180+ client := b.client
181+ // clientOpts is the list the live client was actually built from, kept so
182+ // a second client on another model can be built the same way.
183+ clientOpts := b.opts
184+ authNote := b.note
185+ // ccSource is kept so its write-back failure hook can be wired once the
186+ // places a warning can go — the notice channel, the event log — exist.
187+ ccSource := b.source
188+ // systemPrefix carries the Claude Code identity block. It is set only when
189+ // the credential was borrowed from Claude Code's own login: an OAuth
190+ // request that omits it is rejected (as a 429, not a 401 — it reads like a
191+ // quota problem and is not one).
192+ var systemPrefix string
193+ // ccInForce is what this session is running on, for the settings panel.
194+ // It is on or nothing: a run where the question arose and the login was
195+ // not borrowed has no credential and has already stopped above, and where
196+ // it did not arise — a key was given, or the endpoint is not Anthropic's —
197+ // the panel reads the file rather than claim a restart would change
198+ // anything.
199+ var ccInForce string
200+ if ccSource != nil {
201+ systemPrefix = agent.ClaudeCodeIdentity
202+ ccInForce = config.ClaudeCodeAuthOn
191203 }
192204
193205 effortSetting := config.Or(*effort, cfg.Effort)
@@ -780,6 +792,12 @@ func run() error {
780792 // nothing on a machine with no checker installed for anything in
781793 // the tree, whatever the file says.
782794 Diagnostics: diagMode,
795+ // Whether to ask about borrowing Claude Code's login: only where
796+ // nothing else supplied a credential and there is a login to
797+ // borrow. And what this session is actually running on, so the
798+ // panel knows when a change waits for the next start.
799+ OfferClaudeCodeAuth: ccAskable,
800+ ClaudeCodeAuth: ccInForce,
783801 CopyClaudeSkills: func() (int, error) {
784802 copied, problems, err := skills.CopyClaude(home, root)
785803 for _, p := range problems {
@@ -938,47 +956,6 @@ func beginTurn(parent context.Context) (context.Context, func()) {
938956 }
939957 }
940958
941- // errNoCredentials spells out every way to supply one, because "unauthorized"
942- // against a gateway that ignores the header is a confusing failure.
943- var errNoCredentials = errors.New(`no credentials. Supply one of:
944- export ANTHROPIC_API_KEY=sk-ant-... (sent as x-api-key)
945- export ANTHROPIC_AUTH_TOKEN=... (sent as Authorization: Bearer)
946- -api-key / -auth-token (flags override the environment)
947- log in with Claude Code (borrowed automatically, Anthropic endpoint only)
948- To use a non-Anthropic endpoint, set ANTHROPIC_BASE_URL or -base-url as well.`)
949-
950- // resolveClaudeCode returns a token source borrowed from Claude Code's login,
951- // or nil when one should not be used. The second return is a line for the
952- // user: a borrowed credential is worth announcing, the same way a non-default
953- // endpoint is.
954- //
955- // Two conditions gate it. The client must have no credential already, because
956- // we never override what the user typed. And the endpoint must be Anthropic's,
957- // because a credential we found on disk is not one the user chose to send —
958- // forwarding it to a -base-url someone else operates would leak it to a third
959- // party because a file happened to exist.
960- func resolveClaudeCode(client *llm.Client, home string, disabled bool) (*claudecode.Source, string, error) {
961- if disabled || client.HasCredentials() || !client.IsDefaultEndpoint() {
962- return nil, "", nil
963- }
964- creds, err := claudecode.Load(home)
965- if errors.Is(err, claudecode.ErrNotFound) {
966- return nil, "", nil
967- }
968- if err != nil {
969- return nil, "", err
970- }
971- note := fmt.Sprintf("using your Claude Code login (%s plan) from ~/%s", creds.SubscriptionType, claudecode.Filename)
972- // home is handed on so a rotated refresh token can be written back. That
973- // is the only circumstance in which this process writes to Claude Code's
974- // file, and not writing then is what would break its login.
975- //
976- // The refresh client is deliberately not Codey's: that one allows
977- // ten minutes for a model to answer, which is the wrong budget for a token
978- // exchange running under a lock. NewSource supplies a short-timeout client.
979- return claudecode.NewSource(creds, nil, home), note, nil
980- }
981-
982959 // interactive reports whether both ends of the terminal are a real one. A
983960 // piped stdin or a redirected stdout means the full-screen UI would be wrong,
984961 // and the line-based REPL still works.
+12−0internal/claudecode/claudecode.goView File
@@ -49,6 +49,18 @@ type stored struct {
4949 } `json:"claudeAiOauth"`
5050 }
5151
52+ // Present reports whether Claude Code has a credentials file in home, without
53+ // reading it. It is what decides whether a question about borrowing the login
54+ // is worth asking, and asking is all it is for: whether the file holds a
55+ // usable token is Load's business, and only once someone has said yes.
56+ func Present(home string) bool {
57+ if home == "" {
58+ return false
59+ }
60+ _, err := os.Stat(filepath.Join(home, Filename))
61+ return err == nil
62+ }
63+
5264 // Load reads and parses the credential store. home is passed in rather than
5365 // looked up so tests can point it at a fixture, matching skills.Discover.
5466 func Load(home string) (*Credentials, error) {
+15−0internal/claudecode/claudecode_test.goView File
@@ -72,6 +72,21 @@ func TestLoadMissingFileIsErrNotFound(t *testing.T) {
7272 }
7373 }
7474
75+ // Present only looks. A file it cannot parse is still a file, because the
76+ // question it answers is whether there is a login to ask about, and the
77+ // answer to a broken one is Load's error once someone has said yes.
78+ func TestPresentOnlyChecksTheFileIsThere(t *testing.T) {
79+ if Present(t.TempDir()) {
80+ t.Error("an empty home has no Claude Code login")
81+ }
82+ if Present("") {
83+ t.Error("no home directory has no Claude Code login")
84+ }
85+ if !Present(write(t, "{not json")) {
86+ t.Error("a credentials file is present whatever is in it")
87+ }
88+ }
89+
7590 // A file that exists but does not parse is a broken install, and saying so
7691 // with the path beats falling through to "no credentials".
7792 func TestLoadMalformedJSONNamesThePath(t *testing.T) {
+29−0internal/config/config.goView File
@@ -118,6 +118,20 @@ const (
118118 DiagnosticsOff = "off"
119119 )
120120
121+ // Claude Code login settings, as written to the file.
122+ //
123+ // Empty means off, and it has to: the login in ~/.claude/.credentials.json is
124+ // issued to Claude Code, and using it from another client is outside
125+ // Anthropic's terms for it. Borrowing it is therefore something you ask for on
126+ // your own machine, never something a fresh install does because a file
127+ // happened to be there. An empty value is also how the startup path tells
128+ // "never asked" apart from an explicit "off", which is what decides whether
129+ // someone upgrading from the silent fallback is asked once or simply told.
130+ const (
131+ ClaudeCodeAuthOn = "on"
132+ ClaudeCodeAuthOff = "off"
133+ )
134+
121135 // DefaultSearchCommand is the command WebSearchCustom runs when nothing else
122136 // is configured: ddgs, the DuckDuckGo CLI, which needs no account and no API
123137 // key. {{query}} and {{count}} are substituted as whole argv elements, never
@@ -165,6 +179,10 @@ type Config struct {
165179 // agent edits is handed to whatever language server or linter this
166180 // machine has for it, and what they say handed back. Empty means on.
167181 Diagnostics string `json:"diagnostics,omitempty"`
182+ // ClaudeCodeAuth is ClaudeCodeAuthOn or ClaudeCodeAuthOff: whether a run
183+ // with no API key or token of its own may borrow Claude Code's OAuth
184+ // login. Empty means off, and means nobody has been asked yet.
185+ ClaudeCodeAuth string `json:"claude_code_auth,omitempty"`
168186 // Hooks maps a lifecycle event name to the shell commands run when it
169187 // happens; the names are the ones in hooks.All. It is the one setting
170188 // here the wizard never asks about and never writes: a hook is a command
@@ -299,6 +317,11 @@ func ValidClaudeSkills(mode string) bool { return contains(ClaudeSkillsModes, mo
299317 // "off" turns it off.
300318 func (c Config) DiagnosticsEnabled() bool { return c.Diagnostics != DiagnosticsOff }
301319
320+ // ClaudeCodeAuthEnabled reports whether the config asks for Claude Code's
321+ // login to be borrowed. Only an explicit "on" does: this is the one setting
322+ // where the cautious reading is also the one Anthropic's terms require.
323+ func (c Config) ClaudeCodeAuthEnabled() bool { return c.ClaudeCodeAuth == ClaudeCodeAuthOn }
324+
302325 // AutoCompactEnabled reports whether the conversation should fold itself when
303326 // the window fills. Only an explicit "off" turns it off.
304327 func (c Config) AutoCompactEnabled() bool { return c.AutoCompact != AutoCompactOff }
@@ -435,6 +458,12 @@ func sanitize(c Config) Config {
435458 if c.Diagnostics != DiagnosticsOn && c.Diagnostics != DiagnosticsOff {
436459 c.Diagnostics = ""
437460 }
461+ // An unrecognised word is not an "on". It is dropped to empty rather than
462+ // to "off", so the one-time question at startup still gets asked: a typo
463+ // is not an answer.
464+ if c.ClaudeCodeAuth != ClaudeCodeAuthOn && c.ClaudeCodeAuth != ClaudeCodeAuthOff {
465+ c.ClaudeCodeAuth = ""
466+ }
438467 c.SearchCommand = strings.TrimSpace(c.SearchCommand)
439468 // Anything other than the one word means the marker was not written by a
440469 // wizard that finished, so it does not count as one.
+55−0internal/config/config_test.goView File
@@ -531,3 +531,58 @@ func TestEqualSeesMCPServers(t *testing.T) {
531531 t.Error("no args should equal an empty args list")
532532 }
533533 }
534+
535+ // The Claude Code login is borrowed only on an explicit "on". A config written
536+ // before the setting existed, and one with a word this build does not know,
537+ // both read as off — and both read as never asked, which is what lets the
538+ // startup path ask someone upgrading from the silent fallback exactly once.
539+ func TestClaudeCodeAuthRoundTripsAndDefaultsToOff(t *testing.T) {
540+ isolate(t)
541+ for _, v := range []string{ClaudeCodeAuthOn, ClaudeCodeAuthOff} {
542+ if err := Save(Config{ClaudeCodeAuth: v}); err != nil {
543+ t.Fatal(err)
544+ }
545+ got, _, err := Load()
546+ if err != nil {
547+ t.Fatal(err)
548+ }
549+ if got.ClaudeCodeAuth != v {
550+ t.Errorf("%q did not survive a round trip: got %q", v, got.ClaudeCodeAuth)
551+ }
552+ if got.ClaudeCodeAuthEnabled() != (v == ClaudeCodeAuthOn) {
553+ t.Errorf("%q: enabled = %v", v, got.ClaudeCodeAuthEnabled())
554+ }
555+ }
556+
557+ if err := Save(Config{ClaudeCodeAuth: "yes"}); err != nil {
558+ t.Fatal(err)
559+ }
560+ got, _, err := Load()
561+ if err != nil {
562+ t.Fatal(err)
563+ }
564+ if got.ClaudeCodeAuth != "" {
565+ t.Errorf("an unrecognised value survived: %q", got.ClaudeCodeAuth)
566+ }
567+ if got.ClaudeCodeAuthEnabled() {
568+ t.Error("an unrecognised value turned the Claude Code login on")
569+ }
570+ if (Config{}).ClaudeCodeAuthEnabled() {
571+ t.Error("an empty config borrows the Claude Code login; it must be off until asked for")
572+ }
573+ }
574+
575+ // The key is spelled the way the README and the error message spell it.
576+ func TestClaudeCodeAuthKeyName(t *testing.T) {
577+ path := isolate(t)
578+ if err := Save(Config{ClaudeCodeAuth: ClaudeCodeAuthOn}); err != nil {
579+ t.Fatal(err)
580+ }
581+ raw, err := os.ReadFile(path)
582+ if err != nil {
583+ t.Fatal(err)
584+ }
585+ if !strings.Contains(string(raw), `"claude_code_auth": "on"`) {
586+ t.Errorf("the file does not say \"claude_code_auth\": \"on\":\n%s", raw)
587+ }
588+ }
+1−1internal/llm/client.goView File
@@ -88,7 +88,7 @@ func (c *Client) Clone(model string) *Client {
8888 // authenticate with. It is one value rather than three copies of a string
8989 // because ListModels, CreateMessage and StreamMessage all reach it, and a
9090 // caller comparing them should not have to hope they were typed alike.
91- var errNoCredentials = errors.New("llm: no credentials: set ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or log in with Claude Code")
91+ var errNoCredentials = errors.New("llm: no credentials: set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN")
9292
9393 // Option configures a Client.
9494 type Option func(*Client)
+21−0internal/tui/model.goView File
@@ -134,6 +134,20 @@ type Options struct {
134134 // config reads.
135135 Diagnostics string
136136
137+ // OfferClaudeCodeAuth says the question of borrowing Claude Code's login
138+ // arises on this run: no key or token was supplied, the endpoint is
139+ // Anthropic's, and Claude Code is logged in here. It is the only thing
140+ // that puts the question in the wizard — someone with an API key has
141+ // nothing to decide. Decided by the caller, because it is about the flags,
142+ // the environment and the disk, none of which are the screen's.
143+ OfferClaudeCodeAuth bool
144+ // ClaudeCodeAuth is whether this session is running on that login: on
145+ // when it is, and empty when the caller has nothing to say — the screens
146+ // then read the config. Like Diagnostics it is what was delivered, so the
147+ // screens can say when an answer waits for the next start: the client was
148+ // built, credential and all, before the first frame.
149+ ClaudeCodeAuth string
150+
137151 // NewSession builds a second conversation for a new tab, rooted at the
138152 // directory it is given. The TUI cannot build one itself: an agent is a
139153 // client, a tool set, a state and a policy, and which of those a run is
@@ -306,6 +320,7 @@ type model struct {
306320 setWeb string
307321 setClaude string
308322 setDiag string
323+ setCCAuth string
309324
310325 // The model picker: what the endpoint offered, where the cursor is, and
311326 // whether we are still waiting to be told.
@@ -568,6 +583,7 @@ func (m *model) openWizard() tea.Cmd {
568583 // palette; nothing selected underneath it survives that.
569584 m.clearSelection()
570585 m.wiz = newWizard(m.opts.Client, m.opts.Config, m.opts.ClaudeSkills, m.opts.HerdrPresent && m.opts.InstallHerdrSkill != nil)
586+ m.wiz.offerClaudeCodeAuth = m.opts.OfferClaudeCodeAuth
571587 if m.ready {
572588 // The wizard has never seen a resize, so it is told the size the model
573589 // already knows rather than rendering into a zero-sized screen until
@@ -628,6 +644,11 @@ func (m *model) closeWizard() tea.Cmd {
628644 if m.opts.Policy != nil && m.opts.Policy.YOLO {
629645 m.tr.addNotice("accept-all is on — tool calls run without asking.")
630646 }
647+ // Same again for the Claude Code login: the client this session talks
648+ // through was built, credential and all, before the wizard opened.
649+ if want := claudeCodeAuthMode(cfg); w.offerClaudeCodeAuth && m.opts.ClaudeCodeAuth != "" && want != m.opts.ClaudeCodeAuth {
650+ m.tr.addNotice(claudeCodeAuthRestartNote(want))
651+ }
631652 if cfg.ClaudeSkillsMode() == config.ClaudeSkillsCopy && m.opts.CopyClaudeSkills != nil {
632653 // Done here rather than as a command: it is a copy between two
633654 // directories on the same disk, over in the time a frame takes, and a
+39−1internal/tui/settings.goView File
@@ -28,13 +28,14 @@ const (
2828 setApprovalsRow
2929 setWebSearchRow
3030 setDiagnosticsRow
31+ setClaudeCodeAuthRow
3132 setClaudeSkillsRow
3233 setThemeRow
3334 numSettingRows
3435 )
3536
3637 var settingLabels = [numSettingRows]string{
37- "Model", "Effort", "Approvals", "Web search", "Diagnostics", "Claude skills", "Theme",
38+ "Model", "Effort", "Approvals", "Web search", "Diagnostics", "Claude Code login", "Claude skills", "Theme",
3839 }
3940
4041 // settingsHeadRows is how many rows sit above the list: the heading, which
@@ -49,6 +50,7 @@ func (m *model) openSettings() tea.Cmd {
4950 m.setModelWas = m.modelInForce()
5051 m.setWeb = m.opts.Config.WebSearchMode()
5152 m.setClaude = m.opts.Config.ClaudeSkillsMode()
53+ m.setCCAuth = claudeCodeAuthMode(m.opts.Config)
5254 m.setDiag = diagnosticsInForce(m.opts.Config)
5355 // A theme change repaints everything, including whatever was marked.
5456 m.clearSelection()
@@ -170,6 +172,8 @@ func (m *model) settingsConfig() config.Config {
170172 cfg.WebSearch = v
171173 case setDiagnosticsRow:
172174 cfg.Diagnostics = v
175+ case setClaudeCodeAuthRow:
176+ cfg.ClaudeCodeAuth = v
173177 case setClaudeSkillsRow:
174178 cfg.ClaudeSkills = v
175179 case setThemeRow:
@@ -197,6 +201,11 @@ func settingsSummary(cfg config.Config) string {
197201 if mode := cfg.ClaudeSkillsMode(); mode != config.ClaudeSkillsOff {
198202 s += " · claude skills " + mode
199203 }
204+ // Named only when on, like web search: it is the exception, and the one
205+ // worth being reminded of.
206+ if cfg.ClaudeCodeAuthEnabled() {
207+ s += " · claude code login"
208+ }
200209 return s
201210 }
202211
@@ -236,6 +245,8 @@ func (m *model) settingValue(r settingRow) string {
236245 return config.Or(m.setWeb, m.webSearchInForce())
237246 case setDiagnosticsRow:
238247 return config.Or(m.setDiag, diagnosticsInForce(m.opts.Config))
248+ case setClaudeCodeAuthRow:
249+ return config.Or(m.setCCAuth, claudeCodeAuthMode(m.opts.Config))
239250 case setClaudeSkillsRow:
240251 return config.Or(m.setClaude, m.opts.Config.ClaudeSkillsMode())
241252 case setThemeRow:
@@ -263,6 +274,8 @@ func settingOptions(r settingRow) []wizOption {
263274 return webSearchOptions()
264275 case setDiagnosticsRow:
265276 return diagnosticsOptions()
277+ case setClaudeCodeAuthRow:
278+ return claudeCodeAuthOptions()
266279 case setClaudeSkillsRow:
267280 return claudeSkillsOptions()
268281 case setThemeRow:
@@ -307,6 +320,10 @@ func (m *model) useSetting(r settingRow, v string) {
307320 // Same again: whether an edit is checked was decided when the edit
308321 // tool was built, so this row edits the file and says so.
309322 m.setDiag = v
323+ case setClaudeCodeAuthRow:
324+ // And again: the client, credential and all, was built before the
325+ // first frame was drawn.
326+ m.setCCAuth = v
310327 case setClaudeSkillsRow:
311328 // And again: which skills the model was told about went into the
312329 // first request of the session, which has already gone out.
@@ -450,6 +467,9 @@ func (m *model) settingDetail(r settingRow) string {
450467 if r == setClaudeSkillsRow && v != m.claudeSkillsInForce() {
451468 desc += " · takes effect next start"
452469 }
470+ if r == setClaudeCodeAuthRow && v != m.claudeCodeAuthInForce() {
471+ desc += " · takes effect next start"
472+ }
453473 return desc
454474 }
455475 }
@@ -509,6 +529,24 @@ func (m *model) claudeSkillsInForce() string {
509529 return config.Or(m.opts.ClaudeSkillsMode, m.opts.Config.ClaudeSkillsMode())
510530 }
511531
532+ // claudeCodeAuthInForce is what this session is running on, falling back to
533+ // the config where main did not say — which it does not when the question
534+ // never arose, because a key was given or the endpoint is not Anthropic's.
535+ // There the setting changes nothing on the next start either, and a panel
536+ // promising otherwise would be wrong.
537+ func (m *model) claudeCodeAuthInForce() string {
538+ return config.Or(m.opts.ClaudeCodeAuth, claudeCodeAuthMode(m.opts.Config))
539+ }
540+
541+ // claudeCodeAuthRestartNote is what the wizard says when its answer about the
542+ // Claude Code login is not the one this session started under.
543+ func claudeCodeAuthRestartNote(mode string) string {
544+ if mode == config.ClaudeCodeAuthOn {
545+ return "Claude Code login: on — saved; it is borrowed from the next start of codey, when no API key is set"
546+ }
547+ return "Claude Code login: off — saved; from the next start codey needs ANTHROPIC_API_KEY or a gateway token"
548+ }
549+
512550 // webSearchRestartNote is what both screens say when the web-search answer is
513551 // one this process cannot take on. It names the restart because that is the
514552 // whole of the fix, and there is no way to do it from inside the session: the
+49−0internal/tui/settings_test.goView File
@@ -470,6 +470,55 @@ func TestSettingsClaudeSkillsRow(t *testing.T) {
470470 }
471471 }
472472
473+ // The Claude Code login row: off unless the file says on, a change waits for
474+ // the next start, and only a touched row is written.
475+ func TestSettingsClaudeCodeLoginRow(t *testing.T) {
476+ m := newTestModel(t)
477+ m.openSettings()
478+ m.setIdx = setClaudeCodeAuthRow
479+
480+ if got := m.settingValue(setClaudeCodeAuthRow); got != config.ClaudeCodeAuthOff {
481+ t.Fatalf("an empty config opened the row on %q, want %q", got, config.ClaudeCodeAuthOff)
482+ }
483+ if strings.Contains(m.settingDetail(setClaudeCodeAuthRow), "next start") {
484+ t.Error("an unchanged row warns about a restart it does not need")
485+ }
486+ if cfg := m.settingsConfig(); cfg.ClaudeCodeAuth != "" {
487+ t.Errorf("an untouched row wrote %q", cfg.ClaudeCodeAuth)
488+ }
489+
490+ m.cycleSetting(setClaudeCodeAuthRow, 1)
491+ if got := m.settingValue(setClaudeCodeAuthRow); got != config.ClaudeCodeAuthOn {
492+ t.Fatalf("cycling landed on %q, want %q", got, config.ClaudeCodeAuthOn)
493+ }
494+ detail := m.settingDetail(setClaudeCodeAuthRow)
495+ if !strings.Contains(detail, "next start") || !strings.Contains(detail, "terms") {
496+ t.Errorf("the detail does not say when it takes effect, or what the terms are: %q", detail)
497+ }
498+ cfg := m.settingsConfig()
499+ if cfg.ClaudeCodeAuth != config.ClaudeCodeAuthOn {
500+ t.Errorf("saved %q, want %q", cfg.ClaudeCodeAuth, config.ClaudeCodeAuthOn)
501+ }
502+ if !strings.Contains(settingsSummary(cfg), "claude code login") {
503+ t.Errorf("the summary does not mention the login: %q", settingsSummary(cfg))
504+ }
505+ }
506+
507+ // Where the question never arose — a key was given — main says nothing about
508+ // the session, and the row reads the file without promising a restart would
509+ // change anything.
510+ func TestSettingsClaudeCodeLoginRowWithAKey(t *testing.T) {
511+ m := newTestModel(t)
512+ m.opts.Config = config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOn}
513+ m.openSettings()
514+ if got := m.settingValue(setClaudeCodeAuthRow); got != config.ClaudeCodeAuthOn {
515+ t.Fatalf("the row opened on %q, want the configured %q", got, config.ClaudeCodeAuthOn)
516+ }
517+ if strings.Contains(m.settingDetail(setClaudeCodeAuthRow), "next start") {
518+ t.Error("a row the session is not affected by warns about a restart")
519+ }
520+ }
521+
473522 // Every row has a label wide enough not to collide with its value. The panel
474523 // measures the column, and this is what says it still does.
475524 func TestSettingsLabelsDoNotRunIntoTheirValues(t *testing.T) {
+55−4internal/tui/wizard.goView File
@@ -9,6 +9,7 @@ import (
99 "github.com/charmbracelet/bubbles/textinput"
1010 tea "github.com/charmbracelet/bubbletea"
1111
12+ "github.com/johannSo/codey/internal/claudecode"
1213 "github.com/johannSo/codey/internal/config"
1314 "github.com/johannSo/codey/internal/llm"
1415 "github.com/johannSo/codey/internal/skills"
@@ -33,6 +34,7 @@ const (
3334 stepApprovals
3435 stepWebSearch
3536 stepTheme
37+ stepClaudeCodeAuth
3638 stepClaudeSkills
3739 stepHerdr
3840 stepDone
@@ -43,15 +45,19 @@ const (
4345 // positions on it.
4446 const baseRailSteps = 5
4547
46- // questions is the steps this run will ask, in order. The two at the end are
47- // conditional — Claude's skills are only offered on a machine that has some,
48- // herdr's only where herdr is — so the rail, the "step n of m" counter and the
48+ // questions is the steps this run will ask, in order. The three at the end are
49+ // conditional — the Claude Code login is only asked about where it could be
50+ // borrowed, Claude's skills only on a machine that has some, herdr's only
51+ // where herdr is — so the rail, the "step n of m" counter and the
4952 // next/previous arithmetic all read this list rather than the step's own
5053 // ordinal. With one conditional step it was enough to keep it last; with two,
5154 // the second is in the middle whenever the first is skipped, and a node the
5255 // marker jumps over is worse than no node.
5356 func (w *wizard) questions() []wizStep {
5457 q := []wizStep{stepModel, stepEffort, stepApprovals, stepWebSearch, stepTheme}
58+ if w.offerClaudeCodeAuth {
59+ q = append(q, stepClaudeCodeAuth)
60+ }
5561 if w.claudeSkills > 0 {
5662 q = append(q, stepClaudeSkills)
5763 }
@@ -172,6 +178,12 @@ type wizard struct {
172178 // where "some skills" is a mystery.
173179 claudeSkills int
174180
181+ // offerClaudeCodeAuth is whether to ask about borrowing Claude Code's
182+ // login: true only where no key or token was supplied, the endpoint is
183+ // Anthropic's, and Claude Code is logged in here. Someone with an API key
184+ // has nothing to decide, and is not asked.
185+ offerClaudeCodeAuth bool
186+
175187 // offerHerdr is whether this machine has herdr, and so whether the skill
176188 // question is asked at all.
177189 offerHerdr bool
@@ -476,6 +488,10 @@ func (w *wizard) advance() tea.Cmd {
476488 w.cfg.Theme = w.options()[w.cursor].value
477489 return w.goTo(w.next(stepTheme))
478490
491+ case stepClaudeCodeAuth:
492+ w.cfg.ClaudeCodeAuth = w.options()[w.cursor].value
493+ return w.goTo(w.next(stepClaudeCodeAuth))
494+
479495 case stepClaudeSkills:
480496 w.cfg.ClaudeSkills = w.options()[w.cursor].value
481497 return w.goTo(w.next(stepClaudeSkills))
@@ -503,7 +519,7 @@ func (w *wizard) back() tea.Cmd {
503519 return w.goTo(stepApprovals)
504520 case stepTheme:
505521 return w.goTo(stepWebSearch)
506- case stepClaudeSkills, stepHerdr:
522+ case stepClaudeCodeAuth, stepClaudeSkills, stepHerdr:
507523 return w.goTo(w.prev(w.step))
508524 }
509525 return nil
@@ -563,6 +579,8 @@ func (w *wizard) current() string {
563579 return w.cfg.WebSearchMode()
564580 case stepTheme:
565581 return w.cfg.Theme
582+ case stepClaudeCodeAuth:
583+ return claudeCodeAuthMode(w.cfg)
566584 case stepClaudeSkills:
567585 return w.cfg.ClaudeSkillsMode()
568586 }
@@ -619,6 +637,8 @@ func (w *wizard) options() []wizOption {
619637 return webSearchOptions()
620638 case stepTheme:
621639 return themeOptions()
640+ case stepClaudeCodeAuth:
641+ return claudeCodeAuthOptions()
622642 case stepClaudeSkills:
623643 return claudeSkillsOptions()
624644 case stepHerdr:
@@ -627,6 +647,37 @@ func (w *wizard) options() []wizOption {
627647 return nil
628648 }
629649
650+ // claudeCodeAuthOptions are the two answers to "borrow Claude Code's login when
651+ // there is no API key".
652+ //
653+ // No is first and the default, and it is the one place where the default is
654+ // not merely the cautious reading but the required one: the login is issued
655+ // to Claude Code, and using it from another client is outside Anthropic's
656+ // terms for it. Both rows say so, because the cursor opens on no and the
657+ // sentence about terms has to be on screen before anyone moves it to yes.
658+ func claudeCodeAuthOptions() []wizOption {
659+ return []wizOption{{
660+ value: config.ClaudeCodeAuthOff,
661+ label: "No, I'll use an API key",
662+ note: "default",
663+ desc: "Codey uses ANTHROPIC_API_KEY, or a gateway's token. Claude Code's login is issued to Claude Code; using it from another client is outside Anthropic's terms for it.",
664+ }, {
665+ value: config.ClaudeCodeAuthOn,
666+ label: "Yes, use my Claude Code login",
667+ note: "this machine only",
668+ desc: "borrows ~/" + claudecode.Filename + " when no key is set, against Anthropic's own endpoint only. It is issued to Claude Code, and using it from another client is outside Anthropic's terms for it.",
669+ }}
670+ }
671+
672+ // claudeCodeAuthMode reads a config's answer as one of the two words, so no
673+ // caller has to spell the empty-means-off rule out again.
674+ func claudeCodeAuthMode(cfg config.Config) string {
675+ if cfg.ClaudeCodeAuthEnabled() {
676+ return config.ClaudeCodeAuthOn
677+ }
678+ return config.ClaudeCodeAuthOff
679+ }
680+
630681 // claudeSkillsOptions are the three things to do with the skills Claude Code
631682 // has on this machine, in the config's order: nothing, read them in place,
632683 // take a copy.
+129−0internal/tui/wizard_test.goView File
@@ -1193,3 +1193,132 @@ func TestCancellingTheWizardCopiesNothing(t *testing.T) {
11931193 t.Errorf("the copy ran %d times for a wizard that was escaped out of", called)
11941194 }
11951195 }
1196+
1197+ // newLoginWizard is newClaudeWizard on a machine where the Claude Code login
1198+ // question does or does not arise, with n Claude skills.
1199+ func newLoginWizard(t *testing.T, offer bool, n int) *wizard {
1200+ t.Helper()
1201+ w := newClaudeWizard(t, n, false)
1202+ w.offerClaudeCodeAuth = offer
1203+ return w
1204+ }
1205+
1206+ // Someone with an API key has nothing to decide, and a machine without
1207+ // Claude Code has nothing to borrow: the question is asked only where main
1208+ // says it arises, and it comes straight after the theme, ahead of the other
1209+ // question about Claude Code.
1210+ func TestTheClaudeCodeLoginStepIsOnlyAskedWhenItArises(t *testing.T) {
1211+ tests := []struct {
1212+ name string
1213+ offer bool
1214+ n int
1215+ want wizStep
1216+ }{
1217+ {name: "not offered", offer: false, n: 0, want: stepDone},
1218+ {name: "not offered, skills", offer: false, n: 3, want: stepClaudeSkills},
1219+ {name: "offered", offer: true, n: 0, want: stepClaudeCodeAuth},
1220+ {name: "offered, skills", offer: true, n: 3, want: stepClaudeCodeAuth},
1221+ }
1222+ for _, tc := range tests {
1223+ t.Run(tc.name, func(t *testing.T) {
1224+ w := newLoginWizard(t, tc.offer, tc.n)
1225+ // Intro, model, effort, approvals, web search, theme.
1226+ enterThrough(w, 6)
1227+ if w.step != tc.want {
1228+ t.Errorf("after the theme step the wizard is on %v, want %v", w.step, tc.want)
1229+ }
1230+ if tc.offer && w.rails() != baseRailSteps+1+boolInt(tc.n > 0) {
1231+ t.Errorf("rails = %d: the login question is not counted", w.rails())
1232+ }
1233+ })
1234+ }
1235+ }
1236+
1237+ func boolInt(b bool) int {
1238+ if b {
1239+ return 1
1240+ }
1241+ return 0
1242+ }
1243+
1244+ // Enter through the step is no: the cursor opens on it, the terms are on
1245+ // screen before anyone moves it, and only moving to yes and pressing enter
1246+ // writes "on".
1247+ func TestTheClaudeCodeLoginStepRecordsTheAnswer(t *testing.T) {
1248+ tests := []struct {
1249+ name string
1250+ down int
1251+ want string
1252+ }{
1253+ {name: "no is the row the cursor opens on", down: 0, want: config.ClaudeCodeAuthOff},
1254+ {name: "yes", down: 1, want: config.ClaudeCodeAuthOn},
1255+ }
1256+ for _, tc := range tests {
1257+ t.Run(tc.name, func(t *testing.T) {
1258+ w := newLoginWizard(t, true, 0)
1259+ enterThrough(w, 6)
1260+ if w.step != stepClaudeCodeAuth {
1261+ t.Fatalf("the walk did not reach the login step, stopped at %v", w.step)
1262+ }
1263+ if view := w.View(); !strings.Contains(view, "Claude Code login") || !strings.Contains(view, "terms") {
1264+ t.Errorf("the step does not ask about the login with the terms on screen:\n%s", view)
1265+ }
1266+
1267+ down(w, tc.down)
1268+ w.Update(key("enter"))
1269+ settle(w)
1270+
1271+ if w.result == nil {
1272+ t.Fatal("answering the last question did not finish the wizard")
1273+ }
1274+ if w.result.ClaudeCodeAuth != tc.want {
1275+ t.Errorf("claude_code_auth = %q, want %q", w.result.ClaudeCodeAuth, tc.want)
1276+ }
1277+ })
1278+ }
1279+ }
1280+
1281+ // Re-running setup opens the step on what is in force, so enter through it
1282+ // changes nothing — and esc goes back to the theme.
1283+ func TestTheClaudeCodeLoginStepPreselectsAndGoesBack(t *testing.T) {
1284+ w := newLoginWizard(t, true, 0)
1285+ w.cfg.ClaudeCodeAuth = config.ClaudeCodeAuthOn
1286+ enterThrough(w, 6)
1287+ if got := w.options()[w.cursor].value; got != config.ClaudeCodeAuthOn {
1288+ t.Errorf("the step opened on %q, want the configured %q", got, config.ClaudeCodeAuthOn)
1289+ }
1290+ w.Update(key("esc"))
1291+ settle(w)
1292+ if w.step != stepTheme {
1293+ t.Errorf("esc from the login step landed on %v, want the theme", w.step)
1294+ }
1295+ }
1296+
1297+ // The wizard is told by main whether to ask, through the options.
1298+ func TestOpenWizardPassesOnTheLoginQuestion(t *testing.T) {
1299+ m := newTestModel(t)
1300+ m.opts.OfferClaudeCodeAuth = true
1301+ m.openWizard()
1302+ if !m.wiz.offerClaudeCodeAuth {
1303+ t.Error("the wizard was not told the login question arises")
1304+ }
1305+ }
1306+
1307+ // The session's client was built before the wizard opened, so an answer that
1308+ // differs from what it is running on says when it will take effect.
1309+ func TestALoginAnswerTheSessionCannotTakeSaysSo(t *testing.T) {
1310+ m := newTestModel(t)
1311+ m.opts.SaveConfig = func(config.Config) error { return nil }
1312+ m.opts.OfferClaudeCodeAuth = true
1313+ m.opts.ClaudeCodeAuth = config.ClaudeCodeAuthOn
1314+
1315+ m.wiz = newWizard(nil, m.opts.Config, 0, false)
1316+ m.wiz.offerClaudeCodeAuth = true
1317+ m.wiz.cfg.ClaudeCodeAuth = config.ClaudeCodeAuthOff
1318+ m.wiz.finish()
1319+ runCmd(t, m.closeWizard())
1320+
1321+ if view := m.tr.render(); !strings.Contains(view, "Claude Code login: off") || !strings.Contains(view, "next start") {
1322+ t.Errorf("turning the login off mid-session does not say when it takes effect:\n%s", view)
1323+ }
1324+ }
+2−0internal/tui/wizard_view.goView File
@@ -164,6 +164,8 @@ func (w *wizard) question() string {
164164 return "Should it be able to search the web?"
165165 case stepTheme:
166166 return "How should it look?"
167+ case stepClaudeCodeAuth:
168+ return "No API key is set. Use your Claude Code login instead?"
167169 case stepClaudeSkills:
168170 // The number is the offer: it says these are real, already on the
169171 // machine, and how much is being agreed to.