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 "github.com/patriceckhart/zot/packages/provider/auth"
16)
17
18// QuickModelShortcut is one configured keyboard shortcut slot.
19type QuickModelShortcut struct {
20 Provider string `json:"provider"`
21 Model string `json:"model"`
22}
23
24// Config is the persisted user configuration.
25type Config struct {
26 Provider string `json:"provider"`
27 Model string `json:"model"`
28 Reasoning string `json:"reasoning"`
29 Temperature *float32 `json:"temperature,omitempty"`
30 Theme string `json:"theme"`
31
32 // ToolRender selects how tool calls are drawn in interactive mode.
33 // "box" (default, or empty) wraps each call in a bordered panel;
34 // "flat" drops the frame for a quiet header line plus indented,
35 // frameless output. The ZOT_FLAT_TOOLS env var overrides this when
36 // set ("1"/"true" forces flat, "0"/"false" forces box).
37 ToolRender string `json:"tool_render,omitempty"`
38
39 // CompactInput renders sent user messages as a single quiet gutter
40 // line instead of a padded, background-tinted bubble. nil/false
41 // (the default) keeps the bubble. The ZOT_COMPACT_INPUT env var
42 // overrides this when set.
43 CompactInput *bool `json:"compact_input,omitempty"`
44
45 // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by
46 // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super.
47 QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"`
48
49 // InlineImagesEnabled controls whether zot draws screenshots inline
50 // when the terminal supports an image protocol. nil/missing means
51 // auto (enabled when supported); false disables; true forces the
52 // detected protocol when available.
53 InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"`
54
55 // AutoSwarmEnabled lets the main agent spawn background sub-agents
56 // for parallel sub-tasks via a built-in swarm_spawn tool. Off by
57 // default; nil/missing means disabled. Toggle from /settings.
58 AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
59
60 // RecursiveFileSuggest controls the @-mention file picker. When true
61 // the picker fuzzy-searches the whole project tree below the working
62 // directory; nil/missing/false keeps the default directory-by-
63 // directory browse. Toggle from /settings.
64 RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`
65
66 // RespectGitignore controls whether the @-mention file picker hides
67 // files and directories matched by the project's root .gitignore (in
68 // both flat and recursive modes). nil/missing means the default,
69 // which is on; false shows ignored entries. Toggle from /settings.
70 RespectGitignore *bool `json:"respect_gitignore,omitempty"`
71
72 // CompactMode renders the interactive transcript with less chrome:
73 // tool calls use flat headers instead of bordered panels, and sent
74 // user messages render without padded background bubbles. Off by
75 // default; nil/missing means disabled. Toggle from /settings.
76 CompactMode *bool `json:"compact_mode,omitempty"`
77
78 // TUIInputStyle controls the main input rendering. Supported values:
79 // "plain" (default), "lines", and "block".
80 TUIInputStyle string `json:"tui_input_style,omitempty"`
81
82 // TUIStatusPosition controls whether model, usage, and cwd information
83 // render above or below the main input. Supported values: "above_input"
84 // (default) and "below_input".
85 TUIStatusPosition string `json:"tui_status_position,omitempty"`
86
87 // TUIWorkingPosition controls whether the busy/working spinner renders
88 // above or below the main input. Supported values: "above_input"
89 // (default) and "below_input".
90 TUIWorkingPosition string `json:"tui_working_position,omitempty"`
91
92 // Insecure skips TLS verification for custom inference endpoints.
93 Insecure bool `json:"insecure,omitempty"`
94
95 // LastChangelogShown is the version whose release-notes
96 // dialog the user has already seen. When the running binary's
97 // version differs, the next interactive run shows the
98 // changelog (fetched from the GitHub release page) once and
99 // updates this field. Empty means "never shown".
100 LastChangelogShown string `json:"last_changelog_shown,omitempty"`
101}
102
103// ZotHome returns $ZOT_HOME or the OS-default data dir.
104//
105// All zot state (config.json, auth.json, sessions/, logs/) lives under
106// this directory.
107func ZotHome() string {
108 if v := os.Getenv("ZOT_HOME"); v != "" {
109 return v
110 }
111 if v := os.Getenv("XDG_STATE_HOME"); v != "" {
112 return filepath.Join(v, "zot")
113 }
114 switch runtime.GOOS {
115 case "darwin":
116 if home, err := os.UserHomeDir(); err == nil {
117 return filepath.Join(home, "Library", "Application Support", "zot")
118 }
119 case "windows":
120 if v := os.Getenv("LOCALAPPDATA"); v != "" {
121 return filepath.Join(v, "zot")
122 }
123 }
124 if home, err := os.UserHomeDir(); err == nil {
125 return filepath.Join(home, ".local", "state", "zot")
126 }
127 return ".zot"
128}
129
130// ConfigPath returns the path to config.json.
131func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") }
132
133// FlatToolRender reports whether tool calls should render flat (no
134// bordered panel). The ZOT_FLAT_TOOLS env var takes precedence over
135// the config when set: "1"/"true"/"yes"/"on" force flat, "0"/"false"/
136// "no"/"off" force box. Otherwise the config's tool_render is
137// consulted; "flat" is flat, anything else (including empty) is box.
138func (c Config) FlatToolRender() bool {
139 if v := strings.TrimSpace(strings.ToLower(os.Getenv("ZOT_FLAT_TOOLS"))); v != "" {
140 switch v {
141 case "1", "true", "yes", "on", "flat":
142 return true
143 case "0", "false", "no", "off", "box":
144 return false
145 }
146 }
147 return strings.EqualFold(strings.TrimSpace(c.ToolRender), "flat")
148}
149
150// CompactUserInput reports whether sent user messages should render as
151// a single quiet gutter line instead of a padded, background-tinted
152// bubble. The ZOT_COMPACT_INPUT env var takes precedence over the
153// config when set: "1"/"true"/"yes"/"on" force compact, "0"/"false"/
154// "no"/"off" force the bubble. Otherwise the config's compact_input
155// is consulted (nil/false means the bubble).
156func (c Config) CompactUserInput() bool {
157 if v := strings.TrimSpace(strings.ToLower(os.Getenv("ZOT_COMPACT_INPUT"))); v != "" {
158 switch v {
159 case "1", "true", "yes", "on", "compact":
160 return true
161 case "0", "false", "no", "off", "bubble":
162 return false
163 }
164 }
165 return c.CompactInput != nil && *c.CompactInput
166}
167
168// AuthPath returns the path to auth.json.
169func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }
170
171// KimiCLIFallbackDisabledPath returns a sentinel that disables falling
172// back to the official Kimi Code CLI token after `zot /logout kimi`.
173func KimiCLIFallbackDisabledPath() string {
174 return filepath.Join(ZotHome(), "kimi-cli-fallback-disabled")
175}
176
177// SessionsPath returns the directory holding session files.
178func SessionsPath() string { return filepath.Join(ZotHome(), "sessions") }
179
180// LogsPath returns the directory holding log files.
181func LogsPath() string { return filepath.Join(ZotHome(), "logs") }
182
183// LoadConfig reads the config file, returning defaults if missing.
184func LoadConfig() (Config, error) {
185 var c Config
186 b, err := os.ReadFile(ConfigPath())
187 if errors.Is(err, os.ErrNotExist) {
188 return c, nil
189 }
190 if err != nil {
191 return c, err
192 }
193 if err := json.Unmarshal(b, &c); err != nil {
194 return c, fmt.Errorf("parse config: %w", err)
195 }
196 return c, nil
197}
198
199// SaveConfig writes the config file, creating parent dirs.
200func SaveConfig(c Config) error {
201 if err := os.MkdirAll(ZotHome(), 0o755); err != nil {
202 return err
203 }
204 b, err := json.MarshalIndent(c, "", " ")
205 if err != nil {
206 return err
207 }
208 return os.WriteFile(ConfigPath(), b, 0o644)
209}
210
211// AuthStoreFor returns the auth.Store backed by AuthPath().
212func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) }
213
214// ResolveCredential returns the credential (api key or oauth access
215// token), the method ("apikey"/"oauth"), and an error when no
216// credential is available.
217//
218// Lookup order:
219// 1. explicit (e.g. --api-key): treated as API key
220// 2. provider-specific env var: treated as API key
221// 3. auth.json: api key OR oauth, whichever is present
222func ResolveCredential(provider, explicit string) (cred, method string, err error) {
223 cred, method, _, err = ResolveCredentialFull(provider, explicit)
224 return cred, method, err
225}
226
227// ResolveCredentialFull is like ResolveCredential but also returns a
228// provider-specific accountID when the credential is an OpenAI OAuth
229// token (the ChatGPT account id extracted from the stored id_token).
230// accountID is "" for API-key auth and for anthropic.
231func ResolveCredentialFull(provider, explicit string) (cred, method, accountID string, err error) {
232 if explicit != "" {
233 return explicit, "apikey", "", nil
234 }
235 switch provider {
236 case "anthropic":
237 // ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY.
238 // Useful when both are set and the user wants subscription auth
239 // without editing auth.json.
240 if v := os.Getenv("ANTHROPIC_OAUTH_TOKEN"); v != "" {
241 return v, "oauth", "", nil
242 }
243 if v := os.Getenv("ANTHROPIC_API_KEY"); v != "" {
244 return v, "apikey", "", nil
245 }
246 case "openai":
247 if v := os.Getenv("OPENAI_API_KEY"); v != "" {
248 return v, "apikey", "", nil
249 }
250 case "openai-codex":
251 // ChatGPT/Codex subscription route. It intentionally ignores
252 // OPENAI_API_KEY so users can keep both OpenAI API and Codex
253 // subscription credentials configured and choose by provider.
254 case "openai-responses":
255 // Public OpenAI Responses API. Same env var as the chat-completions
256 // `openai` provider; users pick the wire format by provider id.
257 if v := os.Getenv("OPENAI_API_KEY"); v != "" {
258 return v, "apikey", "", nil
259 }
260 case "kimi":
261 if v := os.Getenv("KIMI_API_KEY"); v != "" {
262 return v, "apikey", "", nil
263 }
264 if v := os.Getenv("MOONSHOT_API_KEY"); v != "" {
265 return v, "apikey", "", nil
266 }
267 case "google":
268 // Both env names are widely-used in the Google ecosystem;
269 // GEMINI_API_KEY is the AI Studio default, GOOGLE_API_KEY
270 // is the older / generic name. Either works.
271 if v := os.Getenv("GEMINI_API_KEY"); v != "" {
272 return v, "apikey", "", nil
273 }
274 if v := os.Getenv("GOOGLE_API_KEY"); v != "" {
275 return v, "apikey", "", nil
276 }
277 case "deepseek":
278 if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {
279 return v, "apikey", "", nil
280 }
281 case "moonshotai", "moonshotai-cn":
282 // Moonshot direct API (separate from kimi-coding, which is the
283 // Anthropic-Messages-fronted /coding endpoint with subscription OAuth).
284 if v := os.Getenv("MOONSHOT_API_KEY"); v != "" {
285 return v, "apikey", "", nil
286 }
287 case "groq":
288 if v := os.Getenv("GROQ_API_KEY"); v != "" {
289 return v, "apikey", "", nil
290 }
291 case "xai":
292 if v := os.Getenv("XAI_API_KEY"); v != "" {
293 return v, "apikey", "", nil
294 }
295 case "cerebras":
296 if v := os.Getenv("CEREBRAS_API_KEY"); v != "" {
297 return v, "apikey", "", nil
298 }
299 case "together":
300 if v := os.Getenv("TOGETHER_API_KEY"); v != "" {
301 return v, "apikey", "", nil
302 }
303 case "huggingface":
304 if v := os.Getenv("HF_TOKEN"); v != "" {
305 return v, "apikey", "", nil
306 }
307 case "openrouter":
308 if v := os.Getenv("OPENROUTER_API_KEY"); v != "" {
309 return v, "apikey", "", nil
310 }
311 case "mistral":
312 if v := os.Getenv("MISTRAL_API_KEY"); v != "" {
313 return v, "apikey", "", nil
314 }
315 case "zai":
316 if v := os.Getenv("ZAI_API_KEY"); v != "" {
317 return v, "apikey", "", nil
318 }
319 case "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp":
320 envVar := "XIAOMI_API_KEY"
321 switch provider {
322 case "xiaomi-token-plan-ams":
323 envVar = "XIAOMI_TOKEN_PLAN_AMS_API_KEY"
324 case "xiaomi-token-plan-cn":
325 envVar = "XIAOMI_TOKEN_PLAN_CN_API_KEY"
326 case "xiaomi-token-plan-sgp":
327 envVar = "XIAOMI_TOKEN_PLAN_SGP_API_KEY"
328 }
329 if v := os.Getenv(envVar); v != "" {
330 return v, "apikey", "", nil
331 }
332 case "minimax":
333 if v := os.Getenv("MINIMAX_API_KEY"); v != "" {
334 return v, "apikey", "", nil
335 }
336 case "minimax-cn":
337 if v := os.Getenv("MINIMAX_CN_API_KEY"); v != "" {
338 return v, "apikey", "", nil
339 }
340 if v := os.Getenv("MINIMAX_API_KEY"); v != "" {
341 return v, "apikey", "", nil
342 }
343 case "fireworks":
344 if v := os.Getenv("FIREWORKS_API_KEY"); v != "" {
345 return v, "apikey", "", nil
346 }
347 case "vercel-ai-gateway":
348 if v := os.Getenv("AI_GATEWAY_API_KEY"); v != "" {
349 return v, "apikey", "", nil
350 }
351 case "opencode", "opencode-go":
352 if v := os.Getenv("OPENCODE_API_KEY"); v != "" {
353 return v, "apikey", "", nil
354 }
355 case "github-copilot":
356 if v := os.Getenv("COPILOT_GITHUB_TOKEN"); v != "" {
357 return v, "apikey", "", nil
358 }
359 if v := os.Getenv("GITHUB_COPILOT_TOKEN"); v != "" {
360 return v, "apikey", "", nil
361 }
362 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
363 if v := os.Getenv("CLOUDFLARE_API_KEY"); v != "" {
364 return v, "apikey", "", nil
365 }
366 case "amazon-bedrock":
367 // Bedrock has many credential sources (AWS_PROFILE, IAM keys,
368 // container creds, IRSA, bearer token). We surface a sentinel so
369 // Resolve doesn't error on missing key; the real client (when
370 // implemented) will resolve credentials through aws-sdk-go-v2.
371 if os.Getenv("AWS_ACCESS_KEY_ID") != "" || os.Getenv("AWS_PROFILE") != "" ||
372 os.Getenv("AWS_BEARER_TOKEN_BEDROCK") != "" {
373 return "<aws>", "apikey", "", nil
374 }
375 case "google-vertex":
376 if v := os.Getenv("GOOGLE_CLOUD_API_KEY"); v != "" {
377 return v, "apikey", "", nil
378 }
379 // Check for Application Default Credentials (gcloud auth application-default login)
380 // or service account JSON, matching the actual NewVertex client behavior.
381 if os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") != "" {
382 return "<adc>", "apikey", "", nil
383 }
384 // Check for the platform-specific default ADC path that gcloud writes to.
385 if adcPath, err := auth.GoogleApplicationDefaultCredentialsPath(); err == nil {
386 if _, err := os.Stat(adcPath); err == nil {
387 return "<adc>", "apikey", "", nil
388 }
389 }
390 case "azure-openai-responses":
391 if v := os.Getenv("AZURE_OPENAI_API_KEY"); v != "" {
392 return v, "apikey", "", nil
393 }
394 }
395 // Generic env var fallback for custom providers: normalize the
396 // provider id to a shell-friendly env var name (hyphens to
397 // underscores) and check {NAME}_API_KEY before auth.json.
398 if v := os.Getenv(normalizeCustomProviderEnvVar(provider) + "_API_KEY"); v != "" {
399 return v, "apikey", "", nil
400 }
401 c, err := AuthStoreFor().Load()
402 if err != nil {
403 return "", "", "", err
404 }
405 if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok && pc.APIKey != "" {
406 return pc.APIKey, "apikey", "", nil
407 }
408 switch provider {
409 case "anthropic":
410 if c.Anthropic.APIKey != "" {
411 return c.Anthropic.APIKey, "apikey", "", nil
412 }
413 if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" {
414 tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth)
415 return tok.AccessToken, "oauth", "", nil
416 }
417 case "openai":
418 if c.OpenAI.APIKey != "" {
419 return c.OpenAI.APIKey, "apikey", "", nil
420 }
421 case "openai-codex":
422 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {
423 tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)
424 return tok.AccessToken, "oauth", tok.AccountID, nil
425 }
426 case "kimi":
427 if c.Kimi.APIKey != "" {
428 return c.Kimi.APIKey, "apikey", "", nil
429 }
430 if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" {
431 tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth)
432 return tok.AccessToken, "oauth", "", nil
433 }
434 if kimiCLIFallbackDisabled() {
435 break
436 }
437 if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" {
438 tok, _ = refreshIfExpired("kimi", tok)
439 return tok.AccessToken, "oauth", "", nil
440 }
441 case "deepseek":
442 if c.DeepSeek.APIKey != "" {
443 return c.DeepSeek.APIKey, "apikey", "", nil
444 }
445 case "google":
446 // Google is API-key only — no OAuth path. We still load
447 // auth.json so /login api-key flows work without exporting
448 // an env var.
449 if c.Google.APIKey != "" {
450 return c.Google.APIKey, "apikey", "", nil
451 }
452 case "github-copilot":
453 if c.GithubCopilot.APIKey != "" {
454 return c.GithubCopilot.APIKey, "apikey", "", nil
455 }
456 if c.GithubCopilot.OAuth != nil && c.GithubCopilot.OAuth.AccessToken != "" {
457 return c.GithubCopilot.OAuth.AccessToken, "oauth", "", nil
458 }
459 }
460 return "", "", "", fmt.Errorf("no credential for %s", provider)
461}
462
463func kimiCLIFallbackDisabled() bool {
464 _, err := os.Stat(KimiCLIFallbackDisabledPath())
465 return err == nil
466}
467
468func SetKimiCLIFallbackDisabled(disabled bool) error {
469 path := KimiCLIFallbackDisabledPath()
470 if !disabled {
471 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
472 return err
473 }
474 return nil
475 }
476 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
477 return err
478 }
479 return os.WriteFile(path, []byte("disabled\n"), 0o600)
480}
481
482// normalizeCustomProviderEnvVar converts a provider id such as
483// "my-company" to "MY_COMPANY", matching the common convention for
484// shell environment variables.
485func normalizeCustomProviderEnvVar(provider string) string {
486 provider = strings.ToUpper(provider)
487 provider = strings.ReplaceAll(provider, "-", "_")
488 return provider
489}
490
491func loadKimiCodeCLIToken() *auth.OAuthToken {
492 home, err := os.UserHomeDir()
493 if err != nil {
494 return nil
495 }
496 b, err := os.ReadFile(filepath.Join(home, ".kimi", "credentials", "kimi-code.json"))
497 if err != nil {
498 return nil
499 }
500 var raw struct {
501 AccessToken string `json:"access_token"`
502 RefreshToken string `json:"refresh_token"`
503 TokenType string `json:"token_type"`
504 ExpiresAt float64 `json:"expires_at"`
505 Scope string `json:"scope"`
506 ExpiresIn float64 `json:"expires_in"`
507 }
508 if err := json.Unmarshal(b, &raw); err != nil || raw.AccessToken == "" {
509 return nil
510 }
511 sec := int64(raw.ExpiresAt)
512 nsec := int64((raw.ExpiresAt - float64(sec)) * 1e9)
513 return &auth.OAuthToken{
514 AccessToken: raw.AccessToken,
515 RefreshToken: raw.RefreshToken,
516 TokenType: raw.TokenType,
517 Scope: raw.Scope,
518 ClientID: auth.KimiOAuth.ClientID,
519 Expiry: time.Unix(sec, nsec),
520 }
521}
522
523// loadOAuthToken reads the current OAuth token from auth.json for the
524// given provider. Returns nil if no token is stored.
525func loadOAuthToken(providerName string) *auth.OAuthToken {
526 c, err := AuthStoreFor().Load()
527 if err != nil {
528 return nil
529 }
530 switch providerName {
531 case "anthropic":
532 if c.Anthropic.OAuth != nil {
533 return c.Anthropic.OAuth
534 }
535 case "openai":
536 if c.OpenAI.OAuth != nil {
537 return c.OpenAI.OAuth
538 }
539 case "kimi":
540 if c.Kimi.OAuth != nil {
541 return c.Kimi.OAuth
542 }
543 if kimiCLIFallbackDisabled() {
544 return nil
545 }
546 return loadKimiCodeCLIToken()
547 case "github-copilot":
548 if c.GithubCopilot.OAuth != nil {
549 return c.GithubCopilot.OAuth
550 }
551 }
552 return nil
553}
554
555// refreshIfExpired returns a usable OAuth token for the given provider,
556// refreshing it synchronously when it's past (or near) expiry. The
557// refreshed token is persisted to auth.json.
558//
559// Failures return the original token unchanged — the caller then makes
560// a request with the stale access_token, which will 401. That's still
561// better than crashing at credential-resolution time.
562func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) {
563 if tok == nil {
564 return &auth.OAuthToken{}, fmt.Errorf("nil token")
565 }
566 if !tok.Expired() {
567 return tok, nil
568 }
569 if tok.RefreshToken == "" {
570 return tok, fmt.Errorf("%s oauth token expired and no refresh_token available — run /login again", providerName)
571 }
572
573 var op auth.OAuthProvider
574 switch providerName {
575 case "anthropic":
576 op = auth.AnthropicOAuth
577 case "openai":
578 op = auth.OpenAIOAuth
579 case "kimi":
580 op = auth.KimiOAuth
581 default:
582 return tok, fmt.Errorf("unknown provider %q", providerName)
583 }
584
585 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
586 defer cancel()
587 next, err := op.Refresh(ctx, tok.RefreshToken)
588 if err != nil {
589 return tok, fmt.Errorf("refresh %s: %w", providerName, err)
590 }
591 // Preserve the refresh token if the server omitted it (Anthropic often does).
592 if next.RefreshToken == "" {
593 next.RefreshToken = tok.RefreshToken
594 }
595 // Carry over account id (openai) / id_token across refreshes.
596 if next.AccountID == "" {
597 next.AccountID = tok.AccountID
598 }
599 if next.IDToken == "" {
600 next.IDToken = tok.IDToken
601 }
602 if err := AuthStoreFor().SetOAuth(providerName, *next); err != nil {
603 return next, fmt.Errorf("persist refreshed token: %w", err)
604 }
605 return next, nil
606}