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 "time"
13
14 "github.com/patriceckhart/zot/packages/provider/auth"
15)
16
17// Config is the persisted user configuration.
18type Config struct {
19 Provider string `json:"provider"`
20 Model string `json:"model"`
21 Reasoning string `json:"reasoning"`
22 Temperature *float32 `json:"temperature,omitempty"`
23 Theme string `json:"theme"`
24
25 // InlineImagesEnabled controls whether zot draws screenshots inline
26 // when the terminal supports an image protocol. nil/missing means
27 // auto (enabled when supported); false disables; true forces the
28 // detected protocol when available.
29 InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"`
30
31 // AutoSwarmEnabled lets the main agent spawn background sub-agents
32 // for parallel sub-tasks via a built-in swarm_spawn tool. Off by
33 // default; nil/missing means disabled. Toggle from /settings.
34 AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
35
36 // RecursiveFileSuggest controls the @-mention file picker. When true
37 // the picker fuzzy-searches the whole project tree below the working
38 // directory; nil/missing/false keeps the default directory-by-
39 // directory browse. Toggle from /settings.
40 RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`
41
42 // RespectGitignore controls whether the @-mention file picker hides
43 // files and directories matched by the project's root .gitignore (in
44 // both flat and recursive modes). nil/missing means the default,
45 // which is on; false shows ignored entries. Toggle from /settings.
46 RespectGitignore *bool `json:"respect_gitignore,omitempty"`
47
48 // LastChangelogShown is the version whose release-notes
49 // dialog the user has already seen. When the running binary's
50 // version differs, the next interactive run shows the
51 // changelog (fetched from the GitHub release page) once and
52 // updates this field. Empty means "never shown".
53 LastChangelogShown string `json:"last_changelog_shown,omitempty"`
54}
55
56// ZotHome returns $ZOT_HOME or the OS-default data dir.
57//
58// All zot state (config.json, auth.json, sessions/, logs/) lives under
59// this directory.
60func ZotHome() string {
61 if v := os.Getenv("ZOT_HOME"); v != "" {
62 return v
63 }
64 switch runtime.GOOS {
65 case "darwin":
66 if home, err := os.UserHomeDir(); err == nil {
67 return filepath.Join(home, "Library", "Application Support", "zot")
68 }
69 case "windows":
70 if v := os.Getenv("LOCALAPPDATA"); v != "" {
71 return filepath.Join(v, "zot")
72 }
73 }
74 if v := os.Getenv("XDG_STATE_HOME"); v != "" {
75 return filepath.Join(v, "zot")
76 }
77 if home, err := os.UserHomeDir(); err == nil {
78 return filepath.Join(home, ".local", "state", "zot")
79 }
80 return ".zot"
81}
82
83// ConfigPath returns the path to config.json.
84func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") }
85
86// AuthPath returns the path to auth.json.
87func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }
88
89// KimiCLIFallbackDisabledPath returns a sentinel that disables falling
90// back to the official Kimi Code CLI token after `zot /logout kimi`.
91func KimiCLIFallbackDisabledPath() string {
92 return filepath.Join(ZotHome(), "kimi-cli-fallback-disabled")
93}
94
95// SessionsPath returns the directory holding session files.
96func SessionsPath() string { return filepath.Join(ZotHome(), "sessions") }
97
98// LogsPath returns the directory holding log files.
99func LogsPath() string { return filepath.Join(ZotHome(), "logs") }
100
101// LoadConfig reads the config file, returning defaults if missing.
102func LoadConfig() (Config, error) {
103 var c Config
104 b, err := os.ReadFile(ConfigPath())
105 if errors.Is(err, os.ErrNotExist) {
106 return c, nil
107 }
108 if err != nil {
109 return c, err
110 }
111 if err := json.Unmarshal(b, &c); err != nil {
112 return c, fmt.Errorf("parse config: %w", err)
113 }
114 return c, nil
115}
116
117// SaveConfig writes the config file, creating parent dirs.
118func SaveConfig(c Config) error {
119 if err := os.MkdirAll(ZotHome(), 0o755); err != nil {
120 return err
121 }
122 b, err := json.MarshalIndent(c, "", " ")
123 if err != nil {
124 return err
125 }
126 return os.WriteFile(ConfigPath(), b, 0o644)
127}
128
129// AuthStoreFor returns the auth.Store backed by AuthPath().
130func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) }
131
132// ResolveCredential returns the credential (api key or oauth access
133// token), the method ("apikey"/"oauth"), and an error when no
134// credential is available.
135//
136// Lookup order:
137// 1. explicit (e.g. --api-key): treated as API key
138// 2. provider-specific env var: treated as API key
139// 3. auth.json: api key OR oauth, whichever is present
140func ResolveCredential(provider, explicit string) (cred, method string, err error) {
141 cred, method, _, err = ResolveCredentialFull(provider, explicit)
142 return cred, method, err
143}
144
145// ResolveCredentialFull is like ResolveCredential but also returns a
146// provider-specific accountID when the credential is an OpenAI OAuth
147// token (the ChatGPT account id extracted from the stored id_token).
148// accountID is "" for API-key auth and for anthropic.
149func ResolveCredentialFull(provider, explicit string) (cred, method, accountID string, err error) {
150 if explicit != "" {
151 return explicit, "apikey", "", nil
152 }
153 switch provider {
154 case "anthropic":
155 // ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY.
156 // Useful when both are set and the user wants subscription auth
157 // without editing auth.json.
158 if v := os.Getenv("ANTHROPIC_OAUTH_TOKEN"); v != "" {
159 return v, "oauth", "", nil
160 }
161 if v := os.Getenv("ANTHROPIC_API_KEY"); v != "" {
162 return v, "apikey", "", nil
163 }
164 case "openai":
165 if v := os.Getenv("OPENAI_API_KEY"); v != "" {
166 return v, "apikey", "", nil
167 }
168 case "openai-codex":
169 // ChatGPT/Codex subscription route. It intentionally ignores
170 // OPENAI_API_KEY so users can keep both OpenAI API and Codex
171 // subscription credentials configured and choose by provider.
172 case "openai-responses":
173 // Public OpenAI Responses API. Same env var as the chat-completions
174 // `openai` provider; users pick the wire format by provider id.
175 if v := os.Getenv("OPENAI_API_KEY"); v != "" {
176 return v, "apikey", "", nil
177 }
178 case "kimi":
179 if v := os.Getenv("KIMI_API_KEY"); v != "" {
180 return v, "apikey", "", nil
181 }
182 if v := os.Getenv("MOONSHOT_API_KEY"); v != "" {
183 return v, "apikey", "", nil
184 }
185 case "google":
186 // Both env names are widely-used in the Google ecosystem;
187 // GEMINI_API_KEY is the AI Studio default, GOOGLE_API_KEY
188 // is the older / generic name. Either works.
189 if v := os.Getenv("GEMINI_API_KEY"); v != "" {
190 return v, "apikey", "", nil
191 }
192 if v := os.Getenv("GOOGLE_API_KEY"); v != "" {
193 return v, "apikey", "", nil
194 }
195 case "deepseek":
196 if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {
197 return v, "apikey", "", nil
198 }
199 case "moonshotai", "moonshotai-cn":
200 // Moonshot direct API (separate from kimi-coding, which is the
201 // Anthropic-Messages-fronted /coding endpoint with subscription OAuth).
202 if v := os.Getenv("MOONSHOT_API_KEY"); v != "" {
203 return v, "apikey", "", nil
204 }
205 case "groq":
206 if v := os.Getenv("GROQ_API_KEY"); v != "" {
207 return v, "apikey", "", nil
208 }
209 case "xai":
210 if v := os.Getenv("XAI_API_KEY"); v != "" {
211 return v, "apikey", "", nil
212 }
213 case "cerebras":
214 if v := os.Getenv("CEREBRAS_API_KEY"); v != "" {
215 return v, "apikey", "", nil
216 }
217 case "together":
218 if v := os.Getenv("TOGETHER_API_KEY"); v != "" {
219 return v, "apikey", "", nil
220 }
221 case "huggingface":
222 if v := os.Getenv("HF_TOKEN"); v != "" {
223 return v, "apikey", "", nil
224 }
225 case "openrouter":
226 if v := os.Getenv("OPENROUTER_API_KEY"); v != "" {
227 return v, "apikey", "", nil
228 }
229 case "mistral":
230 if v := os.Getenv("MISTRAL_API_KEY"); v != "" {
231 return v, "apikey", "", nil
232 }
233 case "zai":
234 if v := os.Getenv("ZAI_API_KEY"); v != "" {
235 return v, "apikey", "", nil
236 }
237 case "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp":
238 envVar := "XIAOMI_API_KEY"
239 switch provider {
240 case "xiaomi-token-plan-ams":
241 envVar = "XIAOMI_TOKEN_PLAN_AMS_API_KEY"
242 case "xiaomi-token-plan-cn":
243 envVar = "XIAOMI_TOKEN_PLAN_CN_API_KEY"
244 case "xiaomi-token-plan-sgp":
245 envVar = "XIAOMI_TOKEN_PLAN_SGP_API_KEY"
246 }
247 if v := os.Getenv(envVar); v != "" {
248 return v, "apikey", "", nil
249 }
250 case "minimax":
251 if v := os.Getenv("MINIMAX_API_KEY"); v != "" {
252 return v, "apikey", "", nil
253 }
254 case "minimax-cn":
255 if v := os.Getenv("MINIMAX_CN_API_KEY"); v != "" {
256 return v, "apikey", "", nil
257 }
258 if v := os.Getenv("MINIMAX_API_KEY"); v != "" {
259 return v, "apikey", "", nil
260 }
261 case "fireworks":
262 if v := os.Getenv("FIREWORKS_API_KEY"); v != "" {
263 return v, "apikey", "", nil
264 }
265 case "vercel-ai-gateway":
266 if v := os.Getenv("AI_GATEWAY_API_KEY"); v != "" {
267 return v, "apikey", "", nil
268 }
269 case "opencode", "opencode-go":
270 if v := os.Getenv("OPENCODE_API_KEY"); v != "" {
271 return v, "apikey", "", nil
272 }
273 case "github-copilot":
274 if v := os.Getenv("COPILOT_GITHUB_TOKEN"); v != "" {
275 return v, "apikey", "", nil
276 }
277 if v := os.Getenv("GITHUB_COPILOT_TOKEN"); v != "" {
278 return v, "apikey", "", nil
279 }
280 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
281 if v := os.Getenv("CLOUDFLARE_API_KEY"); v != "" {
282 return v, "apikey", "", nil
283 }
284 case "amazon-bedrock":
285 // Bedrock has many credential sources (AWS_PROFILE, IAM keys,
286 // container creds, IRSA, bearer token). We surface a sentinel so
287 // Resolve doesn't error on missing key; the real client (when
288 // implemented) will resolve credentials through aws-sdk-go-v2.
289 if os.Getenv("AWS_ACCESS_KEY_ID") != "" || os.Getenv("AWS_PROFILE") != "" ||
290 os.Getenv("AWS_BEARER_TOKEN_BEDROCK") != "" {
291 return "<aws>", "apikey", "", nil
292 }
293 case "google-vertex":
294 if v := os.Getenv("GOOGLE_CLOUD_API_KEY"); v != "" {
295 return v, "apikey", "", nil
296 }
297 case "azure-openai-responses":
298 if v := os.Getenv("AZURE_OPENAI_API_KEY"); v != "" {
299 return v, "apikey", "", nil
300 }
301 }
302 c, err := AuthStoreFor().Load()
303 if err != nil {
304 return "", "", "", err
305 }
306 if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok && pc.APIKey != "" {
307 return pc.APIKey, "apikey", "", nil
308 }
309 switch provider {
310 case "anthropic":
311 if c.Anthropic.APIKey != "" {
312 return c.Anthropic.APIKey, "apikey", "", nil
313 }
314 if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" {
315 tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth)
316 return tok.AccessToken, "oauth", "", nil
317 }
318 case "openai":
319 if c.OpenAI.APIKey != "" {
320 return c.OpenAI.APIKey, "apikey", "", nil
321 }
322 case "openai-codex":
323 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {
324 tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)
325 return tok.AccessToken, "oauth", tok.AccountID, nil
326 }
327 case "kimi":
328 if c.Kimi.APIKey != "" {
329 return c.Kimi.APIKey, "apikey", "", nil
330 }
331 if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" {
332 tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth)
333 return tok.AccessToken, "oauth", "", nil
334 }
335 if kimiCLIFallbackDisabled() {
336 break
337 }
338 if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" {
339 tok, _ = refreshIfExpired("kimi", tok)
340 return tok.AccessToken, "oauth", "", nil
341 }
342 case "deepseek":
343 if c.DeepSeek.APIKey != "" {
344 return c.DeepSeek.APIKey, "apikey", "", nil
345 }
346 case "google":
347 // Google is API-key only — no OAuth path. We still load
348 // auth.json so /login api-key flows work without exporting
349 // an env var.
350 if c.Google.APIKey != "" {
351 return c.Google.APIKey, "apikey", "", nil
352 }
353 case "github-copilot":
354 if c.GithubCopilot.APIKey != "" {
355 return c.GithubCopilot.APIKey, "apikey", "", nil
356 }
357 if c.GithubCopilot.OAuth != nil && c.GithubCopilot.OAuth.AccessToken != "" {
358 return c.GithubCopilot.OAuth.AccessToken, "oauth", "", nil
359 }
360 }
361 return "", "", "", fmt.Errorf("no credential for %s", provider)
362}
363
364func kimiCLIFallbackDisabled() bool {
365 _, err := os.Stat(KimiCLIFallbackDisabledPath())
366 return err == nil
367}
368
369func SetKimiCLIFallbackDisabled(disabled bool) error {
370 path := KimiCLIFallbackDisabledPath()
371 if !disabled {
372 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
373 return err
374 }
375 return nil
376 }
377 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
378 return err
379 }
380 return os.WriteFile(path, []byte("disabled\n"), 0o600)
381}
382
383func loadKimiCodeCLIToken() *auth.OAuthToken {
384 home, err := os.UserHomeDir()
385 if err != nil {
386 return nil
387 }
388 b, err := os.ReadFile(filepath.Join(home, ".kimi", "credentials", "kimi-code.json"))
389 if err != nil {
390 return nil
391 }
392 var raw struct {
393 AccessToken string `json:"access_token"`
394 RefreshToken string `json:"refresh_token"`
395 TokenType string `json:"token_type"`
396 ExpiresAt float64 `json:"expires_at"`
397 Scope string `json:"scope"`
398 ExpiresIn float64 `json:"expires_in"`
399 }
400 if err := json.Unmarshal(b, &raw); err != nil || raw.AccessToken == "" {
401 return nil
402 }
403 sec := int64(raw.ExpiresAt)
404 nsec := int64((raw.ExpiresAt - float64(sec)) * 1e9)
405 return &auth.OAuthToken{
406 AccessToken: raw.AccessToken,
407 RefreshToken: raw.RefreshToken,
408 TokenType: raw.TokenType,
409 Scope: raw.Scope,
410 ClientID: auth.KimiOAuth.ClientID,
411 Expiry: time.Unix(sec, nsec),
412 }
413}
414
415// loadOAuthToken reads the current OAuth token from auth.json for the
416// given provider. Returns nil if no token is stored.
417func loadOAuthToken(providerName string) *auth.OAuthToken {
418 c, err := AuthStoreFor().Load()
419 if err != nil {
420 return nil
421 }
422 switch providerName {
423 case "anthropic":
424 if c.Anthropic.OAuth != nil {
425 return c.Anthropic.OAuth
426 }
427 case "openai":
428 if c.OpenAI.OAuth != nil {
429 return c.OpenAI.OAuth
430 }
431 case "kimi":
432 if c.Kimi.OAuth != nil {
433 return c.Kimi.OAuth
434 }
435 if kimiCLIFallbackDisabled() {
436 return nil
437 }
438 return loadKimiCodeCLIToken()
439 case "github-copilot":
440 if c.GithubCopilot.OAuth != nil {
441 return c.GithubCopilot.OAuth
442 }
443 }
444 return nil
445}
446
447// refreshIfExpired returns a usable OAuth token for the given provider,
448// refreshing it synchronously when it's past (or near) expiry. The
449// refreshed token is persisted to auth.json.
450//
451// Failures return the original token unchanged — the caller then makes
452// a request with the stale access_token, which will 401. That's still
453// better than crashing at credential-resolution time.
454func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) {
455 if tok == nil {
456 return &auth.OAuthToken{}, fmt.Errorf("nil token")
457 }
458 if !tok.Expired() {
459 return tok, nil
460 }
461 if tok.RefreshToken == "" {
462 return tok, fmt.Errorf("%s oauth token expired and no refresh_token available — run /login again", providerName)
463 }
464
465 var op auth.OAuthProvider
466 switch providerName {
467 case "anthropic":
468 op = auth.AnthropicOAuth
469 case "openai":
470 op = auth.OpenAIOAuth
471 case "kimi":
472 op = auth.KimiOAuth
473 default:
474 return tok, fmt.Errorf("unknown provider %q", providerName)
475 }
476
477 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
478 defer cancel()
479 next, err := op.Refresh(ctx, tok.RefreshToken)
480 if err != nil {
481 return tok, fmt.Errorf("refresh %s: %w", providerName, err)
482 }
483 // Preserve the refresh token if the server omitted it (Anthropic often does).
484 if next.RefreshToken == "" {
485 next.RefreshToken = tok.RefreshToken
486 }
487 // Carry over account id (openai) / id_token across refreshes.
488 if next.AccountID == "" {
489 next.AccountID = tok.AccountID
490 }
491 if next.IDToken == "" {
492 next.IDToken = tok.IDToken
493 }
494 if err := AuthStoreFor().SetOAuth(providerName, *next); err != nil {
495 return next, fmt.Errorf("persist refreshed token: %w", err)
496 }
497 return next, nil
498}