Vectrum
Sign In
218 lines · 9.7 KB · Go
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
}