on main help sign in
refactor(config): introduce ConfigStore and Scope for better config m… (#2395)

* refactor(config): introduce ConfigStore and Scope for better config management This makes config.Config immutable and introduces a ConfigStore that manages the config and provides helper methods for accessing config values with proper scoping (global, workspace). This allows us to avoid passing around mutable config objects and ensures that all parts of the code are accessing the most up-to-date config values. It also lays the groundwork for future features like per-workspace config overrides. * fixt: lint

commit5ff8d6876005ba48686a58fa71417c3a3f17bebe authorAyman Bagabas <ayman.bagabas@gmail.com> dateWed 11 Mar 2026, 20:12 (7 months ago) parent562d49f
40 files changed+648−544
internal/agent/agent_tool.go+1−1 internal/agent/agentic_fetch_tool.go+5−5 internal/agent/common_test.go+9−9 internal/agent/coordinator.go+35−35 internal/agent/coordinator_test.go+1−1 internal/agent/prompt/prompt.go+15−14 internal/agent/prompts.go+1−1 internal/agent/tools/list_mcp_resources.go+1−1 internal/agent/tools/mcp-tools.go+2−2 internal/agent/tools/mcp/init.go+4−4 internal/agent/tools/mcp/prompts.go+1−1 internal/agent/tools/mcp/resources.go+2−2 internal/agent/tools/mcp/tools.go+5−5 internal/agent/tools/read_mcp_resource.go+1−1 internal/app/app.go+20−14 internal/cmd/login.go+9−9 internal/cmd/logs.go+1−1 internal/cmd/models.go+2−2 internal/cmd/root.go+3−2 internal/cmd/stats.go+1−1 internal/commands/commands.go+1−1 internal/config/config.go−248 internal/config/copilot.go−47 internal/config/init.go+15−14 internal/config/load.go+45−17 internal/config/load_test.go+44−40 internal/config/recent_models_test.go+36−28 internal/config/scope.go+11 internal/config/store.go+336 internal/lsp/manager.go+6−6 internal/ui/common/common.go+6−1 internal/ui/dialog/api_key_input.go+3−3 internal/ui/dialog/filepicker.go+1−1 internal/ui/dialog/models.go+1−1 internal/ui/dialog/oauth.go+2−2 internal/ui/model/header.go+1−2 internal/ui/model/landing.go+1−1 internal/ui/model/onboarding.go+5−6 internal/ui/model/sidebar.go+2−2 internal/ui/model/ui.go+13−13
internal/agent/agent_tool.go+1−1
@@ -24,7 +24,7 @@ const (
2424)
2525
2626func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error) {
27− agentCfg, ok := c.cfg.Agents[config.AgentTask]
27+ agentCfg, ok := c.cfg.Config().Agents[config.AgentTask]
2828 if !ok {
2929 return nil, errors.New("task agent not configured")
3030 }
internal/agent/agentic_fetch_tool.go+5−5
@@ -98,7 +98,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
9898 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
9999 }
100100
101− tmpDir, err := os.MkdirTemp(c.cfg.Options.DataDirectory, "crush-fetch-*")
101+ tmpDir, err := os.MkdirTemp(c.cfg.Config().Options.DataDirectory, "crush-fetch-*")
102102 if err != nil {
103103 return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %s", err)), nil
104104 }
@@ -151,12 +151,12 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
151151 return fantasy.ToolResponse{}, fmt.Errorf("error building models: %s", err)
152152 }
153153
154− systemPrompt, err := promptTemplate.Build(ctx, small.Model.Provider(), small.Model.Model(), *c.cfg)
154+ systemPrompt, err := promptTemplate.Build(ctx, small.Model.Provider(), small.Model.Model(), c.cfg)
155155 if err != nil {
156156 return fantasy.ToolResponse{}, fmt.Errorf("error building system prompt: %s", err)
157157 }
158158
159− smallProviderCfg, ok := c.cfg.Providers.Get(small.ModelCfg.Provider)
159+ smallProviderCfg, ok := c.cfg.Config().Providers.Get(small.ModelCfg.Provider)
160160 if !ok {
161161 return fantasy.ToolResponse{}, errors.New("small model provider not configured")
162162 }
@@ -167,7 +167,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
167167 webFetchTool,
168168 webSearchTool,
169169 tools.NewGlobTool(tmpDir),
170− tools.NewGrepTool(tmpDir, c.cfg.Tools.Grep),
170+ tools.NewGrepTool(tmpDir, c.cfg.Config().Tools.Grep),
171171 tools.NewSourcegraphTool(client),
172172 tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir),
173173 }
@@ -177,7 +177,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
177177 SmallModel: small,
178178 SystemPromptPrefix: smallProviderCfg.SystemPromptPrefix,
179179 SystemPrompt: systemPrompt,
180− DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
180+ DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize,
181181 IsYolo: c.permissions.SkipRequests(),
182182 Sessions: c.sessions,
183183 Messages: c.messages,
internal/agent/common_test.go+9−9
@@ -185,36 +185,36 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
185185
186186 // NOTE(@andreynering): Set a fixed config to ensure cassettes match
187187 // independently of user config on `$HOME/.config/crush/crush.json`.
188− cfg.Options.Attribution = &config.Attribution{
188+ cfg.Config().Options.Attribution = &config.Attribution{
189189 TrailerStyle: "co-authored-by",
190190 GeneratedWith: true,
191191 }
192192
193193 // Clear some fields to avoid issues with VCR cassette matching.
194− cfg.Options.SkillsPaths = nil
195− cfg.Options.ContextPaths = nil
196− cfg.LSP = nil
194+ cfg.Config().Options.SkillsPaths = nil
195+ cfg.Config().Options.ContextPaths = nil
196+ cfg.Config().LSP = nil
197197
198− systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg)
198+ systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), cfg)
199199 if err != nil {
200200 return nil, err
201201 }
202202
203203 // Get the model name for the bash tool
204204 modelName := large.Model() // fallback to ID if Name not available
205− if model := cfg.GetModel(large.Provider(), large.Model()); model != nil {
205+ if model := cfg.Config().GetModel(large.Provider(), large.Model()); model != nil {
206206 modelName = model.Name
207207 }
208208
209209 allTools := []fantasy.AgentTool{
210− tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
210+ tools.NewBashTool(env.permissions, env.workingDir, cfg.Config().Options.Attribution, modelName),
211211 tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
212212 tools.NewEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
213213 tools.NewMultiEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
214214 tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
215215 tools.NewGlobTool(env.workingDir),
216− tools.NewGrepTool(env.workingDir, cfg.Tools.Grep),
217− tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
216+ tools.NewGrepTool(env.workingDir, cfg.Config().Tools.Grep),
217+ tools.NewLsTool(env.permissions, env.workingDir, cfg.Config().Tools.Ls),
218218 tools.NewSourcegraphTool(r.GetDefaultClient()),
219219 tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir),
220220 tools.NewWriteTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
internal/agent/coordinator.go+35−35
@@ -74,7 +74,7 @@ type Coordinator interface {
7474}
7575
7676type coordinator struct {
77− cfg *config.Config
77+ cfg *config.ConfigStore
7878 sessions session.Service
7979 messages message.Service
8080 permissions permission.Service
@@ -91,7 +91,7 @@ type coordinator struct {
9191
9292func NewCoordinator(
9393 ctx context.Context,
94− cfg *config.Config,
94+ cfg *config.ConfigStore,
9595 sessions session.Service,
9696 messages message.Service,
9797 permissions permission.Service,
@@ -112,7 +112,7 @@ func NewCoordinator(
112112 agents: make(map[string]SessionAgent),
113113 }
114114
115− agentCfg, ok := cfg.Agents[config.AgentCoder]
115+ agentCfg, ok := cfg.Config().Agents[config.AgentCoder]
116116 if !ok {
117117 return nil, errCoderAgentNotConfigured
118118 }
@@ -160,7 +160,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
160160 attachments = filteredAttachments
161161 }
162162
163− providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
163+ providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
164164 if !ok {
165165 return nil, errModelProviderNotConfigured
166166 }
@@ -383,14 +383,14 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
383383 return nil, err
384384 }
385385
386− largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
386+ largeProviderCfg, _ := c.cfg.Config().Providers.Get(large.ModelCfg.Provider)
387387 result := NewSessionAgent(SessionAgentOptions{
388388 LargeModel: large,
389389 SmallModel: small,
390390 SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix,
391391 SystemPrompt: "",
392392 IsSubAgent: isSubAgent,
393− DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
393+ DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize,
394394 IsYolo: c.permissions.SkipRequests(),
395395 Sessions: c.sessions,
396396 Messages: c.messages,
@@ -399,7 +399,7 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
399399 })
400400
401401 c.readyWg.Go(func() error {
402− systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
402+ systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), c.cfg)
403403 if err != nil {
404404 return err
405405 }
@@ -439,14 +439,14 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
439439
440440 // Get the model name for the agent
441441 modelName := ""
442− if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
443− if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
442+ if modelCfg, ok := c.cfg.Config().Models[agent.Model]; ok {
443+ if model := c.cfg.Config().GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
444444 modelName = model.Name
445445 }
446446 }
447447
448448 allTools = append(allTools,
449− tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
449+ tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Options.Attribution, modelName),
450450 tools.NewJobOutputTool(),
451451 tools.NewJobKillTool(),
452452 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
@@ -454,20 +454,20 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
454454 tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
455455 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
456456 tools.NewGlobTool(c.cfg.WorkingDir()),
457− tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Tools.Grep),
458− tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
457+ tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Config().Tools.Grep),
458+ tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Tools.Ls),
459459 tools.NewSourcegraphTool(nil),
460460 tools.NewTodosTool(c.sessions),
461− tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
461+ tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Config().Options.SkillsPaths...),
462462 tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
463463 )
464464
465465 // Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
466− if len(c.cfg.LSP) > 0 || c.cfg.Options.AutoLSP == nil || *c.cfg.Options.AutoLSP {
466+ if len(c.cfg.Config().LSP) > 0 || c.cfg.Config().Options.AutoLSP == nil || *c.cfg.Config().Options.AutoLSP {
467467 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
468468 }
469469
470− if len(c.cfg.MCP) > 0 {
470+ if len(c.cfg.Config().MCP) > 0 {
471471 allTools = append(
472472 allTools,
473473 tools.NewListMCPResourcesTool(c.cfg, c.permissions),
@@ -513,16 +513,16 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
513513
514514// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
515515func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
516− largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
516+ largeModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeLarge]
517517 if !ok {
518518 return Model{}, Model{}, errLargeModelNotSelected
519519 }
520− smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
520+ smallModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeSmall]
521521 if !ok {
522522 return Model{}, Model{}, errSmallModelNotSelected
523523 }
524524
525− largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
525+ largeProviderCfg, ok := c.cfg.Config().Providers.Get(largeModelCfg.Provider)
526526 if !ok {
527527 return Model{}, Model{}, errLargeModelProviderNotConfigured
528528 }
@@ -532,7 +532,7 @@ func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Mo
532532 return Model{}, Model{}, err
533533 }
534534
535− smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
535+ smallProviderCfg, ok := c.cfg.Config().Providers.Get(smallModelCfg.Provider)
536536 if !ok {
537537 return Model{}, Model{}, errSmallModelProviderNotConfigured
538538 }
@@ -620,7 +620,7 @@ func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map
620620 opts = append(opts, anthropic.WithBaseURL(baseURL))
621621 }
622622
623− if c.cfg.Options.Debug {
623+ if c.cfg.Config().Options.Debug {
624624 httpClient := log.NewHTTPClient()
625625 opts = append(opts, anthropic.WithHTTPClient(httpClient))
626626 }
@@ -632,7 +632,7 @@ func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[st
632632 openai.WithAPIKey(apiKey),
633633 openai.WithUseResponsesAPI(),
634634 }
635− if c.cfg.Options.Debug {
635+ if c.cfg.Config().Options.Debug {
636636 httpClient := log.NewHTTPClient()
637637 opts = append(opts, openai.WithHTTPClient(httpClient))
638638 }
@@ -649,7 +649,7 @@ func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[stri
649649 opts := []openrouter.Option{
650650 openrouter.WithAPIKey(apiKey),
651651 }
652− if c.cfg.Options.Debug {
652+ if c.cfg.Config().Options.Debug {
653653 httpClient := log.NewHTTPClient()
654654 opts = append(opts, openrouter.WithHTTPClient(httpClient))
655655 }
@@ -663,7 +663,7 @@ func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]s
663663 opts := []vercel.Option{
664664 vercel.WithAPIKey(apiKey),
665665 }
666− if c.cfg.Options.Debug {
666+ if c.cfg.Config().Options.Debug {
667667 httpClient := log.NewHTTPClient()
668668 opts = append(opts, vercel.WithHTTPClient(httpClient))
669669 }
@@ -683,8 +683,8 @@ func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers
683683 var httpClient *http.Client
684684 if providerID == string(catwalk.InferenceProviderCopilot) {
685685 opts = append(opts, openaicompat.WithUseResponsesAPI())
686− httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug)
687− } else if c.cfg.Options.Debug {
686+ httpClient = copilot.NewClient(isSubAgent, c.cfg.Config().Options.Debug)
687+ } else if c.cfg.Config().Options.Debug {
688688 httpClient = log.NewHTTPClient()
689689 }
690690 if httpClient != nil {
@@ -708,7 +708,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str
708708 azure.WithAPIKey(apiKey),
709709 azure.WithUseResponsesAPI(),
710710 }
711− if c.cfg.Options.Debug {
711+ if c.cfg.Config().Options.Debug {
712712 httpClient := log.NewHTTPClient()
713713 opts = append(opts, azure.WithHTTPClient(httpClient))
714714 }
@@ -727,7 +727,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str
727727
728728func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
729729 var opts []bedrock.Option
730− if c.cfg.Options.Debug {
730+ if c.cfg.Config().Options.Debug {
731731 httpClient := log.NewHTTPClient()
732732 opts = append(opts, bedrock.WithHTTPClient(httpClient))
733733 }
@@ -746,7 +746,7 @@ func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[st
746746 google.WithBaseURL(baseURL),
747747 google.WithGeminiAPIKey(apiKey),
748748 }
749− if c.cfg.Options.Debug {
749+ if c.cfg.Config().Options.Debug {
750750 httpClient := log.NewHTTPClient()
751751 opts = append(opts, google.WithHTTPClient(httpClient))
752752 }
@@ -758,7 +758,7 @@ func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[st
758758
759759func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
760760 opts := []google.Option{}
761− if c.cfg.Options.Debug {
761+ if c.cfg.Config().Options.Debug {
762762 httpClient := log.NewHTTPClient()
763763 opts = append(opts, google.WithHTTPClient(httpClient))
764764 }
@@ -779,7 +779,7 @@ func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provid
779779 hyper.WithBaseURL(baseURL),
780780 hyper.WithAPIKey(apiKey),
781781 }
782− if c.cfg.Options.Debug {
782+ if c.cfg.Config().Options.Debug {
783783 httpClient := log.NewHTTPClient()
784784 opts = append(opts, hyper.WithHTTPClient(httpClient))
785785 }
@@ -887,7 +887,7 @@ func (c *coordinator) UpdateModels(ctx context.Context) error {
887887 }
888888 c.currentAgent.SetModels(large, small)
889889
890− agentCfg, ok := c.cfg.Agents[config.AgentCoder]
890+ agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
891891 if !ok {
892892 return errCoderAgentNotConfigured
893893 }
@@ -909,7 +909,7 @@ func (c *coordinator) QueuedPromptsList(sessionID string) []string {
909909}
910910
911911func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
912− providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
912+ providerCfg, ok := c.cfg.Config().Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
913913 if !ok {
914914 return errModelProviderNotConfigured
915915 }
@@ -922,7 +922,7 @@ func (c *coordinator) isUnauthorized(err error) bool {
922922}
923923
924924func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
925− if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
925+ if err := c.cfg.RefreshOAuthToken(ctx, config.ScopeGlobal, providerCfg.ID); err != nil {
926926 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
927927 return err
928928 }
@@ -940,7 +940,7 @@ func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg con
940940 }
941941
942942 providerCfg.APIKey = newAPIKey
943− c.cfg.Providers.Set(providerCfg.ID, providerCfg)
943+ c.cfg.Config().Providers.Set(providerCfg.ID, providerCfg)
944944
945945 if err := c.UpdateModels(ctx); err != nil {
946946 return err
@@ -984,7 +984,7 @@ func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (f
984984 maxTokens = model.ModelCfg.MaxTokens
985985 }
986986
987− providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
987+ providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
988988 if !ok {
989989 return fantasy.ToolResponse{}, errModelProviderNotConfigured
990990 }
internal/agent/coordinator_test.go+1−1
@@ -44,7 +44,7 @@ func (m *mockSessionAgent) Summarize(context.Context, string, fantasy.ProviderOp
4444func newTestCoordinator(t *testing.T, env fakeEnv, providerID string, providerCfg config.ProviderConfig) *coordinator {
4545 cfg, err := config.Init(env.workingDir, "", false)
4646 require.NoError(t, err)
47− cfg.Providers.Set(providerID, providerCfg)
47+ cfg.Config().Providers.Set(providerID, providerCfg)
4848 return &coordinator{
4949 cfg: cfg,
5050 sessions: env.sessions,
internal/agent/prompt/prompt.go+15−14
@@ -76,13 +76,13 @@ func NewPrompt(name, promptTemplate string, opts ...Option) (*Prompt, error) {
7676 return p, nil
7777}
7878
79−func (p *Prompt) Build(ctx context.Context, provider, model string, cfg config.Config) (string, error) {
79+func (p *Prompt) Build(ctx context.Context, provider, model string, store *config.ConfigStore) (string, error) {
8080 t, err := template.New(p.name).Parse(p.template)
8181 if err != nil {
8282 return "", fmt.Errorf("parsing template: %w", err)
8383 }
8484 var sb strings.Builder
85− d, err := p.promptData(ctx, provider, model, cfg)
85+ d, err := p.promptData(ctx, provider, model, store)
8686 if err != nil {
8787 return "", err
8888 }
@@ -104,11 +104,11 @@ func processFile(filePath string) *ContextFile {
104104 }
105105}
106106
107−func processContextPath(p string, cfg config.Config) []ContextFile {
107+func processContextPath(p string, store *config.ConfigStore) []ContextFile {
108108 var contexts []ContextFile
109109 fullPath := p
110110 if !filepath.IsAbs(p) {
111− fullPath = filepath.Join(cfg.WorkingDir(), p)
111+ fullPath = filepath.Join(store.WorkingDir(), p)
112112 }
113113 info, err := os.Stat(fullPath)
114114 if err != nil {
@@ -136,11 +136,11 @@ func processContextPath(p string, cfg config.Config) []ContextFile {
136136}
137137
138138// expandPath expands ~ and environment variables in file paths
139−func expandPath(path string, cfg config.Config) string {
139+func expandPath(path string, store *config.ConfigStore) string {
140140 path = home.Long(path)
141141 // Handle environment variable expansion using the same pattern as config
142142 if strings.HasPrefix(path, "$") {
143− if expanded, err := cfg.Resolver().ResolveValue(path); err == nil {
143+ if expanded, err := store.Resolver().ResolveValue(path); err == nil {
144144 path = expanded
145145 }
146146 }
@@ -148,19 +148,20 @@ func expandPath(path string, cfg config.Config) string {
148148 return path
149149}
150150
151−func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg config.Config) (PromptDat, error) {
152− workingDir := cmp.Or(p.workingDir, cfg.WorkingDir())
151+func (p *Prompt) promptData(ctx context.Context, provider, model string, store *config.ConfigStore) (PromptDat, error) {
152+ workingDir := cmp.Or(p.workingDir, store.WorkingDir())
153153 platform := cmp.Or(p.platform, runtime.GOOS)
154154
155155 files := map[string][]ContextFile{}
156156
157+ cfg := store.Config()
157158 for _, pth := range cfg.Options.ContextPaths {
158− expanded := expandPath(pth, cfg)
159+ expanded := expandPath(pth, store)
159160 pathKey := strings.ToLower(expanded)
160161 if _, ok := files[pathKey]; ok {
161162 continue
162163 }
163− content := processContextPath(expanded, cfg)
164+ content := processContextPath(expanded, store)
164165 files[pathKey] = content
165166 }
166167
@@ -169,18 +170,18 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con
169170 if len(cfg.Options.SkillsPaths) > 0 {
170171 expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths))
171172 for _, pth := range cfg.Options.SkillsPaths {
172− expandedPaths = append(expandedPaths, expandPath(pth, cfg))
173+ expandedPaths = append(expandedPaths, expandPath(pth, store))
173174 }
174175 if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 {
175176 availSkillXML = skills.ToPromptXML(discoveredSkills)
176177 }
177178 }
178179
179− isGit := isGitRepo(cfg.WorkingDir())
180+ isGit := isGitRepo(store.WorkingDir())
180181 data := PromptDat{
181182 Provider: provider,
182183 Model: model,
183− Config: cfg,
184+ Config: *cfg,
184185 WorkingDir: filepath.ToSlash(workingDir),
185186 IsGitRepo: isGit,
186187 Platform: platform,
@@ -189,7 +190,7 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con
189190 }
190191 if isGit {
191192 var err error
192− data.GitStatus, err = getGitStatus(ctx, cfg.WorkingDir())
193+ data.GitStatus, err = getGitStatus(ctx, store.WorkingDir())
193194 if err != nil {
194195 return PromptDat{}, err
195196 }
internal/agent/prompts.go+1−1
@@ -33,7 +33,7 @@ func taskPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
3333 return systemPrompt, nil
3434}
3535
36−func InitializePrompt(cfg config.Config) (string, error) {
36+func InitializePrompt(cfg *config.ConfigStore) (string, error) {
3737 systemPrompt, err := prompt.NewPrompt("initialize", string(initializePromptTmpl))
3838 if err != nil {
3939 return "", err
internal/agent/tools/list_mcp_resources.go+1−1
@@ -28,7 +28,7 @@ const ListMCPResourcesToolName = "list_mcp_resources"
2828//go:embed list_mcp_resources.md
2929var listMCPResourcesDescription []byte
3030
31−func NewListMCPResourcesTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool {
31+func NewListMCPResourcesTool(cfg *config.ConfigStore, permissions permission.Service) fantasy.AgentTool {
3232 return fantasy.NewParallelAgentTool(
3333 ListMCPResourcesToolName,
3434 string(listMCPResourcesDescription),
internal/agent/tools/mcp-tools.go+2−2
@@ -11,7 +11,7 @@ import (
1111)
1212
1313// GetMCPTools gets all the currently available MCP tools.
14−func GetMCPTools(permissions permission.Service, cfg *config.Config, wd string) []*Tool {
14+func GetMCPTools(permissions permission.Service, cfg *config.ConfigStore, wd string) []*Tool {
1515 var result []*Tool
1616 for mcpName, tools := range mcp.Tools() {
1717 for _, tool := range tools {
@@ -31,7 +31,7 @@ func GetMCPTools(permissions permission.Service, cfg *config.Config, wd string)
3131type Tool struct {
3232 mcpName string
3333 tool *mcp.Tool
34− cfg *config.Config
34+ cfg *config.ConfigStore
3535 permissions permission.Service
3636 workingDir string
3737 providerOptions fantasy.ProviderOptions
internal/agent/tools/mcp/init.go+4−4
@@ -163,11 +163,11 @@ func Close(ctx context.Context) error {
163163}
164164
165165// Initialize initializes MCP clients based on the provided configuration.
166−func Initialize(ctx context.Context, permissions permission.Service, cfg *config.Config) {
166+func Initialize(ctx context.Context, permissions permission.Service, cfg *config.ConfigStore) {
167167 slog.Info("Initializing MCP clients")
168168 var wg sync.WaitGroup
169169 // Initialize states for all configured MCPs
170− for name, m := range cfg.MCP {
170+ for name, m := range cfg.Config().MCP {
171171 if m.Disabled {
172172 updateState(name, StateDisabled, nil, nil, Counts{})
173173 slog.Debug("Skipping disabled MCP", "name", name)
@@ -253,13 +253,13 @@ func WaitForInit(ctx context.Context) error {
253253 }
254254}
255255
256−func getOrRenewClient(ctx context.Context, cfg *config.Config, name string) (*ClientSession, error) {
256+func getOrRenewClient(ctx context.Context, cfg *config.ConfigStore, name string) (*ClientSession, error) {
257257 sess, ok := sessions.Get(name)
258258 if !ok {
259259 return nil, fmt.Errorf("mcp '%s' not available", name)
260260 }
261261
262− m := cfg.MCP[name]
262+ m := cfg.Config().MCP[name]
263263 state, _ := states.Get(name)
264264
265265 timeout := mcpTimeout(m)
internal/agent/tools/mcp/prompts.go+1−1
@@ -20,7 +20,7 @@ func Prompts() iter.Seq2[string, []*Prompt] {
2020}
2121
2222// GetPromptMessages retrieves the content of an MCP prompt with the given arguments.
23−func GetPromptMessages(ctx context.Context, cfg *config.Config, clientName, promptName string, args map[string]string) ([]string, error) {
23+func GetPromptMessages(ctx context.Context, cfg *config.ConfigStore, clientName, promptName string, args map[string]string) ([]string, error) {
2424 c, err := getOrRenewClient(ctx, cfg, clientName)
2525 if err != nil {
2626 return nil, err
internal/agent/tools/mcp/resources.go+2−2
@@ -24,7 +24,7 @@ func Resources() iter.Seq2[string, []*Resource] {
2424}
2525
2626// ListResources returns the current resources for an MCP server.
27−func ListResources(ctx context.Context, cfg *config.Config, name string) ([]*Resource, error) {
27+func ListResources(ctx context.Context, cfg *config.ConfigStore, name string) ([]*Resource, error) {
2828 session, err := getOrRenewClient(ctx, cfg, name)
2929 if err != nil {
3030 return nil, err
@@ -43,7 +43,7 @@ func ListResources(ctx context.Context, cfg *config.Config, name string) ([]*Res
4343}
4444
4545// ReadResource reads the contents of a resource from an MCP server.
46−func ReadResource(ctx context.Context, cfg *config.Config, name, uri string) ([]*ResourceContents, error) {
46+func ReadResource(ctx context.Context, cfg *config.ConfigStore, name, uri string) ([]*ResourceContents, error) {
4747 session, err := getOrRenewClient(ctx, cfg, name)
4848 if err != nil {
4949 return nil, err
internal/agent/tools/mcp/tools.go+5−5
@@ -32,7 +32,7 @@ func Tools() iter.Seq2[string, []*Tool] {
3232}
3333
3434// RunTool runs an MCP tool with the given input parameters.
35−func RunTool(ctx context.Context, cfg *config.Config, name, toolName string, input string) (ToolResult, error) {
35+func RunTool(ctx context.Context, cfg *config.ConfigStore, name, toolName string, input string) (ToolResult, error) {
3636 var args map[string]any
3737 if err := json.Unmarshal([]byte(input), &args); err != nil {
3838 return ToolResult{}, fmt.Errorf("error parsing parameters: %s", err)
@@ -108,7 +108,7 @@ func RunTool(ctx context.Context, cfg *config.Config, name, toolName string, inp
108108
109109// RefreshTools gets the updated list of tools from the MCP and updates the
110110// global state.
111−func RefreshTools(ctx context.Context, cfg *config.Config, name string) {
111+func RefreshTools(ctx context.Context, cfg *config.ConfigStore, name string) {
112112 session, ok := sessions.Get(name)
113113 if !ok {
114114 slog.Warn("Refresh tools: no session", "name", name)
@@ -139,7 +139,7 @@ func getTools(ctx context.Context, session *ClientSession) ([]*Tool, error) {
139139 return result.Tools, nil
140140}
141141
142−func updateTools(cfg *config.Config, name string, tools []*Tool) int {
142+func updateTools(cfg *config.ConfigStore, name string, tools []*Tool) int {
143143 tools = filterDisabledTools(cfg, name, tools)
144144 if len(tools) == 0 {
145145 allTools.Del(name)
@@ -150,8 +150,8 @@ func updateTools(cfg *config.Config, name string, tools []*Tool) int {
150150}
151151
152152// filterDisabledTools removes tools that are disabled via config.
153−func filterDisabledTools(cfg *config.Config, mcpName string, tools []*Tool) []*Tool {
154− mcpCfg, ok := cfg.MCP[mcpName]
153+func filterDisabledTools(cfg *config.ConfigStore, mcpName string, tools []*Tool) []*Tool {
154+ mcpCfg, ok := cfg.Config().MCP[mcpName]
155155 if !ok || len(mcpCfg.DisabledTools) == 0 {
156156 return tools
157157 }
internal/agent/tools/read_mcp_resource.go+1−1
@@ -30,7 +30,7 @@ const ReadMCPResourceToolName = "read_mcp_resource"
3030//go:embed read_mcp_resource.md
3131var readMCPResourceDescription []byte
3232
33−func NewReadMCPResourceTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool {
33+func NewReadMCPResourceTool(cfg *config.ConfigStore, permissions permission.Service) fantasy.AgentTool {
3434 return fantasy.NewParallelAgentTool(
3535 ReadMCPResourceToolName,
3636 string(readMCPResourceDescription),
internal/app/app.go+20−14
@@ -61,7 +61,7 @@ type App struct {
6161
6262 LSPManager *lsp.Manager
6363
64− config *config.Config
64+ config *config.ConfigStore
6565
6666 serviceEventsWG *sync.WaitGroup
6767 eventsCtx context.Context
@@ -75,11 +75,12 @@ type App struct {
7575}
7676
7777// New initializes a new application instance.
78−func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
78+func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore) (*App, error) {
7979 q := db.New(conn)
8080 sessions := session.NewService(q, conn)
8181 messages := message.NewService(q)
8282 files := history.NewService(q, conn)
83+ cfg := store.Config()
8384 skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
8485 var allowedTools []string
8586 if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
@@ -90,13 +91,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
9091 Sessions: sessions,
9192 Messages: messages,
9293 History: files,
93− Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
94+ Permissions: permission.NewPermissionService(store.WorkingDir(), skipPermissionsRequests, allowedTools),
9495 FileTracker: filetracker.NewService(q),
95− LSPManager: lsp.NewManager(cfg),
96+ LSPManager: lsp.NewManager(store),
9697
9798 globalCtx: ctx,
9899
99− config: cfg,
100+ config: store,
100101
101102 events: make(chan tea.Msg, 100),
102103 serviceEventsWG: &sync.WaitGroup{},
@@ -109,7 +110,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
109110 // Check for updates in the background.
110111 go app.checkForUpdates(ctx)
111112
112− go mcp.Initialize(ctx, app.Permissions, cfg)
113+ go mcp.Initialize(ctx, app.Permissions, store)
113114
114115 // cleanup database upon app shutdown
115116 app.cleanupFuncs = append(
@@ -141,8 +142,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
141142 return app, nil
142143}
143144
144−// Config returns the application configuration.
145+// Config returns the pure-data configuration.
145146func (app *App) Config() *config.Config {
147+ return app.config.Config()
148+}
149+
150+// Store returns the config store.
151+func (app *App) Store() *config.ConfigStore {
146152 return app.config
147153}
148154
@@ -178,7 +184,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
178184 }
179185 stderrTTY = term.IsTerminal(os.Stderr.Fd())
180186 stdinTTY = term.IsTerminal(os.Stdin.Fd())
181− progress = app.config.Options.Progress == nil || *app.config.Options.Progress
187+ progress = app.config.Config().Options.Progress == nil || *app.config.Config().Options.Progress
182188
183189 if !hideSpinner && stderrTTY {
184190 t := styles.DefaultStyles()
@@ -331,7 +337,7 @@ func (app *App) UpdateAgentModel(ctx context.Context) error {
331337// If largeModel is provided but smallModel is not, the small model defaults to
332338// the provider's default small model.
333339func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, smallModel string) error {
334− providers := app.config.Providers.Copy()
340+ providers := app.config.Config().Providers.Copy()
335341
336342 largeMatches, smallMatches, err := findModels(providers, largeModel, smallModel)
337343 if err != nil {
@@ -348,7 +354,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
348354 }
349355 largeProviderID = found.provider
350356 slog.Info("Overriding large model for non-interactive run", "provider", found.provider, "model", found.modelID)
351− app.config.Models[config.SelectedModelTypeLarge] = config.SelectedModel{
357+ app.config.Config().Models[config.SelectedModelTypeLarge] = config.SelectedModel{
352358 Provider: found.provider,
353359 Model: found.modelID,
354360 }
@@ -362,7 +368,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
362368 return err
363369 }
364370 slog.Info("Overriding small model for non-interactive run", "provider", found.provider, "model", found.modelID)
365− app.config.Models[config.SelectedModelTypeSmall] = config.SelectedModel{
371+ app.config.Config().Models[config.SelectedModelTypeSmall] = config.SelectedModel{
366372 Provider: found.provider,
367373 Model: found.modelID,
368374 }
@@ -370,7 +376,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
370376 case largeModel != "":
371377 // No small model specified, but large model was - use provider's default.
372378 smallCfg := app.GetDefaultSmallModel(largeProviderID)
373− app.config.Models[config.SelectedModelTypeSmall] = smallCfg
379+ app.config.Config().Models[config.SelectedModelTypeSmall] = smallCfg
374380 }
375381
376382 return app.AgentCoordinator.UpdateModels(ctx)
@@ -379,7 +385,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
379385// GetDefaultSmallModel returns the default small model for the given
380386// provider. Falls back to the large model if no default is found.
381387func (app *App) GetDefaultSmallModel(providerID string) config.SelectedModel {
382− cfg := app.config
388+ cfg := app.config.Config()
383389 largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
384390
385391 // Find the provider in the known providers list to get its default small model.
@@ -481,7 +487,7 @@ func setupSubscriber[T any](
481487}
482488
483489func (app *App) InitCoderAgent(ctx context.Context) error {
484− coderAgentCfg := app.config.Agents[config.AgentCoder]
490+ coderAgentCfg := app.config.Config().Agents[config.AgentCoder]
485491 if coderAgentCfg.ID == "" {
486492 return fmt.Errorf("coder agent configuration is missing")
487493 }
internal/cmd/login.go+9−9
@@ -52,16 +52,16 @@ crush login copilot
5252 }
5353 switch provider {
5454 case "hyper":
55− return loginHyper(app.Config())
55+ return loginHyper(app.Store())
5656 case "copilot", "github", "github-copilot":
57− return loginCopilot(app.Config())
57+ return loginCopilot(app.Store())
5858 default:
5959 return fmt.Errorf("unknown platform: %s", args[0])
6060 }
6161 },
6262}
6363
64−func loginHyper(cfg *config.Config) error {
64+func loginHyper(cfg *config.ConfigStore) error {
6565 if !hyperp.Enabled() {
6666 return fmt.Errorf("hyper not enabled")
6767 }
@@ -112,8 +112,8 @@ func loginHyper(cfg *config.Config) error {
112112 }
113113
114114 if err := cmp.Or(
115− cfg.SetConfigField("providers.hyper.api_key", token.AccessToken),
116− cfg.SetConfigField("providers.hyper.oauth", token),
115+ cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken),
116+ cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.oauth", token),
117117 ); err != nil {
118118 return err
119119 }
@@ -123,10 +123,10 @@ func loginHyper(cfg *config.Config) error {
123123 return nil
124124}
125125
126−func loginCopilot(cfg *config.Config) error {
126+func loginCopilot(cfg *config.ConfigStore) error {
127127 ctx := getLoginContext()
128128
129− if cfg.HasConfigField("providers.copilot.oauth") {
129+ if cfg.HasConfigField(config.ScopeGlobal, "providers.copilot.oauth") {
130130 fmt.Println("You are already logged in to GitHub Copilot.")
131131 return nil
132132 }
@@ -177,8 +177,8 @@ func loginCopilot(cfg *config.Config) error {
177177 }
178178
179179 if err := cmp.Or(
180− cfg.SetConfigField("providers.copilot.api_key", token.AccessToken),
181− cfg.SetConfigField("providers.copilot.oauth", token),
180+ cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
181+ cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.oauth", token),
182182 ); err != nil {
183183 return err
184184 }
internal/cmd/logs.go+1−1
@@ -55,7 +55,7 @@ var logsCmd = &cobra.Command{
5555 if err != nil {
5656 return fmt.Errorf("failed to load configuration: %v", err)
5757 }
58− logsFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
58+ logsFile := filepath.Join(cfg.Config().Options.DataDirectory, "logs", "crush.log")
5959 _, err = os.Stat(logsFile)
6060 if os.IsNotExist(err) {
6161 log.Warn("Looks like you are not in a crush project. No logs found.")
internal/cmd/models.go+2−2
@@ -38,7 +38,7 @@ crush models gpt5`,
3838 return err
3939 }
4040
41− if !cfg.IsConfigured() {
41+ if !cfg.Config().IsConfigured() {
4242 return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
4343 }
4444
@@ -55,7 +55,7 @@ crush models gpt5`,
5555 var providerIDs []string
5656 providerModels := make(map[string][]string)
5757
58− for providerID, provider := range cfg.Providers.Seq2() {
58+ for providerID, provider := range cfg.Config().Providers.Seq2() {
5959 if provider.Disable {
6060 continue
6161 }
internal/cmd/root.go+3−2
@@ -189,11 +189,12 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
189189 return nil, err
190190 }
191191
192− cfg, err := config.Init(cwd, dataDir, debug)
192+ store, err := config.Init(cwd, dataDir, debug)
193193 if err != nil {
194194 return nil, err
195195 }
196196
197+ cfg := store.Config()
197198 if cfg.Permissions == nil {
198199 cfg.Permissions = &config.Permissions{}
199200 }
@@ -215,7 +216,7 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
215216 return nil, err
216217 }
217218
218− appInstance, err := app.New(ctx, conn, cfg)
219+ appInstance, err := app.New(ctx, conn, store)
219220 if err != nil {
220221 slog.Error("Failed to create app instance", "error", err)
221222 return nil, err
internal/cmd/stats.go+1−1
@@ -131,7 +131,7 @@ func runStats(cmd *cobra.Command, _ []string) error {
131131 if err != nil {
132132 return fmt.Errorf("failed to initialize config: %w", err)
133133 }
134− dataDir = cfg.Options.DataDirectory
134+ dataDir = cfg.Config().Options.DataDirectory
135135 }
136136
137137 conn, err := db.Connect(ctx, dataDir)
internal/commands/commands.go+1−1
@@ -227,7 +227,7 @@ func isMarkdownFile(name string) bool {
227227 return strings.HasSuffix(strings.ToLower(name), ".md")
228228}
229229
230−func GetMCPPrompt(cfg *config.Config, clientID, promptID string, args map[string]string) (string, error) {
230+func GetMCPPrompt(cfg *config.ConfigStore, clientID, promptID string, args map[string]string) (string, error) {
231231 // TODO: we should pass the context down
232232 result, err := mcp.GetPromptMessages(context.Background(), cfg, clientID, promptID, args)
233233 if err != nil {
internal/config/config.go−248
@@ -8,22 +8,16 @@ import (
88 "maps"
99 "net/http"
1010 "net/url"
11− "os"
12− "path/filepath"
1311 "slices"
1412 "strings"
1513 "time"
1614
1715 "charm.land/catwalk/pkg/catwalk"
18− hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
1916 "github.com/charmbracelet/crush/internal/csync"
2017 "github.com/charmbracelet/crush/internal/env"
2118 "github.com/charmbracelet/crush/internal/oauth"
2219 "github.com/charmbracelet/crush/internal/oauth/copilot"
23− "github.com/charmbracelet/crush/internal/oauth/hyper"
2420 "github.com/invopop/jsonschema"
25− "github.com/tidwall/gjson"
26− "github.com/tidwall/sjson"
2721)
2822
2923const (
@@ -398,17 +392,6 @@ type Config struct {
398392 Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
399393
400394 Agents map[string]Agent `json:"-"`
401−
402− // Internal
403− workingDir string `json:"-"`
404− // TODO: find a better way to do this this should probably not be part of the config
405− resolver VariableResolver
406− dataConfigDir string `json:"-"`
407− knownProviders []catwalk.Provider `json:"-"`
408−}
409−
410−func (c *Config) WorkingDir() string {
411− return c.workingDir
412395}
413396
414397func (c *Config) EnabledProviders() []ProviderConfig {
@@ -472,235 +455,8 @@ func (c *Config) SmallModel() *catwalk.Model {
472455 return c.GetModel(model.Provider, model.Model)
473456}
474457
475−func (c *Config) SetCompactMode(enabled bool) error {
476− if c.Options == nil {
477− c.Options = &Options{}
478− }
479− c.Options.TUI.CompactMode = enabled
480− return c.SetConfigField("options.tui.compact_mode", enabled)
481−}
482−
483−func (c *Config) Resolve(key string) (string, error) {
484− if c.resolver == nil {
485− return "", fmt.Errorf("no variable resolver configured")
486− }
487− return c.resolver.ResolveValue(key)
488−}
489−
490−func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error {
491− c.Models[modelType] = model
492− if err := c.SetConfigField(fmt.Sprintf("models.%s", modelType), model); err != nil {
493− return fmt.Errorf("failed to update preferred model: %w", err)
494− }
495− if err := c.recordRecentModel(modelType, model); err != nil {
496− return err
497− }
498− return nil
499−}
500−
501−func (c *Config) HasConfigField(key string) bool {
502− data, err := os.ReadFile(c.dataConfigDir)
503− if err != nil {
504− return false
505− }
506− return gjson.Get(string(data), key).Exists()
507−}
508−
509−func (c *Config) SetConfigField(key string, value any) error {
510− data, err := os.ReadFile(c.dataConfigDir)
511− if err != nil {
512− if os.IsNotExist(err) {
513− data = []byte("{}")
514− } else {
515− return fmt.Errorf("failed to read config file: %w", err)
516− }
517− }
518−
519− newValue, err := sjson.Set(string(data), key, value)
520− if err != nil {
521− return fmt.Errorf("failed to set config field %s: %w", key, err)
522− }
523− if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil {
524− return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err)
525− }
526− if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil {
527− return fmt.Errorf("failed to write config file: %w", err)
528− }
529− return nil
530−}
531−
532−func (c *Config) RemoveConfigField(key string) error {
533− data, err := os.ReadFile(c.dataConfigDir)
534− if err != nil {
535− return fmt.Errorf("failed to read config file: %w", err)
536− }
537−
538− newValue, err := sjson.Delete(string(data), key)
539− if err != nil {
540− return fmt.Errorf("failed to delete config field %s: %w", key, err)
541− }
542− if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil {
543− return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err)
544− }
545− if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil {
546− return fmt.Errorf("failed to write config file: %w", err)
547− }
548− return nil
549−}
550−
551−// RefreshOAuthToken refreshes the OAuth token for the given provider.
552−func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error {
553− providerConfig, exists := c.Providers.Get(providerID)
554− if !exists {
555− return fmt.Errorf("provider %s not found", providerID)
556− }
557−
558− if providerConfig.OAuthToken == nil {
559− return fmt.Errorf("provider %s does not have an OAuth token", providerID)
560− }
561−
562− var newToken *oauth.Token
563− var refreshErr error
564− switch providerID {
565− case string(catwalk.InferenceProviderCopilot):
566− newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
567− case hyperp.Name:
568− newToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
569− default:
570− return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
571− }
572− if refreshErr != nil {
573− return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
574− }
575−
576− slog.Info("Successfully refreshed OAuth token", "provider", providerID)
577− providerConfig.OAuthToken = newToken
578− providerConfig.APIKey = newToken.AccessToken
579−
580− switch providerID {
581− case string(catwalk.InferenceProviderCopilot):
582− providerConfig.SetupGitHubCopilot()
583− }
584−
585− c.Providers.Set(providerID, providerConfig)
586−
587− if err := cmp.Or(
588− c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), newToken.AccessToken),
589− c.SetConfigField(fmt.Sprintf("providers.%s.oauth", providerID), newToken),
590− ); err != nil {
591− return fmt.Errorf("failed to persist refreshed token: %w", err)
592− }
593−
594− return nil
595−}
596−
597−func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
598− var providerConfig ProviderConfig
599− var exists bool
600− var setKeyOrToken func()
601−
602− switch v := apiKey.(type) {
603− case string:
604− if err := c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil {
605− return fmt.Errorf("failed to save api key to config file: %w", err)
606− }
607− setKeyOrToken = func() { providerConfig.APIKey = v }
608− case *oauth.Token:
609− if err := cmp.Or(
610− c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), v.AccessToken),
611− c.SetConfigField(fmt.Sprintf("providers.%s.oauth", providerID), v),
612− ); err != nil {
613− return err
614− }
615− setKeyOrToken = func() {
616− providerConfig.APIKey = v.AccessToken
617− providerConfig.OAuthToken = v
618− switch providerID {
619− case string(catwalk.InferenceProviderCopilot):
620− providerConfig.SetupGitHubCopilot()
621− }
622− }
623− }
624−
625− providerConfig, exists = c.Providers.Get(providerID)
626− if exists {
627− setKeyOrToken()
628− c.Providers.Set(providerID, providerConfig)
629− return nil
630− }
631−
632− var foundProvider *catwalk.Provider
633− for _, p := range c.knownProviders {
634− if string(p.ID) == providerID {
635− foundProvider = &p
636− break
637− }
638− }
639−
640− if foundProvider != nil {
641− // Create new provider config based on known provider
642− providerConfig = ProviderConfig{
643− ID: providerID,
644− Name: foundProvider.Name,
645− BaseURL: foundProvider.APIEndpoint,
646− Type: foundProvider.Type,
647− Disable: false,
648− ExtraHeaders: make(map[string]string),
649− ExtraParams: make(map[string]string),
650− Models: foundProvider.Models,
651− }
652− setKeyOrToken()
653− } else {
654− return fmt.Errorf("provider with ID %s not found in known providers", providerID)
655− }
656− // Store the updated provider config
657− c.Providers.Set(providerID, providerConfig)
658− return nil
659−}
660−
661458const maxRecentModelsPerType = 5
662459
663−func (c *Config) recordRecentModel(modelType SelectedModelType, model SelectedModel) error {
664− if model.Provider == "" || model.Model == "" {
665− return nil
666− }
667−
668− if c.RecentModels == nil {
669− c.RecentModels = make(map[SelectedModelType][]SelectedModel)
670− }
671−
672− eq := func(a, b SelectedModel) bool {
673− return a.Provider == b.Provider && a.Model == b.Model
674− }
675−
676− entry := SelectedModel{
677− Provider: model.Provider,
678− Model: model.Model,
679− }
680−
681− current := c.RecentModels[modelType]
682− withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool {
683− return eq(existing, entry)
684− })
685−
686− updated := append([]SelectedModel{entry}, withoutCurrent...)
687− if len(updated) > maxRecentModelsPerType {
688− updated = updated[:maxRecentModelsPerType]
689− }
690−
691− if slices.EqualFunc(current, updated, eq) {
692− return nil
693− }
694−
695− c.RecentModels[modelType] = updated
696−
697− if err := c.SetConfigField(fmt.Sprintf("recent_models.%s", modelType), updated); err != nil {
698− return fmt.Errorf("failed to persist recent models: %w", err)
699− }
700−
701− return nil
702−}
703−
704460func allToolNames() []string {
705461 return []string{
706462 "agent",
@@ -780,10 +536,6 @@ func (c *Config) SetupAgents() {
780536 c.Agents = agents
781537}
782538
783−func (c *Config) Resolver() VariableResolver {
784− return c.resolver
785−}
786−
787539func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
788540 var (
789541 providerID = catwalk.InferenceProvider(c.ID)
internal/config/copilot.go−47
@@ -1,48 +1 @@
11package config
2−
3−import (
4− "cmp"
5− "context"
6− "log/slog"
7− "testing"
8−
9− "charm.land/catwalk/pkg/catwalk"
10− "github.com/charmbracelet/crush/internal/oauth"
11− "github.com/charmbracelet/crush/internal/oauth/copilot"
12−)
13−
14−func (c *Config) ImportCopilot() (*oauth.Token, bool) {
15− if testing.Testing() {
16− return nil, false
17− }
18−
19− if c.HasConfigField("providers.copilot.api_key") || c.HasConfigField("providers.copilot.oauth") {
20− return nil, false
21− }
22−
23− diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
24− if !hasDiskToken {
25− return nil, false
26− }
27−
28− slog.Info("Found existing GitHub Copilot token on disk. Authenticating...")
29− token, err := copilot.RefreshToken(context.TODO(), diskToken)
30− if err != nil {
31− slog.Error("Unable to import GitHub Copilot token", "error", err)
32− return nil, false
33− }
34−
35− if err := c.SetProviderAPIKey(string(catwalk.InferenceProviderCopilot), token); err != nil {
36− return token, false
37− }
38−
39− if err := cmp.Or(
40− c.SetConfigField("providers.copilot.api_key", token.AccessToken),
41− c.SetConfigField("providers.copilot.oauth", token),
42− ); err != nil {
43− slog.Error("Unable to save GitHub Copilot token to disk", "error", err)
44− }
45−
46− slog.Info("GitHub Copilot successfully imported")
47− return token, true
48−}
internal/config/init.go+15−14
@@ -18,19 +18,20 @@ type ProjectInitFlag struct {
1818 Initialized bool `json:"initialized"`
1919}
2020
21−func Init(workingDir, dataDir string, debug bool) (*Config, error) {
22− cfg, err := Load(workingDir, dataDir, debug)
21+func Init(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
22+ store, err := Load(workingDir, dataDir, debug)
2323 if err != nil {
2424 return nil, err
2525 }
26− return cfg, nil
26+ return store, nil
2727}
2828
29−func ProjectNeedsInitialization(cfg *Config) (bool, error) {
30− if cfg == nil {
29+func ProjectNeedsInitialization(store *ConfigStore) (bool, error) {
30+ if store == nil {
3131 return false, fmt.Errorf("config not loaded")
3232 }
3333
34+ cfg := store.Config()
3435 flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
3536
3637 _, err := os.Stat(flagFilePath)
@@ -42,7 +43,7 @@ func ProjectNeedsInitialization(cfg *Config) (bool, error) {
4243 return false, fmt.Errorf("failed to check init flag file: %w", err)
4344 }
4445
45− someContextFileExists, err := contextPathsExist(cfg.WorkingDir())
46+ someContextFileExists, err := contextPathsExist(store.WorkingDir())
4647 if err != nil {
4748 return false, fmt.Errorf("failed to check for context files: %w", err)
4849 }
@@ -51,7 +52,7 @@ func ProjectNeedsInitialization(cfg *Config) (bool, error) {
5152 }
5253
5354 // If the working directory has no non-ignored files, skip initialization step
54− empty, err := dirHasNoVisibleFiles(cfg.WorkingDir())
55+ empty, err := dirHasNoVisibleFiles(store.WorkingDir())
5556 if err != nil {
5657 return false, fmt.Errorf("failed to check if directory is empty: %w", err)
5758 }
@@ -90,7 +91,7 @@ func contextPathsExist(dir string) (bool, error) {
9091 return false, nil
9192}
9293
93−// dirHasNoVisibleFiles returns true if the directory has no files/dirs after applying ignore rules
94+// dirHasNoVisibleFiles returns true if the directory has no files/dirs after applying ignore rules.
9495func dirHasNoVisibleFiles(dir string) (bool, error) {
9596 files, _, err := fsext.ListDirectory(dir, nil, 1, 1)
9697 if err != nil {
@@ -99,11 +100,11 @@ func dirHasNoVisibleFiles(dir string) (bool, error) {
99100 return len(files) == 0, nil
100101}
101102
102−func MarkProjectInitialized(cfg *Config) error {
103− if cfg == nil {
103+func MarkProjectInitialized(store *ConfigStore) error {
104+ if store == nil {
104105 return fmt.Errorf("config not loaded")
105106 }
106− flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
107+ flagFilePath := filepath.Join(store.Config().Options.DataDirectory, InitFlagFilename)
107108
108109 file, err := os.Create(flagFilePath)
109110 if err != nil {
@@ -114,13 +115,13 @@ func MarkProjectInitialized(cfg *Config) error {
114115 return nil
115116}
116117
117−func HasInitialDataConfig(cfg *Config) bool {
118− if cfg == nil {
118+func HasInitialDataConfig(store *ConfigStore) bool {
119+ if store == nil {
119120 return false
120121 }
121122 cfgPath := GlobalConfigData()
122123 if _, err := os.Stat(cfgPath); err != nil {
123124 return false
124125 }
125− return cfg.IsConfigured()
126+ return store.Config().IsConfigured()
126127}
internal/config/load.go+45−17
@@ -29,8 +29,9 @@ import (
2929
3030const defaultCatwalkURL = "https://catwalk.charm.sh"
3131
32−// Load loads the configuration from the default paths.
33−func Load(workingDir, dataDir string, debug bool) (*Config, error) {
32+// Load loads the configuration from the default paths and returns a
33+// ConfigStore that owns both the pure-data Config and all runtime state.
34+func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
3435 configPaths := lookupConfigs(workingDir)
3536
3637 cfg, err := loadFromConfigPaths(configPaths)
@@ -38,10 +39,15 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
3839 return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
3940 }
4041
41− cfg.dataConfigDir = GlobalConfigData()
42−
4342 cfg.setDefaults(workingDir, dataDir)
4443
44+ store := &ConfigStore{
45+ config: cfg,
46+ workingDir: workingDir,
47+ globalDataPath: GlobalConfigData(),
48+ workspacePath: filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName)),
49+ }
50+
4551 if debug {
4652 cfg.Options.Debug = true
4753 }
@@ -52,6 +58,18 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
5258 cfg.Options.Debug,
5359 )
5460
61+ // Load workspace config last so it has highest priority.
62+ if wsData, err := os.ReadFile(store.workspacePath); err == nil && len(wsData) > 0 {
63+ merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
64+ if mergeErr == nil {
65+ // Preserve defaults that setDefaults already applied.
66+ dataDir := cfg.Options.DataDirectory
67+ *cfg = *merged
68+ cfg.setDefaults(workingDir, dataDir)
69+ store.config = cfg
70+ }
71+ }
72+
5573 if !isInsideWorktree() {
5674 const depth = 2
5775 const items = 100
@@ -72,26 +90,36 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
7290 if err != nil {
7391 return nil, err
7492 }
75− cfg.knownProviders = providers
93+ store.knownProviders = providers
7694
7795 env := env.New()
7896 // Configure providers
7997 valueResolver := NewShellVariableResolver(env)
80− cfg.resolver = valueResolver
81− if err := cfg.configureProviders(env, valueResolver, cfg.knownProviders); err != nil {
98+ store.resolver = valueResolver
99+ if err := cfg.configureProviders(store, env, valueResolver, store.knownProviders); err != nil {
82100 return nil, fmt.Errorf("failed to configure providers: %w", err)
83101 }
84102
85103 if !cfg.IsConfigured() {
86104 slog.Warn("No providers configured")
87− return cfg, nil
105+ return store, nil
88106 }
89107
90− if err := cfg.configureSelectedModels(cfg.knownProviders); err != nil {
108+ if err := configureSelectedModels(store, store.knownProviders); err != nil {
91109 return nil, fmt.Errorf("failed to configure selected models: %w", err)
92110 }
93− cfg.SetupAgents()
94− return cfg, nil
111+ store.SetupAgents()
112+ return store, nil
113+}
114+
115+// mustMarshalConfig marshals the config to JSON bytes, returning empty JSON on
116+// error.
117+func mustMarshalConfig(cfg *Config) []byte {
118+ data, err := json.Marshal(cfg)
119+ if err != nil {
120+ return []byte("{}")
121+ }
122+ return data
95123}
96124
97125func PushPopCrushEnv() func() {
@@ -122,7 +150,7 @@ func PushPopCrushEnv() func() {
122150 return restore
123151}
124152
125−func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
153+func (c *Config) configureProviders(store *ConfigStore, env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
126154 knownProviderNames := make(map[string]bool)
127155 restore := PushPopCrushEnv()
128156 defer restore()
@@ -209,7 +237,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
209237 switch {
210238 case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
211239 // Claude Code subscription is not supported anymore. Remove to show onboarding.
212− c.RemoveConfigField("providers.anthropic")
240+ store.RemoveConfigField(ScopeGlobal, "providers.anthropic")
213241 c.Providers.Del(string(p.ID))
214242 continue
215243 case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
@@ -340,7 +368,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
340368}
341369
342370func (c *Config) setDefaults(workingDir, dataDir string) {
343− c.workingDir = workingDir
344371 if c.Options == nil {
345372 c.Options = &Options{}
346373 }
@@ -524,7 +551,8 @@ func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (large
524551 return largeModel, smallModel, err
525552}
526553
527−func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) error {
554+func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provider) error {
555+ c := store.config
528556 defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
529557 if err != nil {
530558 return fmt.Errorf("failed to select default models: %w", err)
@@ -543,7 +571,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
543571 if model == nil {
544572 large = defaultLarge
545573 // override the model type to large
546− err := c.UpdatePreferredModel(SelectedModelTypeLarge, large)
574+ err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, large)
547575 if err != nil {
548576 return fmt.Errorf("failed to update preferred large model: %w", err)
549577 }
@@ -587,7 +615,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
587615 if model == nil {
588616 small = defaultSmall
589617 // override the model type to small
590− err := c.UpdatePreferredModel(SelectedModelTypeSmall, small)
618+ err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, small)
591619 if err != nil {
592620 return fmt.Errorf("failed to update preferred small model: %w", err)
593621 }
internal/config/load_test.go+44−40
@@ -36,6 +36,11 @@ func TestConfig_LoadFromBytes(t *testing.T) {
3636 require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
3737}
3838
39+// testStore wraps a Config in a minimal ConfigStore for testing.
40+func testStore(cfg *Config) *ConfigStore {
41+ return &ConfigStore{config: cfg}
42+}
43+
3944func TestConfig_setDefaults(t *testing.T) {
4045 cfg := &Config{}
4146
@@ -53,7 +58,6 @@ func TestConfig_setDefaults(t *testing.T) {
5358 for _, path := range defaultContextPaths {
5459 require.Contains(t, cfg.Options.ContextPaths, path)
5560 }
56− require.Equal(t, "/tmp", cfg.workingDir)
5761}
5862
5963func TestConfig_configureProviders(t *testing.T) {
@@ -74,7 +78,7 @@ func TestConfig_configureProviders(t *testing.T) {
7478 "OPENAI_API_KEY": "test-key",
7579 })
7680 resolver := NewEnvironmentVariableResolver(env)
77− err := cfg.configureProviders(env, resolver, knownProviders)
81+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
7882 require.NoError(t, err)
7983 require.Equal(t, 1, cfg.Providers.Len())
8084
@@ -117,7 +121,7 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
117121 "OPENAI_API_KEY": "test-key",
118122 })
119123 resolver := NewEnvironmentVariableResolver(env)
120− err := cfg.configureProviders(env, resolver, knownProviders)
124+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
121125 require.NoError(t, err)
122126 require.Equal(t, 1, cfg.Providers.Len())
123127
@@ -159,7 +163,7 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
159163 "OPENAI_API_KEY": "test-key",
160164 })
161165 resolver := NewEnvironmentVariableResolver(env)
162− err := cfg.configureProviders(env, resolver, knownProviders)
166+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
163167 require.NoError(t, err)
164168 // Should be to because of the env variable
165169 require.Equal(t, cfg.Providers.Len(), 2)
@@ -195,7 +199,7 @@ func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
195199 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
196200 })
197201 resolver := NewEnvironmentVariableResolver(env)
198− err := cfg.configureProviders(env, resolver, knownProviders)
202+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
199203 require.NoError(t, err)
200204 require.Equal(t, cfg.Providers.Len(), 1)
201205
@@ -221,7 +225,7 @@ func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
221225 cfg.setDefaults("/tmp", "")
222226 env := env.NewFromMap(map[string]string{})
223227 resolver := NewEnvironmentVariableResolver(env)
224− err := cfg.configureProviders(env, resolver, knownProviders)
228+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
225229 require.NoError(t, err)
226230 // Provider should not be configured without credentials
227231 require.Equal(t, cfg.Providers.Len(), 0)
@@ -246,7 +250,7 @@ func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
246250 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
247251 })
248252 resolver := NewEnvironmentVariableResolver(env)
249− err := cfg.configureProviders(env, resolver, knownProviders)
253+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
250254 require.Error(t, err)
251255}
252256
@@ -269,7 +273,7 @@ func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
269273 "VERTEXAI_LOCATION": "us-central1",
270274 })
271275 resolver := NewEnvironmentVariableResolver(env)
272− err := cfg.configureProviders(env, resolver, knownProviders)
276+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
273277 require.NoError(t, err)
274278 require.Equal(t, cfg.Providers.Len(), 1)
275279
@@ -301,7 +305,7 @@ func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
301305 "GOOGLE_CLOUD_LOCATION": "us-central1",
302306 })
303307 resolver := NewEnvironmentVariableResolver(env)
304− err := cfg.configureProviders(env, resolver, knownProviders)
308+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
305309 require.NoError(t, err)
306310 // Provider should not be configured without proper credentials
307311 require.Equal(t, cfg.Providers.Len(), 0)
@@ -326,7 +330,7 @@ func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
326330 "GOOGLE_CLOUD_LOCATION": "us-central1",
327331 })
328332 resolver := NewEnvironmentVariableResolver(env)
329− err := cfg.configureProviders(env, resolver, knownProviders)
333+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
330334 require.NoError(t, err)
331335 // Provider should not be configured without project
332336 require.Equal(t, cfg.Providers.Len(), 0)
@@ -350,7 +354,7 @@ func TestConfig_configureProvidersSetProviderID(t *testing.T) {
350354 "OPENAI_API_KEY": "test-key",
351355 })
352356 resolver := NewEnvironmentVariableResolver(env)
353− err := cfg.configureProviders(env, resolver, knownProviders)
357+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
354358 require.NoError(t, err)
355359 require.Equal(t, cfg.Providers.Len(), 1)
356360
@@ -541,7 +545,7 @@ func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
541545 "OPENAI_API_KEY": "test-key",
542546 })
543547 resolver := NewEnvironmentVariableResolver(env)
544− err := cfg.configureProviders(env, resolver, knownProviders)
548+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
545549 require.NoError(t, err)
546550
547551 require.Equal(t, cfg.Providers.Len(), 1)
@@ -569,7 +573,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
569573
570574 env := env.NewFromMap(map[string]string{})
571575 resolver := NewEnvironmentVariableResolver(env)
572− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
576+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
573577 require.NoError(t, err)
574578
575579 require.Equal(t, cfg.Providers.Len(), 1)
@@ -592,7 +596,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
592596
593597 env := env.NewFromMap(map[string]string{})
594598 resolver := NewEnvironmentVariableResolver(env)
595− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
599+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
596600 require.NoError(t, err)
597601
598602 require.Equal(t, cfg.Providers.Len(), 0)
@@ -614,7 +618,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
614618
615619 env := env.NewFromMap(map[string]string{})
616620 resolver := NewEnvironmentVariableResolver(env)
617− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
621+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
618622 require.NoError(t, err)
619623
620624 require.Equal(t, cfg.Providers.Len(), 0)
@@ -639,7 +643,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
639643
640644 env := env.NewFromMap(map[string]string{})
641645 resolver := NewEnvironmentVariableResolver(env)
642− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
646+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
643647 require.NoError(t, err)
644648
645649 require.Equal(t, cfg.Providers.Len(), 0)
@@ -664,7 +668,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
664668
665669 env := env.NewFromMap(map[string]string{})
666670 resolver := NewEnvironmentVariableResolver(env)
667− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
671+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
668672 require.NoError(t, err)
669673
670674 require.Equal(t, cfg.Providers.Len(), 1)
@@ -692,7 +696,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
692696
693697 env := env.NewFromMap(map[string]string{})
694698 resolver := NewEnvironmentVariableResolver(env)
695− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
699+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
696700 require.NoError(t, err)
697701
698702 require.Equal(t, cfg.Providers.Len(), 1)
@@ -722,7 +726,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
722726
723727 env := env.NewFromMap(map[string]string{})
724728 resolver := NewEnvironmentVariableResolver(env)
725− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
729+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
726730 require.NoError(t, err)
727731
728732 require.Equal(t, cfg.Providers.Len(), 0)
@@ -757,7 +761,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
757761 "GOOGLE_GENAI_USE_VERTEXAI": "false",
758762 })
759763 resolver := NewEnvironmentVariableResolver(env)
760− err := cfg.configureProviders(env, resolver, knownProviders)
764+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
761765 require.NoError(t, err)
762766
763767 require.Equal(t, cfg.Providers.Len(), 0)
@@ -788,7 +792,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
788792
789793 env := env.NewFromMap(map[string]string{})
790794 resolver := NewEnvironmentVariableResolver(env)
791− err := cfg.configureProviders(env, resolver, knownProviders)
795+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
792796 require.NoError(t, err)
793797
794798 require.Equal(t, cfg.Providers.Len(), 0)
@@ -819,7 +823,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
819823
820824 env := env.NewFromMap(map[string]string{})
821825 resolver := NewEnvironmentVariableResolver(env)
822− err := cfg.configureProviders(env, resolver, knownProviders)
826+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
823827 require.NoError(t, err)
824828
825829 require.Equal(t, cfg.Providers.Len(), 0)
@@ -852,7 +856,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
852856 "OPENAI_API_KEY": "test-key",
853857 })
854858 resolver := NewEnvironmentVariableResolver(env)
855− err := cfg.configureProviders(env, resolver, knownProviders)
859+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
856860 require.NoError(t, err)
857861
858862 require.Equal(t, cfg.Providers.Len(), 1)
@@ -886,7 +890,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
886890 cfg.setDefaults("/tmp", "")
887891 env := env.NewFromMap(map[string]string{})
888892 resolver := NewEnvironmentVariableResolver(env)
889− err := cfg.configureProviders(env, resolver, knownProviders)
893+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
890894 require.NoError(t, err)
891895
892896 large, small, err := cfg.defaultModelSelection(knownProviders)
@@ -922,7 +926,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
922926 cfg.setDefaults("/tmp", "")
923927 env := env.NewFromMap(map[string]string{})
924928 resolver := NewEnvironmentVariableResolver(env)
925− err := cfg.configureProviders(env, resolver, knownProviders)
929+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
926930 require.NoError(t, err)
927931
928932 _, _, err = cfg.defaultModelSelection(knownProviders)
@@ -952,7 +956,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
952956 cfg.setDefaults("/tmp", "")
953957 env := env.NewFromMap(map[string]string{})
954958 resolver := NewEnvironmentVariableResolver(env)
955− err := cfg.configureProviders(env, resolver, knownProviders)
959+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
956960 require.NoError(t, err)
957961 _, _, err = cfg.defaultModelSelection(knownProviders)
958962 require.Error(t, err)
@@ -995,7 +999,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
995999 cfg.setDefaults("/tmp", "")
9961000 env := env.NewFromMap(map[string]string{})
9971001 resolver := NewEnvironmentVariableResolver(env)
998− err := cfg.configureProviders(env, resolver, knownProviders)
1002+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
9991003 require.NoError(t, err)
10001004 large, small, err := cfg.defaultModelSelection(knownProviders)
10011005 require.NoError(t, err)
@@ -1039,7 +1043,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
10391043 cfg.setDefaults("/tmp", "")
10401044 env := env.NewFromMap(map[string]string{})
10411045 resolver := NewEnvironmentVariableResolver(env)
1042− err := cfg.configureProviders(env, resolver, knownProviders)
1046+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
10431047 require.NoError(t, err)
10441048 _, _, err = cfg.defaultModelSelection(knownProviders)
10451049 require.Error(t, err)
@@ -1081,7 +1085,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
10811085 cfg.setDefaults("/tmp", "")
10821086 env := env.NewFromMap(map[string]string{})
10831087 resolver := NewEnvironmentVariableResolver(env)
1084− err := cfg.configureProviders(env, resolver, knownProviders)
1088+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
10851089 require.NoError(t, err)
10861090 large, small, err := cfg.defaultModelSelection(knownProviders)
10871091 require.NoError(t, err)
@@ -1126,7 +1130,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
11261130 "OPENAI_API_KEY": "test-key",
11271131 })
11281132 resolver := NewEnvironmentVariableResolver(env)
1129− err := cfg.configureProviders(env, resolver, knownProviders)
1133+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
11301134 require.ErrorContains(t, err, "no custom providers")
11311135
11321136 // openai should NOT be present because it lacks base_url and models.
@@ -1169,7 +1173,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
11691173 "OPENAI_API_KEY": "test-key",
11701174 })
11711175 resolver := NewEnvironmentVariableResolver(env)
1172− err := cfg.configureProviders(env, resolver, knownProviders)
1176+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
11731177 require.NoError(t, err)
11741178
11751179 // Only fully specified provider should be present.
@@ -1223,7 +1227,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
12231227 "ANTHROPIC_API_KEY": "test-key",
12241228 })
12251229 resolver := NewEnvironmentVariableResolver(env)
1226− err := cfg.configureProviders(env, resolver, knownProviders)
1230+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
12271231 require.NoError(t, err)
12281232
12291233 // Both providers should be present.
@@ -1251,7 +1255,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
12511255
12521256 env := env.NewFromMap(map[string]string{})
12531257 resolver := NewEnvironmentVariableResolver(env)
1254− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
1258+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
12551259 require.ErrorContains(t, err, "no custom providers")
12561260
12571261 // Provider should be rejected for missing models.
@@ -1275,7 +1279,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
12751279
12761280 env := env.NewFromMap(map[string]string{})
12771281 resolver := NewEnvironmentVariableResolver(env)
1278− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
1282+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
12791283 require.ErrorContains(t, err, "no custom providers")
12801284
12811285 // Provider should be rejected for missing base_url.
@@ -1340,10 +1344,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
13401344 cfg.setDefaults("/tmp", "")
13411345 env := env.NewFromMap(map[string]string{})
13421346 resolver := NewEnvironmentVariableResolver(env)
1343− err := cfg.configureProviders(env, resolver, knownProviders)
1347+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
13441348 require.NoError(t, err)
13451349
1346− err = cfg.configureSelectedModels(knownProviders)
1350+ err = configureSelectedModels(testStore(cfg), knownProviders)
13471351 require.NoError(t, err)
13481352 large := cfg.Models[SelectedModelTypeLarge]
13491353 small := cfg.Models[SelectedModelTypeSmall]
@@ -1402,10 +1406,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
14021406 cfg.setDefaults("/tmp", "")
14031407 env := env.NewFromMap(map[string]string{})
14041408 resolver := NewEnvironmentVariableResolver(env)
1405− err := cfg.configureProviders(env, resolver, knownProviders)
1409+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
14061410 require.NoError(t, err)
14071411
1408− err = cfg.configureSelectedModels(knownProviders)
1412+ err = configureSelectedModels(testStore(cfg), knownProviders)
14091413 require.NoError(t, err)
14101414 large := cfg.Models[SelectedModelTypeLarge]
14111415 small := cfg.Models[SelectedModelTypeSmall]
@@ -1447,10 +1451,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
14471451 cfg.setDefaults("/tmp", "")
14481452 env := env.NewFromMap(map[string]string{})
14491453 resolver := NewEnvironmentVariableResolver(env)
1450− err := cfg.configureProviders(env, resolver, knownProviders)
1454+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
14511455 require.NoError(t, err)
14521456
1453− err = cfg.configureSelectedModels(knownProviders)
1457+ err = configureSelectedModels(testStore(cfg), knownProviders)
14541458 require.NoError(t, err)
14551459 large := cfg.Models[SelectedModelTypeLarge]
14561460 require.Equal(t, "large-model", large.Model)
internal/config/recent_models_test.go+36−28
@@ -31,15 +31,23 @@ func readRecentModels(t *testing.T, path string) map[string]any {
3131 return rm
3232}
3333
34+// testStoreWithPath creates a ConfigStore backed by a Config for recent model tests.
35+func testStoreWithPath(cfg *Config, dir string) *ConfigStore {
36+ return &ConfigStore{
37+ config: cfg,
38+ globalDataPath: filepath.Join(dir, "config.json"),
39+ }
40+}
41+
3442func TestRecordRecentModel_AddsAndPersists(t *testing.T) {
3543 t.Parallel()
3644
3745 dir := t.TempDir()
3846 cfg := &Config{}
3947 cfg.setDefaults(dir, "")
40− cfg.dataConfigDir = filepath.Join(dir, "config.json")
48+ store := testStoreWithPath(cfg, dir)
4149
42− err := cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
50+ err := store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
4351 require.NoError(t, err)
4452
4553 // in-memory state
@@ -48,7 +56,7 @@ func TestRecordRecentModel_AddsAndPersists(t *testing.T) {
4856 require.Equal(t, "gpt-4o", cfg.RecentModels[SelectedModelTypeLarge][0].Model)
4957
5058 // persisted state
51− rm := readRecentModels(t, cfg.dataConfigDir)
59+ rm := readRecentModels(t, store.globalDataPath)
5260 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
5361 require.True(t, ok)
5462 require.Len(t, large, 1)
@@ -64,13 +72,13 @@ func TestRecordRecentModel_DedupeAndMoveToFront(t *testing.T) {
6472 dir := t.TempDir()
6573 cfg := &Config{}
6674 cfg.setDefaults(dir, "")
67− cfg.dataConfigDir = filepath.Join(dir, "config.json")
75+ store := testStoreWithPath(cfg, dir)
6876
6977 // Add two entries
70− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
71− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "anthropic", Model: "claude"}))
78+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
79+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "anthropic", Model: "claude"}))
7280 // Re-add first; should move to front and not duplicate
73− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
81+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
7482
7583 got := cfg.RecentModels[SelectedModelTypeLarge]
7684 require.Len(t, got, 2)
@@ -84,7 +92,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) {
8492 dir := t.TempDir()
8593 cfg := &Config{}
8694 cfg.setDefaults(dir, "")
87− cfg.dataConfigDir = filepath.Join(dir, "config.json")
95+ store := testStoreWithPath(cfg, dir)
8896
8997 // Insert 6 unique models; max is 5
9098 entries := []SelectedModel{
@@ -96,7 +104,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) {
96104 {Provider: "p6", Model: "m6"},
97105 }
98106 for _, e := range entries {
99− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, e))
107+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, e))
100108 }
101109
102110 // in-memory state
@@ -110,7 +118,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) {
110118 require.Equal(t, SelectedModel{Provider: "p2", Model: "m2"}, got[4])
111119
112120 // persisted state: verify trimmed to 5 and newest-first order
113− rm := readRecentModels(t, cfg.dataConfigDir)
121+ rm := readRecentModels(t, store.globalDataPath)
114122 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
115123 require.True(t, ok)
116124 require.Len(t, large, 5)
@@ -129,12 +137,12 @@ func TestRecordRecentModel_SkipsEmptyValues(t *testing.T) {
129137 dir := t.TempDir()
130138 cfg := &Config{}
131139 cfg.setDefaults(dir, "")
132− cfg.dataConfigDir = filepath.Join(dir, "config.json")
140+ store := testStoreWithPath(cfg, dir)
133141
134142 // Missing provider
135− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "", Model: "m"}))
143+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "", Model: "m"}))
136144 // Missing model
137− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: ""}))
145+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: ""}))
138146
139147 _, ok := cfg.RecentModels[SelectedModelTypeLarge]
140148 // Map may be initialized, but should have no entries
@@ -142,8 +150,8 @@ func TestRecordRecentModel_SkipsEmptyValues(t *testing.T) {
142150 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 0)
143151 }
144152 // No file should be written (stat via fs.FS)
145− baseDir := filepath.Dir(cfg.dataConfigDir)
146− fileName := filepath.Base(cfg.dataConfigDir)
153+ baseDir := filepath.Dir(store.globalDataPath)
154+ fileName := filepath.Base(store.globalDataPath)
147155 _, err := fs.Stat(os.DirFS(baseDir), fileName)
148156 require.True(t, os.IsNotExist(err))
149157}
@@ -154,13 +162,13 @@ func TestRecordRecentModel_NoPersistOnNoop(t *testing.T) {
154162 dir := t.TempDir()
155163 cfg := &Config{}
156164 cfg.setDefaults(dir, "")
157− cfg.dataConfigDir = filepath.Join(dir, "config.json")
165+ store := testStoreWithPath(cfg, dir)
158166
159167 entry := SelectedModel{Provider: "openai", Model: "gpt-4o"}
160− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, entry))
168+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, entry))
161169
162− baseDir := filepath.Dir(cfg.dataConfigDir)
163− fileName := filepath.Base(cfg.dataConfigDir)
170+ baseDir := filepath.Dir(store.globalDataPath)
171+ fileName := filepath.Base(store.globalDataPath)
164172 before, err := fs.ReadFile(os.DirFS(baseDir), fileName)
165173 require.NoError(t, err)
166174
@@ -170,7 +178,7 @@ func TestRecordRecentModel_NoPersistOnNoop(t *testing.T) {
170178 beforeMod := stBefore.ModTime()
171179
172180 // Re-record same entry should be a no-op (no write)
173− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, entry))
181+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, entry))
174182
175183 after, err := fs.ReadFile(os.DirFS(baseDir), fileName)
176184 require.NoError(t, err)
@@ -188,17 +196,17 @@ func TestUpdatePreferredModel_UpdatesRecents(t *testing.T) {
188196 dir := t.TempDir()
189197 cfg := &Config{}
190198 cfg.setDefaults(dir, "")
191− cfg.dataConfigDir = filepath.Join(dir, "config.json")
199+ store := testStoreWithPath(cfg, dir)
192200
193201 sel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
194− require.NoError(t, cfg.UpdatePreferredModel(SelectedModelTypeSmall, sel))
202+ require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, sel))
195203
196204 // in-memory
197205 require.Equal(t, sel, cfg.Models[SelectedModelTypeSmall])
198206 require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
199207
200208 // persisted (read via fs.FS)
201− rm := readRecentModels(t, cfg.dataConfigDir)
209+ rm := readRecentModels(t, store.globalDataPath)
202210 small, ok := rm[string(SelectedModelTypeSmall)].([]any)
203211 require.True(t, ok)
204212 require.Len(t, small, 1)
@@ -210,14 +218,14 @@ func TestRecordRecentModel_TypeIsolation(t *testing.T) {
210218 dir := t.TempDir()
211219 cfg := &Config{}
212220 cfg.setDefaults(dir, "")
213− cfg.dataConfigDir = filepath.Join(dir, "config.json")
221+ store := testStoreWithPath(cfg, dir)
214222
215223 // Add models to both large and small types
216224 largeModel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
217225 smallModel := SelectedModel{Provider: "anthropic", Model: "claude"}
218226
219− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, largeModel))
220− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeSmall, smallModel))
227+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, largeModel))
228+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeSmall, smallModel))
221229
222230 // in-memory: verify types maintain separate histories
223231 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 1)
@@ -227,14 +235,14 @@ func TestRecordRecentModel_TypeIsolation(t *testing.T) {
227235
228236 // Add another to large, verify small unchanged
229237 anotherLarge := SelectedModel{Provider: "google", Model: "gemini"}
230− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, anotherLarge))
238+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, anotherLarge))
231239
232240 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 2)
233241 require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
234242 require.Equal(t, smallModel, cfg.RecentModels[SelectedModelTypeSmall][0])
235243
236244 // persisted state: verify both types exist with correct lengths and contents
237− rm := readRecentModels(t, cfg.dataConfigDir)
245+ rm := readRecentModels(t, store.globalDataPath)
238246
239247 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
240248 require.True(t, ok)
internal/config/scope.gonew file+11
@@ -0,0 +1,11 @@
1+package config
2+
3+// Scope determines which config file is targeted for read/write operations.
4+type Scope int
5+
6+const (
7+ // ScopeGlobal targets the global data config (~/.local/share/crush/crush.json).
8+ ScopeGlobal Scope = iota
9+ // ScopeWorkspace targets the workspace config (.crush/crush.json).
10+ ScopeWorkspace
11+)
internal/config/store.gonew file+336
@@ -0,0 +1,336 @@
1+package config
2+
3+import (
4+ "cmp"
5+ "context"
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/oauth"
15+ "github.com/charmbracelet/crush/internal/oauth/copilot"
16+ "github.com/charmbracelet/crush/internal/oauth/hyper"
17+ "github.com/tidwall/gjson"
18+ "github.com/tidwall/sjson"
19+)
20+
21+// ConfigStore is the single entry point for all config access. It owns the
22+// pure-data Config, runtime state (working directory, resolver, known
23+// providers), and persistence to both global and workspace config files.
24+type ConfigStore struct {
25+ config *Config
26+ workingDir string
27+ resolver VariableResolver
28+ globalDataPath string // ~/.local/share/crush/crush.json
29+ workspacePath string // .crush/crush.json
30+ knownProviders []catwalk.Provider
31+}
32+
33+// Config returns the pure-data config struct (read-only after load).
34+func (s *ConfigStore) Config() *Config {
35+ return s.config
36+}
37+
38+// WorkingDir returns the current working directory.
39+func (s *ConfigStore) WorkingDir() string {
40+ return s.workingDir
41+}
42+
43+// Resolver returns the variable resolver.
44+func (s *ConfigStore) Resolver() VariableResolver {
45+ return s.resolver
46+}
47+
48+// Resolve resolves a variable reference using the configured resolver.
49+func (s *ConfigStore) Resolve(key string) (string, error) {
50+ if s.resolver == nil {
51+ return "", fmt.Errorf("no variable resolver configured")
52+ }
53+ return s.resolver.ResolveValue(key)
54+}
55+
56+// KnownProviders returns the list of known providers.
57+func (s *ConfigStore) KnownProviders() []catwalk.Provider {
58+ return s.knownProviders
59+}
60+
61+// SetupAgents configures the coder and task agents on the config.
62+func (s *ConfigStore) SetupAgents() {
63+ s.config.SetupAgents()
64+}
65+
66+// configPath returns the file path for the given scope.
67+func (s *ConfigStore) configPath(scope Scope) string {
68+ switch scope {
69+ case ScopeWorkspace:
70+ return s.workspacePath
71+ default:
72+ return s.globalDataPath
73+ }
74+}
75+
76+// HasConfigField checks whether a key exists in the config file for the given
77+// scope.
78+func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
79+ data, err := os.ReadFile(s.configPath(scope))
80+ if err != nil {
81+ return false
82+ }
83+ return gjson.Get(string(data), key).Exists()
84+}
85+
86+// SetConfigField sets a key/value pair in the config file for the given scope.
87+func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
88+ path := s.configPath(scope)
89+ data, err := os.ReadFile(path)
90+ if err != nil {
91+ if os.IsNotExist(err) {
92+ data = []byte("{}")
93+ } else {
94+ return fmt.Errorf("failed to read config file: %w", err)
95+ }
96+ }
97+
98+ newValue, err := sjson.Set(string(data), key, value)
99+ if err != nil {
100+ return fmt.Errorf("failed to set config field %s: %w", key, err)
101+ }
102+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
103+ return fmt.Errorf("failed to create config directory %q: %w", path, err)
104+ }
105+ if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
106+ return fmt.Errorf("failed to write config file: %w", err)
107+ }
108+ return nil
109+}
110+
111+// RemoveConfigField removes a key from the config file for the given scope.
112+func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
113+ path := s.configPath(scope)
114+ data, err := os.ReadFile(path)
115+ if err != nil {
116+ return fmt.Errorf("failed to read config file: %w", err)
117+ }
118+
119+ newValue, err := sjson.Delete(string(data), key)
120+ if err != nil {
121+ return fmt.Errorf("failed to delete config field %s: %w", key, err)
122+ }
123+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
124+ return fmt.Errorf("failed to create config directory %q: %w", path, err)
125+ }
126+ if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
127+ return fmt.Errorf("failed to write config file: %w", err)
128+ }
129+ return nil
130+}
131+
132+// UpdatePreferredModel updates the preferred model for the given type and
133+// persists it to the config file at the given scope.
134+func (s *ConfigStore) UpdatePreferredModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
135+ s.config.Models[modelType] = model
136+ if err := s.SetConfigField(scope, fmt.Sprintf("models.%s", modelType), model); err != nil {
137+ return fmt.Errorf("failed to update preferred model: %w", err)
138+ }
139+ if err := s.recordRecentModel(scope, modelType, model); err != nil {
140+ return err
141+ }
142+ return nil
143+}
144+
145+// SetCompactMode sets the compact mode setting and persists it.
146+func (s *ConfigStore) SetCompactMode(scope Scope, enabled bool) error {
147+ if s.config.Options == nil {
148+ s.config.Options = &Options{}
149+ }
150+ s.config.Options.TUI.CompactMode = enabled
151+ return s.SetConfigField(scope, "options.tui.compact_mode", enabled)
152+}
153+
154+// SetProviderAPIKey sets the API key for a provider and persists it.
155+func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey any) error {
156+ var providerConfig ProviderConfig
157+ var exists bool
158+ var setKeyOrToken func()
159+
160+ switch v := apiKey.(type) {
161+ case string:
162+ if err := s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil {
163+ return fmt.Errorf("failed to save api key to config file: %w", err)
164+ }
165+ setKeyOrToken = func() { providerConfig.APIKey = v }
166+ case *oauth.Token:
167+ if err := cmp.Or(
168+ s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v.AccessToken),
169+ s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), v),
170+ ); err != nil {
171+ return err
172+ }
173+ setKeyOrToken = func() {
174+ providerConfig.APIKey = v.AccessToken
175+ providerConfig.OAuthToken = v
176+ switch providerID {
177+ case string(catwalk.InferenceProviderCopilot):
178+ providerConfig.SetupGitHubCopilot()
179+ }
180+ }
181+ }
182+
183+ providerConfig, exists = s.config.Providers.Get(providerID)
184+ if exists {
185+ setKeyOrToken()
186+ s.config.Providers.Set(providerID, providerConfig)
187+ return nil
188+ }
189+
190+ var foundProvider *catwalk.Provider
191+ for _, p := range s.knownProviders {
192+ if string(p.ID) == providerID {
193+ foundProvider = &p
194+ break
195+ }
196+ }
197+
198+ if foundProvider != nil {
199+ providerConfig = ProviderConfig{
200+ ID: providerID,
201+ Name: foundProvider.Name,
202+ BaseURL: foundProvider.APIEndpoint,
203+ Type: foundProvider.Type,
204+ Disable: false,
205+ ExtraHeaders: make(map[string]string),
206+ ExtraParams: make(map[string]string),
207+ Models: foundProvider.Models,
208+ }
209+ setKeyOrToken()
210+ } else {
211+ return fmt.Errorf("provider with ID %s not found in known providers", providerID)
212+ }
213+ s.config.Providers.Set(providerID, providerConfig)
214+ return nil
215+}
216+
217+// RefreshOAuthToken refreshes the OAuth token for the given provider.
218+func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
219+ providerConfig, exists := s.config.Providers.Get(providerID)
220+ if !exists {
221+ return fmt.Errorf("provider %s not found", providerID)
222+ }
223+
224+ if providerConfig.OAuthToken == nil {
225+ return fmt.Errorf("provider %s does not have an OAuth token", providerID)
226+ }
227+
228+ var newToken *oauth.Token
229+ var refreshErr error
230+ switch providerID {
231+ case string(catwalk.InferenceProviderCopilot):
232+ newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
233+ case hyperp.Name:
234+ newToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
235+ default:
236+ return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
237+ }
238+ if refreshErr != nil {
239+ return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
240+ }
241+
242+ slog.Info("Successfully refreshed OAuth token", "provider", providerID)
243+ providerConfig.OAuthToken = newToken
244+ providerConfig.APIKey = newToken.AccessToken
245+
246+ switch providerID {
247+ case string(catwalk.InferenceProviderCopilot):
248+ providerConfig.SetupGitHubCopilot()
249+ }
250+
251+ s.config.Providers.Set(providerID, providerConfig)
252+
253+ if err := cmp.Or(
254+ s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), newToken.AccessToken),
255+ s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), newToken),
256+ ); err != nil {
257+ return fmt.Errorf("failed to persist refreshed token: %w", err)
258+ }
259+
260+ return nil
261+}
262+
263+// recordRecentModel records a model in the recent models list.
264+func (s *ConfigStore) recordRecentModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
265+ if model.Provider == "" || model.Model == "" {
266+ return nil
267+ }
268+
269+ if s.config.RecentModels == nil {
270+ s.config.RecentModels = make(map[SelectedModelType][]SelectedModel)
271+ }
272+
273+ eq := func(a, b SelectedModel) bool {
274+ return a.Provider == b.Provider && a.Model == b.Model
275+ }
276+
277+ entry := SelectedModel{
278+ Provider: model.Provider,
279+ Model: model.Model,
280+ }
281+
282+ current := s.config.RecentModels[modelType]
283+ withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool {
284+ return eq(existing, entry)
285+ })
286+
287+ updated := append([]SelectedModel{entry}, withoutCurrent...)
288+ if len(updated) > maxRecentModelsPerType {
289+ updated = updated[:maxRecentModelsPerType]
290+ }
291+
292+ if slices.EqualFunc(current, updated, eq) {
293+ return nil
294+ }
295+
296+ s.config.RecentModels[modelType] = updated
297+
298+ if err := s.SetConfigField(scope, fmt.Sprintf("recent_models.%s", modelType), updated); err != nil {
299+ return fmt.Errorf("failed to persist recent models: %w", err)
300+ }
301+
302+ return nil
303+}
304+
305+// ImportCopilot attempts to import a GitHub Copilot token from disk.
306+func (s *ConfigStore) ImportCopilot() (*oauth.Token, bool) {
307+ if s.HasConfigField(ScopeGlobal, "providers.copilot.api_key") || s.HasConfigField(ScopeGlobal, "providers.copilot.oauth") {
308+ return nil, false
309+ }
310+
311+ diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
312+ if !hasDiskToken {
313+ return nil, false
314+ }
315+
316+ slog.Info("Found existing GitHub Copilot token on disk. Authenticating...")
317+ token, err := copilot.RefreshToken(context.TODO(), diskToken)
318+ if err != nil {
319+ slog.Error("Unable to import GitHub Copilot token", "error", err)
320+ return nil, false
321+ }
322+
323+ if err := s.SetProviderAPIKey(ScopeGlobal, string(catwalk.InferenceProviderCopilot), token); err != nil {
324+ return token, false
325+ }
326+
327+ if err := cmp.Or(
328+ s.SetConfigField(ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
329+ s.SetConfigField(ScopeGlobal, "providers.copilot.oauth", token),
330+ ); err != nil {
331+ slog.Error("Unable to save GitHub Copilot token to disk", "error", err)
332+ }
333+
334+ slog.Info("GitHub Copilot successfully imported")
335+ return token, true
336+}
internal/lsp/manager.go+6−6
@@ -26,18 +26,18 @@ var unavailable = csync.NewMap[string, struct{}]()
2626// Manager handles lazy initialization of LSP clients based on file types.
2727type Manager struct {
2828 clients *csync.Map[string, *Client]
29− cfg *config.Config
29+ cfg *config.ConfigStore
3030 manager *powernapconfig.Manager
3131 callback func(name string, client *Client)
3232}
3333
3434// NewManager creates a new LSP manager service.
35−func NewManager(cfg *config.Config) *Manager {
35+func NewManager(cfg *config.ConfigStore) *Manager {
3636 manager := powernapconfig.NewManager()
3737 manager.LoadDefaults()
3838
3939 // Merge user-configured LSPs into the manager.
40− for name, clientConfig := range cfg.LSP {
40+ for name, clientConfig := range cfg.Config().LSP {
4141 if clientConfig.Disabled {
4242 slog.Debug("LSP disabled by user config", "name", name)
4343 manager.RemoveServer(name)
@@ -194,7 +194,7 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server
194194 cfg,
195195 s.cfg.Resolver(),
196196 s.cfg.WorkingDir(),
197− s.cfg.Options.DebugLSP,
197+ s.cfg.Config().Options.DebugLSP,
198198 )
199199 if err != nil {
200200 slog.Error("Failed to create LSP client", "name", name, "error", err)
@@ -244,7 +244,7 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server
244244}
245245
246246func (s *Manager) isUserConfigured(name string) bool {
247− cfg, ok := s.cfg.LSP[name]
247+ cfg, ok := s.cfg.Config().LSP[name]
248248 return ok && !cfg.Disabled
249249}
250250
@@ -258,7 +258,7 @@ func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig)
258258 InitOptions: server.InitOptions,
259259 Options: server.Settings,
260260 }
261− if userCfg, ok := s.cfg.LSP[name]; ok {
261+ if userCfg, ok := s.cfg.Config().LSP[name]; ok {
262262 cfg.Timeout = userCfg.Timeout
263263 }
264264 return cfg
internal/ui/common/common.go+6−1
@@ -26,11 +26,16 @@ type Common struct {
2626 Styles *styles.Styles
2727}
2828
29−// Config returns the configuration associated with this [Common] instance.
29+// Config returns the pure-data configuration associated with this [Common] instance.
3030func (c *Common) Config() *config.Config {
3131 return c.App.Config()
3232}
3333
34+// Store returns the config store associated with this [Common] instance.
35+func (c *Common) Store() *config.ConfigStore {
36+ return c.App.Store()
37+}
38+
3439// DefaultCommon returns the default common UI configurations.
3540func DefaultCommon(app *app.App) *Common {
3641 s := styles.DefaultStyles()
internal/ui/dialog/api_key_input.go+3−3
@@ -296,7 +296,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
296296 Type: m.provider.Type,
297297 BaseURL: m.provider.APIEndpoint,
298298 }
299− err := providerConfig.TestConnection(m.com.Config().Resolver())
299+ err := providerConfig.TestConnection(m.com.Store().Resolver())
300300
301301 // intentionally wait for at least 750ms to make sure the user sees the spinner
302302 elapsed := time.Since(start)
@@ -312,9 +312,9 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
312312}
313313
314314func (m *APIKeyInput) saveKeyAndContinue() Action {
315− cfg := m.com.Config()
315+ store := m.com.Store()
316316
317− err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value())
317+ err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.input.Value())
318318 if err != nil {
319319 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
320320 }
internal/ui/dialog/filepicker.go+1−1
@@ -123,7 +123,7 @@ func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) {
123123
124124// WorkingDir returns the current working directory of the [FilePicker].
125125func (f *FilePicker) WorkingDir() string {
126− wd := f.com.Config().WorkingDir()
126+ wd := f.com.Store().WorkingDir()
127127 if len(wd) > 0 {
128128 return wd
129129 }
internal/ui/dialog/models.go+1−1
@@ -490,7 +490,7 @@ func (m *Models) setProviderItems() error {
490490
491491 if len(validRecentItems) != len(recentItems) {
492492 // FIXME: Does this need to be here? Is it mutating the config during a read?
493− if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
493+ if err := m.com.Store().SetConfigField(config.ScopeGlobal, fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
494494 return fmt.Errorf("failed to update recent models: %w", err)
495495 }
496496 }
internal/ui/dialog/oauth.go+2−2
@@ -373,9 +373,9 @@ func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
373373}
374374
375375func (m *OAuth) saveKeyAndContinue() Action {
376− cfg := m.com.Config()
376+ store := m.com.Store()
377377
378− err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token)
378+ err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.token)
379379 if err != nil {
380380 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
381381 }
internal/ui/model/header.go+1−2
@@ -143,8 +143,7 @@ func renderHeaderDetails(
143143 metadata = dot + metadata
144144
145145 const dirTrimLimit = 4
146− cfg := com.Config()
147− cwd := fsext.DirTrim(fsext.PrettyPath(cfg.WorkingDir()), dirTrimLimit)
146+ cwd := fsext.DirTrim(fsext.PrettyPath(com.Store().WorkingDir()), dirTrimLimit)
148147 cwd = t.Header.WorkingDir.Render(cwd)
149148
150149 result := cwd + metadata
internal/ui/model/landing.go+1−1
@@ -22,7 +22,7 @@ func (m *UI) selectedLargeModel() *agent.Model {
2222func (m *UI) landingView() string {
2323 t := m.com.Styles
2424 width := m.layout.main.Dx()
25− cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
25+ cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width)
2626
2727 parts := []string{
2828 cwd,
internal/ui/model/onboarding.go+5−6
@@ -19,7 +19,7 @@ import (
1919// markProjectInitialized marks the current project as initialized in the config.
2020func (m *UI) markProjectInitialized() tea.Msg {
2121 // TODO: handle error so we show it in the tui footer
22− err := config.MarkProjectInitialized(m.com.Config())
22+ err := config.MarkProjectInitialized(m.com.Store())
2323 if err != nil {
2424 slog.Error(err.Error())
2525 }
@@ -52,10 +52,10 @@ func (m *UI) initializeProject() tea.Cmd {
5252 if cmd := m.newSession(); cmd != nil {
5353 cmds = append(cmds, cmd)
5454 }
55− cfg := m.com.Config()
55+ cfg := m.com.Store()
5656
5757 initialize := func() tea.Msg {
58− initPrompt, err := agent.InitializePrompt(*cfg)
58+ initPrompt, err := agent.InitializePrompt(cfg)
5959 if err != nil {
6060 return util.InfoMsg{Type: util.InfoTypeError, Msg: err.Error()}
6161 }
@@ -77,10 +77,9 @@ func (m *UI) skipInitializeProject() tea.Cmd {
7777
7878// initializeView renders the project initialization prompt with Yes/No buttons.
7979func (m *UI) initializeView() string {
80− cfg := m.com.Config()
8180 s := m.com.Styles.Initialize
82− cwd := home.Short(cfg.WorkingDir())
83− initFile := cfg.Options.InitializeAs
81+ cwd := home.Short(m.com.Store().WorkingDir())
82+ initFile := m.com.Config().Options.InitializeAs
8483
8584 header := s.Header.Render("Would you like to initialize this project?")
8685 path := s.Accent.PaddingLeft(2).Render(cwd)
internal/ui/model/sidebar.go+2−2
@@ -112,7 +112,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
112112 height := area.Dy()
113113
114114 title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
115− cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
115+ cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width)
116116 sidebarLogo := m.sidebarLogo
117117 if height < logoHeightBreakpoint {
118118 sidebarLogo = logo.SmallRender(m.com.Styles, width)
@@ -138,7 +138,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
138138
139139 lspSection := m.lspInfo(width, maxLSPs, true)
140140 mcpSection := m.mcpInfo(width, maxMCPs, true)
141− filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles, true)
141+ filesSection := m.filesInfo(m.com.Store().WorkingDir(), width, maxFiles, true)
142142
143143 uv.NewStyledString(
144144 lipgloss.NewStyle().
internal/ui/model/ui.go+13−13
@@ -317,7 +317,7 @@ func New(com *common.Common) *UI {
317317 desiredFocus := uiFocusEditor
318318 if !com.Config().IsConfigured() {
319319 desiredState = uiOnboarding
320− } else if n, _ := config.ProjectNeedsInitialization(com.Config()); n {
320+ } else if n, _ := config.ProjectNeedsInitialization(com.Store()); n {
321321 desiredState = uiInitialize
322322 }
323323
@@ -579,7 +579,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
579579 case mcp.EventPromptsListChanged:
580580 return m, handleMCPPromptsEvent(msg.Payload.Name)
581581 case mcp.EventToolsListChanged:
582− return m, handleMCPToolsEvent(m.com.Config(), msg.Payload.Name)
582+ return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name)
583583 case mcp.EventResourcesListChanged:
584584 return m, handleMCPResourcesEvent(msg.Payload.Name)
585585 }
@@ -1301,7 +1301,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
13011301
13021302 currentModel := cfg.Models[agentCfg.Model]
13031303 currentModel.Think = !currentModel.Think
1304− if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
1304+ if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
13051305 return util.ReportError(err)()
13061306 }
13071307 m.com.App.UpdateAgentModel(context.TODO())
@@ -1342,7 +1342,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
13421342
13431343 // Attempt to import GitHub Copilot tokens from VSCode if available.
13441344 if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1345− m.com.Config().ImportCopilot()
1345+ m.com.Store().ImportCopilot()
13461346 }
13471347
13481348 if !isConfigured() || msg.ReAuthenticate {
@@ -1353,12 +1353,12 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
13531353 break
13541354 }
13551355
1356− if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
1356+ if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
13571357 cmds = append(cmds, util.ReportError(err))
13581358 } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
13591359 // Ensure small model is set is unset.
13601360 smallModel := m.com.App.GetDefaultSmallModel(providerID)
1361− if err := cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallModel); err != nil {
1361+ if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
13621362 cmds = append(cmds, util.ReportError(err))
13631363 }
13641364 }
@@ -1404,7 +1404,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
14041404
14051405 currentModel := cfg.Models[agentCfg.Model]
14061406 currentModel.ReasoningEffort = msg.Effort
1407− if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
1407+ if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
14081408 cmds = append(cmds, util.ReportError(err))
14091409 break
14101410 }
@@ -2016,7 +2016,7 @@ func (m *UI) View() tea.View {
20162016 }
20172017 v.MouseMode = tea.MouseModeCellMotion
20182018 v.ReportFocus = m.caps.ReportFocusEvents
2019− v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
2019+ v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir())
20202020
20212021 canvas := uv.NewScreenBuffer(m.width, m.height)
20222022 v.Cursor = m.Draw(canvas, canvas.Bounds())
@@ -2255,7 +2255,7 @@ func (m *UI) FullHelp() [][]key.Binding {
22552255func (m *UI) toggleCompactMode() tea.Cmd {
22562256 m.forceCompactMode = !m.forceCompactMode
22572257
2258− err := m.com.Config().SetCompactMode(m.forceCompactMode)
2258+ err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
22592259 if err != nil {
22602260 return util.ReportError(err)
22612261 }
@@ -2637,7 +2637,7 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu
26372637 return func() tea.Msg {
26382638 contents, err := mcp.ReadResource(
26392639 context.Background(),
2640− m.com.Config(),
2640+ m.com.Store(),
26412641 item.MCPName,
26422642 item.URI,
26432643 )
@@ -3299,7 +3299,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
32993299
33003300 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
33013301 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3302− filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3302+ filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
33033303 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
33043304 uv.NewStyledString(
33053305 s.CompactDetails.View.
@@ -3317,7 +3317,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
33173317
33183318func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
33193319 load := func() tea.Msg {
3320− prompt, err := commands.GetMCPPrompt(m.com.Config(), clientID, promptID, arguments)
3320+ prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
33213321 if err != nil {
33223322 // TODO: make this better
33233323 return util.ReportError(err)()
@@ -3358,7 +3358,7 @@ func handleMCPPromptsEvent(name string) tea.Cmd {
33583358 }
33593359}
33603360
3361−func handleMCPToolsEvent(cfg *config.Config, name string) tea.Cmd {
3361+func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
33623362 return func() tea.Msg {
33633363 mcp.RefreshTools(
33643364 context.Background(),