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// SessionsPath returns the directory holding session files.
72func SessionsPath() string { return filepath.Join(ZotHome(), "sessions") }
73
74// LogsPath returns the directory holding log files.
75func LogsPath() string { return filepath.Join(ZotHome(), "logs") }
76
77// LoadConfig reads the config file, returning defaults if missing.
78func LoadConfig() (Config, error) {
79 var c Config
80 b, err := os.ReadFile(ConfigPath())
81 if errors.Is(err, os.ErrNotExist) {
82 return c, nil
83 }
84 if err != nil {
85 return c, err
86 }
87 if err := json.Unmarshal(b, &c); err != nil {
88 return c, fmt.Errorf("parse config: %w", err)
89 }
90 return c, nil
91}
92
93// SaveConfig writes the config file, creating parent dirs.
94func SaveConfig(c Config) error {
95 if err := os.MkdirAll(ZotHome(), 0o755); err != nil {
96 return err
97 }
98 b, err := json.MarshalIndent(c, "", " ")
99 if err != nil {
100 return err
101 }
102 return os.WriteFile(ConfigPath(), b, 0o644)
103}
104
105// AuthStoreFor returns the auth.Store backed by AuthPath().
106func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) }
107
108// ResolveCredential returns the credential (api key or oauth access
109// token), the method ("apikey"/"oauth"), and an error when no
110// credential is available.
111//
112// Lookup order:
113// 1. explicit (e.g. --api-key): treated as API key
114// 2. provider-specific env var: treated as API key
115// 3. auth.json: api key OR oauth, whichever is present
116func ResolveCredential(provider, explicit string) (cred, method string, err error) {
117 cred, method, _, err = ResolveCredentialFull(provider, explicit)
118 return cred, method, err
119}
120
121// ResolveCredentialFull is like ResolveCredential but also returns a
122// provider-specific accountID when the credential is an OpenAI OAuth
123// token (the ChatGPT account id extracted from the stored id_token).
124// accountID is "" for API-key auth and for anthropic.
125func ResolveCredentialFull(provider, explicit string) (cred, method, accountID string, err error) {
126 if explicit != "" {
127 return explicit, "apikey", "", nil
128 }
129 switch provider {
130 case "anthropic":
131 if v := os.Getenv("ANTHROPIC_API_KEY"); v != "" {
132 return v, "apikey", "", nil
133 }
134 case "openai":
135 if v := os.Getenv("OPENAI_API_KEY"); v != "" {
136 return v, "apikey", "", nil
137 }
138 }
139 c, err := AuthStoreFor().Load()
140 if err != nil {
141 return "", "", "", err
142 }
143 switch provider {
144 case "anthropic":
145 if c.Anthropic.APIKey != "" {
146 return c.Anthropic.APIKey, "apikey", "", nil
147 }
148 if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" {
149 tok, _ := refreshIfExpired("anthropic", c.Anthropic.OAuth)
150 return tok.AccessToken, "oauth", "", nil
151 }
152 case "openai":
153 if c.OpenAI.APIKey != "" {
154 return c.OpenAI.APIKey, "apikey", "", nil
155 }
156 if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" {
157 tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)
158 return tok.AccessToken, "oauth", tok.AccountID, nil
159 }
160 }
161 return "", "", "", fmt.Errorf("no credential for %s", provider)
162}
163
164// loadOAuthToken reads the current OAuth token from auth.json for the
165// given provider. Returns nil if no token is stored.
166func loadOAuthToken(providerName string) *auth.OAuthToken {
167 c, err := AuthStoreFor().Load()
168 if err != nil {
169 return nil
170 }
171 switch providerName {
172 case "anthropic":
173 if c.Anthropic.OAuth != nil {
174 return c.Anthropic.OAuth
175 }
176 case "openai":
177 if c.OpenAI.OAuth != nil {
178 return c.OpenAI.OAuth
179 }
180 }
181 return nil
182}
183
184// refreshIfExpired returns a usable OAuth token for the given provider,
185// refreshing it synchronously when it's past (or near) expiry. The
186// refreshed token is persisted to auth.json.
187//
188// Failures return the original token unchanged — the caller then makes
189// a request with the stale access_token, which will 401. That's still
190// better than crashing at credential-resolution time.
191func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthToken, error) {
192 if tok == nil {
193 return &auth.OAuthToken{}, fmt.Errorf("nil token")
194 }
195 if !tok.Expired() {
196 return tok, nil
197 }
198 if tok.RefreshToken == "" {
199 return tok, fmt.Errorf("%s oauth token expired and no refresh_token available — run /login again", providerName)
200 }
201
202 var op auth.OAuthProvider
203 switch providerName {
204 case "anthropic":
205 op = auth.AnthropicOAuth
206 case "openai":
207 op = auth.OpenAIOAuth
208 default:
209 return tok, fmt.Errorf("unknown provider %q", providerName)
210 }
211
212 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
213 defer cancel()
214 next, err := op.Refresh(ctx, tok.RefreshToken)
215 if err != nil {
216 return tok, fmt.Errorf("refresh %s: %w", providerName, err)
217 }
218 // Preserve the refresh token if the server omitted it (Anthropic often does).
219 if next.RefreshToken == "" {
220 next.RefreshToken = tok.RefreshToken
221 }
222 // Carry over account id (openai) / id_token across refreshes.
223 if next.AccountID == "" {
224 next.AccountID = tok.AccountID
225 }
226 if next.IDToken == "" {
227 next.IDToken = tok.IDToken
228 }
229 if err := AuthStoreFor().SetOAuth(providerName, *next); err != nil {
230 return next, fmt.Errorf("persist refreshed token: %w", err)
231 }
232 return next, nil
233}