Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 4 changed files with 111 additions and 0 deletions.
| @@ -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 | + } | |