package main // Where the credential comes from. // // An API key or a gateway token is the ordinary answer, and nothing here // touches one: the llm package reads them from the flags and the environment. // What this file decides is the other answer — the OAuth login Claude Code // keeps in ~/.claude/.credentials.json — and the rule for it is that it is // borrowed only when someone asked. That login is issued to Claude Code, and // using it from another client is outside Anthropic's terms for it. It used to // be borrowed silently whenever nothing else was set; that was tolerable on // the one machine Codey was written on and is not for a binary other people // install, where "a file happened to exist" is not consent. import ( "bufio" "errors" "fmt" "io" "path/filepath" "strings" "github.com/johannSo/codey/internal/claudecode" "github.com/johannSo/codey/internal/config" "github.com/johannSo/codey/internal/llm" ) // claudeCodeAllowed is whether this run may borrow Claude Code's login, read // in order of how deliberate each source is: -no-claude-code-auth, then // -claude-code-auth, then the config, then off. // // The refusal outranks the request so that a launch line which has collected // both — a wrapper script that adds one, a settings box that adds the other — // comes out on the side that sends nothing it was not sure about. And the // default is off, so that only a person can turn it on. func claudeCodeAllowed(refuse, request bool, cfg config.Config) bool { switch { case refuse: return false case request: return true } return cfg.ClaudeCodeAuthEnabled() } // claudeCodeAskable reports whether the question of borrowing Claude Code's // login arises at all for this client: nothing else was supplied, the // endpoint is Anthropic's, and there is a login on disk to borrow. Anywhere // else the answer would change nothing, and a question whose answer changes // nothing is one the wizard should not ask and an error should not suggest. // // It looks for the file without reading it. Whether what is in there is a // usable token is a question for after someone has said yes. func claudeCodeAskable(client *llm.Client, home string) bool { return !client.HasCredentials() && client.IsDefaultEndpoint() && claudecode.Present(home) } // resolveClaudeCode returns a token source borrowed from Claude Code's login, // or nil when one should not be used. The second return is a line for the // user: a borrowed credential is worth announcing, the same way a non-default // endpoint is. // // Three conditions gate it. It has to have been asked for, which is what // allowed carries — see claudeCodeAllowed. The client must have no credential // already, because we never override what the user typed. And the endpoint // must be Anthropic's, because a credential we found on disk is not one the // user chose to send — forwarding it to a -base-url someone else operates // would leak it to a third party because a file happened to exist. All three // are checked before the file is opened. func resolveClaudeCode(client *llm.Client, home string, allowed bool) (*claudecode.Source, string, error) { if !allowed || client.HasCredentials() || !client.IsDefaultEndpoint() { return nil, "", nil } creds, err := claudecode.Load(home) if errors.Is(err, claudecode.ErrNotFound) { return nil, "", nil } if err != nil { return nil, "", err } note := fmt.Sprintf("using your Claude Code login (%s plan) from ~/%s", creds.SubscriptionType, claudecode.Filename) // home is handed on so a rotated refresh token can be written back. That // is the only circumstance in which this process writes to Claude Code's // file, and not writing then is what would break its login. // // The refresh client is deliberately not Codey's: that one allows // ten minutes for a model to answer, which is the wrong budget for a token // exchange running under a lock. NewSource supplies a short-timeout client. return claudecode.NewSource(creds, nil, home), note, nil } // borrowed is the client startup ends up with, and what it took to get it. type borrowed struct { client *llm.Client // opts is the list the client was built from, kept so a second client on // another model can be built the same way. opts []llm.Option // source is set only when the credential is Claude Code's, so its // write-back failure hook can be wired once there is somewhere to report. source *claudecode.Source note string } // borrowClaudeCode builds the client from opts, and rebuilds it on Claude // Code's login when that is allowed and nothing else supplied a credential. // // The options are a slice rather than a client because the rebuild has to // replay them: replaying the same list with one more option is how the rebuilt // client is guaranteed to be the one that passed the endpoint check. It is // the one place both the session and the `codey models` probe get their // client from, so the probe cannot report a login the session would refuse. func borrowClaudeCode(opts []llm.Option, home string, allowed bool) (borrowed, error) { b := borrowed{client: llm.New(opts...), opts: opts} if home == "" { // No home directory is not fatal: an explicit credential still works. return b, nil } src, note, err := resolveClaudeCode(b.client, home, allowed) if err != nil || src == nil { return b, err } b.opts = append(append([]llm.Option(nil), opts...), llm.WithTokenSource(src.Token)) b.client = llm.New(b.opts...) // Not expected to ever trigger: resolveClaudeCode already checked the // endpoint before we agreed to borrow this credential, but that check ran // against the client we had before this rebuild. This is a guard against // a future refactor reordering things so the two clients quietly diverge, // not a condition anyone should see fire. if !b.client.IsDefaultEndpoint() { return borrowed{}, errors.New("internal: discovered credential on a non-default endpoint") } b.source, b.note = src, note return b, nil } // shouldAskClaudeCode is whether startup, having found no credential, stops to // ask once about the Claude Code login instead of only failing. // // It exists for the people who relied on the silent fallback: after upgrading, // their next start would otherwise end in "no credentials" for no reason they // can see. So it asks — but only of a person at a terminal, since a -p run, a // pipe and a protocol client have nobody to answer; only when the config has // never recorded an answer, so it is asked once rather than on every start; // only when the config was read, since the answer is saved over it; and never // when -no-claude-code-auth already said no. func shouldAskClaudeCode(cfg config.Config, cfgErr error, askable, refused, tty bool) bool { return askable && tty && !refused && cfgErr == nil && cfg.ClaudeCodeAuth == "" } // askClaudeCode is that question, on the plain terminal before anything takes // the screen over. The answer is saved either way: yes so the next start does // not ask again, and no for the same reason — someone who said no should be // told how to get a key, not asked the same thing every morning. Anything but // a yes is a no, an empty line included, because the cautious answer is the // one enter should give. func askClaudeCode(in io.Reader, out io.Writer, cfg config.Config, save func(config.Config) error) (bool, config.Config) { fmt.Fprintf(out, "codey: no API key is set, but Claude Code is logged in on this machine (~/%s).\n", claudecode.Filename) fmt.Fprintln(out, "codey: that login is issued to Claude Code; using it from another client is outside Anthropic's terms for it.") fmt.Fprint(out, "codey: use it for Codey on this machine? [y/N] ") line, _ := bufio.NewReader(in).ReadString('\n') yes := false switch strings.ToLower(strings.TrimSpace(line)) { case "y", "yes": yes = true } cfg.ClaudeCodeAuth = config.ClaudeCodeAuthOff if yes { cfg.ClaudeCodeAuth = config.ClaudeCodeAuthOn } if save != nil { if err := save(cfg); err != nil { // The answer still holds for this run; only remembering it failed, // and the next start will ask again, which is the honest outcome. fmt.Fprintf(out, "codey: could not save the answer: %v\n", err) } } return yes, cfg } // apiKeysURL is where an Anthropic API key is created. const apiKeysURL = "https://console.anthropic.com/settings/keys" // noCredentialsError says how to get a credential, because "unauthorized" // against a gateway that ignores the header is a confusing failure and "no // credentials" on its own is a dead end. // // The Claude Code login is mentioned only where it could be borrowed — there // is a login on disk and the endpoint is Anthropic's — and with the terms // attached, because it is a way to start that is yours to choose on your own // machine rather than the first thing to try. func noCredentialsError(claudeCode bool, cfgPath string) error { var b strings.Builder b.WriteString("no credentials. Set one of:\n") b.WriteString(" export ANTHROPIC_API_KEY=sk-ant-... create one at " + apiKeysURL + "\n") b.WriteString(" -base-url URL with ANTHROPIC_AUTH_TOKEN for a gateway (or -api-key / -auth-token)") if claudeCode { b.WriteString("\nClaude Code is logged in here: -claude-code-auth, or \"claude_code_auth\": \"on\" in " + cfgPath + ", borrows that login.") b.WriteString("\nIt is issued to Claude Code; using it from another client is outside Anthropic's terms for it.") } return errors.New(b.String()) } // configPathLabel is where the config lives, with the home directory spelled // as ~ so the message reads the way the README does. func configPathLabel(home string) string { p, err := config.Path() if err != nil { return "Codey's config" } if home != "" { if rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, "..") { return "~/" + filepath.ToSlash(rel) } } return p }