// Command codey is the CLI entry point: flag parsing, wiring, and the REPL.
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"sync/atomic"
"time"
"codey/internal/agent"
"codey/internal/approval"
"codey/internal/ask"
"codey/internal/claudecode"
"codey/internal/config"
"codey/internal/llm"
"codey/internal/metrics"
"codey/internal/serve"
"codey/internal/skills"
"codey/internal/statedir"
"codey/internal/subagent"
"codey/internal/tools"
"codey/internal/tui"
"codey/internal/ui"
"codey/internal/websearch"
)
// version is what the TUI's welcome block reports. It is a constant rather
// than build-stamped: Codey is built from this tree, and a stamp that
// can disagree with the source is worse than no stamp.
const version = "v0.6.0"
// effectiveWebMode is the web-search mode this process actually ended up with.
// Asking for one and getting it are different things: -no-web says no, and a
// search command that is not installed is not a search command.
func effectiveWebMode(asked string, searcher websearch.Searcher, hosted []llm.ToolDef) string {
switch {
case asked == config.WebSearchClaude && len(hosted) > 0:
return config.WebSearchClaude
case asked == config.WebSearchCustom && searcher != nil:
return config.WebSearchCustom
}
return config.WebSearchNone
}
// hostedSearchesPerTurn caps how many searches Anthropic's hosted tool may run
// for one request. There is no approval prompt in front of a hosted search —
// it happens inside a request already approved — so the cap is the only thing
// standing between a confused model and a bill.
const hostedSearchesPerTurn = 5
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "codey:", err)
os.Exit(1)
}
}
func run() error {
workdir := flag.String("C", ".", "working root the agent operates in")
model := flag.String("model", "", "model id (default "+llm.DefaultModel+")")
baseURL := flag.String("base-url", "", "API endpoint, for a proxy, gateway, or local server speaking the Messages API (env ANTHROPIC_BASE_URL, default "+llm.DefaultBaseURL+")")
apiKey := flag.String("api-key", "", "credential sent as x-api-key (env ANTHROPIC_API_KEY). Visible to other processes; prefer the env var")
authToken := flag.String("auth-token", "", "credential sent as Authorization: Bearer, for OAuth tokens and most gateways (env ANTHROPIC_AUTH_TOKEN)")
effort := flag.String("effort", "", "output_config.effort: low|medium|high|xhigh|max (default: the API's, high)")
maxTurns := flag.Int("max-turns", agent.NoTurnLimit, "turn cap per prompt; 0 is no cap, which is the default — worth setting for an unattended -p run")
contextWindow := flag.Int("context-window", agent.DefaultContextWindow, "model context window, used to decide when to compact")
compactAt := flag.Float64("compact-at", agent.DefaultCompactAt, "fraction of the window at which to compact")
compactFloor := flag.Float64("compact-floor", agent.DefaultCompactFloor, "fraction past which compaction is forced, keeping only a short tail")
noAutoCompact := flag.Bool("no-auto-compact", false, "never fold the conversation on its own; stop the turn when the window fills instead")
noCache := flag.Bool("no-cache", false, "disable prompt cache breakpoints")
noStream := flag.Bool("no-stream", false, "wait for each complete response instead of streaming")
logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path, relative to the working root")
prompt := flag.String("p", "", "run one prompt and exit, instead of starting the REPL")
resume := flag.String("resume", "", "resume a saved session by id, or \"latest\"")
verbose := flag.Bool("v", false, "print full tool output")
yolo := flag.Bool("yolo", false, "skip every approval prompt. Only against a throwaway clone or inside a container")
noTUI := flag.Bool("no-tui", false, "use the plain line-based REPL instead of the full-screen TUI")
noWeb := flag.Bool("no-web", false, "do not offer web search, whatever the config says")
noSkills := flag.Bool("no-skills", false, "skip discovering skills in ~/"+skills.Dirname+" and ./"+skills.Dirname)
noAgents := flag.Bool("no-agents", false, "do not offer the subagent tools, so the agent works alone")
noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "do not fall back to the OAuth login in ~/"+claudecode.Filename)
// Read the line before the flag package does. Three invocations belong to
// a program driving Codey rather than to a person — see agentcmd.go — and
// two of them are not spelled in a grammar flag parsing can read.
inv := scanArgs(os.Args[1:])
if inv.Kind == invVersion {
printVersion(os.Stdout)
return nil
}
if err := flag.CommandLine.Parse(inv.Rest); err != nil {
return err
}
root, err := filepath.Abs(*workdir)
if err != nil {
return err
}
// A clone last used under the old name keeps its sessions and its event
// log. This runs before anything opens a file inside the state directory,
// and is reported rather than fatal: a stale directory that cannot be
// moved is a reason to say so, not a reason to refuse to start.
stateMoved, stateErr := statedir.Migrate(root)
// Saved preferences sit under the flags and over the built-in defaults. A
// config that cannot be read is reported and then ignored: Codey
// still starts, and the wizard can write a good one over it.
cfg, _, cfgErr := config.Load()
// Held as a slice because the Claude Code fallback below may have to
// rebuild the client with one more option, and replaying the same list is
// how the rebuilt client is guaranteed to be the one that passed the
// endpoint check.
opts := []llm.Option{
llm.WithModel(config.Or(*model, cfg.Model)),
llm.WithBaseURL(*baseURL),
llm.WithAPIKey(*apiKey),
llm.WithAuthToken(*authToken),
}
client := llm.New(opts...)
// clientOpts is the list the live client was actually built from, kept so
// a second client on another model can be built the same way. The Claude
// Code branch below is the only thing that adds to it.
clientOpts := opts
home, err := os.UserHomeDir()
if err != nil {
// No home directory is not fatal: an explicit credential still works.
home = ""
}
var authNote string
// systemPrefix carries the Claude Code identity block. It is set only when
// the credential was borrowed from Claude Code's own login: an OAuth
// request that omits it is rejected (as a 429, not a 401 — it reads like a
// quota problem and is not one).
var systemPrefix string
// ccSource is kept so its write-back failure hook can be wired once the
// places a warning can go — the notice channel, the event log — exist.
var ccSource *claudecode.Source
if home != "" {
src, note, err := resolveClaudeCode(client, home, *noClaudeCodeAuth)
if err != nil {
return err
}
if src != nil {
ccSource = src
clientOpts = append(opts, llm.WithTokenSource(src.Token))
client = llm.New(clientOpts...)
// 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 !client.IsDefaultEndpoint() {
return errors.New("internal: discovered credential on a non-default endpoint")
}
authNote = note
systemPrefix = agent.ClaudeCodeIdentity
}
}
// The login-and-models probe answers here, where a borrowed Claude Code
// credential already counts as being logged in, and before the check
// below: "not logged in" is the useful answer to that question, not an
// error exit that tells the asker nothing.
if inv.Kind == invModels {
printModels(os.Stdout, client)
return nil
}
if !client.HasCredentials() {
return errNoCredentials
}
effortSetting := config.Or(*effort, cfg.Effort)
// An accept-all default is a decision about what the agent may do
// unattended, so -yolo can turn it on but a config can only be the default
// for a session where the flag said nothing.
acceptAll := *yolo || cfg.Approvals == config.ApprovalsAcceptAll
// Same direction: the flag can only turn the automatic fold off, and a
// config that never mentioned it leaves it on.
autoCompactOff := *noAutoCompact || !cfg.AutoCompactEnabled()
session := time.Now().UTC().Format("20060102T150405Z")
log, err := metrics.Open(filepath.Join(root, *logPath), session)
if err != nil {
return err
}
defer log.Close()
log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{
"root": root, "model": client.Model(), "base_url": client.BaseURL,
}})
// The TUI takes over the screen, so anything worth saying before the loop
// starts has to go into its scrollback rather than to stderr.
useTUI := !*noTUI && *prompt == "" && interactive()
var banner []string
notice := func(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if useTUI {
banner = append(banner, msg)
return
}
fmt.Fprintln(os.Stderr, "codey: "+msg)
}
if cfgErr != nil {
notice("%v — using defaults; /setup will write a fresh one", cfgErr)
}
if stateMoved {
notice("moved %s to %s — sessions and metrics came with it", statedir.LegacyDir, statedir.Dir)
}
if stateErr != nil {
notice("%v — starting a fresh %s; the old one is untouched", stateErr, statedir.Dir)
}
if !client.IsDefaultEndpoint() {
// Worth saying out loud: the prompt and every file the agent reads go
// to this endpoint, not to Anthropic.
notice("sending requests to %s", client.BaseURL)
}
if authNote != "" {
notice("%s", authNote)
}
if ccSource != nil {
// A rotation we could not store means the credential on disk is dead
// and its replacement dies with this process — the user's next `claude`
// will ask them to log in again, for a reason nothing else would
// explain. It goes to the event log unconditionally, because that
// survives the session; stderr only outside the TUI, which owns the
// screen once it starts.
ccSource.OnWriteBackFailure = func(err error) {
log.Log(metrics.Event{Type: "credential_writeback_failed", Extra: map[string]any{
"error": err.Error(), "file": "~/" + claudecode.Filename,
}})
if !useTUI {
fmt.Fprintf(os.Stderr, "codey: your Claude Code login was renewed but ~/%s could not be updated: %v\n", claudecode.Filename, err)
fmt.Fprintln(os.Stderr, "codey: run `claude` when convenient — its stored login is now out of date.")
}
}
}
var found []skills.Skill
if !*noSkills {
var problems []error
found, problems = skills.Discover(home, root)
for _, p := range problems {
notice("skill skipped: %v", p)
}
}
// Skill directories are readable by the search tools and by nothing else,
// so a skill can read the files it ships with.
var skillDirs []string
for _, s := range found {
skillDirs = append(skillDirs, s.Dir)
}
// Web access, if it was asked for. The mode lives in the config rather
// than in a flag because it is a standing decision about what leaves this
// machine; -no-web is the single-run override, and like -yolo's opposite
// it can only take capability away.
//
// The hosted mode is a tool definition on the request and nothing local.
// The custom mode is two ordinary tools, registered below — but only if
// the search command is actually installed: an agent told it can search,
// which discovers on its first attempt that nothing is there, has spent a
// turn learning what this line already knows.
webMode := cfg.WebSearchMode()
if *noWeb {
webMode = config.WebSearchNone
}
var (
searcher websearch.Searcher
serverTools []llm.ToolDef
)
switch webMode {
case config.WebSearchClaude:
serverTools = []llm.ToolDef{llm.WebSearchTool(hostedSearchesPerTurn)}
case config.WebSearchCustom:
line := cfg.SearchCommandLine()
s, err := websearch.NewCommand(line)
switch {
case err != nil:
notice("web search is off: %v", err)
default:
if err := websearch.Available(line); err != nil {
notice("web search is off: %v", err)
} else {
searcher = s
}
}
}
// One factory, used for the session's own agent and for every subagent:
// a child that could reach further than its parent would be a way around
// the parent's limits, and a child that could reach less would be a
// surprise the model has no way to know about.
//
// The root is a parameter rather than a captured variable because
// /worktree rebuilds the tool set against a different directory: each tool
// closes over the root it is handed, so moving the work means building the
// set again, and this is the only place that knows how.
toolsIn := func(root string) func(*agent.State, *metrics.Logger) *tools.Registry {
return func(state *agent.State, log *metrics.Logger) *tools.Registry {
reg := tools.NewRegistry(log).Register(
tools.NewRead(root, state, skillDirs...),
tools.NewGlob(root, skillDirs...),
tools.NewGrep(root, skillDirs...),
tools.NewEdit(root, state),
tools.NewWrite(root, state),
tools.NewBash(root),
)
if t := tools.NewSkill(found); t != nil {
reg.Register(t)
}
// The pair go in together: a search result nothing can read is
// half a capability, and the model would fetch by guessing at
// bash and curl instead.
if t := tools.NewWebSearch(searcher); t != nil {
reg.Register(t, tools.NewWebFetch())
}
return reg
}
}
newTools := toolsIn(root)
state := agent.NewState()
reg := newTools(state, log)
in := bufio.NewScanner(os.Stdin)
in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
render := &ui.Renderer{W: os.Stdout, Verbose: *verbose}
approver := &ui.Approver{In: in, Out: os.Stdout}
policy := approval.New(approver.Prompt)
// Questions go to the line front end until a TUI takes them over in
// tui.Run. The tool belongs to the session's own agent and is registered
// here rather than in newTools, so a subagent still cannot stop and ask:
// its report goes to the parent, and the parent is not a person.
asker := ask.NewAsker((&ui.Asker{In: in, Out: os.Stdout}).Prompt)
reg.Register(ask.Tool(asker.Ask))
policy.YOLO = acceptAll
if acceptAll && !useTUI {
// The TUI says this itself, in the banner.
fmt.Fprintln(os.Stderr, "codey: -yolo — no approval prompts. Use a throwaway clone or a container.")
}
base := agent.Options{
MaxTurns: *maxTurns,
Effort: effortSetting,
ContextWindow: *contextWindow,
CompactAt: *compactAt,
CompactFloor: *compactFloor,
NoAutoCompact: autoCompactOff,
NoCache: *noCache,
Stream: !*noStream,
Skills: found,
ServerTools: serverTools,
SystemPrefix: systemPrefix,
}
// An agent run hands the whole thing over to the protocol and never comes
// back. It goes here, after everything a session needs has been built and
// before the front ends that assume a person: no subagent supervisor,
// because their reports need somewhere to be shown, and no question tool,
// because the protocol has no box to put a question in.
//
// Nothing above this line has written to stdout. Nothing below it may.
if inv.Kind == invAgent {
if acceptAll || inv.Permission == serve.ModeFullAccess {
inv.Permission = serve.ModeFullAccess
notice("no approval prompts. Use a throwaway clone or a container.")
}
return serveACP(context.Background(), acpDeps{
Permission: inv.Permission,
NoAgents: *noAgents,
Client: client,
ClientOpts: clientOpts,
Log: log,
Base: base,
Tools: toolsIn,
})
}
// Subagents are a TUI feature: their reports come back as messages the
// front end puts into the conversation, and the line-based REPL has
// nowhere to show four agents working at once. Offering the tools where
// nothing can deliver the answers would be worse than not offering them.
// The options a subagent runs under are the ones its parent had before the
// model listing was added: a child never chooses a model for a child of
// its own, and a paragraph about a choice it will not make is a paragraph
// it pays for on every request. Held here because every tab builds a
// supervisor of its own from it.
subBase := base
var sup *subagent.Supervisor
if useTUI && !*noAgents {
// What the endpoint serves, so the agent picks subagent models from a
// real list rather than from memory. It is asked for once, here,
// because the answer goes into the cached prompt prefix and must not
// change mid-session. An endpoint that does not answer simply leaves
// the listing out: no list beats a made-up one, and the agent is told
// to inherit this session's model when it has nothing to choose from.
listCtx, cancelList := context.WithTimeout(context.Background(), 10*time.Second)
models, listErr := client.ListModels(listCtx)
cancelList()
if listErr != nil {
notice("could not list the models this endpoint serves (%v) — subagents will run on %s unless the agent is told otherwise", listErr, client.Model())
}
sup = subagent.New(context.Background(), subagent.Options{
Client: client,
Log: log,
Policy: policy,
Base: base,
Tools: newTools,
})
defer sup.Shutdown()
reg.Register(sup.Tools()...)
// Only the agent that can spawn is told what there is to choose from:
// the supervisor above was handed the options before this line, so a
// subagent's prompt does not carry a paragraph about a choice it will
// never make.
base.Models = models
}
a := agent.New(client, reg, state, log, base)
a.Observe = render.Handle
a.Approve = policy
// newSessionTools is what /worktree calls to move the work: it builds the
// session's whole tool set against another root and takes the supervisor
// with it, because a child that kept the old root would be working in a
// directory its parent can no longer see.
newSessionTools := func(r string) *tools.Registry {
next := toolsIn(r)
built := next(state, log)
// The session's own agent keeps the tool that asks you a question,
// wherever the work has moved to. It is registered here rather than
// in the factory for the same reason the spawn tools are: a subagent
// reports to its parent, and the parent is not a person to ask.
built.Register(ask.Tool(asker.Ask))
if sup != nil {
sup.SetTools(next)
built.Register(sup.Tools()...)
}
return built
}
sessionDir := filepath.Join(root, agent.SessionDir)
// workRoot is where the tools work; root stays where the state lives. The
// two are the same until /worktree moves the work, or until a session that
// had moved is resumed below.
workRoot, worktree := root, ""
if *resume != "" {
prev, err := agent.ResolveSession(sessionDir, *resume)
if err != nil {
return err
}
// Sessions belong to the directory they were recorded in. The path
// above already scopes them there; this catches a file that was copied
// or linked in from another checkout, whose conversation is about
// paths that do not exist here.
if !prev.BelongsTo(root) {
return fmt.Errorf("session %s belongs to %s, not %s — resume it from there", prev.ID, prev.Root, root)
}
a.Restore(prev)
session = prev.ID
notice("resumed session %s (%d messages, %d turns)", prev.ID, len(prev.Messages), prev.Turns)
// A session whose work had moved into a worktree goes back to it, so
// resuming picks up where it left off rather than in the main checkout
// with a conversation about files that are not there. A tree since
// deleted is said out loud and then ignored: the conversation is still
// worth having, just here.
if prev.Worktree != "" {
if fi, statErr := os.Stat(prev.Worktree); statErr == nil && fi.IsDir() {
workRoot, worktree = prev.Worktree, prev.Worktree
a.SetTools(newSessionTools(workRoot))
notice("working in the worktree %s", workRoot)
} else {
notice("the worktree %s is gone — working in %s instead", prev.Worktree, root)
}
}
}
// Every tab is a session of its own, so opening one means building the
// whole stack a session stands on a second time: its own state, its own
// tool set, its own approval policy and its own subagent supervisor. Only
// the client, the event log and what was discovered at startup are shared,
// because those are about the run rather than about a conversation.
//
// What a tab may do is what this run may do. The flags and the config were
// read once, above, and every line here reads from that same decision —
// a second session is not a way around a limit the first one has.
taken := map[string]bool{session: true}
newSession := func(sessRoot string) (tui.SessionSpec, error) {
id, err := freshSessionID(sessionDir, taken)
if err != nil {
return tui.SessionSpec{}, err
}
st := agent.NewState()
mk := toolsIn(sessRoot)
tabReg := mk(st, log)
// A policy of its own: what you have permanently allowed in one
// conversation was allowed for that conversation, and the prompt has
// to come up in the tab whose agent is asking.
pol := approval.New(nil)
pol.YOLO = acceptAll
var tabSup *subagent.Supervisor
if !*noAgents {
tabSup = subagent.New(context.Background(), subagent.Options{
Client: client,
Log: log,
Policy: pol,
Base: subBase,
Tools: mk,
})
tabReg.Register(tabSup.Tools()...)
}
a := agent.New(client, tabReg, st, log, base)
a.Approve = pol
log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{
"root": sessRoot, "model": client.Model(), "base_url": client.BaseURL, "tab": id,
}})
return tui.SessionSpec{
Agent: a,
Policy: pol,
SessionID: id,
ToolNames: toolNames(tabReg),
NewTools: func(r string) *tools.Registry {
next := toolsIn(r)
built := next(st, log)
if tabSup != nil {
tabSup.SetTools(next)
built.Register(tabSup.Tools()...)
}
return built
},
Agents: agentsOrNil(tabSup),
Close: func() {
if tabSup != nil {
tabSup.Shutdown()
}
},
}, nil
}
if useTUI {
// No signal handler here: Bubble Tea puts the terminal in raw mode, so
// ctrl-c arrives as a key and the model decides what it means.
return tui.Run(context.Background(), tui.Options{
Agent: a,
Policy: policy,
Asker: asker,
Client: client,
Root: workRoot,
HomeRoot: root,
Worktree: worktree,
SessionDir: sessionDir,
SessionID: session,
ToolNames: toolNames(reg),
NewTools: newSessionTools,
SystemPrefix: systemPrefix,
Agents: agentsOrNil(sup),
Verbose: *verbose,
YOLO: acceptAll,
Skills: found,
Version: version,
Effort: effortSetting,
ContextWindow: *contextWindow,
Banner: banner,
// A config that does not say the setup was finished is what
// "first run" means, and it is the only thing that opens the
// wizard unasked. A flag-only invocation is still a first run:
// the flags say what this run does, not what the next one should
// default to. A config we could not read is not a first run
// either — the wizard's answers would be saved over settings that
// are probably still in there.
Config: cfg,
FirstRun: !cfg.Done() && cfgErr == nil,
// What was delivered, not what was asked for: a missing search
// command or -no-web leaves the mode at none however the config
// reads, and the screens need the truth to know when to say that
// a change needs a restart.
WebSearch: effectiveWebMode(webMode, searcher, serverTools),
// Applying the answers is the TUI's own job; persisting them is
// the only part that belongs out here.
SaveConfig: config.Save,
// What ctrl+t needs to open a second conversation.
NewSession: newSession,
})
}
ctx, stopSignals := interruptible(context.Background())
defer stopSignals()
if *prompt != "" {
return once(ctx, a, render, sessionDir, session, root, client, *prompt)
}
fmt.Printf("codey · %s · %s\ntools: %s\ntype a prompt, ctrl-c to interrupt a turn, ctrl-d to exit\n\n",
client.Model(), root, strings.Join(toolNames(reg), ", "))
for {
fmt.Print("> ")
if !in.Scan() {
fmt.Println()
return in.Err()
}
line := strings.TrimSpace(in.Text())
switch {
case line == "":
continue
case line == "/exit", line == "/quit":
return nil
case line == "/allowed":
allowed := policy.Remembered()
if len(allowed) == 0 {
fmt.Println("nothing is permanently allowed this session")
}
for _, a := range allowed {
fmt.Println(" " + a)
}
continue
}
if err := once(ctx, a, render, sessionDir, session, root, client, line); err != nil {
render.Error(err)
}
fmt.Println()
}
}
// freshSessionID names a new session after the moment it was opened, and makes
// sure the name is free. A second-resolution timestamp is a fine name for one
// session and a collision waiting to happen for two opened in the same breath,
// which is exactly what a second tab is — so both the directory and the ids
// already handed out this run are checked, and a suffix settles it.
func freshSessionID(dir string, taken map[string]bool) (string, error) {
stamp := time.Now().UTC().Format("20060102T150405Z")
for n := 1; n < 1000; n++ {
id := stamp
if n > 1 {
id = fmt.Sprintf("%s-%d", stamp, n)
}
if taken[id] {
continue
}
_, err := os.Stat(filepath.Join(dir, id+".json"))
switch {
case os.IsNotExist(err):
taken[id] = true
return id, nil
case err != nil:
return "", err
}
}
return "", errors.New("no free session name — that is a thousand sessions in one second")
}
// once runs a single prompt, prints the session totals, and saves the session.
func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, sessionDir, id, root string, client *llm.Client, prompt string) error {
turnCtx, endTurn := beginTurn(ctx)
_, runErr := a.Run(turnCtx, prompt)
endTurn()
render.Flush()
usage, turns := a.State().Usage()
render.Usage(usage, turns, client.Model())
if err := a.Save(sessionDir, id, root, "", client.Model()); err != nil {
fmt.Fprintln(os.Stderr, "codey: saving session:", err)
}
if errors.Is(runErr, context.Canceled) {
// An interrupted turn is not an error: the point is to leave the
// session intact so the next message can correct course.
fmt.Println("interrupted — the session is intact; say what to do differently")
return nil
}
return runErr
}
// interrupt plumbing. The first ctrl-c cancels the turn in progress; a second
// within two seconds, or one with nothing running, exits.
var (
turnCancel atomic.Pointer[context.CancelFunc]
lastSignal atomic.Int64
)
func interruptible(parent context.Context) (context.Context, func()) {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
go func() {
for range ch {
now := time.Now()
prev := lastSignal.Swap(now.UnixNano())
cancel := turnCancel.Load()
if cancel == nil || (prev != 0 && now.Sub(time.Unix(0, prev)) < 2*time.Second) {
fmt.Fprintln(os.Stderr, "\ncodey: exiting")
os.Exit(130)
}
fmt.Fprintln(os.Stderr, "\ncodey: interrupting this turn (ctrl-c again to exit)")
(*cancel)()
}
}()
return parent, func() { signal.Stop(ch) }
}
// beginTurn makes the running turn cancellable by the signal handler.
func beginTurn(parent context.Context) (context.Context, func()) {
ctx, cancel := context.WithCancel(parent)
turnCancel.Store(&cancel)
return ctx, func() {
turnCancel.Store(nil)
lastSignal.Store(0)
cancel()
}
}
// errNoCredentials spells out every way to supply one, because "unauthorized"
// against a gateway that ignores the header is a confusing failure.
var errNoCredentials = errors.New(`no credentials. Supply one of:
export ANTHROPIC_API_KEY=sk-ant-... (sent as x-api-key)
export ANTHROPIC_AUTH_TOKEN=... (sent as Authorization: Bearer)
-api-key / -auth-token (flags override the environment)
log in with Claude Code (borrowed automatically, Anthropic endpoint only)
To use a non-Anthropic endpoint, set ANTHROPIC_BASE_URL or -base-url as well.`)
// 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.
//
// Two conditions gate it. 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.
func resolveClaudeCode(client *llm.Client, home string, disabled bool) (*claudecode.Source, string, error) {
if disabled || 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
}
// interactive reports whether both ends of the terminal are a real one. A
// piped stdin or a redirected stdout means the full-screen UI would be wrong,
// and the line-based REPL still works.
func interactive() bool {
for _, f := range []*os.File{os.Stdin, os.Stdout} {
info, err := f.Stat()
if err != nil || info.Mode()&os.ModeCharDevice == 0 {
return false
}
}
return true
}
// agentsOrNil keeps a nil *Supervisor from arriving as a non-nil interface
// holding a nil pointer, which is the way the sidebar would appear for a
// session that has no supervisor at all.
func agentsOrNil(sup *subagent.Supervisor) tui.Agents {
if sup == nil {
return nil
}
return sup
}
func toolNames(reg *tools.Registry) []string {
var names []string
for _, t := range reg.List() {
names = append(names, t.Name())
}
return names
}