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