store.go

  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}