Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 17 changed files with 1083 additions and 175 deletions.
| @@ -15,14 +15,17 @@ go build -o codey ./cmd/codey | ||
| 15 | 15 | ./codey -C /path/to/repo -p "why does TestFoo fail?" # one prompt |
| 16 | 16 | ``` |
| 17 | 17 | |
| 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): | |
| 21 | 21 | |
| 22 | 22 | ```bash |
| 23 | 23 | export ANTHROPIC_API_KEY=sk-ant-... |
| 24 | 24 | ``` |
| 25 | 25 | |
| 26 | + Started with nothing at all, Codey stops and says how to get one rather than | |
| 27 | + failing on the first request. | |
| 28 | + | |
| 26 | 29 | A different endpoint — a gateway, a proxy, a local server speaking the Messages |
| 27 | 30 | API: |
| 28 | 31 | |
| @@ -38,13 +41,28 @@ what [the setup wizard](#setup) writes on a first run. `-auth-token` sends | ||
| 38 | 41 | stderr, because your prompt and every file the agent reads go wherever that URL |
| 39 | 42 | points. |
| 40 | 43 | |
| 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 | |
| 43 | 56 | endpoint — a credential found on disk is not one you chose to send, so it is |
| 44 | 57 | 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. | |
| 48 | 66 | |
| 49 | 67 | On macOS Claude Code keeps this in the Keychain rather than a file, so there is |
| 50 | 68 | nothing to borrow there. An expired access token is refreshed automatically; the |
| @@ -55,7 +73,7 @@ would break your Claude Code login. | ||
| 55 | 73 | Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`, |
| 56 | 74 | `-resume latest`, `-no-stream`, `-no-cache`, `-no-auto-compact`, `-v`, `-yolo`, |
| 57 | 75 | `-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`. | |
| 59 | 77 | |
| 60 | 78 | ### Flags and thresholds |
| 61 | 79 | |
| @@ -758,8 +776,9 @@ agents at once. `-p` and `-no-tui` runs are not offered the tools at all, and | ||
| 758 | 776 | |
| 759 | 777 | The first run on a terminal opens a full-screen wizard instead of the chat: the |
| 760 | 778 | 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 | |
| 763 | 782 | [herdr](#herdr). |
| 764 | 783 | "First run" means the config file does not say `"setup": "done"` — the marker |
| 765 | 784 | 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. | ||
| 789 | 808 | | Approvals | ask before every mutating call, or accept them all | |
| 790 | 809 | | Web search | `none`, Claude's hosted search, or a command on this machine | |
| 791 | 810 | | 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 | | |
| 792 | 812 | | Claude skills | leave them alone, use them in place, or copy them once — asked only where there are some | |
| 793 | 813 | | herdr skill | install it, or not — asked only where herdr is | |
| 794 | 814 | |
| 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 | |
| 796 | 816 | 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. | |
| 799 | 827 | The herdr answer is also the one that is not a preference: it is an act, |
| 800 | 828 | carried out once, so nothing about it is written to the config. It appears when |
| 801 | 829 | `HERDR_ENV=1` or `herdr` is on `PATH`. What it installs, and what happens when |
| @@ -829,12 +857,19 @@ honoured): | ||
| 829 | 857 | "search_command": "ddgs text -q {{query}} -m {{count}} -nc", |
| 830 | 858 | "claude_skills": "use", |
| 831 | 859 | "diagnostics": "on", |
| 860 | + "claude_code_auth": "off", | |
| 832 | 861 | "theme": "ember", |
| 833 | 862 | "auto_compact": "on", |
| 834 | 863 | "setup": "done" |
| 835 | 864 | } |
| 836 | 865 | ``` |
| 837 | 866 | |
| 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 | + | |
| 838 | 873 | `auto_compact` is the one key neither the wizard nor `/settings` offers a row |
| 839 | 874 | for — set it by hand, or pass `-no-auto-compact` for a single run. |
| 840 | 875 | `diagnostics` is the one `/settings` has a row for and the wizard does not |
| @@ -906,13 +941,14 @@ in place, applied as you move. | ||
| 906 | 941 | |
| 907 | 942 | ``` |
| 908 | 943 | 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 | |
| 916 | 952 | claude-opus-5 · enter to pick from anthropic |
| 917 | 953 | saved to ~/.config/codey/config.json |
| 918 | 954 | ``` |
| @@ -929,6 +965,13 @@ level the other does not use. The model row is the exception: its choices come | ||
| 929 | 965 | from the endpoint rather than from this build, so `enter` hands over to the |
| 930 | 966 | `/model` picker and lands back here once you have chosen. |
| 931 | 967 | |
| 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 | + | |
| 932 | 975 | What the panel shows is the *session*, not the file — `/effort`, `shift+tab` |
| 933 | 976 | and `-yolo` all change a setting without the file hearing about it, and a panel |
| 934 | 977 | that disagreed with the status bar two lines below it would be worse than |
| @@ -1403,7 +1446,10 @@ instead of adding one. | ||
| 1403 | 1446 | Two invocations exist only for that provider card: `codey --version`, and |
| 1404 | 1447 | `codey models`, which prints whether there is a credential and what the |
| 1405 | 1448 | 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. | |
| 1407 | 1453 | |
| 1408 | 1454 | `docs/superpowers/specs/2026-09-19-t3code-provider-design.md` is the research |
| 1409 | 1455 | this is built on: what every T3 Code provider requires of a binary, and why the |
| @@ -151,9 +151,19 @@ const modelsTimeout = 8 * time.Second | ||
| 151 | 151 | // marked. It exits zero even when it has nothing good to say — a non-zero exit |
| 152 | 152 | // makes the client discard the whole listing, including the part that was |
| 153 | 153 | // 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) { | |
| 155 | 161 | 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.") | |
| 157 | 167 | return |
| 158 | 168 | } |
| 159 | 169 | fmt.Fprintf(w, "You are logged in to %s.\n", client.BaseURL) |
| @@ -161,7 +161,7 @@ func TestModelsOutputParsesTheWayTheClientParsesIt(t *testing.T) { | ||
| 161 | 161 | // No endpoint is reachable here, so this exercises the fallback: the one |
| 162 | 162 | // model we were configured with, and a login state that is still true. |
| 163 | 163 | var out bytes.Buffer |
| 164 | - printModels(&out, testClient(t)) | |
| 164 | + printModels(&out, testClient(t), false) | |
| 165 | 165 | |
| 166 | 166 | loggedIn, models, def := parseLikeT3(out.String()) |
| 167 | 167 | if !loggedIn { |
| @@ -174,7 +174,7 @@ func TestModelsOutputParsesTheWayTheClientParsesIt(t *testing.T) { | ||
| 174 | 174 | |
| 175 | 175 | func TestLoggedOutSaysSoRatherThanFailing(t *testing.T) { |
| 176 | 176 | var out bytes.Buffer |
| 177 | - printModels(&out, noCredentialClient(t)) | |
| 177 | + printModels(&out, noCredentialClient(t), false) | |
| 178 | 178 | |
| 179 | 179 | if loggedIn, _, _ := parseLikeT3(out.String()); loggedIn { |
| 180 | 180 | t.Errorf("a client with no credential reported itself logged in:\n%s", out.String()) |
| @@ -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 | + } | |
| @@ -1,11 +1,17 @@ | ||
| 1 | 1 | package main |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "bytes" | |
| 4 | 5 | "context" |
| 6 | + "errors" | |
| 7 | + "io" | |
| 8 | + "net/http" | |
| 5 | 9 | "os" |
| 6 | 10 | "path/filepath" |
| 11 | + "strings" | |
| 7 | 12 | "testing" |
| 8 | 13 | |
| 14 | + "github.com/johannSo/codey/internal/config" | |
| 9 | 15 | "github.com/johannSo/codey/internal/llm" |
| 10 | 16 | ) |
| 11 | 17 | |
| @@ -27,34 +33,12 @@ func fakeHome(t *testing.T) string { | ||
| 27 | 33 | return home |
| 28 | 34 | } |
| 29 | 35 | |
| 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 | - | |
| 52 | 36 | // tripwireHome writes a credentials file that is malformed JSON: a |
| 53 | 37 | // 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. | |
| 58 | 42 | func tripwireHome(t *testing.T) string { |
| 59 | 43 | t.Helper() |
| 60 | 44 | home := t.TempDir() |
| @@ -68,54 +52,121 @@ func tripwireHome(t *testing.T) string { | ||
| 68 | 52 | return home |
| 69 | 53 | } |
| 70 | 54 | |
| 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}, | |
| 77 | 85 | } |
| 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 | + }) | |
| 80 | 93 | } |
| 81 | 94 | } |
| 82 | 95 | |
| 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)) | |
| 92 | 98 | 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}, | |
| 96 | 122 | } |
| 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 | + }) | |
| 99 | 150 | } |
| 100 | 151 | } |
| 101 | 152 | |
| 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) | |
| 106 | 158 | if err != nil { |
| 107 | - t.Fatalf("resolveClaudeCode: %v", err) | |
| 159 | + t.Fatalf("resolveClaudeCode read the file despite an explicit credential: %v", err) | |
| 108 | 160 | } |
| 109 | 161 | 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") | |
| 111 | 163 | } |
| 112 | 164 | } |
| 113 | 165 | |
| 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. | |
| 115 | 168 | 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) | |
| 119 | 170 | if err != nil { |
| 120 | 171 | t.Fatalf("a missing file is not an error, got %v", err) |
| 121 | 172 | } |
| @@ -123,3 +174,208 @@ func TestClaudeCodeMissingFileIsNotAnError(t *testing.T) { | ||
| 123 | 174 | t.Error("no file means no source") |
| 124 | 175 | } |
| 125 | 176 | } |
| 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 | + } | |
| @@ -89,7 +89,8 @@ func run() error { | ||
| 89 | 89 | noWeb := flag.Bool("no-web", false, "do not offer web search, whatever the config says") |
| 90 | 90 | noSkills := flag.Bool("no-skills", false, "skip discovering skills in ~/"+skills.Dirname+", ./"+skills.Dirname+" and Claude Code's own") |
| 91 | 91 | 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") | |
| 93 | 94 | noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr") |
| 94 | 95 | noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it") |
| 95 | 96 | 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 { | ||
| 125 | 126 | // still starts, and the wizard can write a good one over it. |
| 126 | 127 | cfg, _, cfgErr := config.Load() |
| 127 | 128 | |
| 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. | |
| 132 | 131 | opts := []llm.Option{ |
| 133 | 132 | llm.WithModel(config.Or(*model, cfg.Model)), |
| 134 | 133 | llm.WithBaseURL(*baseURL), |
| 135 | 134 | llm.WithAPIKey(*apiKey), |
| 136 | 135 | llm.WithAuthToken(*authToken), |
| 137 | 136 | } |
| 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 | |
| 143 | 137 | |
| 144 | 138 | home, err := os.UserHomeDir() |
| 145 | 139 | if err != nil { |
| 146 | 140 | // No home directory is not fatal: an explicit credential still works. |
| 147 | 141 | home = "" |
| 148 | 142 | } |
| 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 | |
| 179 | 152 | } |
| 180 | 153 | // The login-and-models probe answers here, where a borrowed Claude Code |
| 181 | 154 | // credential already counts as being logged in, and before the check |
| 182 | 155 | // 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. | |
| 184 | 159 | if inv.Kind == invModels { |
| 185 | - printModels(os.Stdout, client) | |
| 160 | + printModels(os.Stdout, b.client, ccAskable) | |
| 186 | 161 | return nil |
| 187 | 162 | } |
| 188 | 163 | |
| 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 | |
| 191 | 203 | } |
| 192 | 204 | |
| 193 | 205 | effortSetting := config.Or(*effort, cfg.Effort) |
| @@ -780,6 +792,12 @@ func run() error { | ||
| 780 | 792 | // nothing on a machine with no checker installed for anything in |
| 781 | 793 | // the tree, whatever the file says. |
| 782 | 794 | 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, | |
| 783 | 801 | CopyClaudeSkills: func() (int, error) { |
| 784 | 802 | copied, problems, err := skills.CopyClaude(home, root) |
| 785 | 803 | for _, p := range problems { |
| @@ -938,47 +956,6 @@ func beginTurn(parent context.Context) (context.Context, func()) { | ||
| 938 | 956 | } |
| 939 | 957 | } |
| 940 | 958 | |
| 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 | - | |
| 982 | 959 | // interactive reports whether both ends of the terminal are a real one. A |
| 983 | 960 | // piped stdin or a redirected stdout means the full-screen UI would be wrong, |
| 984 | 961 | // and the line-based REPL still works. |
| @@ -49,6 +49,18 @@ type stored struct { | ||
| 49 | 49 | } `json:"claudeAiOauth"` |
| 50 | 50 | } |
| 51 | 51 | |
| 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 | + | |
| 52 | 64 | // Load reads and parses the credential store. home is passed in rather than |
| 53 | 65 | // looked up so tests can point it at a fixture, matching skills.Discover. |
| 54 | 66 | func Load(home string) (*Credentials, error) { |
| @@ -72,6 +72,21 @@ func TestLoadMissingFileIsErrNotFound(t *testing.T) { | ||
| 72 | 72 | } |
| 73 | 73 | } |
| 74 | 74 | |
| 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 | + | |
| 75 | 90 | // A file that exists but does not parse is a broken install, and saying so |
| 76 | 91 | // with the path beats falling through to "no credentials". |
| 77 | 92 | func TestLoadMalformedJSONNamesThePath(t *testing.T) { |
| @@ -118,6 +118,20 @@ const ( | ||
| 118 | 118 | DiagnosticsOff = "off" |
| 119 | 119 | ) |
| 120 | 120 | |
| 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 | + | |
| 121 | 135 | // DefaultSearchCommand is the command WebSearchCustom runs when nothing else |
| 122 | 136 | // is configured: ddgs, the DuckDuckGo CLI, which needs no account and no API |
| 123 | 137 | // key. {{query}} and {{count}} are substituted as whole argv elements, never |
| @@ -165,6 +179,10 @@ type Config struct { | ||
| 165 | 179 | // agent edits is handed to whatever language server or linter this |
| 166 | 180 | // machine has for it, and what they say handed back. Empty means on. |
| 167 | 181 | 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"` | |
| 168 | 186 | // Hooks maps a lifecycle event name to the shell commands run when it |
| 169 | 187 | // happens; the names are the ones in hooks.All. It is the one setting |
| 170 | 188 | // 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 | ||
| 299 | 317 | // "off" turns it off. |
| 300 | 318 | func (c Config) DiagnosticsEnabled() bool { return c.Diagnostics != DiagnosticsOff } |
| 301 | 319 | |
| 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 | + | |
| 302 | 325 | // AutoCompactEnabled reports whether the conversation should fold itself when |
| 303 | 326 | // the window fills. Only an explicit "off" turns it off. |
| 304 | 327 | func (c Config) AutoCompactEnabled() bool { return c.AutoCompact != AutoCompactOff } |
| @@ -435,6 +458,12 @@ func sanitize(c Config) Config { | ||
| 435 | 458 | if c.Diagnostics != DiagnosticsOn && c.Diagnostics != DiagnosticsOff { |
| 436 | 459 | c.Diagnostics = "" |
| 437 | 460 | } |
| 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 | + } | |
| 438 | 467 | c.SearchCommand = strings.TrimSpace(c.SearchCommand) |
| 439 | 468 | // Anything other than the one word means the marker was not written by a |
| 440 | 469 | // wizard that finished, so it does not count as one. |
| @@ -531,3 +531,58 @@ func TestEqualSeesMCPServers(t *testing.T) { | ||
| 531 | 531 | t.Error("no args should equal an empty args list") |
| 532 | 532 | } |
| 533 | 533 | } |
| 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 | + } | |
| @@ -88,7 +88,7 @@ func (c *Client) Clone(model string) *Client { | ||
| 88 | 88 | // authenticate with. It is one value rather than three copies of a string |
| 89 | 89 | // because ListModels, CreateMessage and StreamMessage all reach it, and a |
| 90 | 90 | // 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") | |
| 92 | 92 | |
| 93 | 93 | // Option configures a Client. |
| 94 | 94 | type Option func(*Client) |
| @@ -134,6 +134,20 @@ type Options struct { | ||
| 134 | 134 | // config reads. |
| 135 | 135 | Diagnostics string |
| 136 | 136 | |
| 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 | + | |
| 137 | 151 | // NewSession builds a second conversation for a new tab, rooted at the |
| 138 | 152 | // directory it is given. The TUI cannot build one itself: an agent is a |
| 139 | 153 | // client, a tool set, a state and a policy, and which of those a run is |
| @@ -306,6 +320,7 @@ type model struct { | ||
| 306 | 320 | setWeb string |
| 307 | 321 | setClaude string |
| 308 | 322 | setDiag string |
| 323 | + setCCAuth string | |
| 309 | 324 | |
| 310 | 325 | // The model picker: what the endpoint offered, where the cursor is, and |
| 311 | 326 | // whether we are still waiting to be told. |
| @@ -568,6 +583,7 @@ func (m *model) openWizard() tea.Cmd { | ||
| 568 | 583 | // palette; nothing selected underneath it survives that. |
| 569 | 584 | m.clearSelection() |
| 570 | 585 | 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 | |
| 571 | 587 | if m.ready { |
| 572 | 588 | // The wizard has never seen a resize, so it is told the size the model |
| 573 | 589 | // already knows rather than rendering into a zero-sized screen until |
| @@ -628,6 +644,11 @@ func (m *model) closeWizard() tea.Cmd { | ||
| 628 | 644 | if m.opts.Policy != nil && m.opts.Policy.YOLO { |
| 629 | 645 | m.tr.addNotice("accept-all is on — tool calls run without asking.") |
| 630 | 646 | } |
| 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 | + } | |
| 631 | 652 | if cfg.ClaudeSkillsMode() == config.ClaudeSkillsCopy && m.opts.CopyClaudeSkills != nil { |
| 632 | 653 | // Done here rather than as a command: it is a copy between two |
| 633 | 654 | // directories on the same disk, over in the time a frame takes, and a |
| @@ -28,13 +28,14 @@ const ( | ||
| 28 | 28 | setApprovalsRow |
| 29 | 29 | setWebSearchRow |
| 30 | 30 | setDiagnosticsRow |
| 31 | + setClaudeCodeAuthRow | |
| 31 | 32 | setClaudeSkillsRow |
| 32 | 33 | setThemeRow |
| 33 | 34 | numSettingRows |
| 34 | 35 | ) |
| 35 | 36 | |
| 36 | 37 | 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", | |
| 38 | 39 | } |
| 39 | 40 | |
| 40 | 41 | // settingsHeadRows is how many rows sit above the list: the heading, which |
| @@ -49,6 +50,7 @@ func (m *model) openSettings() tea.Cmd { | ||
| 49 | 50 | m.setModelWas = m.modelInForce() |
| 50 | 51 | m.setWeb = m.opts.Config.WebSearchMode() |
| 51 | 52 | m.setClaude = m.opts.Config.ClaudeSkillsMode() |
| 53 | + m.setCCAuth = claudeCodeAuthMode(m.opts.Config) | |
| 52 | 54 | m.setDiag = diagnosticsInForce(m.opts.Config) |
| 53 | 55 | // A theme change repaints everything, including whatever was marked. |
| 54 | 56 | m.clearSelection() |
| @@ -170,6 +172,8 @@ func (m *model) settingsConfig() config.Config { | ||
| 170 | 172 | cfg.WebSearch = v |
| 171 | 173 | case setDiagnosticsRow: |
| 172 | 174 | cfg.Diagnostics = v |
| 175 | + case setClaudeCodeAuthRow: | |
| 176 | + cfg.ClaudeCodeAuth = v | |
| 173 | 177 | case setClaudeSkillsRow: |
| 174 | 178 | cfg.ClaudeSkills = v |
| 175 | 179 | case setThemeRow: |
| @@ -197,6 +201,11 @@ func settingsSummary(cfg config.Config) string { | ||
| 197 | 201 | if mode := cfg.ClaudeSkillsMode(); mode != config.ClaudeSkillsOff { |
| 198 | 202 | s += " · claude skills " + mode |
| 199 | 203 | } |
| 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 | + } | |
| 200 | 209 | return s |
| 201 | 210 | } |
| 202 | 211 | |
| @@ -236,6 +245,8 @@ func (m *model) settingValue(r settingRow) string { | ||
| 236 | 245 | return config.Or(m.setWeb, m.webSearchInForce()) |
| 237 | 246 | case setDiagnosticsRow: |
| 238 | 247 | return config.Or(m.setDiag, diagnosticsInForce(m.opts.Config)) |
| 248 | + case setClaudeCodeAuthRow: | |
| 249 | + return config.Or(m.setCCAuth, claudeCodeAuthMode(m.opts.Config)) | |
| 239 | 250 | case setClaudeSkillsRow: |
| 240 | 251 | return config.Or(m.setClaude, m.opts.Config.ClaudeSkillsMode()) |
| 241 | 252 | case setThemeRow: |
| @@ -263,6 +274,8 @@ func settingOptions(r settingRow) []wizOption { | ||
| 263 | 274 | return webSearchOptions() |
| 264 | 275 | case setDiagnosticsRow: |
| 265 | 276 | return diagnosticsOptions() |
| 277 | + case setClaudeCodeAuthRow: | |
| 278 | + return claudeCodeAuthOptions() | |
| 266 | 279 | case setClaudeSkillsRow: |
| 267 | 280 | return claudeSkillsOptions() |
| 268 | 281 | case setThemeRow: |
| @@ -307,6 +320,10 @@ func (m *model) useSetting(r settingRow, v string) { | ||
| 307 | 320 | // Same again: whether an edit is checked was decided when the edit |
| 308 | 321 | // tool was built, so this row edits the file and says so. |
| 309 | 322 | 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 | |
| 310 | 327 | case setClaudeSkillsRow: |
| 311 | 328 | // And again: which skills the model was told about went into the |
| 312 | 329 | // first request of the session, which has already gone out. |
| @@ -450,6 +467,9 @@ func (m *model) settingDetail(r settingRow) string { | ||
| 450 | 467 | if r == setClaudeSkillsRow && v != m.claudeSkillsInForce() { |
| 451 | 468 | desc += " · takes effect next start" |
| 452 | 469 | } |
| 470 | + if r == setClaudeCodeAuthRow && v != m.claudeCodeAuthInForce() { | |
| 471 | + desc += " · takes effect next start" | |
| 472 | + } | |
| 453 | 473 | return desc |
| 454 | 474 | } |
| 455 | 475 | } |
| @@ -509,6 +529,24 @@ func (m *model) claudeSkillsInForce() string { | ||
| 509 | 529 | return config.Or(m.opts.ClaudeSkillsMode, m.opts.Config.ClaudeSkillsMode()) |
| 510 | 530 | } |
| 511 | 531 | |
| 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 | + | |
| 512 | 550 | // webSearchRestartNote is what both screens say when the web-search answer is |
| 513 | 551 | // one this process cannot take on. It names the restart because that is the |
| 514 | 552 | // whole of the fix, and there is no way to do it from inside the session: the |
| @@ -470,6 +470,55 @@ func TestSettingsClaudeSkillsRow(t *testing.T) { | ||
| 470 | 470 | } |
| 471 | 471 | } |
| 472 | 472 | |
| 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 | + | |
| 473 | 522 | // Every row has a label wide enough not to collide with its value. The panel |
| 474 | 523 | // measures the column, and this is what says it still does. |
| 475 | 524 | func TestSettingsLabelsDoNotRunIntoTheirValues(t *testing.T) { |
| @@ -9,6 +9,7 @@ import ( | ||
| 9 | 9 | "github.com/charmbracelet/bubbles/textinput" |
| 10 | 10 | tea "github.com/charmbracelet/bubbletea" |
| 11 | 11 | |
| 12 | + "github.com/johannSo/codey/internal/claudecode" | |
| 12 | 13 | "github.com/johannSo/codey/internal/config" |
| 13 | 14 | "github.com/johannSo/codey/internal/llm" |
| 14 | 15 | "github.com/johannSo/codey/internal/skills" |
| @@ -33,6 +34,7 @@ const ( | ||
| 33 | 34 | stepApprovals |
| 34 | 35 | stepWebSearch |
| 35 | 36 | stepTheme |
| 37 | + stepClaudeCodeAuth | |
| 36 | 38 | stepClaudeSkills |
| 37 | 39 | stepHerdr |
| 38 | 40 | stepDone |
| @@ -43,15 +45,19 @@ const ( | ||
| 43 | 45 | // positions on it. |
| 44 | 46 | const baseRailSteps = 5 |
| 45 | 47 | |
| 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 | |
| 49 | 52 | // next/previous arithmetic all read this list rather than the step's own |
| 50 | 53 | // ordinal. With one conditional step it was enough to keep it last; with two, |
| 51 | 54 | // the second is in the middle whenever the first is skipped, and a node the |
| 52 | 55 | // marker jumps over is worse than no node. |
| 53 | 56 | func (w *wizard) questions() []wizStep { |
| 54 | 57 | q := []wizStep{stepModel, stepEffort, stepApprovals, stepWebSearch, stepTheme} |
| 58 | + if w.offerClaudeCodeAuth { | |
| 59 | + q = append(q, stepClaudeCodeAuth) | |
| 60 | + } | |
| 55 | 61 | if w.claudeSkills > 0 { |
| 56 | 62 | q = append(q, stepClaudeSkills) |
| 57 | 63 | } |
| @@ -172,6 +178,12 @@ type wizard struct { | ||
| 172 | 178 | // where "some skills" is a mystery. |
| 173 | 179 | claudeSkills int |
| 174 | 180 | |
| 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 | + | |
| 175 | 187 | // offerHerdr is whether this machine has herdr, and so whether the skill |
| 176 | 188 | // question is asked at all. |
| 177 | 189 | offerHerdr bool |
| @@ -476,6 +488,10 @@ func (w *wizard) advance() tea.Cmd { | ||
| 476 | 488 | w.cfg.Theme = w.options()[w.cursor].value |
| 477 | 489 | return w.goTo(w.next(stepTheme)) |
| 478 | 490 | |
| 491 | + case stepClaudeCodeAuth: | |
| 492 | + w.cfg.ClaudeCodeAuth = w.options()[w.cursor].value | |
| 493 | + return w.goTo(w.next(stepClaudeCodeAuth)) | |
| 494 | + | |
| 479 | 495 | case stepClaudeSkills: |
| 480 | 496 | w.cfg.ClaudeSkills = w.options()[w.cursor].value |
| 481 | 497 | return w.goTo(w.next(stepClaudeSkills)) |
| @@ -503,7 +519,7 @@ func (w *wizard) back() tea.Cmd { | ||
| 503 | 519 | return w.goTo(stepApprovals) |
| 504 | 520 | case stepTheme: |
| 505 | 521 | return w.goTo(stepWebSearch) |
| 506 | - case stepClaudeSkills, stepHerdr: | |
| 522 | + case stepClaudeCodeAuth, stepClaudeSkills, stepHerdr: | |
| 507 | 523 | return w.goTo(w.prev(w.step)) |
| 508 | 524 | } |
| 509 | 525 | return nil |
| @@ -563,6 +579,8 @@ func (w *wizard) current() string { | ||
| 563 | 579 | return w.cfg.WebSearchMode() |
| 564 | 580 | case stepTheme: |
| 565 | 581 | return w.cfg.Theme |
| 582 | + case stepClaudeCodeAuth: | |
| 583 | + return claudeCodeAuthMode(w.cfg) | |
| 566 | 584 | case stepClaudeSkills: |
| 567 | 585 | return w.cfg.ClaudeSkillsMode() |
| 568 | 586 | } |
| @@ -619,6 +637,8 @@ func (w *wizard) options() []wizOption { | ||
| 619 | 637 | return webSearchOptions() |
| 620 | 638 | case stepTheme: |
| 621 | 639 | return themeOptions() |
| 640 | + case stepClaudeCodeAuth: | |
| 641 | + return claudeCodeAuthOptions() | |
| 622 | 642 | case stepClaudeSkills: |
| 623 | 643 | return claudeSkillsOptions() |
| 624 | 644 | case stepHerdr: |
| @@ -627,6 +647,37 @@ func (w *wizard) options() []wizOption { | ||
| 627 | 647 | return nil |
| 628 | 648 | } |
| 629 | 649 | |
| 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 | + | |
| 630 | 681 | // claudeSkillsOptions are the three things to do with the skills Claude Code |
| 631 | 682 | // has on this machine, in the config's order: nothing, read them in place, |
| 632 | 683 | // take a copy. |
| @@ -1193,3 +1193,132 @@ func TestCancellingTheWizardCopiesNothing(t *testing.T) { | ||
| 1193 | 1193 | t.Errorf("the copy ran %d times for a wizard that was escaped out of", called) |
| 1194 | 1194 | } |
| 1195 | 1195 | } |
| 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 | + } | |
| @@ -164,6 +164,8 @@ func (w *wizard) question() string { | ||
| 164 | 164 | return "Should it be able to search the web?" |
| 165 | 165 | case stepTheme: |
| 166 | 166 | return "How should it look?" |
| 167 | + case stepClaudeCodeAuth: | |
| 168 | + return "No API key is set. Use your Claude Code login instead?" | |
| 167 | 169 | case stepClaudeSkills: |
| 168 | 170 | // The number is the offer: it says these are real, already on the |
| 169 | 171 | // machine, and how much is being agreed to. |