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