1// Package agent wires the provider, core, tools, auth, and modes into a CLI.
2package agent
3
4import (
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "runtime"
12 "strings"
13 "time"
14
15 providerpkg "github.com/bnema/zut/packages/provider"
16 "github.com/bnema/zut/packages/provider/auth"
17)
18
19// QuickModelShortcut is one configured keyboard shortcut slot.
20type QuickModelShortcut struct {
21 Provider string `json:"provider"`
22 Model string `json:"model"`
23}
24
25// SubagentsConfig contains the resident manager policy. max_concurrent limits
26// simultaneous turns, queue_timeout bounds admission waits, allowed_tools
27// limits child capabilities, and allowed_roots limits eligible workspaces.
28// Obsolete keys such as tui_subagent_position are ignored.
29type SubagentsConfig struct {
30 MaxConcurrent int `json:"max_concurrent,omitempty"`
31 QueueTimeout string `json:"queue_timeout,omitempty"`
32 AllowedTools []string `json:"allowed_tools,omitempty"`
33 AllowedRoots []string `json:"allowed_roots,omitempty"`
34}
35
36// Config is the persisted user configuration.
37type Config struct {
38 Provider string `json:"provider"`
39 Model string `json:"model"`
40 Reasoning string `json:"reasoning"`
41 Temperature *float32 `json:"temperature,omitempty"`
42 Theme string `json:"theme"`
43
44 // FastMode requests OpenAI's fast service tier. Off by default;
45 // nil/missing means disabled. Other providers reject fast-mode
46 // requests at the provider boundary.
47 FastMode *bool `json:"fast_mode,omitempty"`
48
49 // ToolRender selects how tool calls are drawn in interactive mode.
50 // "box" (default, or empty) wraps each call in a bordered panel;
51 // "flat" drops the frame for a quiet header line plus indented,
52 // frameless output. The ZUT_FLAT_TOOLS env var overrides this when
53 // set ("1"/"true" forces flat, "0"/"false" forces box).
54 ToolRender string `json:"tool_render,omitempty"`
55
56 // CompactInput renders sent user messages as a single quiet gutter
57 // line instead of a padded, background-tinted bubble. nil/false
58 // (the default) keeps the bubble. The ZUT_COMPACT_INPUT env var
59 // overrides this when set.
60 CompactInput *bool `json:"compact_input,omitempty"`
61
62 // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by
63 // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super.
64 QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"`
65
66 // InlineImagesEnabled controls whether zut draws screenshots inline
67 // when the terminal supports an image protocol. nil/missing means
68 // auto (enabled when supported); false disables; true forces the
69 // detected protocol when available.
70 InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"`
71
72 // TerminalAlertsEnabled controls interactive terminal alerts from the
73 // main agent and extensions. nil/missing means enabled; false disables
74 // alerts. Toggle from /settings.
75 TerminalAlertsEnabled *bool `json:"terminal_alerts_enabled,omitempty"`
76
77 // TerminalTitleEnabled controls the hidden title request and OSC 0 title
78 // updates in interactive mode. nil/missing means enabled; false disables
79 // both so no extra provider request is made. Toggle from /settings.
80 TerminalTitleEnabled *bool `json:"terminal_title_enabled,omitempty"`
81
82 // Subagents controls manager-owned child lifecycle and resource policy.
83 Subagents SubagentsConfig `json:"subagents,omitempty"`
84
85 // AutoSubagentsEnabled lets the main agent spawn background sub-agents
86 // and query their live state via built-in auto-subagent tools. Off by
87 // default; nil/missing means disabled. Toggle from /settings.
88 AutoSubagentsEnabled *bool `json:"auto_subagents_enabled,omitempty"`
89
90 // PonytailEnabled controls the compact coding-guidance addendum. On by
91 // default; nil/missing means enabled. Toggle from /settings.
92 PonytailEnabled *bool `json:"ponytail_enabled,omitempty"`
93
94 // WebSearchEnabled controls the built-in web_search capability for
95 // normal CLI sessions. nil/missing means enabled so existing config
96 // files retain the default-on behavior.
97 WebSearchEnabled *bool `json:"web_search_enabled,omitempty"`
98
99 // LSPEnabled controls the built-in lsp tool and write-time diagnostics
100 // for the main session. nil/missing means enabled. Toggle from
101 // /settings.
102 LSPEnabled *bool `json:"lsp_enabled,omitempty"`
103
104 // SubagentLSPEnabled controls LSP availability in resident child agents.
105 // nil/missing means enabled. Toggle from /settings.
106 SubagentLSPEnabled *bool `json:"subagent_lsp_enabled,omitempty"`
107
108 // LSPDiagnosticsOnWrite enables bounded diagnostics after a successful
109 // write. nil/missing means enabled when LSP itself is enabled.
110 LSPDiagnosticsOnWrite *bool `json:"lsp_diagnostics_on_write,omitempty"`
111
112 // LSPDiagnosticsOnEdit enables bounded diagnostics after a successful
113 // edit. nil/missing means disabled to keep multi-edit sequences quiet.
114 LSPDiagnosticsOnEdit *bool `json:"lsp_diagnostics_on_edit,omitempty"`
115
116 // AutoCompactThreshold is the percentage of the model context window
117 // that triggers automatic transcript compaction in interactive mode.
118 // nil/missing means 85; valid values are 0, 70, 80, 85, 90, or 95.
119 // zero disables percentage-based triggers.
120 AutoCompactThreshold *int `json:"auto_compact_threshold,omitempty"`
121
122 // JailByDefault confines tools to the session working directory when
123 // a new agent starts. Off by default; nil/missing means disabled.
124 // The live session can still override this with /jail or /unjail.
125 JailByDefault *bool `json:"jail_by_default,omitempty"`
126
127 // RecursiveFileSuggest controls the @-mention file picker. When true
128 // the picker fuzzy-searches the whole project tree below the working
129 // directory; nil/missing/false keeps the default directory-by-
130 // directory browse. Toggle from /settings.
131 RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`
132
133 // RespectGitignore controls whether the @-mention file picker hides
134 // files and directories matched by the project's root .gitignore (in
135 // both flat and recursive modes). nil/missing means the default,
136 // which is on; false shows ignored entries. Toggle from /settings.
137 RespectGitignore *bool `json:"respect_gitignore,omitempty"`
138
139 // CompactMode renders the interactive transcript with less chrome:
140 // tool calls use flat headers instead of bordered panels, and sent
141 // user messages render without padded background bubbles. Off by
142 // default; nil/missing means disabled. Toggle from /settings.
143 CompactMode *bool `json:"compact_mode,omitempty"`
144
145 // ShowInstructionsAtStartup lists loaded context files, extensions,
146 // and user-installed skills above the transcript. Off by default;
147 // nil/missing means disabled. Toggle from /settings.
148 ShowInstructionsAtStartup *bool `json:"show_instructions_at_startup,omitempty"`
149
150 // TUIInputStyle controls the main input rendering. Supported values:
151 // "plain" (default), "lines", and "block".
152 TUIInputStyle string `json:"tui_input_style,omitempty"`
153
154 // TUIStatusPosition controls whether model, usage, and cwd information
155 // render above or below the main input. Supported values: "above_input"
156 // (default) and "below_input".
157 TUIStatusPosition string `json:"tui_status_position,omitempty"`
158
159 // TUIWorkingPosition controls whether the busy/working spinner renders
160 // above or below the main input. Supported values: "above_input"
161 // (default) and "below_input".
162 TUIWorkingPosition string `json:"tui_working_position,omitempty"`
163
164 // Insecure skips TLS verification for custom inference endpoints.
165 Insecure bool `json:"insecure,omitempty"`
166
167 // HTTPProxy is a global proxy URL used for HTTP and HTTPS requests when
168 // the corresponding standard proxy environment variable is not already set.
169 HTTPProxy string `json:"http_proxy,omitempty"`
170
171 // LastChangelogShown is the version whose release-notes
172 // dialog the user has already seen. When the running binary's
173 // version differs, the next interactive run shows the
174 // changelog (fetched from the GitHub release page) once and
175 // updates this field. Empty means "never shown".
176 LastChangelogShown string `json:"last_changelog_shown,omitempty"`
177}
178
179// ZutHome returns $ZUT_HOME or the OS-default data dir.
180//
181// All zut state (config.json, auth.json, sessions/, logs/) lives under
182// this directory.
183func ZutHome() string {
184 if v := os.Getenv("ZUT_HOME"); v != "" {
185 return v
186 }
187 if v := os.Getenv("XDG_STATE_HOME"); v != "" {
188 return filepath.Join(v, "zut")
189 }
190 switch runtime.GOOS {
191 case "darwin":
192 if home, err := os.UserHomeDir(); err == nil {
193 return filepath.Join(home, "Library", "Application Support", "zut")
194 }
195 case "windows":
196 if v := os.Getenv("LOCALAPPDATA"); v != "" {
197 return filepath.Join(v, "zut")
198 }
199 }
200 if home, err := os.UserHomeDir(); err == nil {
201 return filepath.Join(home, ".local", "state", "zut")
202 }
203 return ".zut"
204}
205
206// ConfigPath returns the path to config.json.
207func ConfigPath() string { return filepath.Join(ZutHome(), "config.json") }
208
209// FlatToolRender reports whether tool calls should render flat (no
210// bordered panel). The ZUT_FLAT_TOOLS env var takes precedence over
211// the config when set: "1"/"true"/"yes"/"on" force flat, "0"/"false"/
212// "no"/"off" force box. Otherwise the config's tool_render is
213// consulted; "flat" is flat, anything else (including empty) is box.
214func (c Config) FlatToolRender() bool {
215 if v := strings.TrimSpace(strings.ToLower(os.Getenv("ZUT_FLAT_TOOLS"))); v != "" {
216 switch v {
217 case "1", "true", "yes", "on", "flat":
218 return true
219 case "0", "false", "no", "off", "box":
220 return false
221 }
222 }
223 return strings.EqualFold(strings.TrimSpace(c.ToolRender), "flat")
224}
225
226// CompactUserInput reports whether sent user messages should render as
227// a single quiet gutter line instead of a padded, background-tinted
228// bubble. The ZUT_COMPACT_INPUT env var takes precedence over the
229// config when set: "1"/"true"/"yes"/"on" force compact, "0"/"false"/
230// "no"/"off" force the bubble. Otherwise the config's compact_input
231// is consulted (nil/false means the bubble).
232func (c Config) CompactUserInput() bool {
233 if v := strings.TrimSpace(strings.ToLower(os.Getenv("ZUT_COMPACT_INPUT"))); v != "" {
234 switch v {
235 case "1", "true", "yes", "on", "compact":
236 return true
237 case "0", "false", "no", "off", "bubble":
238 return false
239 }
240 }
241 return c.CompactInput != nil && *c.CompactInput
242}
243
244// LSPEnabledFor reports whether LSP is enabled for a main or subagent
245// sub-agent session. Both settings default to true so new installations
246// get code intelligence without a migration or generated config file.
247func (c Config) LSPEnabledFor(subagent bool) bool {
248 value := c.LSPEnabled
249 if subagent {
250 value = c.SubagentLSPEnabled
251 }
252 return value == nil || *value
253}
254
255// PonytailModeEnabled reports the default-on coding-guidance preference
256// without requiring a generated config entry.
257func (c Config) PonytailModeEnabled() bool {
258 return c.PonytailEnabled == nil || *c.PonytailEnabled
259}
260
261// WebSearchEnabledForCLI reports the default-on web-search preference
262// without requiring a generated config entry.
263func (c Config) WebSearchEnabledForCLI() bool {
264 return c.WebSearchEnabled == nil || *c.WebSearchEnabled
265}
266
267// LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics
268// preference without requiring a generated config entry.
269func (c Config) LSPDiagnosticsOnWriteEnabled(subagent bool) bool {
270 return c.LSPEnabledFor(subagent) && (c.LSPDiagnosticsOnWrite == nil || *c.LSPDiagnosticsOnWrite)
271}
272
273// LSPDiagnosticsOnEditEnabled reports the default-off edit diagnostics
274// preference without requiring a generated config entry.
275func (c Config) LSPDiagnosticsOnEditEnabled(subagent bool) bool {
276 return c.LSPEnabledFor(subagent) && c.LSPDiagnosticsOnEdit != nil && *c.LSPDiagnosticsOnEdit
277}
278
279// AuthPath returns the path to auth.json.
280func AuthPath() string { return filepath.Join(ZutHome(), "auth.json") }
281
282// KimiCLIFallbackDisabledPath returns a sentinel that disables falling
283// back to the official Kimi Code CLI token after `zut /logout kimi`.
284func KimiCLIFallbackDisabledPath() string {
285 return filepath.Join(ZutHome(), "kimi-cli-fallback-disabled")
286}
287
288// SessionsPath returns the directory holding session files.
289func SessionsPath() string { return filepath.Join(ZutHome(), "sessions") }
290
291// LogsPath returns the directory holding log files.
292func LogsPath() string { return filepath.Join(ZutHome(), "logs") }
293
294// LoadConfig reads the config file, returning defaults if missing.
295func LoadConfig() (Config, error) {
296 var c Config
297 b, err := os.ReadFile(ConfigPath())
298 if errors.Is(err, os.ErrNotExist) {
299 return c, nil
300 }
301 if err != nil {
302 return c, err
303 }
304 if err := json.Unmarshal(b, &c); err != nil {
305 return c, fmt.Errorf("parse config: %w", err)
306 }
307 if err := validateSubagentDurations(c.Subagents); err != nil {
308 return c, err
309 }
310 return c, nil
311}
312
313func validateSubagentDurations(cfg SubagentsConfig) error {
314 values := []struct {
315 name string
316 value string
317 }{
318 {"queue_timeout", cfg.QueueTimeout},
319 }
320 for _, item := range values {
321 value := strings.TrimSpace(item.value)
322 if value == "" {
323 continue
324 }
325 if _, err := time.ParseDuration(value); err != nil {
326 return fmt.Errorf("parse config: subagents.%s: %w", item.name, err)
327 }
328 }
329 return nil
330}
331
332// SaveConfig writes the config file, creating parent dirs.
333func SaveConfig(c Config) error {
334 if err := os.MkdirAll(ZutHome(), 0o755); err != nil {
335 return err
336 }
337 b, err := json.MarshalIndent(c, "", " ")
338 if err != nil {
339 return err
340 }
341 return os.WriteFile(ConfigPath(), b, 0o644)
342}
343
344// applyConfiguredHTTPProxy makes the persisted proxy available to Go's
345// standard HTTP transport and to child processes. Explicit environment
346// variables take precedence over config.json.
347func applyConfiguredHTTPProxy() {
348 cfg, err := LoadConfig()
349 if err != nil {
350 return
351 }
352 proxy := strings.TrimSpace(cfg.HTTPProxy)
353 if proxy == "" {
354 return
355 }
356 if os.Getenv("HTTP_PROXY") == "" && os.Getenv("http_proxy") == "" {
357 _ = os.Setenv("HTTP_PROXY", proxy)
358 }
359 if os.Getenv("HTTPS_PROXY") == "" && os.Getenv("https_proxy") == "" {
360 _ = os.Setenv("HTTPS_PROXY", proxy)
361 }
362}
363
364// AuthStoreFor returns the auth.Store backed by AuthPath().
365func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) }
366
367// ResolveLlamaCPPConfig resolves the router URL and optional API key. The
368// environment overrides the credential stored through /login.
369func ResolveLlamaCPPConfig() (baseURL, apiKey string, err error) {
370 return resolveLlamaCPPConfig(context.Background(), apiKeyCommandExecute)
371}
372
373func resolveLlamaCPPConfig(ctx context.Context, commandMode apiKeyCommandMode) (baseURL, apiKey string, err error) {
374 baseURL = strings.TrimSpace(os.Getenv("LLAMA_BASE_URL"))
375 apiKey = strings.TrimSpace(os.Getenv("LLAMA_API_KEY"))
376 if baseURL == "" {
377 creds, loadErr := AuthStoreFor().Load()
378 if loadErr != nil {
379 return "", "", loadErr
380 }
381 if stored, ok := creds.AdditionalAPIKeyCreds[providerpkg.LlamaCPPProviderID]; ok {
382 baseURL = stored.BaseURL
383 if apiKey == "" {
384 if stored.APIKeyCommand != nil && commandMode == apiKeyCommandSkip {
385 return "", "", nil
386 }
387 if key, found, commandErr := resolveStoredAPIKey(ctx, providerpkg.LlamaCPPProviderID, stored, commandMode); found || commandErr != nil {
388 if commandErr != nil {
389 return "", "", commandErr
390 }
391 apiKey = key
392 }
393 }
394 }
395 }
396 if baseURL == "" {
397 return "", apiKey, nil
398 }
399 baseURL, err = providerpkg.NormalizeLlamaCPPURL(baseURL)
400 return baseURL, apiKey, err
401}
402
403// ResolveCredential returns the credential (api key or oauth access
404// token), the method ("apikey"/"oauth"), and an error when no
405// credential is available.
406//
407// Lookup order:
408// 1. explicit (e.g. --api-key): treated as API key
409// 2. provider-specific env var: treated as API key
410// 3. auth.json: api key OR oauth, whichever is present
411func ResolveCredential(provider, explicit string) (cred, method string, err error) {
412 cred, method, _, err = ResolveCredentialFull(provider, explicit)
413 return cred, method, err
414}
415
416// ResolveCredentialContext is ResolveCredential with caller cancellation.
417func ResolveCredentialContext(ctx context.Context, provider, explicit string) (cred, method string, err error) {
418 cred, method, _, err = ResolveCredentialFullContext(ctx, provider, explicit)
419 return cred, method, err
420}
421
422// ResolveCredentialFull is like ResolveCredential but also returns a
423// provider-specific accountID when the credential is an OpenAI OAuth
424// token (the ChatGPT account id extracted from the stored id_token).
425// accountID is "" for API-key auth and for anthropic.
426func ResolveCredentialFull(provider, explicit string) (cred, method, accountID string, err error) {
427 return ResolveCredentialFullContext(context.Background(), provider, explicit)
428}
429
430// ResolveCredentialFullContext is ResolveCredentialFull with caller cancellation.
431func ResolveCredentialFullContext(ctx context.Context, provider, explicit string) (cred, method, accountID string, err error) {
432 return resolveCredentialFull(ctx, provider, explicit, apiKeyCommandExecute)
433}
434
435type apiKeyCommandMode uint8
436
437const (
438 apiKeyCommandExecute apiKeyCommandMode = iota
439 apiKeyCommandAvailable
440 apiKeyCommandSkip
441)
442
443func resolveCredentialFull(ctx context.Context, provider, explicit string, commandMode apiKeyCommandMode) (cred, method, accountID string, err error) {
444 if explicit != "" {
445 return explicit, "apikey", "", nil
446 }
447 switch provider {
448 case "anthropic":
449 // ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY.
450 // Useful when both are set and the user wants subscription auth
451 // without editing auth.json.
452 if v := os.Getenv("ANTHROPIC_OAUTH_TOKEN"); v != "" {
453 return v, "oauth", "", nil
454 }
455 if v := os.Getenv("ANTHROPIC_API_KEY"); v != "" {
456 return v, "apikey", "", nil
457 }
458 case "openai":
459 if v := os.Getenv("OPENAI_API_KEY"); v != "" {
460 return v, "apikey", "", nil
461 }
462 case "openai-codex":
463 // ChatGPT/Codex subscription route. It intentionally ignores
464 // OPENAI_API_KEY so users can keep both OpenAI API and Codex
465 // subscription credentials configured and choose by provider.
466 case "openai-responses":
467 // Public OpenAI Responses API. Same env var as the chat-completions
468 // `openai` provider; users pick the wire format by provider id.
469 if v := os.Getenv("OPENAI_API_KEY"); v != "" {
470 return v, "apikey", "", nil
471 }
472 case "kimi":
473 if v := os.Getenv("KIMI_API_KEY"); v != "" {
474 return v, "apikey", "", nil
475 }
476 if v := os.Getenv("MOONSHOT_API_KEY"); v != "" {
477 return v, "apikey", "", nil
478 }
479 case "google":
480 // Both env names are widely-used in the Google ecosystem;
481 // GEMINI_API_KEY is the AI Studio default, GOOGLE_API_KEY
482 // is the older / generic name. Either works.
483 if v := os.Getenv("GEMINI_API_KEY"); v != "" {
484 return v, "apikey", "", nil
485 }
486 if v := os.Getenv("GOOGLE_API_KEY"); v != "" {
487 return v, "apikey", "", nil
488 }
489 case "deepseek":
490 if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {
491 return v, "apikey", "", nil
492 }
493 case "llama.cpp":
494 baseURL, apiKey, resolveErr := resolveLlamaCPPConfig(ctx, commandMode)
495 if resolveErr != nil {
496 return "", "", "", resolveErr
497 }
498 if baseURL != "" {
499 return firstNonEmpty(apiKey, "local"), "apikey", "", nil
500 }
501 case "moonshotai", "moonshotai-cn":
502 // Moonshot direct API (separate from kimi-coding, which is the
503 // Anthropic-Messages-fronted /coding endpoint with subscription OAuth).
504 if v := os.Getenv("MOONSHOT_API_KEY"); v != "" {
505 return v, "apikey", "", nil
506 }
507 case "groq":
508 if v := os.Getenv("GROQ_API_KEY"); v != "" {
509 return v, "apikey", "", nil
510 }
511 case "xai":
512 if v := os.Getenv("XAI_API_KEY"); v != "" {
513 return v, "apikey", "", nil
514 }
515 case "cerebras":
516 if v := os.Getenv("CEREBRAS_API_KEY"); v != "" {
517 return v, "apikey", "", nil
518 }
519 case "together":
520 if v := os.Getenv("TOGETHER_API_KEY"); v != "" {
521 return v, "apikey", "", nil
522 }
523 case "huggingface":
524 if v := os.Getenv("HF_TOKEN"); v != "" {
525 return v, "apikey", "", nil
526 }
527 case "openrouter":
528 if v := os.Getenv("OPENROUTER_API_KEY"); v != "" {
529 return v, "apikey", "", nil
530 }
531 case "mistral":
532 if v := os.Getenv("MISTRAL_API_KEY"); v != "" {
533 return v, "apikey", "", nil
534 }
535 case "zai":
536 if v := os.Getenv("ZAI_API_KEY"); v != "" {
537 return v, "apikey", "", nil
538 }
539 case "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp":
540 envVar := "XIAOMI_API_KEY"
541 switch provider {
542 case "xiaomi-token-plan-ams":
543 envVar = "XIAOMI_TOKEN_PLAN_AMS_API_KEY"
544 case "xiaomi-token-plan-cn":
545 envVar = "XIAOMI_TOKEN_PLAN_CN_API_KEY"
546 case "xiaomi-token-plan-sgp":
547 envVar = "XIAOMI_TOKEN_PLAN_SGP_API_KEY"
548 }
549 if v := os.Getenv(envVar); v != "" {
550 return v, "apikey", "", nil
551 }
552 case "minimax":
553 if v := os.Getenv("MINIMAX_API_KEY"); v != "" {
554 return v, "apikey", "", nil
555 }
556 case "minimax-cn":
557 if v := os.Getenv("MINIMAX_CN_API_KEY"); v != "" {
558 return v, "apikey", "", nil
559 }
560 if v := os.Getenv("MINIMAX_API_KEY"); v != "" {
561 return v, "apikey", "", nil
562 }
563 case "fireworks":
564 if v := os.Getenv("FIREWORKS_API_KEY"); v != "" {
565 return v, "apikey", "", nil
566 }
567 case "vercel-ai-gateway":
568 if v := os.Getenv("AI_GATEWAY_API_KEY"); v != "" {
569 return v, "apikey", "", nil
570 }
571 case "opencode", "opencode-go":
572 if v := os.Getenv("OPENCODE_API_KEY"); v != "" {
573 return v, "apikey", "", nil
574 }
575 case "github-copilot":
576 if v := os.Getenv("COPILOT_GITHUB_TOKEN"); v != "" {
577 return v, "apikey", "", nil
578 }
579 if v := os.Getenv("GITHUB_COPILOT_TOKEN"); v != "" {
580 return v, "apikey", "", nil
581 }
582 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
583 if v := os.Getenv("CLOUDFLARE_API_KEY"); v != "" {
584 return v, "apikey", "", nil
585 }
586 case "amazon-bedrock":
587 // Bedrock has many credential sources (AWS_PROFILE, IAM keys,
588 // container creds, IRSA, bearer token). We surface a sentinel so
589 // Resolve doesn't error on missing key; the real client (when
590 // implemented) will resolve credentials through aws-sdk-go-v2.
591 if os.Getenv("AWS_ACCESS_KEY_ID") != "" || os.Getenv("AWS_PROFILE") != "" ||
592 os.Getenv("AWS_BEARER_TOKEN_BEDROCK") != "" {
593 return "<aws>", "apikey", "", nil
594 }
595 case "google-vertex":
596 if v := os.Getenv("GOOGLE_CLOUD_API_KEY"); v != "" {
597 return v, "apikey", "", nil
598 }
599 // Check for Application Default Credentials (gcloud auth application-default login)
600 // or service account JSON, matching the actual NewVertex client behavior.
601 if os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") != "" {
602 return "<adc>", "apikey", "", nil
603 }
604 // Check for the platform-specific default ADC path that gcloud writes to.
605 if adcPath, err := auth.GoogleApplicationDefaultCredentialsPath(); err == nil {
606 if _, err := os.Stat(adcPath); err == nil {
607 return "<adc>", "apikey", "", nil
608 }
609 }
610 case "azure-openai-responses":
611 if v := os.Getenv("AZURE_OPENAI_API_KEY"); v != "" {
612 return v, "apikey", "", nil
613 }
614 }
615 // Generic env var fallback for custom providers: normalize the
616 // provider id to a shell-friendly env var name (hyphens to
617 // underscores) and check {NAME}_API_KEY before auth.json.
618 if v := os.Getenv(normalizeCustomProviderEnvVar(provider) + "_API_KEY"); v != "" {
619 return v, "apikey", "", nil
620 }
621 c, err := AuthStoreFor().Load()
622 if err != nil {
623 return "", "", "", err
624 }
625 if pc, ok := storedProviderCreds(c, provider); ok {
626 if key, found, commandErr := resolveStoredAPIKey(ctx, provider, pc, commandMode); found || commandErr != nil {
627 if commandErr != nil {
628 return "", "", "", commandErr
629 }
630 return key, "apikey", "", nil
631 }
632 if provider == "xai" && pc.OAuth != nil && pc.OAuth.AccessToken != "" {
633 tok, _ := refreshIfExpiredContext(ctx, provider, pc.OAuth)
634 return tok.AccessToken, "oauth", "", nil
635 }
636 }
637 switch provider {
638 case "anthropic":
639 if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" {
640 tok, _ := refreshIfExpiredContext(ctx, "anthropic", c.Anthropic.OAuth)
641 return tok.AccessToken, "oauth", "", nil
642 }
643 case "openai-codex":
644 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {
645 tok, _ := refreshIfExpiredContext(ctx, "openai", c.OpenAI.OAuth)
646 return tok.AccessToken, "oauth", tok.AccountID, nil
647 }
648 case "kimi":
649 if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" {
650 tok, _ := refreshIfExpiredContext(ctx, "kimi", c.Kimi.OAuth)
651 return tok.AccessToken, "oauth", "", nil
652 }
653 if kimiCLIFallbackDisabled() {
654 break
655 }
656 if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" {
657 tok, _ = refreshIfExpiredContext(ctx, "kimi", tok)
658 return tok.AccessToken, "oauth", "", nil
659 }
660 case "github-copilot":
661 if c.GithubCopilot.OAuth != nil && c.GithubCopilot.OAuth.AccessToken != "" {
662 return c.GithubCopilot.OAuth.AccessToken, "oauth", "", nil
663 }
664 }
665 return "", "", "", fmt.Errorf("no credential for %s", provider)
666}
667
668// CredentialAvailable reports whether a provider has a configured credential
669// without executing an api_key_command.
670func CredentialAvailable(provider string) bool {
671 _, _, _, err := resolveCredentialFull(context.Background(), provider, "", apiKeyCommandAvailable)
672 return err == nil
673}
674
675func resolveCredentialForBackground(ctx context.Context, provider string) (cred, method string, err error) {
676 cred, method, _, err = resolveCredentialFull(ctx, provider, "", apiKeyCommandSkip)
677 return cred, method, err
678}
679
680func storedProviderCreds(c auth.Credentials, provider string) (auth.ProviderCreds, bool) {
681 if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok {
682 return pc, true
683 }
684 switch provider {
685 case "anthropic":
686 return c.Anthropic, true
687 case "openai":
688 return c.OpenAI, true
689 case "kimi":
690 return c.Kimi, true
691 case "google":
692 return c.Google, true
693 case "deepseek":
694 return c.DeepSeek, true
695 case "github-copilot":
696 return c.GithubCopilot, true
697 default:
698 return auth.ProviderCreds{}, false
699 }
700}
701
702func resolveStoredAPIKey(ctx context.Context, provider string, creds auth.ProviderCreds, mode apiKeyCommandMode) (key string, found bool, err error) {
703 if creds.APIKey != "" {
704 return creds.APIKey, true, nil
705 }
706 if creds.APIKeyCommand == nil {
707 return "", false, nil
708 }
709 switch mode {
710 case apiKeyCommandAvailable:
711 return "<api-key-command>", true, nil
712 case apiKeyCommandSkip:
713 return "", false, nil
714 default:
715 key, err := auth.ResolveAPIKeyCommand(ctx, *creds.APIKeyCommand)
716 if err != nil {
717 return "", true, fmt.Errorf("resolve api key for %s: %w", provider, err)
718 }
719 return key, true, nil
720 }
721}
722
723func kimiCLIFallbackDisabled() bool {
724 _, err := os.Stat(KimiCLIFallbackDisabledPath())
725 return err == nil
726}
727
728func SetKimiCLIFallbackDisabled(disabled bool) error {
729 path := KimiCLIFallbackDisabledPath()
730 if !disabled {
731 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
732 return err
733 }
734 return nil
735 }
736 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
737 return err
738 }
739 return os.WriteFile(path, []byte("disabled\n"), 0o600)
740}
741
742// normalizeCustomProviderEnvVar converts a provider id such as
743// "my-company" to "MY_COMPANY", matching the common convention for
744// shell environment variables.
745func normalizeCustomProviderEnvVar(provider string) string {
746 provider = strings.ToUpper(provider)
747 provider = strings.ReplaceAll(provider, "-", "_")
748 return provider
749}
750
751func loadKimiCodeCLIToken() *auth.OAuthToken {
752 home, err := os.UserHomeDir()
753 if err != nil {
754 return nil
755 }
756 b, err := os.ReadFile(filepath.Join(home, ".kimi", "credentials", "kimi-code.json"))
757 if err != nil {
758 return nil
759 }
760 var raw struct {
761 AccessToken string `json:"access_token"`
762 RefreshToken string `json:"refresh_token"`
763 TokenType string `json:"token_type"`
764 ExpiresAt float64 `json:"expires_at"`
765 Scope string `json:"scope"`
766 ExpiresIn float64 `json:"expires_in"`
767 }
768 if err := json.Unmarshal(b, &raw); err != nil || raw.AccessToken == "" {
769 return nil
770 }
771 sec := int64(raw.ExpiresAt)
772 nsec := int64((raw.ExpiresAt - float64(sec)) * 1e9)
773 return &auth.OAuthToken{
774 AccessToken: raw.AccessToken,
775 RefreshToken: raw.RefreshToken,
776 TokenType: raw.TokenType,
777 Scope: raw.Scope,
778 ClientID: auth.KimiOAuth.ClientID,
779 Expiry: time.Unix(sec, nsec),
780 }
781}
782
783// loadOAuthToken reads the current OAuth token from auth.json for the
784// given provider. Returns nil if no token is stored.
785func loadOAuthToken(providerName string) *auth.OAuthToken {
786 c, err := AuthStoreFor().Load()
787 if err != nil {
788 return nil
789 }
790 switch providerName {
791 case "anthropic":
792 if c.Anthropic.OAuth != nil {
793 return c.Anthropic.OAuth
794 }
795 case "openai":
796 if c.OpenAI.OAuth != nil {
797 return c.OpenAI.OAuth
798 }
799 case "kimi":
800 if c.Kimi.OAuth != nil {
801 return c.Kimi.OAuth
802 }
803 if kimiCLIFallbackDisabled() {
804 return nil
805 }
806 return loadKimiCodeCLIToken()
807 case "github-copilot":
808 if c.GithubCopilot.OAuth != nil {
809 return c.GithubCopilot.OAuth
810 }
811 }
812 if pc, ok := c.AdditionalAPIKeyCreds[providerName]; ok {
813 return pc.OAuth
814 }
815 return nil
816}
817
818// refreshIfExpired returns a usable OAuth token for the given provider,
819// refreshing it synchronously when it's past (or near) expiry. The
820// refreshed token is persisted to auth.json.
821//
822// Failures return the original token unchanged — the caller then makes
823// a request with the stale access_token, which will 401. That's still
824// better than crashing at credential-resolution time.
825func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) {
826 return refreshIfExpiredContext(context.Background(), providerName, tok)
827}
828
829// refreshIfExpiredContext uses the caller's cancellation deadline while
830// refreshing an expired OAuth token.
831func refreshIfExpiredContext(parent context.Context, providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) {
832 if tok == nil {
833 return &auth.OAuthToken{}, fmt.Errorf("nil token")
834 }
835 if !tok.Expired() {
836 return tok, nil
837 }
838 if tok.RefreshToken == "" {
839 return tok, fmt.Errorf("%s oauth token expired and no refresh_token available — run /login again", providerName)
840 }
841
842 if providerName == "xai" {
843 ctx, cancel := context.WithTimeout(parent, 30*time.Second)
844 defer cancel()
845 next, err := auth.RefreshXAIToken(ctx, tok.RefreshToken)
846 if err != nil {
847 return tok, fmt.Errorf("refresh %s: %w", providerName, err)
848 }
849 if next.RefreshToken == "" {
850 next.RefreshToken = tok.RefreshToken
851 }
852 if err := AuthStoreFor().SetOAuth(providerName, *next); err != nil {
853 return next, fmt.Errorf("persist refreshed token: %w", err)
854 }
855 return next, nil
856 }
857
858 var op auth.OAuthProvider
859 switch providerName {
860 case "anthropic":
861 op = auth.AnthropicOAuth
862 case "openai":
863 op = auth.OpenAIOAuth
864 case "kimi":
865 op = auth.KimiOAuth
866 default:
867 return tok, fmt.Errorf("unknown provider %q", providerName)
868 }
869
870 ctx, cancel := context.WithTimeout(parent, 30*time.Second)
871 defer cancel()
872 next, err := op.Refresh(ctx, tok.RefreshToken)
873 if err != nil {
874 return tok, fmt.Errorf("refresh %s: %w", providerName, err)
875 }
876 // Preserve the refresh token if the server omitted it (Anthropic often does).
877 if next.RefreshToken == "" {
878 next.RefreshToken = tok.RefreshToken
879 }
880 // Carry over account id (openai) / id_token across refreshes.
881 if next.AccountID == "" {
882 next.AccountID = tok.AccountID
883 }
884 if next.IDToken == "" {
885 next.IDToken = tok.IDToken
886 }
887 if err := AuthStoreFor().SetOAuth(providerName, *next); err != nil {
888 return next, fmt.Errorf("persist refreshed token: %w", err)
889 }
890 return next, nil
891}