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 switch runtime.GOOS {
112 case "darwin":
113 if home, err := os.UserHomeDir(); err == nil {
114 return filepath.Join(home, "Library", "Application Support", "zot")
115 }
116 case "windows":
117 if v := os.Getenv("LOCALAPPDATA"); v != "" {
118 return filepath.Join(v, "zot")
119 }
120 }
121 if v := os.Getenv("XDG_STATE_HOME"); v != "" {
122 return filepath.Join(v, "zot")
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 case "azure-openai-responses":
380 if v := os.Getenv("AZURE_OPENAI_API_KEY"); v != "" {
381 return v, "apikey", "", nil
382 }
383 }
384 // Generic env var fallback for custom providers: normalize the
385 // provider id to a shell-friendly env var name (hyphens to
386 // underscores) and check {NAME}_API_KEY before auth.json.
387 if v := os.Getenv(normalizeCustomProviderEnvVar(provider) + "_API_KEY"); v != "" {
388 return v, "apikey", "", nil
389 }
390 c, err := AuthStoreFor().Load()
391 if err != nil {
392 return "", "", "", err
393 }
394 if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok && pc.APIKey != "" {
395 return pc.APIKey, "apikey", "", nil
396 }
397 switch provider {
398 case "anthropic":
399 if c.Anthropic.APIKey != "" {
400 return c.Anthropic.APIKey, "apikey", "", nil
401 }
402 if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" {
403 tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth)
404 return tok.AccessToken, "oauth", "", nil
405 }
406 case "openai":
407 if c.OpenAI.APIKey != "" {
408 return c.OpenAI.APIKey, "apikey", "", nil
409 }
410 case "openai-codex":
411 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {
412 tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)
413 return tok.AccessToken, "oauth", tok.AccountID, nil
414 }
415 case "kimi":
416 if c.Kimi.APIKey != "" {
417 return c.Kimi.APIKey, "apikey", "", nil
418 }
419 if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" {
420 tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth)
421 return tok.AccessToken, "oauth", "", nil
422 }
423 if kimiCLIFallbackDisabled() {
424 break
425 }
426 if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" {
427 tok, _ = refreshIfExpired("kimi", tok)
428 return tok.AccessToken, "oauth", "", nil
429 }
430 case "deepseek":
431 if c.DeepSeek.APIKey != "" {
432 return c.DeepSeek.APIKey, "apikey", "", nil
433 }
434 case "google":
435 // Google is API-key only — no OAuth path. We still load
436 // auth.json so /login api-key flows work without exporting
437 // an env var.
438 if c.Google.APIKey != "" {
439 return c.Google.APIKey, "apikey", "", nil
440 }
441 case "github-copilot":
442 if c.GithubCopilot.APIKey != "" {
443 return c.GithubCopilot.APIKey, "apikey", "", nil
444 }
445 if c.GithubCopilot.OAuth != nil && c.GithubCopilot.OAuth.AccessToken != "" {
446 return c.GithubCopilot.OAuth.AccessToken, "oauth", "", nil
447 }
448 }
449 return "", "", "", fmt.Errorf("no credential for %s", provider)
450}
451
452func kimiCLIFallbackDisabled() bool {
453 _, err := os.Stat(KimiCLIFallbackDisabledPath())
454 return err == nil
455}
456
457func SetKimiCLIFallbackDisabled(disabled bool) error {
458 path := KimiCLIFallbackDisabledPath()
459 if !disabled {
460 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
461 return err
462 }
463 return nil
464 }
465 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
466 return err
467 }
468 return os.WriteFile(path, []byte("disabled\n"), 0o600)
469}
470
471// normalizeCustomProviderEnvVar converts a provider id such as
472// "my-company" to "MY_COMPANY", matching the common convention for
473// shell environment variables.
474func normalizeCustomProviderEnvVar(provider string) string {
475 provider = strings.ToUpper(provider)
476 provider = strings.ReplaceAll(provider, "-", "_")
477 return provider
478}
479
480func loadKimiCodeCLIToken() *auth.OAuthToken {
481 home, err := os.UserHomeDir()
482 if err != nil {
483 return nil
484 }
485 b, err := os.ReadFile(filepath.Join(home, ".kimi", "credentials", "kimi-code.json"))
486 if err != nil {
487 return nil
488 }
489 var raw struct {
490 AccessToken string `json:"access_token"`
491 RefreshToken string `json:"refresh_token"`
492 TokenType string `json:"token_type"`
493 ExpiresAt float64 `json:"expires_at"`
494 Scope string `json:"scope"`
495 ExpiresIn float64 `json:"expires_in"`
496 }
497 if err := json.Unmarshal(b, &raw); err != nil || raw.AccessToken == "" {
498 return nil
499 }
500 sec := int64(raw.ExpiresAt)
501 nsec := int64((raw.ExpiresAt - float64(sec)) * 1e9)
502 return &auth.OAuthToken{
503 AccessToken: raw.AccessToken,
504 RefreshToken: raw.RefreshToken,
505 TokenType: raw.TokenType,
506 Scope: raw.Scope,
507 ClientID: auth.KimiOAuth.ClientID,
508 Expiry: time.Unix(sec, nsec),
509 }
510}
511
512// loadOAuthToken reads the current OAuth token from auth.json for the
513// given provider. Returns nil if no token is stored.
514func loadOAuthToken(providerName string) *auth.OAuthToken {
515 c, err := AuthStoreFor().Load()
516 if err != nil {
517 return nil
518 }
519 switch providerName {
520 case "anthropic":
521 if c.Anthropic.OAuth != nil {
522 return c.Anthropic.OAuth
523 }
524 case "openai":
525 if c.OpenAI.OAuth != nil {
526 return c.OpenAI.OAuth
527 }
528 case "kimi":
529 if c.Kimi.OAuth != nil {
530 return c.Kimi.OAuth
531 }
532 if kimiCLIFallbackDisabled() {
533 return nil
534 }
535 return loadKimiCodeCLIToken()
536 case "github-copilot":
537 if c.GithubCopilot.OAuth != nil {
538 return c.GithubCopilot.OAuth
539 }
540 }
541 return nil
542}
543
544// refreshIfExpired returns a usable OAuth token for the given provider,
545// refreshing it synchronously when it's past (or near) expiry. The
546// refreshed token is persisted to auth.json.
547//
548// Failures return the original token unchanged — the caller then makes
549// a request with the stale access_token, which will 401. That's still
550// better than crashing at credential-resolution time.
551func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) {
552 if tok == nil {
553 return &auth.OAuthToken{}, fmt.Errorf("nil token")
554 }
555 if !tok.Expired() {
556 return tok, nil
557 }
558 if tok.RefreshToken == "" {
559 return tok, fmt.Errorf("%s oauth token expired and no refresh_token available — run /login again", providerName)
560 }
561
562 var op auth.OAuthProvider
563 switch providerName {
564 case "anthropic":
565 op = auth.AnthropicOAuth
566 case "openai":
567 op = auth.OpenAIOAuth
568 case "kimi":
569 op = auth.KimiOAuth
570 default:
571 return tok, fmt.Errorf("unknown provider %q", providerName)
572 }
573
574 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
575 defer cancel()
576 next, err := op.Refresh(ctx, tok.RefreshToken)
577 if err != nil {
578 return tok, fmt.Errorf("refresh %s: %w", providerName, err)
579 }
580 // Preserve the refresh token if the server omitted it (Anthropic often does).
581 if next.RefreshToken == "" {
582 next.RefreshToken = tok.RefreshToken
583 }
584 // Carry over account id (openai) / id_token across refreshes.
585 if next.AccountID == "" {
586 next.AccountID = tok.AccountID
587 }
588 if next.IDToken == "" {
589 next.IDToken = tok.IDToken
590 }
591 if err := AuthStoreFor().SetOAuth(providerName, *next); err != nil {
592 return next, fmt.Errorf("persist refreshed token: %w", err)
593 }
594 return next, nil
595}