// Package agent wires the provider, core, tools, auth, and modes into a CLI. package agent import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "runtime" "time" "github.com/patriceckhart/zot/packages/provider/auth" ) // Config is the persisted user configuration. type Config struct { Provider string `json:"provider"` Model string `json:"model"` Reasoning string `json:"reasoning"` Theme string `json:"theme"` // InlineImagesEnabled controls whether zot draws screenshots inline // when the terminal supports an image protocol. nil/missing means // auto (enabled when supported); false disables; true forces the // detected protocol when available. InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"` // AutoSwarmEnabled lets the main agent spawn background sub-agents // for parallel sub-tasks via a built-in swarm_spawn tool. Off by // default; nil/missing means disabled. Toggle from /settings. AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"` // LastChangelogShown is the version whose release-notes // dialog the user has already seen. When the running binary's // version differs, the next interactive run shows the // changelog (fetched from the GitHub release page) once and // updates this field. Empty means "never shown". LastChangelogShown string `json:"last_changelog_shown,omitempty"` } // ZotHome returns $ZOT_HOME or the OS-default data dir. // // All zot state (config.json, auth.json, sessions/, logs/) lives under // this directory. func ZotHome() string { if v := os.Getenv("ZOT_HOME"); v != "" { return v } switch runtime.GOOS { case "darwin": if home, err := os.UserHomeDir(); err == nil { return filepath.Join(home, "Library", "Application Support", "zot") } case "windows": if v := os.Getenv("LOCALAPPDATA"); v != "" { return filepath.Join(v, "zot") } } if v := os.Getenv("XDG_STATE_HOME"); v != "" { return filepath.Join(v, "zot") } if home, err := os.UserHomeDir(); err == nil { return filepath.Join(home, ".local", "state", "zot") } return ".zot" } // ConfigPath returns the path to config.json. func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") } // AuthPath returns the path to auth.json. func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") } // KimiCLIFallbackDisabledPath returns a sentinel that disables falling // back to the official Kimi Code CLI token after `zot /logout kimi`. func KimiCLIFallbackDisabledPath() string { return filepath.Join(ZotHome(), "kimi-cli-fallback-disabled") } // SessionsPath returns the directory holding session files. func SessionsPath() string { return filepath.Join(ZotHome(), "sessions") } // LogsPath returns the directory holding log files. func LogsPath() string { return filepath.Join(ZotHome(), "logs") } // LoadConfig reads the config file, returning defaults if missing. func LoadConfig() (Config, error) { var c Config b, err := os.ReadFile(ConfigPath()) if errors.Is(err, os.ErrNotExist) { return c, nil } if err != nil { return c, err } if err := json.Unmarshal(b, &c); err != nil { return c, fmt.Errorf("parse config: %w", err) } return c, nil } // SaveConfig writes the config file, creating parent dirs. func SaveConfig(c Config) error { if err := os.MkdirAll(ZotHome(), 0o755); err != nil { return err } b, err := json.MarshalIndent(c, "", " ") if err != nil { return err } return os.WriteFile(ConfigPath(), b, 0o644) } // AuthStoreFor returns the auth.Store backed by AuthPath(). func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) } // ResolveCredential returns the credential (api key or oauth access // token), the method ("apikey"/"oauth"), and an error when no // credential is available. // // Lookup order: // 1. explicit (e.g. --api-key): treated as API key // 2. provider-specific env var: treated as API key // 3. auth.json: api key OR oauth, whichever is present func ResolveCredential(provider, explicit string) (cred, method string, err error) { cred, method, _, err = ResolveCredentialFull(provider, explicit) return cred, method, err } // ResolveCredentialFull is like ResolveCredential but also returns a // provider-specific accountID when the credential is an OpenAI OAuth // token (the ChatGPT account id extracted from the stored id_token). // accountID is "" for API-key auth and for anthropic. func ResolveCredentialFull(provider, explicit string) (cred, method, accountID string, err error) { if explicit != "" { return explicit, "apikey", "", nil } switch provider { case "anthropic": // ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY. // Useful when both are set and the user wants subscription auth // without editing auth.json. if v := os.Getenv("ANTHROPIC_OAUTH_TOKEN"); v != "" { return v, "oauth", "", nil } if v := os.Getenv("ANTHROPIC_API_KEY"); v != "" { return v, "apikey", "", nil } case "openai": if v := os.Getenv("OPENAI_API_KEY"); v != "" { return v, "apikey", "", nil } case "openai-codex": // ChatGPT/Codex subscription route. It intentionally ignores // OPENAI_API_KEY so users can keep both OpenAI API and Codex // subscription credentials configured and choose by provider. case "openai-responses": // Public OpenAI Responses API. Same env var as the chat-completions // `openai` provider; users pick the wire format by provider id. if v := os.Getenv("OPENAI_API_KEY"); v != "" { return v, "apikey", "", nil } case "kimi": if v := os.Getenv("KIMI_API_KEY"); v != "" { return v, "apikey", "", nil } if v := os.Getenv("MOONSHOT_API_KEY"); v != "" { return v, "apikey", "", nil } case "google": // Both env names are widely-used in the Google ecosystem; // GEMINI_API_KEY is the AI Studio default, GOOGLE_API_KEY // is the older / generic name. Either works. if v := os.Getenv("GEMINI_API_KEY"); v != "" { return v, "apikey", "", nil } if v := os.Getenv("GOOGLE_API_KEY"); v != "" { return v, "apikey", "", nil } case "deepseek": if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { return v, "apikey", "", nil } case "moonshotai", "moonshotai-cn": // Moonshot direct API (separate from kimi-coding, which is the // Anthropic-Messages-fronted /coding endpoint with subscription OAuth). if v := os.Getenv("MOONSHOT_API_KEY"); v != "" { return v, "apikey", "", nil } case "groq": if v := os.Getenv("GROQ_API_KEY"); v != "" { return v, "apikey", "", nil } case "xai": if v := os.Getenv("XAI_API_KEY"); v != "" { return v, "apikey", "", nil } case "cerebras": if v := os.Getenv("CEREBRAS_API_KEY"); v != "" { return v, "apikey", "", nil } case "together": if v := os.Getenv("TOGETHER_API_KEY"); v != "" { return v, "apikey", "", nil } case "huggingface": if v := os.Getenv("HF_TOKEN"); v != "" { return v, "apikey", "", nil } case "openrouter": if v := os.Getenv("OPENROUTER_API_KEY"); v != "" { return v, "apikey", "", nil } case "mistral": if v := os.Getenv("MISTRAL_API_KEY"); v != "" { return v, "apikey", "", nil } case "zai": if v := os.Getenv("ZAI_API_KEY"); v != "" { return v, "apikey", "", nil } case "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp": envVar := "XIAOMI_API_KEY" switch provider { case "xiaomi-token-plan-ams": envVar = "XIAOMI_TOKEN_PLAN_AMS_API_KEY" case "xiaomi-token-plan-cn": envVar = "XIAOMI_TOKEN_PLAN_CN_API_KEY" case "xiaomi-token-plan-sgp": envVar = "XIAOMI_TOKEN_PLAN_SGP_API_KEY" } if v := os.Getenv(envVar); v != "" { return v, "apikey", "", nil } case "minimax": if v := os.Getenv("MINIMAX_API_KEY"); v != "" { return v, "apikey", "", nil } case "minimax-cn": if v := os.Getenv("MINIMAX_CN_API_KEY"); v != "" { return v, "apikey", "", nil } if v := os.Getenv("MINIMAX_API_KEY"); v != "" { return v, "apikey", "", nil } case "fireworks": if v := os.Getenv("FIREWORKS_API_KEY"); v != "" { return v, "apikey", "", nil } case "vercel-ai-gateway": if v := os.Getenv("AI_GATEWAY_API_KEY"); v != "" { return v, "apikey", "", nil } case "opencode", "opencode-go": if v := os.Getenv("OPENCODE_API_KEY"); v != "" { return v, "apikey", "", nil } case "github-copilot": if v := os.Getenv("COPILOT_GITHUB_TOKEN"); v != "" { return v, "apikey", "", nil } if v := os.Getenv("GITHUB_COPILOT_TOKEN"); v != "" { return v, "apikey", "", nil } case "cloudflare-workers-ai", "cloudflare-ai-gateway": if v := os.Getenv("CLOUDFLARE_API_KEY"); v != "" { return v, "apikey", "", nil } case "amazon-bedrock": // Bedrock has many credential sources (AWS_PROFILE, IAM keys, // container creds, IRSA, bearer token). We surface a sentinel so // Resolve doesn't error on missing key; the real client (when // implemented) will resolve credentials through aws-sdk-go-v2. if os.Getenv("AWS_ACCESS_KEY_ID") != "" || os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_BEARER_TOKEN_BEDROCK") != "" { return "", "apikey", "", nil } case "google-vertex": if v := os.Getenv("GOOGLE_CLOUD_API_KEY"); v != "" { return v, "apikey", "", nil } case "azure-openai-responses": if v := os.Getenv("AZURE_OPENAI_API_KEY"); v != "" { return v, "apikey", "", nil } } c, err := AuthStoreFor().Load() if err != nil { return "", "", "", err } if pc, ok := c.AdditionalAPIKeyCreds[provider]; ok && pc.APIKey != "" { return pc.APIKey, "apikey", "", nil } switch provider { case "anthropic": if c.Anthropic.APIKey != "" { return c.Anthropic.APIKey, "apikey", "", nil } if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" { tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth) return tok.AccessToken, "oauth", "", nil } case "openai": if c.OpenAI.APIKey != "" { return c.OpenAI.APIKey, "apikey", "", nil } case "openai-codex": if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" { tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth) return tok.AccessToken, "oauth", tok.AccountID, nil } case "kimi": if c.Kimi.APIKey != "" { return c.Kimi.APIKey, "apikey", "", nil } if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" { tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth) return tok.AccessToken, "oauth", "", nil } if kimiCLIFallbackDisabled() { break } if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" { tok, _ = refreshIfExpired("kimi", tok) return tok.AccessToken, "oauth", "", nil } case "deepseek": if c.DeepSeek.APIKey != "" { return c.DeepSeek.APIKey, "apikey", "", nil } case "google": // Google is API-key only — no OAuth path. We still load // auth.json so /login api-key flows work without exporting // an env var. if c.Google.APIKey != "" { return c.Google.APIKey, "apikey", "", nil } case "github-copilot": if c.GithubCopilot.APIKey != "" { return c.GithubCopilot.APIKey, "apikey", "", nil } if c.GithubCopilot.OAuth != nil && c.GithubCopilot.OAuth.AccessToken != "" { return c.GithubCopilot.OAuth.AccessToken, "oauth", "", nil } } return "", "", "", fmt.Errorf("no credential for %s", provider) } func kimiCLIFallbackDisabled() bool { _, err := os.Stat(KimiCLIFallbackDisabledPath()) return err == nil } func SetKimiCLIFallbackDisabled(disabled bool) error { path := KimiCLIFallbackDisabledPath() if !disabled { if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { return err } return nil } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } return os.WriteFile(path, []byte("disabled\n"), 0o600) } func loadKimiCodeCLIToken() *auth.OAuthToken { home, err := os.UserHomeDir() if err != nil { return nil } b, err := os.ReadFile(filepath.Join(home, ".kimi", "credentials", "kimi-code.json")) if err != nil { return nil } var raw struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` TokenType string `json:"token_type"` ExpiresAt float64 `json:"expires_at"` Scope string `json:"scope"` ExpiresIn float64 `json:"expires_in"` } if err := json.Unmarshal(b, &raw); err != nil || raw.AccessToken == "" { return nil } sec := int64(raw.ExpiresAt) nsec := int64((raw.ExpiresAt - float64(sec)) * 1e9) return &auth.OAuthToken{ AccessToken: raw.AccessToken, RefreshToken: raw.RefreshToken, TokenType: raw.TokenType, Scope: raw.Scope, ClientID: auth.KimiOAuth.ClientID, Expiry: time.Unix(sec, nsec), } } // loadOAuthToken reads the current OAuth token from auth.json for the // given provider. Returns nil if no token is stored. func loadOAuthToken(providerName string) *auth.OAuthToken { c, err := AuthStoreFor().Load() if err != nil { return nil } switch providerName { case "anthropic": if c.Anthropic.OAuth != nil { return c.Anthropic.OAuth } case "openai": if c.OpenAI.OAuth != nil { return c.OpenAI.OAuth } case "kimi": if c.Kimi.OAuth != nil { return c.Kimi.OAuth } if kimiCLIFallbackDisabled() { return nil } return loadKimiCodeCLIToken() case "github-copilot": if c.GithubCopilot.OAuth != nil { return c.GithubCopilot.OAuth } } return nil } // refreshIfExpired returns a usable OAuth token for the given provider, // refreshing it synchronously when it's past (or near) expiry. The // refreshed token is persisted to auth.json. // // Failures return the original token unchanged — the caller then makes // a request with the stale access_token, which will 401. That's still // better than crashing at credential-resolution time. func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) { if tok == nil { return &auth.OAuthToken{}, fmt.Errorf("nil token") } if !tok.Expired() { return tok, nil } if tok.RefreshToken == "" { return tok, fmt.Errorf("%s oauth token expired and no refresh_token available — run /login again", providerName) } var op auth.OAuthProvider switch providerName { case "anthropic": op = auth.AnthropicOAuth case "openai": op = auth.OpenAIOAuth case "kimi": op = auth.KimiOAuth default: return tok, fmt.Errorf("unknown provider %q", providerName) } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() next, err := op.Refresh(ctx, tok.RefreshToken) if err != nil { return tok, fmt.Errorf("refresh %s: %w", providerName, err) } // Preserve the refresh token if the server omitted it (Anthropic often does). if next.RefreshToken == "" { next.RefreshToken = tok.RefreshToken } // Carry over account id (openai) / id_token across refreshes. if next.AccountID == "" { next.AccountID = tok.AccountID } if next.IDToken == "" { next.IDToken = tok.IDToken } if err := AuthStoreFor().SetOAuth(providerName, *next); err != nil { return next, fmt.Errorf("persist refreshed token: %w", err) } return next, nil }