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