1package config
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "slices"
11
12 "charm.land/catwalk/pkg/catwalk"
13 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
14 "github.com/charmbracelet/crush/internal/env"
15 "github.com/charmbracelet/crush/internal/oauth"
16 "github.com/charmbracelet/crush/internal/oauth/copilot"
17 "github.com/charmbracelet/crush/internal/oauth/hyper"
18 "github.com/tidwall/gjson"
19 "github.com/tidwall/sjson"
20)
21
22// fileSnapshot captures metadata about a config file at a point in time.
23type fileSnapshot struct {
24 Path string
25 Exists bool
26 Size int64
27 ModTime int64 // UnixNano
28}
29
30// RuntimeOverrides holds per-session settings that are never persisted to
31// disk. They are applied on top of the loaded Config and survive only for
32// the lifetime of the process (or workspace).
33type RuntimeOverrides struct {
34 SkipPermissionRequests bool
35}
36
37// ConfigStore is the single entry point for all config access. It owns the
38// pure-data Config, runtime state (working directory, resolver, known
39// providers), and persistence to both global and workspace config files.
40type ConfigStore struct {
41 config *Config
42 workingDir string
43 resolver VariableResolver
44 globalDataPath string // ~/.local/share/crush/crush.json
45 workspacePath string // .crush/crush.json
46 loadedPaths []string // config files that were successfully loaded
47 knownProviders []catwalk.Provider
48 overrides RuntimeOverrides
49 trackedConfigPaths []string // unique, normalized config file paths
50 snapshots map[string]fileSnapshot // path -> snapshot at last capture
51 autoReloadDisabled bool // set during load/reload to prevent re-entrancy
52 reloadInProgress bool // set during reload to avoid disk writes mid-reload
53}
54
55// Config returns the pure-data config struct (read-only after load).
56func (s *ConfigStore) Config() *Config {
57 return s.config
58}
59
60// WorkingDir returns the current working directory.
61func (s *ConfigStore) WorkingDir() string {
62 return s.workingDir
63}
64
65// Resolver returns the variable resolver.
66func (s *ConfigStore) Resolver() VariableResolver {
67 return s.resolver
68}
69
70// Resolve resolves a variable reference using the configured resolver.
71func (s *ConfigStore) Resolve(key string) (string, error) {
72 if s.resolver == nil {
73 return "", fmt.Errorf("no variable resolver configured")
74 }
75 return s.resolver.ResolveValue(key)
76}
77
78// KnownProviders returns the list of known providers.
79func (s *ConfigStore) KnownProviders() []catwalk.Provider {
80 return s.knownProviders
81}
82
83// SetupAgents configures the coder and task agents on the config.
84func (s *ConfigStore) SetupAgents() {
85 s.config.SetupAgents()
86}
87
88// Overrides returns the runtime overrides for this store.
89func (s *ConfigStore) Overrides() *RuntimeOverrides {
90 return &s.overrides
91}
92
93// LoadedPaths returns the config file paths that were successfully loaded.
94func (s *ConfigStore) LoadedPaths() []string {
95 return slices.Clone(s.loadedPaths)
96}
97
98// configPath returns the file path for the given scope.
99func (s *ConfigStore) configPath(scope Scope) (string, error) {
100 switch scope {
101 case ScopeWorkspace:
102 if s.workspacePath == "" {
103 return "", ErrNoWorkspaceConfig
104 }
105 return s.workspacePath, nil
106 default:
107 return s.globalDataPath, nil
108 }
109}
110
111// HasConfigField checks whether a key exists in the config file for the given
112// scope.
113func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
114 path, err := s.configPath(scope)
115 if err != nil {
116 return false
117 }
118 data, err := os.ReadFile(path)
119 if err != nil {
120 return false
121 }
122 return gjson.Get(string(data), key).Exists()
123}
124
125// SetConfigField sets a key/value pair in the config file for the given scope.
126// After a successful write, it automatically reloads config to keep in-memory
127// state fresh.
128func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
129 return s.SetConfigFields(scope, map[string]any{key: value})
130}
131
132// SetConfigFields sets multiple key/value pairs in the config file for the given
133// scope in a single write. After a successful write, it automatically reloads
134// config to keep in-memory state fresh. This is preferred over multiple
135// SetConfigField calls when writing several fields atomically to avoid
136// intermediate reloads with partial state.
137func (s *ConfigStore) SetConfigFields(scope Scope, kv map[string]any) error {
138 path, err := s.configPath(scope)
139 if err != nil {
140 return fmt.Errorf("%v: %w", kv, err)
141 }
142 data, err := os.ReadFile(path)
143 if err != nil {
144 if os.IsNotExist(err) {
145 data = []byte("{}")
146 } else {
147 return fmt.Errorf("failed to read config file: %w", err)
148 }
149 }
150
151 newValue := string(data)
152 for key, value := range kv {
153 newValue, err = sjson.Set(newValue, key, value)
154 if err != nil {
155 return fmt.Errorf("failed to set config field %s: %w", key, err)
156 }
157 }
158 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
159 return fmt.Errorf("failed to create config directory %q: %w", path, err)
160 }
161 if err := atomicWriteFile(path, []byte(newValue), 0o600); err != nil {
162 return fmt.Errorf("failed to write config file: %w", err)
163 }
164
165 // Auto-reload to keep in-memory state fresh after config edits.
166 // We use context.Background() since this is an internal operation that
167 // shouldn't be cancelled by user context.
168 if err := s.autoReload(context.Background()); err != nil {
169 // Log warning but don't fail the write - disk is already updated.
170 slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
171 }
172
173 return nil
174}
175
176// RemoveConfigField removes a key from the config file for the given scope.
177// After a successful write, it automatically reloads config to keep in-memory
178// state fresh.
179func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
180 path, err := s.configPath(scope)
181 if err != nil {
182 return fmt.Errorf("%s: %w", key, err)
183 }
184 data, err := os.ReadFile(path)
185 if err != nil {
186 return fmt.Errorf("failed to read config file: %w", err)
187 }
188
189 newValue, err := sjson.Delete(string(data), key)
190 if err != nil {
191 return fmt.Errorf("failed to delete config field %s: %w", key, err)
192 }
193 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
194 return fmt.Errorf("failed to create config directory %q: %w", path, err)
195 }
196 if err := atomicWriteFile(path, []byte(newValue), 0o600); err != nil {
197 return fmt.Errorf("failed to write config file: %w", err)
198 }
199
200 // Auto-reload to keep in-memory state fresh after config edits.
201 if err := s.autoReload(context.Background()); err != nil {
202 slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
203 }
204
205 return nil
206}
207
208// UpdatePreferredModel updates the preferred model for the given type and
209// persists it to the config file at the given scope.
210func (s *ConfigStore) UpdatePreferredModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
211 s.config.Models[modelType] = model
212 if err := s.SetConfigField(scope, fmt.Sprintf("models.%s", modelType), model); err != nil {
213 return fmt.Errorf("failed to update preferred model: %w", err)
214 }
215 if err := s.recordRecentModel(scope, modelType, model); err != nil {
216 return err
217 }
218 return nil
219}
220
221// SetCompactMode sets the compact mode setting and persists it.
222func (s *ConfigStore) SetCompactMode(scope Scope, enabled bool) error {
223 if s.config.Options == nil {
224 s.config.Options = &Options{}
225 }
226 s.config.Options.TUI.CompactMode = enabled
227 return s.SetConfigField(scope, "options.tui.compact_mode", enabled)
228}
229
230// SetTransparentBackground sets the transparent background setting and persists it.
231func (s *ConfigStore) SetTransparentBackground(scope Scope, enabled bool) error {
232 if s.config.Options == nil {
233 s.config.Options = &Options{}
234 }
235 s.config.Options.TUI.Transparent = &enabled
236 return s.SetConfigField(scope, "options.tui.transparent", enabled)
237}
238
239// SetProviderAPIKey sets the API key for a provider and persists it.
240func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey any) error {
241 var providerConfig ProviderConfig
242 var exists bool
243 var setKeyOrToken func()
244
245 switch v := apiKey.(type) {
246 case string:
247 if err := s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil {
248 return fmt.Errorf("failed to save api key to config file: %w", err)
249 }
250 setKeyOrToken = func() { providerConfig.APIKey = v }
251 case *oauth.Token:
252 if err := s.SetConfigFields(scope, map[string]any{
253 fmt.Sprintf("providers.%s.api_key", providerID): v.AccessToken,
254 fmt.Sprintf("providers.%s.oauth", providerID): v,
255 }); err != nil {
256 return err
257 }
258 setKeyOrToken = func() {
259 providerConfig.APIKey = v.AccessToken
260 providerConfig.OAuthToken = v
261 switch providerID {
262 case string(catwalk.InferenceProviderCopilot):
263 providerConfig.SetupGitHubCopilot()
264 }
265 }
266 }
267
268 providerConfig, exists = s.config.Providers.Get(providerID)
269 if exists {
270 setKeyOrToken()
271 s.config.Providers.Set(providerID, providerConfig)
272 return nil
273 }
274
275 var foundProvider *catwalk.Provider
276 for _, p := range s.knownProviders {
277 if string(p.ID) == providerID {
278 foundProvider = &p
279 break
280 }
281 }
282
283 if foundProvider != nil {
284 providerConfig = ProviderConfig{
285 ID: providerID,
286 Name: foundProvider.Name,
287 BaseURL: foundProvider.APIEndpoint,
288 Type: foundProvider.Type,
289 Disable: false,
290 ExtraHeaders: make(map[string]string),
291 ExtraParams: make(map[string]string),
292 Models: foundProvider.Models,
293 }
294 setKeyOrToken()
295 } else {
296 return fmt.Errorf("provider with ID %s not found in known providers", providerID)
297 }
298 s.config.Providers.Set(providerID, providerConfig)
299 return nil
300}
301
302// RefreshOAuthToken refreshes the OAuth token for the given provider.
303// Before making an external refresh request, it checks the config file on
304// disk to see if another Crush session has already refreshed the token. If
305// a newer, unexpired token is found, it is used instead of refreshing. If
306// the exchange fails (e.g. because another session already rotated the
307// refresh token), the disk is re-checked to recover the other session's
308// token.
309func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
310 providerConfig, exists := s.config.Providers.Get(providerID)
311 if !exists {
312 return fmt.Errorf("provider %s not found", providerID)
313 }
314
315 if providerConfig.OAuthToken == nil {
316 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
317 }
318
319 // Check if another session refreshed the token recently by reading
320 // the current token from the config file on disk.
321 newToken, err := s.loadTokenFromDisk(scope, providerID)
322 if err != nil {
323 slog.Warn("Failed to read token from config file, proceeding with refresh", "provider", providerID, "error", err)
324 } else if newToken != nil && !newToken.IsExpired() && newToken.AccessToken != providerConfig.OAuthToken.AccessToken {
325 slog.Info("Using token refreshed by another session", "provider", providerID)
326 return s.applyToken(providerConfig, newToken, providerID)
327 }
328
329 var refreshedToken *oauth.Token
330 var refreshErr error
331 switch providerID {
332 case string(catwalk.InferenceProviderCopilot):
333 refreshedToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
334 case hyperp.Name:
335 refreshedToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
336 default:
337 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
338 }
339 if refreshErr != nil {
340 // The exchange may have failed because another session already
341 // rotated the refresh token. Re-read the config file and use the
342 // other session's token if available.
343 if diskToken, diskErr := s.loadTokenFromDisk(scope, providerID); diskErr == nil &&
344 diskToken != nil &&
345 !diskToken.IsExpired() &&
346 diskToken.AccessToken != providerConfig.OAuthToken.AccessToken {
347 slog.Info("Using token refreshed by another session after exchange failure", "provider", providerID)
348 return s.applyToken(providerConfig, diskToken, providerID)
349 }
350 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
351 }
352
353 slog.Info("Successfully refreshed OAuth token", "provider", providerID)
354 providerConfig.OAuthToken = refreshedToken
355 providerConfig.APIKey = refreshedToken.AccessToken
356
357 switch providerID {
358 case string(catwalk.InferenceProviderCopilot):
359 providerConfig.SetupGitHubCopilot()
360 }
361
362 s.config.Providers.Set(providerID, providerConfig)
363
364 if err := s.SetConfigFields(scope, map[string]any{
365 fmt.Sprintf("providers.%s.api_key", providerID): refreshedToken.AccessToken,
366 fmt.Sprintf("providers.%s.oauth", providerID): refreshedToken,
367 }); err != nil {
368 return fmt.Errorf("failed to persist refreshed token: %w", err)
369 }
370
371 return nil
372}
373
374// applyToken updates the in-memory provider config with the given token.
375func (s *ConfigStore) applyToken(providerConfig ProviderConfig, token *oauth.Token, providerID string) error {
376 providerConfig.OAuthToken = token
377 providerConfig.APIKey = token.AccessToken
378 if providerID == string(catwalk.InferenceProviderCopilot) {
379 providerConfig.SetupGitHubCopilot()
380 }
381 s.config.Providers.Set(providerID, providerConfig)
382 return nil
383}
384
385// loadTokenFromDisk reads the OAuth token for the given provider from the
386// config file on disk. Returns nil if the token is not found or matches the
387// current in-memory token.
388func (s *ConfigStore) loadTokenFromDisk(scope Scope, providerID string) (*oauth.Token, error) {
389 path, err := s.configPath(scope)
390 if err != nil {
391 return nil, err
392 }
393
394 data, err := os.ReadFile(path)
395 if err != nil {
396 if os.IsNotExist(err) {
397 return nil, nil
398 }
399 return nil, err
400 }
401
402 oauthKey := fmt.Sprintf("providers.%s.oauth", providerID)
403 oauthResult := gjson.Get(string(data), oauthKey)
404 if !oauthResult.Exists() {
405 return nil, nil
406 }
407
408 var token oauth.Token
409 if err := json.Unmarshal([]byte(oauthResult.Raw), &token); err != nil {
410 return nil, err
411 }
412
413 if token.AccessToken == "" {
414 return nil, nil
415 }
416
417 return &token, nil
418}
419
420// recordRecentModel records a model in the recent models list.
421func (s *ConfigStore) recordRecentModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
422 if model.Provider == "" || model.Model == "" {
423 return nil
424 }
425
426 if s.config.RecentModels == nil {
427 s.config.RecentModels = make(map[SelectedModelType][]SelectedModel)
428 }
429
430 eq := func(a, b SelectedModel) bool {
431 return a.Provider == b.Provider && a.Model == b.Model
432 }
433
434 entry := SelectedModel{
435 Provider: model.Provider,
436 Model: model.Model,
437 }
438
439 current := s.config.RecentModels[modelType]
440 withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool {
441 return eq(existing, entry)
442 })
443
444 updated := append([]SelectedModel{entry}, withoutCurrent...)
445 if len(updated) > maxRecentModelsPerType {
446 updated = updated[:maxRecentModelsPerType]
447 }
448
449 if slices.EqualFunc(current, updated, eq) {
450 return nil
451 }
452
453 s.config.RecentModels[modelType] = updated
454
455 if err := s.SetConfigField(scope, fmt.Sprintf("recent_models.%s", modelType), updated); err != nil {
456 return fmt.Errorf("failed to persist recent models: %w", err)
457 }
458
459 return nil
460}
461
462// NewTestStore creates a ConfigStore for testing purposes.
463func NewTestStore(cfg *Config, loadedPaths ...string) *ConfigStore {
464 return &ConfigStore{
465 config: cfg,
466 loadedPaths: loadedPaths,
467 }
468}
469
470// ImportCopilot attempts to import a GitHub Copilot token from disk.
471func (s *ConfigStore) ImportCopilot() (*oauth.Token, bool) {
472 if s.HasConfigField(ScopeGlobal, "providers.copilot.api_key") || s.HasConfigField(ScopeGlobal, "providers.copilot.oauth") {
473 return nil, false
474 }
475
476 diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
477 if !hasDiskToken {
478 return nil, false
479 }
480
481 slog.Info("Found existing GitHub Copilot token on disk. Authenticating...")
482 token, err := copilot.RefreshToken(context.TODO(), diskToken)
483 if err != nil {
484 slog.Error("Unable to import GitHub Copilot token", "error", err)
485 return nil, false
486 }
487
488 if err := s.SetProviderAPIKey(ScopeGlobal, string(catwalk.InferenceProviderCopilot), token); err != nil {
489 return token, false
490 }
491
492 if err := s.SetConfigFields(ScopeGlobal, map[string]any{
493 "providers.copilot.api_key": token.AccessToken,
494 "providers.copilot.oauth": token,
495 }); err != nil {
496 slog.Error("Unable to save GitHub Copilot token to disk", "error", err)
497 }
498
499 slog.Info("GitHub Copilot successfully imported")
500 return token, true
501}
502
503// StalenessResult contains the result of a staleness check.
504type StalenessResult struct {
505 Dirty bool
506 Changed []string
507 Missing []string
508 Errors map[string]error // stat errors by path
509}
510
511// ConfigStaleness checks whether any tracked config files have changed on disk
512// since the last snapshot. Returns dirty=true if any files changed or went
513// missing, along with sorted lists of affected paths. Stat errors are
514// captured in Errors map but still treated as non-existence for dirty detection.
515func (s *ConfigStore) ConfigStaleness() StalenessResult {
516 var result StalenessResult
517 result.Errors = make(map[string]error)
518
519 for _, path := range s.trackedConfigPaths {
520 snapshot, hadSnapshot := s.snapshots[path]
521
522 info, err := os.Stat(path)
523 exists := err == nil && !info.IsDir()
524
525 if err != nil && !os.IsNotExist(err) {
526 // Capture permission/IO errors separately from non-existence
527 result.Errors[path] = err
528 result.Dirty = true
529 }
530
531 if !exists {
532 if hadSnapshot && snapshot.Exists {
533 // File existed before but now missing
534 result.Missing = append(result.Missing, path)
535 result.Dirty = true
536 }
537 continue
538 }
539
540 // File exists now
541 if !hadSnapshot || !snapshot.Exists {
542 // File didn't exist before but does now
543 result.Changed = append(result.Changed, path)
544 result.Dirty = true
545 continue
546 }
547
548 // Check for content or metadata changes
549 if snapshot.Size != info.Size() || snapshot.ModTime != info.ModTime().UnixNano() {
550 result.Changed = append(result.Changed, path)
551 result.Dirty = true
552 }
553 }
554
555 // Sort for deterministic output
556 slices.Sort(result.Changed)
557 slices.Sort(result.Missing)
558
559 return result
560}
561
562// RefreshStalenessSnapshot captures fresh snapshots of all tracked config files.
563// Call this after reloading config to clear dirty state.
564func (s *ConfigStore) RefreshStalenessSnapshot() error {
565 if s.snapshots == nil {
566 s.snapshots = make(map[string]fileSnapshot)
567 }
568
569 for _, path := range s.trackedConfigPaths {
570 info, err := os.Stat(path)
571 exists := err == nil && !info.IsDir()
572
573 snapshot := fileSnapshot{
574 Path: path,
575 Exists: exists,
576 }
577
578 if exists {
579 snapshot.Size = info.Size()
580 snapshot.ModTime = info.ModTime().UnixNano()
581 }
582
583 s.snapshots[path] = snapshot
584 }
585
586 return nil
587}
588
589// CaptureStalenessSnapshot captures snapshots for the given paths, building the
590// tracked config paths list. Paths are deduplicated and normalized.
591func (s *ConfigStore) CaptureStalenessSnapshot(paths []string) {
592 // Build unique set of normalized paths
593 seen := make(map[string]struct{})
594 for _, p := range paths {
595 if p == "" {
596 continue
597 }
598 // Normalize path
599 abs, err := filepath.Abs(p)
600 if err != nil {
601 abs = p
602 }
603 seen[abs] = struct{}{}
604 }
605
606 // Also track workspace and global config paths if set
607 if s.workspacePath != "" {
608 abs, err := filepath.Abs(s.workspacePath)
609 if err == nil {
610 seen[abs] = struct{}{}
611 }
612 }
613 if s.globalDataPath != "" {
614 abs, err := filepath.Abs(s.globalDataPath)
615 if err == nil {
616 seen[abs] = struct{}{}
617 }
618 }
619
620 // Build sorted list for deterministic ordering
621 s.trackedConfigPaths = make([]string, 0, len(seen))
622 for p := range seen {
623 s.trackedConfigPaths = append(s.trackedConfigPaths, p)
624 }
625 slices.Sort(s.trackedConfigPaths)
626
627 // Capture initial snapshots
628 s.RefreshStalenessSnapshot()
629}
630
631// captureStalenessSnapshot is an alias for CaptureStalenessSnapshot for internal use.
632func (s *ConfigStore) captureStalenessSnapshot(paths []string) {
633 s.CaptureStalenessSnapshot(paths)
634}
635
636// ReloadFromDisk re-runs the config load/merge flow and updates the in-memory
637// config atomically. It rebuilds the staleness snapshot after successful reload.
638// On failure, the store state is rolled back to its previous state.
639func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
640 if s.workingDir == "" {
641 return fmt.Errorf("cannot reload: working directory not set")
642 }
643
644 // Disable auto-reload during reload to prevent nested/re-entrant calls.
645 s.autoReloadDisabled = true
646 s.reloadInProgress = true
647 defer func() {
648 s.autoReloadDisabled = false
649 s.reloadInProgress = false
650 }()
651
652 // Migrate deprecated disable_notifications before reloading config.
653 migrateDisableNotifications()
654
655 configPaths := lookupConfigs(s.workingDir)
656 cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
657 if err != nil {
658 return fmt.Errorf("failed to reload config: %w", err)
659 }
660
661 // Apply defaults (using existing data directory if set)
662 var dataDir string
663 if s.config != nil && s.config.Options != nil {
664 dataDir = s.config.Options.DataDirectory
665 }
666 cfg.setDefaults(s.workingDir, dataDir)
667
668 // Merge workspace config if present
669 workspacePath := filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName))
670 if wsData, err := os.ReadFile(workspacePath); err == nil && len(wsData) > 0 {
671 if !json.Valid(wsData) {
672 return fmt.Errorf("invalid JSON in config file %s", workspacePath)
673 }
674 merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
675 if mergeErr == nil {
676 dataDir := cfg.Options.DataDirectory
677 *cfg = *merged
678 cfg.setDefaults(s.workingDir, dataDir)
679 loadedPaths = append(loadedPaths, workspacePath)
680 }
681 }
682
683 // Validate hooks after all config merging is complete so matcher
684 // regexes are recompiled on the reloaded config (mirrors Load).
685 if err := cfg.ValidateHooks(); err != nil {
686 return fmt.Errorf("invalid hook configuration on reload: %w", err)
687 }
688
689 // Preserve runtime overrides
690 overrides := s.overrides
691
692 // Reconfigure providers
693 env := env.New()
694 resolver := NewShellVariableResolver(env)
695 providers, err := Providers(cfg)
696 if err != nil {
697 return fmt.Errorf("failed to load providers during reload: %w", err)
698 }
699
700 if err := cfg.configureProviders(s, env, resolver, providers); err != nil {
701 return fmt.Errorf("failed to configure providers during reload: %w", err)
702 }
703
704 // Save current state for potential rollback
705 oldConfig := s.config
706 oldLoadedPaths := s.loadedPaths
707 oldResolver := s.resolver
708 oldKnownProviders := s.knownProviders
709 oldOverrides := s.overrides
710 oldWorkspacePath := s.workspacePath
711
712 // Update store state BEFORE running model/agent setup (so they see new config)
713 s.config = cfg
714 s.loadedPaths = loadedPaths
715 s.resolver = resolver
716 s.knownProviders = providers
717 s.overrides = overrides
718 s.workspacePath = workspacePath
719
720 // Mirror startup flow: setup models and agents against NEW config
721 var setupErr error
722 if !cfg.IsConfigured() {
723 slog.Warn("No providers configured after reload")
724 } else {
725 if err := configureSelectedModels(s, providers, false); err != nil {
726 setupErr = fmt.Errorf("failed to configure selected models during reload: %w", err)
727 } else {
728 s.SetupAgents()
729 }
730 }
731
732 // Rollback on setup failure
733 if setupErr != nil {
734 s.config = oldConfig
735 s.loadedPaths = oldLoadedPaths
736 s.resolver = oldResolver
737 s.knownProviders = oldKnownProviders
738 s.overrides = oldOverrides
739 s.workspacePath = oldWorkspacePath
740 return setupErr
741 }
742
743 // Rebuild staleness tracking
744 s.captureStalenessSnapshot(loadedPaths)
745
746 return nil
747}
748
749// autoReload conditionally reloads config from disk after writes.
750// It returns nil (no error) for expected skip cases: when auto-reload is
751// disabled during load/reload flows, or when working directory is not set
752// (e.g., during testing). Only actual reload failures return an error.
753func (s *ConfigStore) autoReload(ctx context.Context) error {
754 if s.autoReloadDisabled {
755 return nil // Expected skip: already in load/reload flow
756 }
757 if s.workingDir == "" {
758 return nil // Expected skip: working directory not set
759 }
760 return s.ReloadFromDisk(ctx)
761}