on main help sign in
refactor(config): introduce ConfigStore and Scope for better config m… (#2395)
5ff8d68Ayman Bagabas
internal/agent/agent_tool.go+1−1
@@ -24,7 +24,7 @@ const (
24)
24)
25
25
26func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error) {
26func (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]
28 if !ok {
28 if !ok {
29 return nil, errors.New("task agent not configured")
29 return nil, errors.New("task agent not configured")
30 }
30 }
internal/agent/agentic_fetch_tool.go+5−5
@@ -98,7 +98,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
98 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
98 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
99 }
99 }
100
100
101− tmpDir, err := os.MkdirTemp(c.cfg.Options.DataDirectory, "crush-fetch-*")
101+ tmpDir, err := os.MkdirTemp(c.cfg.Config().Options.DataDirectory, "crush-fetch-*")
102 if err != nil {
102 if err != nil {
103 return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %s", err)), nil
103 return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %s", err)), nil
104 }
104 }
@@ -151,12 +151,12 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
151 return fantasy.ToolResponse{}, fmt.Errorf("error building models: %s", err)
151 return fantasy.ToolResponse{}, fmt.Errorf("error building models: %s", err)
152 }
152 }
153
153
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)
155 if err != nil {
155 if err != nil {
156 return fantasy.ToolResponse{}, fmt.Errorf("error building system prompt: %s", err)
156 return fantasy.ToolResponse{}, fmt.Errorf("error building system prompt: %s", err)
157 }
157 }
158
158
159− smallProviderCfg, ok := c.cfg.Providers.Get(small.ModelCfg.Provider)
159+ smallProviderCfg, ok := c.cfg.Config().Providers.Get(small.ModelCfg.Provider)
160 if !ok {
160 if !ok {
161 return fantasy.ToolResponse{}, errors.New("small model provider not configured")
161 return fantasy.ToolResponse{}, errors.New("small model provider not configured")
162 }
162 }
@@ -167,7 +167,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
167 webFetchTool,
167 webFetchTool,
168 webSearchTool,
168 webSearchTool,
169 tools.NewGlobTool(tmpDir),
169 tools.NewGlobTool(tmpDir),
170− tools.NewGrepTool(tmpDir, c.cfg.Tools.Grep),
170+ tools.NewGrepTool(tmpDir, c.cfg.Config().Tools.Grep),
171 tools.NewSourcegraphTool(client),
171 tools.NewSourcegraphTool(client),
172 tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir),
172 tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir),
173 }
173 }
@@ -177,7 +177,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
177 SmallModel: small,
177 SmallModel: small,
178 SystemPromptPrefix: smallProviderCfg.SystemPromptPrefix,
178 SystemPromptPrefix: smallProviderCfg.SystemPromptPrefix,
179 SystemPrompt: systemPrompt,
179 SystemPrompt: systemPrompt,
180− DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
180+ DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize,
181 IsYolo: c.permissions.SkipRequests(),
181 IsYolo: c.permissions.SkipRequests(),
182 Sessions: c.sessions,
182 Sessions: c.sessions,
183 Messages: c.messages,
183 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
185
185
186 // NOTE(@andreynering): Set a fixed config to ensure cassettes match
186 // NOTE(@andreynering): Set a fixed config to ensure cassettes match
187 // independently of user config on `$HOME/.config/crush/crush.json`.
187 // independently of user config on `$HOME/.config/crush/crush.json`.
188− cfg.Options.Attribution = &config.Attribution{
188+ cfg.Config().Options.Attribution = &config.Attribution{
189 TrailerStyle: "co-authored-by",
189 TrailerStyle: "co-authored-by",
190 GeneratedWith: true,
190 GeneratedWith: true,
191 }
191 }
192
192
193 // Clear some fields to avoid issues with VCR cassette matching.
193 // Clear some fields to avoid issues with VCR cassette matching.
194− cfg.Options.SkillsPaths = nil
194+ cfg.Config().Options.SkillsPaths = nil
195− cfg.Options.ContextPaths = nil
195+ cfg.Config().Options.ContextPaths = nil
196− cfg.LSP = nil
196+ cfg.Config().LSP = nil
197
197
198− systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg)
198+ systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), cfg)
199 if err != nil {
199 if err != nil {
200 return nil, err
200 return nil, err
201 }
201 }
202
202
203 // Get the model name for the bash tool
203 // Get the model name for the bash tool
204 modelName := large.Model() // fallback to ID if Name not available
204 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 {
206 modelName = model.Name
206 modelName = model.Name
207 }
207 }
208
208
209 allTools := []fantasy.AgentTool{
209 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),
211 tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
211 tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
212 tools.NewEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
212 tools.NewEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
213 tools.NewMultiEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
213 tools.NewMultiEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
214 tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
214 tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
215 tools.NewGlobTool(env.workingDir),
215 tools.NewGlobTool(env.workingDir),
216− tools.NewGrepTool(env.workingDir, cfg.Tools.Grep),
216+ tools.NewGrepTool(env.workingDir, cfg.Config().Tools.Grep),
217− tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
217+ tools.NewLsTool(env.permissions, env.workingDir, cfg.Config().Tools.Ls),
218 tools.NewSourcegraphTool(r.GetDefaultClient()),
218 tools.NewSourcegraphTool(r.GetDefaultClient()),
219 tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir),
219 tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir),
220 tools.NewWriteTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
220 tools.NewWriteTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
internal/agent/coordinator.go+35−35
@@ -74,7 +74,7 @@ type Coordinator interface {
74}
74}
75
75
76type coordinator struct {
76type coordinator struct {
77− cfg *config.Config
77+ cfg *config.ConfigStore
78 sessions session.Service
78 sessions session.Service
79 messages message.Service
79 messages message.Service
80 permissions permission.Service
80 permissions permission.Service
@@ -91,7 +91,7 @@ type coordinator struct {
91
91
92func NewCoordinator(
92func NewCoordinator(
93 ctx context.Context,
93 ctx context.Context,
94− cfg *config.Config,
94+ cfg *config.ConfigStore,
95 sessions session.Service,
95 sessions session.Service,
96 messages message.Service,
96 messages message.Service,
97 permissions permission.Service,
97 permissions permission.Service,
@@ -112,7 +112,7 @@ func NewCoordinator(
112 agents: make(map[string]SessionAgent),
112 agents: make(map[string]SessionAgent),
113 }
113 }
114
114
115− agentCfg, ok := cfg.Agents[config.AgentCoder]
115+ agentCfg, ok := cfg.Config().Agents[config.AgentCoder]
116 if !ok {
116 if !ok {
117 return nil, errCoderAgentNotConfigured
117 return nil, errCoderAgentNotConfigured
118 }
118 }
@@ -160,7 +160,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
160 attachments = filteredAttachments
160 attachments = filteredAttachments
161 }
161 }
162
162
163− providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
163+ providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
164 if !ok {
164 if !ok {
165 return nil, errModelProviderNotConfigured
165 return nil, errModelProviderNotConfigured
166 }
166 }
@@ -383,14 +383,14 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
383 return nil, err
383 return nil, err
384 }
384 }
385
385
386− largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
386+ largeProviderCfg, _ := c.cfg.Config().Providers.Get(large.ModelCfg.Provider)
387 result := NewSessionAgent(SessionAgentOptions{
387 result := NewSessionAgent(SessionAgentOptions{
388 LargeModel: large,
388 LargeModel: large,
389 SmallModel: small,
389 SmallModel: small,
390 SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix,
390 SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix,
391 SystemPrompt: "",
391 SystemPrompt: "",
392 IsSubAgent: isSubAgent,
392 IsSubAgent: isSubAgent,
393− DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
393+ DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize,
394 IsYolo: c.permissions.SkipRequests(),
394 IsYolo: c.permissions.SkipRequests(),
395 Sessions: c.sessions,
395 Sessions: c.sessions,
396 Messages: c.messages,
396 Messages: c.messages,
@@ -399,7 +399,7 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
399 })
399 })
400
400
401 c.readyWg.Go(func() error {
401 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)
403 if err != nil {
403 if err != nil {
404 return err
404 return err
405 }
405 }
@@ -439,14 +439,14 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
439
439
440 // Get the model name for the agent
440 // Get the model name for the agent
441 modelName := ""
441 modelName := ""
442− if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
442+ if modelCfg, ok := c.cfg.Config().Models[agent.Model]; ok {
443− if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
443+ if model := c.cfg.Config().GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
444 modelName = model.Name
444 modelName = model.Name
445 }
445 }
446 }
446 }
447
447
448 allTools = append(allTools,
448 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),
450 tools.NewJobOutputTool(),
450 tools.NewJobOutputTool(),
451 tools.NewJobKillTool(),
451 tools.NewJobKillTool(),
452 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
452 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
@@ -454,20 +454,20 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
454 tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
454 tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
455 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
455 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
456 tools.NewGlobTool(c.cfg.WorkingDir()),
456 tools.NewGlobTool(c.cfg.WorkingDir()),
457− tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Tools.Grep),
457+ tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Config().Tools.Grep),
458− tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
458+ tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Tools.Ls),
459 tools.NewSourcegraphTool(nil),
459 tools.NewSourcegraphTool(nil),
460 tools.NewTodosTool(c.sessions),
460 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...),
462 tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
462 tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
463 )
463 )
464
464
465 // Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
465 // 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 {
467 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
467 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
468 }
468 }
469
469
470− if len(c.cfg.MCP) > 0 {
470+ if len(c.cfg.Config().MCP) > 0 {
471 allTools = append(
471 allTools = append(
472 allTools,
472 allTools,
473 tools.NewListMCPResourcesTool(c.cfg, c.permissions),
473 tools.NewListMCPResourcesTool(c.cfg, c.permissions),
@@ -513,16 +513,16 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
513
513
514// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
514// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
515func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
515func (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]
517 if !ok {
517 if !ok {
518 return Model{}, Model{}, errLargeModelNotSelected
518 return Model{}, Model{}, errLargeModelNotSelected
519 }
519 }
520− smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
520+ smallModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeSmall]
521 if !ok {
521 if !ok {
522 return Model{}, Model{}, errSmallModelNotSelected
522 return Model{}, Model{}, errSmallModelNotSelected
523 }
523 }
524
524
525− largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
525+ largeProviderCfg, ok := c.cfg.Config().Providers.Get(largeModelCfg.Provider)
526 if !ok {
526 if !ok {
527 return Model{}, Model{}, errLargeModelProviderNotConfigured
527 return Model{}, Model{}, errLargeModelProviderNotConfigured
528 }
528 }
@@ -532,7 +532,7 @@ func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Mo
532 return Model{}, Model{}, err
532 return Model{}, Model{}, err
533 }
533 }
534
534
535− smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
535+ smallProviderCfg, ok := c.cfg.Config().Providers.Get(smallModelCfg.Provider)
536 if !ok {
536 if !ok {
537 return Model{}, Model{}, errSmallModelProviderNotConfigured
537 return Model{}, Model{}, errSmallModelProviderNotConfigured
538 }
538 }
@@ -620,7 +620,7 @@ func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map
620 opts = append(opts, anthropic.WithBaseURL(baseURL))
620 opts = append(opts, anthropic.WithBaseURL(baseURL))
621 }
621 }
622
622
623− if c.cfg.Options.Debug {
623+ if c.cfg.Config().Options.Debug {
624 httpClient := log.NewHTTPClient()
624 httpClient := log.NewHTTPClient()
625 opts = append(opts, anthropic.WithHTTPClient(httpClient))
625 opts = append(opts, anthropic.WithHTTPClient(httpClient))
626 }
626 }
@@ -632,7 +632,7 @@ func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[st
632 openai.WithAPIKey(apiKey),
632 openai.WithAPIKey(apiKey),
633 openai.WithUseResponsesAPI(),
633 openai.WithUseResponsesAPI(),
634 }
634 }
635− if c.cfg.Options.Debug {
635+ if c.cfg.Config().Options.Debug {
636 httpClient := log.NewHTTPClient()
636 httpClient := log.NewHTTPClient()
637 opts = append(opts, openai.WithHTTPClient(httpClient))
637 opts = append(opts, openai.WithHTTPClient(httpClient))
638 }
638 }
@@ -649,7 +649,7 @@ func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[stri
649 opts := []openrouter.Option{
649 opts := []openrouter.Option{
650 openrouter.WithAPIKey(apiKey),
650 openrouter.WithAPIKey(apiKey),
651 }
651 }
652− if c.cfg.Options.Debug {
652+ if c.cfg.Config().Options.Debug {
653 httpClient := log.NewHTTPClient()
653 httpClient := log.NewHTTPClient()
654 opts = append(opts, openrouter.WithHTTPClient(httpClient))
654 opts = append(opts, openrouter.WithHTTPClient(httpClient))
655 }
655 }
@@ -663,7 +663,7 @@ func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]s
663 opts := []vercel.Option{
663 opts := []vercel.Option{
664 vercel.WithAPIKey(apiKey),
664 vercel.WithAPIKey(apiKey),
665 }
665 }
666− if c.cfg.Options.Debug {
666+ if c.cfg.Config().Options.Debug {
667 httpClient := log.NewHTTPClient()
667 httpClient := log.NewHTTPClient()
668 opts = append(opts, vercel.WithHTTPClient(httpClient))
668 opts = append(opts, vercel.WithHTTPClient(httpClient))
669 }
669 }
@@ -683,8 +683,8 @@ func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers
683 var httpClient *http.Client
683 var httpClient *http.Client
684 if providerID == string(catwalk.InferenceProviderCopilot) {
684 if providerID == string(catwalk.InferenceProviderCopilot) {
685 opts = append(opts, openaicompat.WithUseResponsesAPI())
685 opts = append(opts, openaicompat.WithUseResponsesAPI())
686− httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug)
686+ httpClient = copilot.NewClient(isSubAgent, c.cfg.Config().Options.Debug)
687− } else if c.cfg.Options.Debug {
687+ } else if c.cfg.Config().Options.Debug {
688 httpClient = log.NewHTTPClient()
688 httpClient = log.NewHTTPClient()
689 }
689 }
690 if httpClient != nil {
690 if httpClient != nil {
@@ -708,7 +708,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str
708 azure.WithAPIKey(apiKey),
708 azure.WithAPIKey(apiKey),
709 azure.WithUseResponsesAPI(),
709 azure.WithUseResponsesAPI(),
710 }
710 }
711− if c.cfg.Options.Debug {
711+ if c.cfg.Config().Options.Debug {
712 httpClient := log.NewHTTPClient()
712 httpClient := log.NewHTTPClient()
713 opts = append(opts, azure.WithHTTPClient(httpClient))
713 opts = append(opts, azure.WithHTTPClient(httpClient))
714 }
714 }
@@ -727,7 +727,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str
727
727
728func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
728func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
729 var opts []bedrock.Option
729 var opts []bedrock.Option
730− if c.cfg.Options.Debug {
730+ if c.cfg.Config().Options.Debug {
731 httpClient := log.NewHTTPClient()
731 httpClient := log.NewHTTPClient()
732 opts = append(opts, bedrock.WithHTTPClient(httpClient))
732 opts = append(opts, bedrock.WithHTTPClient(httpClient))
733 }
733 }
@@ -746,7 +746,7 @@ func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[st
746 google.WithBaseURL(baseURL),
746 google.WithBaseURL(baseURL),
747 google.WithGeminiAPIKey(apiKey),
747 google.WithGeminiAPIKey(apiKey),
748 }
748 }
749− if c.cfg.Options.Debug {
749+ if c.cfg.Config().Options.Debug {
750 httpClient := log.NewHTTPClient()
750 httpClient := log.NewHTTPClient()
751 opts = append(opts, google.WithHTTPClient(httpClient))
751 opts = append(opts, google.WithHTTPClient(httpClient))
752 }
752 }
@@ -758,7 +758,7 @@ func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[st
758
758
759func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
759func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
760 opts := []google.Option{}
760 opts := []google.Option{}
761− if c.cfg.Options.Debug {
761+ if c.cfg.Config().Options.Debug {
762 httpClient := log.NewHTTPClient()
762 httpClient := log.NewHTTPClient()
763 opts = append(opts, google.WithHTTPClient(httpClient))
763 opts = append(opts, google.WithHTTPClient(httpClient))
764 }
764 }
@@ -779,7 +779,7 @@ func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provid
779 hyper.WithBaseURL(baseURL),
779 hyper.WithBaseURL(baseURL),
780 hyper.WithAPIKey(apiKey),
780 hyper.WithAPIKey(apiKey),
781 }
781 }
782− if c.cfg.Options.Debug {
782+ if c.cfg.Config().Options.Debug {
783 httpClient := log.NewHTTPClient()
783 httpClient := log.NewHTTPClient()
784 opts = append(opts, hyper.WithHTTPClient(httpClient))
784 opts = append(opts, hyper.WithHTTPClient(httpClient))
785 }
785 }
@@ -887,7 +887,7 @@ func (c *coordinator) UpdateModels(ctx context.Context) error {
887 }
887 }
888 c.currentAgent.SetModels(large, small)
888 c.currentAgent.SetModels(large, small)
889
889
890− agentCfg, ok := c.cfg.Agents[config.AgentCoder]
890+ agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
891 if !ok {
891 if !ok {
892 return errCoderAgentNotConfigured
892 return errCoderAgentNotConfigured
893 }
893 }
@@ -909,7 +909,7 @@ func (c *coordinator) QueuedPromptsList(sessionID string) []string {
909}
909}
910
910
911func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
911func (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)
913 if !ok {
913 if !ok {
914 return errModelProviderNotConfigured
914 return errModelProviderNotConfigured
915 }
915 }
@@ -922,7 +922,7 @@ func (c *coordinator) isUnauthorized(err error) bool {
922}
922}
923
923
924func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
924func (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 {
926 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
926 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
927 return err
927 return err
928 }
928 }
@@ -940,7 +940,7 @@ func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg con
940 }
940 }
941
941
942 providerCfg.APIKey = newAPIKey
942 providerCfg.APIKey = newAPIKey
943− c.cfg.Providers.Set(providerCfg.ID, providerCfg)
943+ c.cfg.Config().Providers.Set(providerCfg.ID, providerCfg)
944
944
945 if err := c.UpdateModels(ctx); err != nil {
945 if err := c.UpdateModels(ctx); err != nil {
946 return err
946 return err
@@ -984,7 +984,7 @@ func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (f
984 maxTokens = model.ModelCfg.MaxTokens
984 maxTokens = model.ModelCfg.MaxTokens
985 }
985 }
986
986
987− providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
987+ providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
988 if !ok {
988 if !ok {
989 return fantasy.ToolResponse{}, errModelProviderNotConfigured
989 return fantasy.ToolResponse{}, errModelProviderNotConfigured
990 }
990 }
internal/agent/coordinator_test.go+1−1
@@ -44,7 +44,7 @@ func (m *mockSessionAgent) Summarize(context.Context, string, fantasy.ProviderOp
44func newTestCoordinator(t *testing.T, env fakeEnv, providerID string, providerCfg config.ProviderConfig) *coordinator {
44func newTestCoordinator(t *testing.T, env fakeEnv, providerID string, providerCfg config.ProviderConfig) *coordinator {
45 cfg, err := config.Init(env.workingDir, "", false)
45 cfg, err := config.Init(env.workingDir, "", false)
46 require.NoError(t, err)
46 require.NoError(t, err)
47− cfg.Providers.Set(providerID, providerCfg)
47+ cfg.Config().Providers.Set(providerID, providerCfg)
48 return &coordinator{
48 return &coordinator{
49 cfg: cfg,
49 cfg: cfg,
50 sessions: env.sessions,
50 sessions: env.sessions,
internal/agent/prompt/prompt.go+15−14
@@ -76,13 +76,13 @@ func NewPrompt(name, promptTemplate string, opts ...Option) (*Prompt, error) {
76 return p, nil
76 return p, nil
77}
77}
78
78
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) {
80 t, err := template.New(p.name).Parse(p.template)
80 t, err := template.New(p.name).Parse(p.template)
81 if err != nil {
81 if err != nil {
82 return "", fmt.Errorf("parsing template: %w", err)
82 return "", fmt.Errorf("parsing template: %w", err)
83 }
83 }
84 var sb strings.Builder
84 var sb strings.Builder
85− d, err := p.promptData(ctx, provider, model, cfg)
85+ d, err := p.promptData(ctx, provider, model, store)
86 if err != nil {
86 if err != nil {
87 return "", err
87 return "", err
88 }
88 }
@@ -104,11 +104,11 @@ func processFile(filePath string) *ContextFile {
104 }
104 }
105}
105}
106
106
107−func processContextPath(p string, cfg config.Config) []ContextFile {
107+func processContextPath(p string, store *config.ConfigStore) []ContextFile {
108 var contexts []ContextFile
108 var contexts []ContextFile
109 fullPath := p
109 fullPath := p
110 if !filepath.IsAbs(p) {
110 if !filepath.IsAbs(p) {
111− fullPath = filepath.Join(cfg.WorkingDir(), p)
111+ fullPath = filepath.Join(store.WorkingDir(), p)
112 }
112 }
113 info, err := os.Stat(fullPath)
113 info, err := os.Stat(fullPath)
114 if err != nil {
114 if err != nil {
@@ -136,11 +136,11 @@ func processContextPath(p string, cfg config.Config) []ContextFile {
136}
136}
137
137
138// expandPath expands ~ and environment variables in file paths
138// 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 {
140 path = home.Long(path)
140 path = home.Long(path)
141 // Handle environment variable expansion using the same pattern as config
141 // Handle environment variable expansion using the same pattern as config
142 if strings.HasPrefix(path, "$") {
142 if strings.HasPrefix(path, "$") {
143− if expanded, err := cfg.Resolver().ResolveValue(path); err == nil {
143+ if expanded, err := store.Resolver().ResolveValue(path); err == nil {
144 path = expanded
144 path = expanded
145 }
145 }
146 }
146 }
@@ -148,19 +148,20 @@ func expandPath(path string, cfg config.Config) string {
148 return path
148 return path
149}
149}
150
150
151−func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg config.Config) (PromptDat, error) {
151+func (p *Prompt) promptData(ctx context.Context, provider, model string, store *config.ConfigStore) (PromptDat, error) {
152− workingDir := cmp.Or(p.workingDir, cfg.WorkingDir())
152+ workingDir := cmp.Or(p.workingDir, store.WorkingDir())
153 platform := cmp.Or(p.platform, runtime.GOOS)
153 platform := cmp.Or(p.platform, runtime.GOOS)
154
154
155 files := map[string][]ContextFile{}
155 files := map[string][]ContextFile{}
156
156
157+ cfg := store.Config()
157 for _, pth := range cfg.Options.ContextPaths {
158 for _, pth := range cfg.Options.ContextPaths {
158− expanded := expandPath(pth, cfg)
159+ expanded := expandPath(pth, store)
159 pathKey := strings.ToLower(expanded)
160 pathKey := strings.ToLower(expanded)
160 if _, ok := files[pathKey]; ok {
161 if _, ok := files[pathKey]; ok {
161 continue
162 continue
162 }
163 }
163− content := processContextPath(expanded, cfg)
164+ content := processContextPath(expanded, store)
164 files[pathKey] = content
165 files[pathKey] = content
165 }
166 }
166
167
@@ -169,18 +170,18 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con
169 if len(cfg.Options.SkillsPaths) > 0 {
170 if len(cfg.Options.SkillsPaths) > 0 {
170 expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths))
171 expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths))
171 for _, pth := range cfg.Options.SkillsPaths {
172 for _, pth := range cfg.Options.SkillsPaths {
172− expandedPaths = append(expandedPaths, expandPath(pth, cfg))
173+ expandedPaths = append(expandedPaths, expandPath(pth, store))
173 }
174 }
174 if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 {
175 if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 {
175 availSkillXML = skills.ToPromptXML(discoveredSkills)
176 availSkillXML = skills.ToPromptXML(discoveredSkills)
176 }
177 }
177 }
178 }
178
179
179− isGit := isGitRepo(cfg.WorkingDir())
180+ isGit := isGitRepo(store.WorkingDir())
180 data := PromptDat{
181 data := PromptDat{
181 Provider: provider,
182 Provider: provider,
182 Model: model,
183 Model: model,
183− Config: cfg,
184+ Config: *cfg,
184 WorkingDir: filepath.ToSlash(workingDir),
185 WorkingDir: filepath.ToSlash(workingDir),
185 IsGitRepo: isGit,
186 IsGitRepo: isGit,
186 Platform: platform,
187 Platform: platform,
@@ -189,7 +190,7 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con
189 }
190 }
190 if isGit {
191 if isGit {
191 var err error
192 var err error
192− data.GitStatus, err = getGitStatus(ctx, cfg.WorkingDir())
193+ data.GitStatus, err = getGitStatus(ctx, store.WorkingDir())
193 if err != nil {
194 if err != nil {
194 return PromptDat{}, err
195 return PromptDat{}, err
195 }
196 }
internal/agent/prompts.go+1−1
@@ -33,7 +33,7 @@ func taskPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
33 return systemPrompt, nil
33 return systemPrompt, nil
34}
34}
35
35
36−func InitializePrompt(cfg config.Config) (string, error) {
36+func InitializePrompt(cfg *config.ConfigStore) (string, error) {
37 systemPrompt, err := prompt.NewPrompt("initialize", string(initializePromptTmpl))
37 systemPrompt, err := prompt.NewPrompt("initialize", string(initializePromptTmpl))
38 if err != nil {
38 if err != nil {
39 return "", err
39 return "", err
internal/agent/tools/list_mcp_resources.go+1−1
@@ -28,7 +28,7 @@ const ListMCPResourcesToolName = "list_mcp_resources"
28//go:embed list_mcp_resources.md
28//go:embed list_mcp_resources.md
29var listMCPResourcesDescription []byte
29var listMCPResourcesDescription []byte
30
30
31−func NewListMCPResourcesTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool {
31+func NewListMCPResourcesTool(cfg *config.ConfigStore, permissions permission.Service) fantasy.AgentTool {
32 return fantasy.NewParallelAgentTool(
32 return fantasy.NewParallelAgentTool(
33 ListMCPResourcesToolName,
33 ListMCPResourcesToolName,
34 string(listMCPResourcesDescription),
34 string(listMCPResourcesDescription),
internal/agent/tools/mcp-tools.go+2−2
@@ -11,7 +11,7 @@ import (
11)
11)
12
12
13// GetMCPTools gets all the currently available MCP tools.
13// 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 {
15 var result []*Tool
15 var result []*Tool
16 for mcpName, tools := range mcp.Tools() {
16 for mcpName, tools := range mcp.Tools() {
17 for _, tool := range tools {
17 for _, tool := range tools {
@@ -31,7 +31,7 @@ func GetMCPTools(permissions permission.Service, cfg *config.Config, wd string)
31type Tool struct {
31type Tool struct {
32 mcpName string
32 mcpName string
33 tool *mcp.Tool
33 tool *mcp.Tool
34− cfg *config.Config
34+ cfg *config.ConfigStore
35 permissions permission.Service
35 permissions permission.Service
36 workingDir string
36 workingDir string
37 providerOptions fantasy.ProviderOptions
37 providerOptions fantasy.ProviderOptions
internal/agent/tools/mcp/init.go+4−4
@@ -163,11 +163,11 @@ func Close(ctx context.Context) error {
163}
163}
164
164
165// Initialize initializes MCP clients based on the provided configuration.
165// 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) {
167 slog.Info("Initializing MCP clients")
167 slog.Info("Initializing MCP clients")
168 var wg sync.WaitGroup
168 var wg sync.WaitGroup
169 // Initialize states for all configured MCPs
169 // Initialize states for all configured MCPs
170− for name, m := range cfg.MCP {
170+ for name, m := range cfg.Config().MCP {
171 if m.Disabled {
171 if m.Disabled {
172 updateState(name, StateDisabled, nil, nil, Counts{})
172 updateState(name, StateDisabled, nil, nil, Counts{})
173 slog.Debug("Skipping disabled MCP", "name", name)
173 slog.Debug("Skipping disabled MCP", "name", name)
@@ -253,13 +253,13 @@ func WaitForInit(ctx context.Context) error {
253 }
253 }
254}
254}
255
255
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) {
257 sess, ok := sessions.Get(name)
257 sess, ok := sessions.Get(name)
258 if !ok {
258 if !ok {
259 return nil, fmt.Errorf("mcp '%s' not available", name)
259 return nil, fmt.Errorf("mcp '%s' not available", name)
260 }
260 }
261
261
262− m := cfg.MCP[name]
262+ m := cfg.Config().MCP[name]
263 state, _ := states.Get(name)
263 state, _ := states.Get(name)
264
264
265 timeout := mcpTimeout(m)
265 timeout := mcpTimeout(m)
internal/agent/tools/mcp/prompts.go+1−1
@@ -20,7 +20,7 @@ func Prompts() iter.Seq2[string, []*Prompt] {
20}
20}
21
21
22// GetPromptMessages retrieves the content of an MCP prompt with the given arguments.
22// 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) {
24 c, err := getOrRenewClient(ctx, cfg, clientName)
24 c, err := getOrRenewClient(ctx, cfg, clientName)
25 if err != nil {
25 if err != nil {
26 return nil, err
26 return nil, err
internal/agent/tools/mcp/resources.go+2−2
@@ -24,7 +24,7 @@ func Resources() iter.Seq2[string, []*Resource] {
24}
24}
25
25
26// ListResources returns the current resources for an MCP server.
26// 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) {
28 session, err := getOrRenewClient(ctx, cfg, name)
28 session, err := getOrRenewClient(ctx, cfg, name)
29 if err != nil {
29 if err != nil {
30 return nil, err
30 return nil, err
@@ -43,7 +43,7 @@ func ListResources(ctx context.Context, cfg *config.Config, name string) ([]*Res
43}
43}
44
44
45// ReadResource reads the contents of a resource from an MCP server.
45// 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) {
47 session, err := getOrRenewClient(ctx, cfg, name)
47 session, err := getOrRenewClient(ctx, cfg, name)
48 if err != nil {
48 if err != nil {
49 return nil, err
49 return nil, err
internal/agent/tools/mcp/tools.go+5−5
@@ -32,7 +32,7 @@ func Tools() iter.Seq2[string, []*Tool] {
32}
32}
33
33
34// RunTool runs an MCP tool with the given input parameters.
34// 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) {
36 var args map[string]any
36 var args map[string]any
37 if err := json.Unmarshal([]byte(input), &args); err != nil {
37 if err := json.Unmarshal([]byte(input), &args); err != nil {
38 return ToolResult{}, fmt.Errorf("error parsing parameters: %s", err)
38 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
108
108
109// RefreshTools gets the updated list of tools from the MCP and updates the
109// RefreshTools gets the updated list of tools from the MCP and updates the
110// global state.
110// global state.
111−func RefreshTools(ctx context.Context, cfg *config.Config, name string) {
111+func RefreshTools(ctx context.Context, cfg *config.ConfigStore, name string) {
112 session, ok := sessions.Get(name)
112 session, ok := sessions.Get(name)
113 if !ok {
113 if !ok {
114 slog.Warn("Refresh tools: no session", "name", name)
114 slog.Warn("Refresh tools: no session", "name", name)
@@ -139,7 +139,7 @@ func getTools(ctx context.Context, session *ClientSession) ([]*Tool, error) {
139 return result.Tools, nil
139 return result.Tools, nil
140}
140}
141
141
142−func updateTools(cfg *config.Config, name string, tools []*Tool) int {
142+func updateTools(cfg *config.ConfigStore, name string, tools []*Tool) int {
143 tools = filterDisabledTools(cfg, name, tools)
143 tools = filterDisabledTools(cfg, name, tools)
144 if len(tools) == 0 {
144 if len(tools) == 0 {
145 allTools.Del(name)
145 allTools.Del(name)
@@ -150,8 +150,8 @@ func updateTools(cfg *config.Config, name string, tools []*Tool) int {
150}
150}
151
151
152// filterDisabledTools removes tools that are disabled via config.
152// filterDisabledTools removes tools that are disabled via config.
153−func filterDisabledTools(cfg *config.Config, mcpName string, tools []*Tool) []*Tool {
153+func filterDisabledTools(cfg *config.ConfigStore, mcpName string, tools []*Tool) []*Tool {
154− mcpCfg, ok := cfg.MCP[mcpName]
154+ mcpCfg, ok := cfg.Config().MCP[mcpName]
155 if !ok || len(mcpCfg.DisabledTools) == 0 {
155 if !ok || len(mcpCfg.DisabledTools) == 0 {
156 return tools
156 return tools
157 }
157 }
internal/agent/tools/read_mcp_resource.go+1−1
@@ -30,7 +30,7 @@ const ReadMCPResourceToolName = "read_mcp_resource"
30//go:embed read_mcp_resource.md
30//go:embed read_mcp_resource.md
31var readMCPResourceDescription []byte
31var readMCPResourceDescription []byte
32
32
33−func NewReadMCPResourceTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool {
33+func NewReadMCPResourceTool(cfg *config.ConfigStore, permissions permission.Service) fantasy.AgentTool {
34 return fantasy.NewParallelAgentTool(
34 return fantasy.NewParallelAgentTool(
35 ReadMCPResourceToolName,
35 ReadMCPResourceToolName,
36 string(readMCPResourceDescription),
36 string(readMCPResourceDescription),
internal/app/app.go+20−14
@@ -61,7 +61,7 @@ type App struct {
61
61
62 LSPManager *lsp.Manager
62 LSPManager *lsp.Manager
63
63
64− config *config.Config
64+ config *config.ConfigStore
65
65
66 serviceEventsWG *sync.WaitGroup
66 serviceEventsWG *sync.WaitGroup
67 eventsCtx context.Context
67 eventsCtx context.Context
@@ -75,11 +75,12 @@ type App struct {
75}
75}
76
76
77// New initializes a new application instance.
77// 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) {
79 q := db.New(conn)
79 q := db.New(conn)
80 sessions := session.NewService(q, conn)
80 sessions := session.NewService(q, conn)
81 messages := message.NewService(q)
81 messages := message.NewService(q)
82 files := history.NewService(q, conn)
82 files := history.NewService(q, conn)
83+ cfg := store.Config()
83 skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
84 skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
84 var allowedTools []string
85 var allowedTools []string
85 if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
86 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) {
90 Sessions: sessions,
91 Sessions: sessions,
91 Messages: messages,
92 Messages: messages,
92 History: files,
93 History: files,
93− Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
94+ Permissions: permission.NewPermissionService(store.WorkingDir(), skipPermissionsRequests, allowedTools),
94 FileTracker: filetracker.NewService(q),
95 FileTracker: filetracker.NewService(q),
95− LSPManager: lsp.NewManager(cfg),
96+ LSPManager: lsp.NewManager(store),
96
97
97 globalCtx: ctx,
98 globalCtx: ctx,
98
99
99− config: cfg,
100+ config: store,
100
101
101 events: make(chan tea.Msg, 100),
102 events: make(chan tea.Msg, 100),
102 serviceEventsWG: &sync.WaitGroup{},
103 serviceEventsWG: &sync.WaitGroup{},
@@ -109,7 +110,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
109 // Check for updates in the background.
110 // Check for updates in the background.
110 go app.checkForUpdates(ctx)
111 go app.checkForUpdates(ctx)
111
112
112− go mcp.Initialize(ctx, app.Permissions, cfg)
113+ go mcp.Initialize(ctx, app.Permissions, store)
113
114
114 // cleanup database upon app shutdown
115 // cleanup database upon app shutdown
115 app.cleanupFuncs = append(
116 app.cleanupFuncs = append(
@@ -141,8 +142,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
141 return app, nil
142 return app, nil
142}
143}
143
144
144−// Config returns the application configuration.
145+// Config returns the pure-data configuration.
145func (app *App) Config() *config.Config {
146func (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 {
146 return app.config
152 return app.config
147}
153}
148
154
@@ -178,7 +184,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
178 }
184 }
179 stderrTTY = term.IsTerminal(os.Stderr.Fd())
185 stderrTTY = term.IsTerminal(os.Stderr.Fd())
180 stdinTTY = term.IsTerminal(os.Stdin.Fd())
186 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
182
188
183 if !hideSpinner && stderrTTY {
189 if !hideSpinner && stderrTTY {
184 t := styles.DefaultStyles()
190 t := styles.DefaultStyles()
@@ -331,7 +337,7 @@ func (app *App) UpdateAgentModel(ctx context.Context) error {
331// If largeModel is provided but smallModel is not, the small model defaults to
337// If largeModel is provided but smallModel is not, the small model defaults to
332// the provider's default small model.
338// the provider's default small model.
333func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, smallModel string) error {
339func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, smallModel string) error {
334− providers := app.config.Providers.Copy()
340+ providers := app.config.Config().Providers.Copy()
335
341
336 largeMatches, smallMatches, err := findModels(providers, largeModel, smallModel)
342 largeMatches, smallMatches, err := findModels(providers, largeModel, smallModel)
337 if err != nil {
343 if err != nil {
@@ -348,7 +354,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
348 }
354 }
349 largeProviderID = found.provider
355 largeProviderID = found.provider
350 slog.Info("Overriding large model for non-interactive run", "provider", found.provider, "model", found.modelID)
356 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{
352 Provider: found.provider,
358 Provider: found.provider,
353 Model: found.modelID,
359 Model: found.modelID,
354 }
360 }
@@ -362,7 +368,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
362 return err
368 return err
363 }
369 }
364 slog.Info("Overriding small model for non-interactive run", "provider", found.provider, "model", found.modelID)
370 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{
366 Provider: found.provider,
372 Provider: found.provider,
367 Model: found.modelID,
373 Model: found.modelID,
368 }
374 }
@@ -370,7 +376,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
370 case largeModel != "":
376 case largeModel != "":
371 // No small model specified, but large model was - use provider's default.
377 // No small model specified, but large model was - use provider's default.
372 smallCfg := app.GetDefaultSmallModel(largeProviderID)
378 smallCfg := app.GetDefaultSmallModel(largeProviderID)
373− app.config.Models[config.SelectedModelTypeSmall] = smallCfg
379+ app.config.Config().Models[config.SelectedModelTypeSmall] = smallCfg
374 }
380 }
375
381
376 return app.AgentCoordinator.UpdateModels(ctx)
382 return app.AgentCoordinator.UpdateModels(ctx)
@@ -379,7 +385,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
379// GetDefaultSmallModel returns the default small model for the given
385// GetDefaultSmallModel returns the default small model for the given
380// provider. Falls back to the large model if no default is found.
386// provider. Falls back to the large model if no default is found.
381func (app *App) GetDefaultSmallModel(providerID string) config.SelectedModel {
387func (app *App) GetDefaultSmallModel(providerID string) config.SelectedModel {
382− cfg := app.config
388+ cfg := app.config.Config()
383 largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
389 largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
384
390
385 // Find the provider in the known providers list to get its default small model.
391 // Find the provider in the known providers list to get its default small model.
@@ -481,7 +487,7 @@ func setupSubscriber[T any](
481}
487}
482
488
483func (app *App) InitCoderAgent(ctx context.Context) error {
489func (app *App) InitCoderAgent(ctx context.Context) error {
484− coderAgentCfg := app.config.Agents[config.AgentCoder]
490+ coderAgentCfg := app.config.Config().Agents[config.AgentCoder]
485 if coderAgentCfg.ID == "" {
491 if coderAgentCfg.ID == "" {
486 return fmt.Errorf("coder agent configuration is missing")
492 return fmt.Errorf("coder agent configuration is missing")
487 }
493 }
internal/cmd/login.go+9−9
@@ -52,16 +52,16 @@ crush login copilot
52 }
52 }
53 switch provider {
53 switch provider {
54 case "hyper":
54 case "hyper":
55− return loginHyper(app.Config())
55+ return loginHyper(app.Store())
56 case "copilot", "github", "github-copilot":
56 case "copilot", "github", "github-copilot":
57− return loginCopilot(app.Config())
57+ return loginCopilot(app.Store())
58 default:
58 default:
59 return fmt.Errorf("unknown platform: %s", args[0])
59 return fmt.Errorf("unknown platform: %s", args[0])
60 }
60 }
61 },
61 },
62}
62}
63
63
64−func loginHyper(cfg *config.Config) error {
64+func loginHyper(cfg *config.ConfigStore) error {
65 if !hyperp.Enabled() {
65 if !hyperp.Enabled() {
66 return fmt.Errorf("hyper not enabled")
66 return fmt.Errorf("hyper not enabled")
67 }
67 }
@@ -112,8 +112,8 @@ func loginHyper(cfg *config.Config) error {
112 }
112 }
113
113
114 if err := cmp.Or(
114 if err := cmp.Or(
115− cfg.SetConfigField("providers.hyper.api_key", token.AccessToken),
115+ cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken),
116− cfg.SetConfigField("providers.hyper.oauth", token),
116+ cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.oauth", token),
117 ); err != nil {
117 ); err != nil {
118 return err
118 return err
119 }
119 }
@@ -123,10 +123,10 @@ func loginHyper(cfg *config.Config) error {
123 return nil
123 return nil
124}
124}
125
125
126−func loginCopilot(cfg *config.Config) error {
126+func loginCopilot(cfg *config.ConfigStore) error {
127 ctx := getLoginContext()
127 ctx := getLoginContext()
128
128
129− if cfg.HasConfigField("providers.copilot.oauth") {
129+ if cfg.HasConfigField(config.ScopeGlobal, "providers.copilot.oauth") {
130 fmt.Println("You are already logged in to GitHub Copilot.")
130 fmt.Println("You are already logged in to GitHub Copilot.")
131 return nil
131 return nil
132 }
132 }
@@ -177,8 +177,8 @@ func loginCopilot(cfg *config.Config) error {
177 }
177 }
178
178
179 if err := cmp.Or(
179 if err := cmp.Or(
180− cfg.SetConfigField("providers.copilot.api_key", token.AccessToken),
180+ cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
181− cfg.SetConfigField("providers.copilot.oauth", token),
181+ cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.oauth", token),
182 ); err != nil {
182 ); err != nil {
183 return err
183 return err
184 }
184 }
internal/cmd/logs.go+1−1
@@ -55,7 +55,7 @@ var logsCmd = &cobra.Command{
55 if err != nil {
55 if err != nil {
56 return fmt.Errorf("failed to load configuration: %v", err)
56 return fmt.Errorf("failed to load configuration: %v", err)
57 }
57 }
58− logsFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
58+ logsFile := filepath.Join(cfg.Config().Options.DataDirectory, "logs", "crush.log")
59 _, err = os.Stat(logsFile)
59 _, err = os.Stat(logsFile)
60 if os.IsNotExist(err) {
60 if os.IsNotExist(err) {
61 log.Warn("Looks like you are not in a crush project. No logs found.")
61 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`,
38 return err
38 return err
39 }
39 }
40
40
41− if !cfg.IsConfigured() {
41+ if !cfg.Config().IsConfigured() {
42 return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
42 return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
43 }
43 }
44
44
@@ -55,7 +55,7 @@ crush models gpt5`,
55 var providerIDs []string
55 var providerIDs []string
56 providerModels := make(map[string][]string)
56 providerModels := make(map[string][]string)
57
57
58− for providerID, provider := range cfg.Providers.Seq2() {
58+ for providerID, provider := range cfg.Config().Providers.Seq2() {
59 if provider.Disable {
59 if provider.Disable {
60 continue
60 continue
61 }
61 }
internal/cmd/root.go+3−2
@@ -189,11 +189,12 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
189 return nil, err
189 return nil, err
190 }
190 }
191
191
192− cfg, err := config.Init(cwd, dataDir, debug)
192+ store, err := config.Init(cwd, dataDir, debug)
193 if err != nil {
193 if err != nil {
194 return nil, err
194 return nil, err
195 }
195 }
196
196
197+ cfg := store.Config()
197 if cfg.Permissions == nil {
198 if cfg.Permissions == nil {
198 cfg.Permissions = &config.Permissions{}
199 cfg.Permissions = &config.Permissions{}
199 }
200 }
@@ -215,7 +216,7 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
215 return nil, err
216 return nil, err
216 }
217 }
217
218
218− appInstance, err := app.New(ctx, conn, cfg)
219+ appInstance, err := app.New(ctx, conn, store)
219 if err != nil {
220 if err != nil {
220 slog.Error("Failed to create app instance", "error", err)
221 slog.Error("Failed to create app instance", "error", err)
221 return nil, err
222 return nil, err
internal/cmd/stats.go+1−1
@@ -131,7 +131,7 @@ func runStats(cmd *cobra.Command, _ []string) error {
131 if err != nil {
131 if err != nil {
132 return fmt.Errorf("failed to initialize config: %w", err)
132 return fmt.Errorf("failed to initialize config: %w", err)
133 }
133 }
134− dataDir = cfg.Options.DataDirectory
134+ dataDir = cfg.Config().Options.DataDirectory
135 }
135 }
136
136
137 conn, err := db.Connect(ctx, dataDir)
137 conn, err := db.Connect(ctx, dataDir)
internal/commands/commands.go+1−1
@@ -227,7 +227,7 @@ func isMarkdownFile(name string) bool {
227 return strings.HasSuffix(strings.ToLower(name), ".md")
227 return strings.HasSuffix(strings.ToLower(name), ".md")
228}
228}
229
229
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) {
231 // TODO: we should pass the context down
231 // TODO: we should pass the context down
232 result, err := mcp.GetPromptMessages(context.Background(), cfg, clientID, promptID, args)
232 result, err := mcp.GetPromptMessages(context.Background(), cfg, clientID, promptID, args)
233 if err != nil {
233 if err != nil {
internal/config/config.go−248
@@ -8,22 +8,16 @@ import (
8 "maps"
8 "maps"
9 "net/http"
9 "net/http"
10 "net/url"
10 "net/url"
11− "os"
12− "path/filepath"
13 "slices"
11 "slices"
14 "strings"
12 "strings"
15 "time"
13 "time"
16
14
17 "charm.land/catwalk/pkg/catwalk"
15 "charm.land/catwalk/pkg/catwalk"
18− hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
19 "github.com/charmbracelet/crush/internal/csync"
16 "github.com/charmbracelet/crush/internal/csync"
20 "github.com/charmbracelet/crush/internal/env"
17 "github.com/charmbracelet/crush/internal/env"
21 "github.com/charmbracelet/crush/internal/oauth"
18 "github.com/charmbracelet/crush/internal/oauth"
22 "github.com/charmbracelet/crush/internal/oauth/copilot"
19 "github.com/charmbracelet/crush/internal/oauth/copilot"
23− "github.com/charmbracelet/crush/internal/oauth/hyper"
24 "github.com/invopop/jsonschema"
20 "github.com/invopop/jsonschema"
25− "github.com/tidwall/gjson"
26− "github.com/tidwall/sjson"
27)
21)
28
22
29const (
23const (
@@ -398,17 +392,6 @@ type Config struct {
398 Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
392 Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
399
393
400 Agents map[string]Agent `json:"-"`
394 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
412}
395}
413
396
414func (c *Config) EnabledProviders() []ProviderConfig {
397func (c *Config) EnabledProviders() []ProviderConfig {
@@ -472,235 +455,8 @@ func (c *Config) SmallModel() *catwalk.Model {
472 return c.GetModel(model.Provider, model.Model)
455 return c.GetModel(model.Provider, model.Model)
473}
456}
474
457
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−
661const maxRecentModelsPerType = 5
458const maxRecentModelsPerType = 5
662
459
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−
704func allToolNames() []string {
460func allToolNames() []string {
705 return []string{
461 return []string{
706 "agent",
462 "agent",
@@ -780,10 +536,6 @@ func (c *Config) SetupAgents() {
780 c.Agents = agents
536 c.Agents = agents
781}
537}
782
538
783−func (c *Config) Resolver() VariableResolver {
784− return c.resolver
785−}
786−
787func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
539func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
788 var (
540 var (
789 providerID = catwalk.InferenceProvider(c.ID)
541 providerID = catwalk.InferenceProvider(c.ID)
internal/config/copilot.go−47
@@ -1,48 +1 @@
1package config
1package 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 {
18 Initialized bool `json:"initialized"`
18 Initialized bool `json:"initialized"`
19}
19}
20
20
21−func Init(workingDir, dataDir string, debug bool) (*Config, error) {
21+func Init(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
22− cfg, err := Load(workingDir, dataDir, debug)
22+ store, err := Load(workingDir, dataDir, debug)
23 if err != nil {
23 if err != nil {
24 return nil, err
24 return nil, err
25 }
25 }
26− return cfg, nil
26+ return store, nil
27}
27}
28
28
29−func ProjectNeedsInitialization(cfg *Config) (bool, error) {
29+func ProjectNeedsInitialization(store *ConfigStore) (bool, error) {
30− if cfg == nil {
30+ if store == nil {
31 return false, fmt.Errorf("config not loaded")
31 return false, fmt.Errorf("config not loaded")
32 }
32 }
33
33
34+ cfg := store.Config()
34 flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
35 flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
35
36
36 _, err := os.Stat(flagFilePath)
37 _, err := os.Stat(flagFilePath)
@@ -42,7 +43,7 @@ func ProjectNeedsInitialization(cfg *Config) (bool, error) {
42 return false, fmt.Errorf("failed to check init flag file: %w", err)
43 return false, fmt.Errorf("failed to check init flag file: %w", err)
43 }
44 }
44
45
45− someContextFileExists, err := contextPathsExist(cfg.WorkingDir())
46+ someContextFileExists, err := contextPathsExist(store.WorkingDir())
46 if err != nil {
47 if err != nil {
47 return false, fmt.Errorf("failed to check for context files: %w", err)
48 return false, fmt.Errorf("failed to check for context files: %w", err)
48 }
49 }
@@ -51,7 +52,7 @@ func ProjectNeedsInitialization(cfg *Config) (bool, error) {
51 }
52 }
52
53
53 // If the working directory has no non-ignored files, skip initialization step
54 // If the working directory has no non-ignored files, skip initialization step
54− empty, err := dirHasNoVisibleFiles(cfg.WorkingDir())
55+ empty, err := dirHasNoVisibleFiles(store.WorkingDir())
55 if err != nil {
56 if err != nil {
56 return false, fmt.Errorf("failed to check if directory is empty: %w", err)
57 return false, fmt.Errorf("failed to check if directory is empty: %w", err)
57 }
58 }
@@ -90,7 +91,7 @@ func contextPathsExist(dir string) (bool, error) {
90 return false, nil
91 return false, nil
91}
92}
92
93
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.
94func dirHasNoVisibleFiles(dir string) (bool, error) {
95func dirHasNoVisibleFiles(dir string) (bool, error) {
95 files, _, err := fsext.ListDirectory(dir, nil, 1, 1)
96 files, _, err := fsext.ListDirectory(dir, nil, 1, 1)
96 if err != nil {
97 if err != nil {
@@ -99,11 +100,11 @@ func dirHasNoVisibleFiles(dir string) (bool, error) {
99 return len(files) == 0, nil
100 return len(files) == 0, nil
100}
101}
101
102
102−func MarkProjectInitialized(cfg *Config) error {
103+func MarkProjectInitialized(store *ConfigStore) error {
103− if cfg == nil {
104+ if store == nil {
104 return fmt.Errorf("config not loaded")
105 return fmt.Errorf("config not loaded")
105 }
106 }
106− flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
107+ flagFilePath := filepath.Join(store.Config().Options.DataDirectory, InitFlagFilename)
107
108
108 file, err := os.Create(flagFilePath)
109 file, err := os.Create(flagFilePath)
109 if err != nil {
110 if err != nil {
@@ -114,13 +115,13 @@ func MarkProjectInitialized(cfg *Config) error {
114 return nil
115 return nil
115}
116}
116
117
117−func HasInitialDataConfig(cfg *Config) bool {
118+func HasInitialDataConfig(store *ConfigStore) bool {
118− if cfg == nil {
119+ if store == nil {
119 return false
120 return false
120 }
121 }
121 cfgPath := GlobalConfigData()
122 cfgPath := GlobalConfigData()
122 if _, err := os.Stat(cfgPath); err != nil {
123 if _, err := os.Stat(cfgPath); err != nil {
123 return false
124 return false
124 }
125 }
125− return cfg.IsConfigured()
126+ return store.Config().IsConfigured()
126}
127}
internal/config/load.go+45−17
@@ -29,8 +29,9 @@ import (
29
29
30const defaultCatwalkURL = "https://catwalk.charm.sh"
30const defaultCatwalkURL = "https://catwalk.charm.sh"
31
31
32−// Load loads the configuration from the default paths.
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.
33−func Load(workingDir, dataDir string, debug bool) (*Config, error) {
34+func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
34 configPaths := lookupConfigs(workingDir)
35 configPaths := lookupConfigs(workingDir)
35
36
36 cfg, err := loadFromConfigPaths(configPaths)
37 cfg, err := loadFromConfigPaths(configPaths)
@@ -38,10 +39,15 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
38 return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
39 return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
39 }
40 }
40
41
41− cfg.dataConfigDir = GlobalConfigData()
42−
43 cfg.setDefaults(workingDir, dataDir)
42 cfg.setDefaults(workingDir, dataDir)
44
43
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+
45 if debug {
51 if debug {
46 cfg.Options.Debug = true
52 cfg.Options.Debug = true
47 }
53 }
@@ -52,6 +58,18 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
52 cfg.Options.Debug,
58 cfg.Options.Debug,
53 )
59 )
54
60
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+
55 if !isInsideWorktree() {
73 if !isInsideWorktree() {
56 const depth = 2
74 const depth = 2
57 const items = 100
75 const items = 100
@@ -72,26 +90,36 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
72 if err != nil {
90 if err != nil {
73 return nil, err
91 return nil, err
74 }
92 }
75− cfg.knownProviders = providers
93+ store.knownProviders = providers
76
94
77 env := env.New()
95 env := env.New()
78 // Configure providers
96 // Configure providers
79 valueResolver := NewShellVariableResolver(env)
97 valueResolver := NewShellVariableResolver(env)
80− cfg.resolver = valueResolver
98+ store.resolver = valueResolver
81− if err := cfg.configureProviders(env, valueResolver, cfg.knownProviders); err != nil {
99+ if err := cfg.configureProviders(store, env, valueResolver, store.knownProviders); err != nil {
82 return nil, fmt.Errorf("failed to configure providers: %w", err)
100 return nil, fmt.Errorf("failed to configure providers: %w", err)
83 }
101 }
84
102
85 if !cfg.IsConfigured() {
103 if !cfg.IsConfigured() {
86 slog.Warn("No providers configured")
104 slog.Warn("No providers configured")
87− return cfg, nil
105+ return store, nil
88 }
106 }
89
107
90− if err := cfg.configureSelectedModels(cfg.knownProviders); err != nil {
108+ if err := configureSelectedModels(store, store.knownProviders); err != nil {
91 return nil, fmt.Errorf("failed to configure selected models: %w", err)
109 return nil, fmt.Errorf("failed to configure selected models: %w", err)
92 }
110 }
93− cfg.SetupAgents()
111+ store.SetupAgents()
94− return cfg, nil
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
95}
123}
96
124
97func PushPopCrushEnv() func() {
125func PushPopCrushEnv() func() {
@@ -122,7 +150,7 @@ func PushPopCrushEnv() func() {
122 return restore
150 return restore
123}
151}
124
152
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 {
126 knownProviderNames := make(map[string]bool)
154 knownProviderNames := make(map[string]bool)
127 restore := PushPopCrushEnv()
155 restore := PushPopCrushEnv()
128 defer restore()
156 defer restore()
@@ -209,7 +237,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
209 switch {
237 switch {
210 case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
238 case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
211 // Claude Code subscription is not supported anymore. Remove to show onboarding.
239 // Claude Code subscription is not supported anymore. Remove to show onboarding.
212− c.RemoveConfigField("providers.anthropic")
240+ store.RemoveConfigField(ScopeGlobal, "providers.anthropic")
213 c.Providers.Del(string(p.ID))
241 c.Providers.Del(string(p.ID))
214 continue
242 continue
215 case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
243 case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
@@ -340,7 +368,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
340}
368}
341
369
342func (c *Config) setDefaults(workingDir, dataDir string) {
370func (c *Config) setDefaults(workingDir, dataDir string) {
343− c.workingDir = workingDir
344 if c.Options == nil {
371 if c.Options == nil {
345 c.Options = &Options{}
372 c.Options = &Options{}
346 }
373 }
@@ -524,7 +551,8 @@ func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (large
524 return largeModel, smallModel, err
551 return largeModel, smallModel, err
525}
552}
526
553
527−func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) error {
554+func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provider) error {
555+ c := store.config
528 defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
556 defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
529 if err != nil {
557 if err != nil {
530 return fmt.Errorf("failed to select default models: %w", err)
558 return fmt.Errorf("failed to select default models: %w", err)
@@ -543,7 +571,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
543 if model == nil {
571 if model == nil {
544 large = defaultLarge
572 large = defaultLarge
545 // override the model type to large
573 // override the model type to large
546− err := c.UpdatePreferredModel(SelectedModelTypeLarge, large)
574+ err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, large)
547 if err != nil {
575 if err != nil {
548 return fmt.Errorf("failed to update preferred large model: %w", err)
576 return fmt.Errorf("failed to update preferred large model: %w", err)
549 }
577 }
@@ -587,7 +615,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
587 if model == nil {
615 if model == nil {
588 small = defaultSmall
616 small = defaultSmall
589 // override the model type to small
617 // override the model type to small
590− err := c.UpdatePreferredModel(SelectedModelTypeSmall, small)
618+ err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, small)
591 if err != nil {
619 if err != nil {
592 return fmt.Errorf("failed to update preferred small model: %w", err)
620 return fmt.Errorf("failed to update preferred small model: %w", err)
593 }
621 }
internal/config/load_test.go+44−40
@@ -36,6 +36,11 @@ func TestConfig_LoadFromBytes(t *testing.T) {
36 require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
36 require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
37}
37}
38
38
39+// testStore wraps a Config in a minimal ConfigStore for testing.
40+func testStore(cfg *Config) *ConfigStore {
41+ return &ConfigStore{config: cfg}
42+}
43+
39func TestConfig_setDefaults(t *testing.T) {
44func TestConfig_setDefaults(t *testing.T) {
40 cfg := &Config{}
45 cfg := &Config{}
41
46
@@ -53,7 +58,6 @@ func TestConfig_setDefaults(t *testing.T) {
53 for _, path := range defaultContextPaths {
58 for _, path := range defaultContextPaths {
54 require.Contains(t, cfg.Options.ContextPaths, path)
59 require.Contains(t, cfg.Options.ContextPaths, path)
55 }
60 }
56− require.Equal(t, "/tmp", cfg.workingDir)
57}
61}
58
62
59func TestConfig_configureProviders(t *testing.T) {
63func TestConfig_configureProviders(t *testing.T) {
@@ -74,7 +78,7 @@ func TestConfig_configureProviders(t *testing.T) {
74 "OPENAI_API_KEY": "test-key",
78 "OPENAI_API_KEY": "test-key",
75 })
79 })
76 resolver := NewEnvironmentVariableResolver(env)
80 resolver := NewEnvironmentVariableResolver(env)
77− err := cfg.configureProviders(env, resolver, knownProviders)
81+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
78 require.NoError(t, err)
82 require.NoError(t, err)
79 require.Equal(t, 1, cfg.Providers.Len())
83 require.Equal(t, 1, cfg.Providers.Len())
80
84
@@ -117,7 +121,7 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
117 "OPENAI_API_KEY": "test-key",
121 "OPENAI_API_KEY": "test-key",
118 })
122 })
119 resolver := NewEnvironmentVariableResolver(env)
123 resolver := NewEnvironmentVariableResolver(env)
120− err := cfg.configureProviders(env, resolver, knownProviders)
124+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
121 require.NoError(t, err)
125 require.NoError(t, err)
122 require.Equal(t, 1, cfg.Providers.Len())
126 require.Equal(t, 1, cfg.Providers.Len())
123
127
@@ -159,7 +163,7 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
159 "OPENAI_API_KEY": "test-key",
163 "OPENAI_API_KEY": "test-key",
160 })
164 })
161 resolver := NewEnvironmentVariableResolver(env)
165 resolver := NewEnvironmentVariableResolver(env)
162− err := cfg.configureProviders(env, resolver, knownProviders)
166+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
163 require.NoError(t, err)
167 require.NoError(t, err)
164 // Should be to because of the env variable
168 // Should be to because of the env variable
165 require.Equal(t, cfg.Providers.Len(), 2)
169 require.Equal(t, cfg.Providers.Len(), 2)
@@ -195,7 +199,7 @@ func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
195 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
199 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
196 })
200 })
197 resolver := NewEnvironmentVariableResolver(env)
201 resolver := NewEnvironmentVariableResolver(env)
198− err := cfg.configureProviders(env, resolver, knownProviders)
202+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
199 require.NoError(t, err)
203 require.NoError(t, err)
200 require.Equal(t, cfg.Providers.Len(), 1)
204 require.Equal(t, cfg.Providers.Len(), 1)
201
205
@@ -221,7 +225,7 @@ func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
221 cfg.setDefaults("/tmp", "")
225 cfg.setDefaults("/tmp", "")
222 env := env.NewFromMap(map[string]string{})
226 env := env.NewFromMap(map[string]string{})
223 resolver := NewEnvironmentVariableResolver(env)
227 resolver := NewEnvironmentVariableResolver(env)
224− err := cfg.configureProviders(env, resolver, knownProviders)
228+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
225 require.NoError(t, err)
229 require.NoError(t, err)
226 // Provider should not be configured without credentials
230 // Provider should not be configured without credentials
227 require.Equal(t, cfg.Providers.Len(), 0)
231 require.Equal(t, cfg.Providers.Len(), 0)
@@ -246,7 +250,7 @@ func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
246 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
250 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
247 })
251 })
248 resolver := NewEnvironmentVariableResolver(env)
252 resolver := NewEnvironmentVariableResolver(env)
249− err := cfg.configureProviders(env, resolver, knownProviders)
253+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
250 require.Error(t, err)
254 require.Error(t, err)
251}
255}
252
256
@@ -269,7 +273,7 @@ func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
269 "VERTEXAI_LOCATION": "us-central1",
273 "VERTEXAI_LOCATION": "us-central1",
270 })
274 })
271 resolver := NewEnvironmentVariableResolver(env)
275 resolver := NewEnvironmentVariableResolver(env)
272− err := cfg.configureProviders(env, resolver, knownProviders)
276+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
273 require.NoError(t, err)
277 require.NoError(t, err)
274 require.Equal(t, cfg.Providers.Len(), 1)
278 require.Equal(t, cfg.Providers.Len(), 1)
275
279
@@ -301,7 +305,7 @@ func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
301 "GOOGLE_CLOUD_LOCATION": "us-central1",
305 "GOOGLE_CLOUD_LOCATION": "us-central1",
302 })
306 })
303 resolver := NewEnvironmentVariableResolver(env)
307 resolver := NewEnvironmentVariableResolver(env)
304− err := cfg.configureProviders(env, resolver, knownProviders)
308+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
305 require.NoError(t, err)
309 require.NoError(t, err)
306 // Provider should not be configured without proper credentials
310 // Provider should not be configured without proper credentials
307 require.Equal(t, cfg.Providers.Len(), 0)
311 require.Equal(t, cfg.Providers.Len(), 0)
@@ -326,7 +330,7 @@ func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
326 "GOOGLE_CLOUD_LOCATION": "us-central1",
330 "GOOGLE_CLOUD_LOCATION": "us-central1",
327 })
331 })
328 resolver := NewEnvironmentVariableResolver(env)
332 resolver := NewEnvironmentVariableResolver(env)
329− err := cfg.configureProviders(env, resolver, knownProviders)
333+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
330 require.NoError(t, err)
334 require.NoError(t, err)
331 // Provider should not be configured without project
335 // Provider should not be configured without project
332 require.Equal(t, cfg.Providers.Len(), 0)
336 require.Equal(t, cfg.Providers.Len(), 0)
@@ -350,7 +354,7 @@ func TestConfig_configureProvidersSetProviderID(t *testing.T) {
350 "OPENAI_API_KEY": "test-key",
354 "OPENAI_API_KEY": "test-key",
351 })
355 })
352 resolver := NewEnvironmentVariableResolver(env)
356 resolver := NewEnvironmentVariableResolver(env)
353− err := cfg.configureProviders(env, resolver, knownProviders)
357+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
354 require.NoError(t, err)
358 require.NoError(t, err)
355 require.Equal(t, cfg.Providers.Len(), 1)
359 require.Equal(t, cfg.Providers.Len(), 1)
356
360
@@ -541,7 +545,7 @@ func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
541 "OPENAI_API_KEY": "test-key",
545 "OPENAI_API_KEY": "test-key",
542 })
546 })
543 resolver := NewEnvironmentVariableResolver(env)
547 resolver := NewEnvironmentVariableResolver(env)
544− err := cfg.configureProviders(env, resolver, knownProviders)
548+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
545 require.NoError(t, err)
549 require.NoError(t, err)
546
550
547 require.Equal(t, cfg.Providers.Len(), 1)
551 require.Equal(t, cfg.Providers.Len(), 1)
@@ -569,7 +573,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
569
573
570 env := env.NewFromMap(map[string]string{})
574 env := env.NewFromMap(map[string]string{})
571 resolver := NewEnvironmentVariableResolver(env)
575 resolver := NewEnvironmentVariableResolver(env)
572− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
576+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
573 require.NoError(t, err)
577 require.NoError(t, err)
574
578
575 require.Equal(t, cfg.Providers.Len(), 1)
579 require.Equal(t, cfg.Providers.Len(), 1)
@@ -592,7 +596,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
592
596
593 env := env.NewFromMap(map[string]string{})
597 env := env.NewFromMap(map[string]string{})
594 resolver := NewEnvironmentVariableResolver(env)
598 resolver := NewEnvironmentVariableResolver(env)
595− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
599+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
596 require.NoError(t, err)
600 require.NoError(t, err)
597
601
598 require.Equal(t, cfg.Providers.Len(), 0)
602 require.Equal(t, cfg.Providers.Len(), 0)
@@ -614,7 +618,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
614
618
615 env := env.NewFromMap(map[string]string{})
619 env := env.NewFromMap(map[string]string{})
616 resolver := NewEnvironmentVariableResolver(env)
620 resolver := NewEnvironmentVariableResolver(env)
617− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
621+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
618 require.NoError(t, err)
622 require.NoError(t, err)
619
623
620 require.Equal(t, cfg.Providers.Len(), 0)
624 require.Equal(t, cfg.Providers.Len(), 0)
@@ -639,7 +643,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
639
643
640 env := env.NewFromMap(map[string]string{})
644 env := env.NewFromMap(map[string]string{})
641 resolver := NewEnvironmentVariableResolver(env)
645 resolver := NewEnvironmentVariableResolver(env)
642− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
646+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
643 require.NoError(t, err)
647 require.NoError(t, err)
644
648
645 require.Equal(t, cfg.Providers.Len(), 0)
649 require.Equal(t, cfg.Providers.Len(), 0)
@@ -664,7 +668,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
664
668
665 env := env.NewFromMap(map[string]string{})
669 env := env.NewFromMap(map[string]string{})
666 resolver := NewEnvironmentVariableResolver(env)
670 resolver := NewEnvironmentVariableResolver(env)
667− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
671+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
668 require.NoError(t, err)
672 require.NoError(t, err)
669
673
670 require.Equal(t, cfg.Providers.Len(), 1)
674 require.Equal(t, cfg.Providers.Len(), 1)
@@ -692,7 +696,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
692
696
693 env := env.NewFromMap(map[string]string{})
697 env := env.NewFromMap(map[string]string{})
694 resolver := NewEnvironmentVariableResolver(env)
698 resolver := NewEnvironmentVariableResolver(env)
695− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
699+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
696 require.NoError(t, err)
700 require.NoError(t, err)
697
701
698 require.Equal(t, cfg.Providers.Len(), 1)
702 require.Equal(t, cfg.Providers.Len(), 1)
@@ -722,7 +726,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
722
726
723 env := env.NewFromMap(map[string]string{})
727 env := env.NewFromMap(map[string]string{})
724 resolver := NewEnvironmentVariableResolver(env)
728 resolver := NewEnvironmentVariableResolver(env)
725− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
729+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
726 require.NoError(t, err)
730 require.NoError(t, err)
727
731
728 require.Equal(t, cfg.Providers.Len(), 0)
732 require.Equal(t, cfg.Providers.Len(), 0)
@@ -757,7 +761,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
757 "GOOGLE_GENAI_USE_VERTEXAI": "false",
761 "GOOGLE_GENAI_USE_VERTEXAI": "false",
758 })
762 })
759 resolver := NewEnvironmentVariableResolver(env)
763 resolver := NewEnvironmentVariableResolver(env)
760− err := cfg.configureProviders(env, resolver, knownProviders)
764+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
761 require.NoError(t, err)
765 require.NoError(t, err)
762
766
763 require.Equal(t, cfg.Providers.Len(), 0)
767 require.Equal(t, cfg.Providers.Len(), 0)
@@ -788,7 +792,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
788
792
789 env := env.NewFromMap(map[string]string{})
793 env := env.NewFromMap(map[string]string{})
790 resolver := NewEnvironmentVariableResolver(env)
794 resolver := NewEnvironmentVariableResolver(env)
791− err := cfg.configureProviders(env, resolver, knownProviders)
795+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
792 require.NoError(t, err)
796 require.NoError(t, err)
793
797
794 require.Equal(t, cfg.Providers.Len(), 0)
798 require.Equal(t, cfg.Providers.Len(), 0)
@@ -819,7 +823,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
819
823
820 env := env.NewFromMap(map[string]string{})
824 env := env.NewFromMap(map[string]string{})
821 resolver := NewEnvironmentVariableResolver(env)
825 resolver := NewEnvironmentVariableResolver(env)
822− err := cfg.configureProviders(env, resolver, knownProviders)
826+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
823 require.NoError(t, err)
827 require.NoError(t, err)
824
828
825 require.Equal(t, cfg.Providers.Len(), 0)
829 require.Equal(t, cfg.Providers.Len(), 0)
@@ -852,7 +856,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
852 "OPENAI_API_KEY": "test-key",
856 "OPENAI_API_KEY": "test-key",
853 })
857 })
854 resolver := NewEnvironmentVariableResolver(env)
858 resolver := NewEnvironmentVariableResolver(env)
855− err := cfg.configureProviders(env, resolver, knownProviders)
859+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
856 require.NoError(t, err)
860 require.NoError(t, err)
857
861
858 require.Equal(t, cfg.Providers.Len(), 1)
862 require.Equal(t, cfg.Providers.Len(), 1)
@@ -886,7 +890,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
886 cfg.setDefaults("/tmp", "")
890 cfg.setDefaults("/tmp", "")
887 env := env.NewFromMap(map[string]string{})
891 env := env.NewFromMap(map[string]string{})
888 resolver := NewEnvironmentVariableResolver(env)
892 resolver := NewEnvironmentVariableResolver(env)
889− err := cfg.configureProviders(env, resolver, knownProviders)
893+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
890 require.NoError(t, err)
894 require.NoError(t, err)
891
895
892 large, small, err := cfg.defaultModelSelection(knownProviders)
896 large, small, err := cfg.defaultModelSelection(knownProviders)
@@ -922,7 +926,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
922 cfg.setDefaults("/tmp", "")
926 cfg.setDefaults("/tmp", "")
923 env := env.NewFromMap(map[string]string{})
927 env := env.NewFromMap(map[string]string{})
924 resolver := NewEnvironmentVariableResolver(env)
928 resolver := NewEnvironmentVariableResolver(env)
925− err := cfg.configureProviders(env, resolver, knownProviders)
929+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
926 require.NoError(t, err)
930 require.NoError(t, err)
927
931
928 _, _, err = cfg.defaultModelSelection(knownProviders)
932 _, _, err = cfg.defaultModelSelection(knownProviders)
@@ -952,7 +956,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
952 cfg.setDefaults("/tmp", "")
956 cfg.setDefaults("/tmp", "")
953 env := env.NewFromMap(map[string]string{})
957 env := env.NewFromMap(map[string]string{})
954 resolver := NewEnvironmentVariableResolver(env)
958 resolver := NewEnvironmentVariableResolver(env)
955− err := cfg.configureProviders(env, resolver, knownProviders)
959+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
956 require.NoError(t, err)
960 require.NoError(t, err)
957 _, _, err = cfg.defaultModelSelection(knownProviders)
961 _, _, err = cfg.defaultModelSelection(knownProviders)
958 require.Error(t, err)
962 require.Error(t, err)
@@ -995,7 +999,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
995 cfg.setDefaults("/tmp", "")
999 cfg.setDefaults("/tmp", "")
996 env := env.NewFromMap(map[string]string{})
1000 env := env.NewFromMap(map[string]string{})
997 resolver := NewEnvironmentVariableResolver(env)
1001 resolver := NewEnvironmentVariableResolver(env)
998− err := cfg.configureProviders(env, resolver, knownProviders)
1002+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
999 require.NoError(t, err)
1003 require.NoError(t, err)
1000 large, small, err := cfg.defaultModelSelection(knownProviders)
1004 large, small, err := cfg.defaultModelSelection(knownProviders)
1001 require.NoError(t, err)
1005 require.NoError(t, err)
@@ -1039,7 +1043,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
1039 cfg.setDefaults("/tmp", "")
1043 cfg.setDefaults("/tmp", "")
1040 env := env.NewFromMap(map[string]string{})
1044 env := env.NewFromMap(map[string]string{})
1041 resolver := NewEnvironmentVariableResolver(env)
1045 resolver := NewEnvironmentVariableResolver(env)
1042− err := cfg.configureProviders(env, resolver, knownProviders)
1046+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1043 require.NoError(t, err)
1047 require.NoError(t, err)
1044 _, _, err = cfg.defaultModelSelection(knownProviders)
1048 _, _, err = cfg.defaultModelSelection(knownProviders)
1045 require.Error(t, err)
1049 require.Error(t, err)
@@ -1081,7 +1085,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
1081 cfg.setDefaults("/tmp", "")
1085 cfg.setDefaults("/tmp", "")
1082 env := env.NewFromMap(map[string]string{})
1086 env := env.NewFromMap(map[string]string{})
1083 resolver := NewEnvironmentVariableResolver(env)
1087 resolver := NewEnvironmentVariableResolver(env)
1084− err := cfg.configureProviders(env, resolver, knownProviders)
1088+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1085 require.NoError(t, err)
1089 require.NoError(t, err)
1086 large, small, err := cfg.defaultModelSelection(knownProviders)
1090 large, small, err := cfg.defaultModelSelection(knownProviders)
1087 require.NoError(t, err)
1091 require.NoError(t, err)
@@ -1126,7 +1130,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1126 "OPENAI_API_KEY": "test-key",
1130 "OPENAI_API_KEY": "test-key",
1127 })
1131 })
1128 resolver := NewEnvironmentVariableResolver(env)
1132 resolver := NewEnvironmentVariableResolver(env)
1129− err := cfg.configureProviders(env, resolver, knownProviders)
1133+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1130 require.ErrorContains(t, err, "no custom providers")
1134 require.ErrorContains(t, err, "no custom providers")
1131
1135
1132 // openai should NOT be present because it lacks base_url and models.
1136 // openai should NOT be present because it lacks base_url and models.
@@ -1169,7 +1173,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1169 "OPENAI_API_KEY": "test-key",
1173 "OPENAI_API_KEY": "test-key",
1170 })
1174 })
1171 resolver := NewEnvironmentVariableResolver(env)
1175 resolver := NewEnvironmentVariableResolver(env)
1172− err := cfg.configureProviders(env, resolver, knownProviders)
1176+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1173 require.NoError(t, err)
1177 require.NoError(t, err)
1174
1178
1175 // Only fully specified provider should be present.
1179 // Only fully specified provider should be present.
@@ -1223,7 +1227,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1223 "ANTHROPIC_API_KEY": "test-key",
1227 "ANTHROPIC_API_KEY": "test-key",
1224 })
1228 })
1225 resolver := NewEnvironmentVariableResolver(env)
1229 resolver := NewEnvironmentVariableResolver(env)
1226− err := cfg.configureProviders(env, resolver, knownProviders)
1230+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1227 require.NoError(t, err)
1231 require.NoError(t, err)
1228
1232
1229 // Both providers should be present.
1233 // Both providers should be present.
@@ -1251,7 +1255,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1251
1255
1252 env := env.NewFromMap(map[string]string{})
1256 env := env.NewFromMap(map[string]string{})
1253 resolver := NewEnvironmentVariableResolver(env)
1257 resolver := NewEnvironmentVariableResolver(env)
1254− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
1258+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1255 require.ErrorContains(t, err, "no custom providers")
1259 require.ErrorContains(t, err, "no custom providers")
1256
1260
1257 // Provider should be rejected for missing models.
1261 // Provider should be rejected for missing models.
@@ -1275,7 +1279,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1275
1279
1276 env := env.NewFromMap(map[string]string{})
1280 env := env.NewFromMap(map[string]string{})
1277 resolver := NewEnvironmentVariableResolver(env)
1281 resolver := NewEnvironmentVariableResolver(env)
1278− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
1282+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1279 require.ErrorContains(t, err, "no custom providers")
1283 require.ErrorContains(t, err, "no custom providers")
1280
1284
1281 // Provider should be rejected for missing base_url.
1285 // Provider should be rejected for missing base_url.
@@ -1340,10 +1344,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
1340 cfg.setDefaults("/tmp", "")
1344 cfg.setDefaults("/tmp", "")
1341 env := env.NewFromMap(map[string]string{})
1345 env := env.NewFromMap(map[string]string{})
1342 resolver := NewEnvironmentVariableResolver(env)
1346 resolver := NewEnvironmentVariableResolver(env)
1343− err := cfg.configureProviders(env, resolver, knownProviders)
1347+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1344 require.NoError(t, err)
1348 require.NoError(t, err)
1345
1349
1346− err = cfg.configureSelectedModels(knownProviders)
1350+ err = configureSelectedModels(testStore(cfg), knownProviders)
1347 require.NoError(t, err)
1351 require.NoError(t, err)
1348 large := cfg.Models[SelectedModelTypeLarge]
1352 large := cfg.Models[SelectedModelTypeLarge]
1349 small := cfg.Models[SelectedModelTypeSmall]
1353 small := cfg.Models[SelectedModelTypeSmall]
@@ -1402,10 +1406,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
1402 cfg.setDefaults("/tmp", "")
1406 cfg.setDefaults("/tmp", "")
1403 env := env.NewFromMap(map[string]string{})
1407 env := env.NewFromMap(map[string]string{})
1404 resolver := NewEnvironmentVariableResolver(env)
1408 resolver := NewEnvironmentVariableResolver(env)
1405− err := cfg.configureProviders(env, resolver, knownProviders)
1409+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1406 require.NoError(t, err)
1410 require.NoError(t, err)
1407
1411
1408− err = cfg.configureSelectedModels(knownProviders)
1412+ err = configureSelectedModels(testStore(cfg), knownProviders)
1409 require.NoError(t, err)
1413 require.NoError(t, err)
1410 large := cfg.Models[SelectedModelTypeLarge]
1414 large := cfg.Models[SelectedModelTypeLarge]
1411 small := cfg.Models[SelectedModelTypeSmall]
1415 small := cfg.Models[SelectedModelTypeSmall]
@@ -1447,10 +1451,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
1447 cfg.setDefaults("/tmp", "")
1451 cfg.setDefaults("/tmp", "")
1448 env := env.NewFromMap(map[string]string{})
1452 env := env.NewFromMap(map[string]string{})
1449 resolver := NewEnvironmentVariableResolver(env)
1453 resolver := NewEnvironmentVariableResolver(env)
1450− err := cfg.configureProviders(env, resolver, knownProviders)
1454+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1451 require.NoError(t, err)
1455 require.NoError(t, err)
1452
1456
1453− err = cfg.configureSelectedModels(knownProviders)
1457+ err = configureSelectedModels(testStore(cfg), knownProviders)
1454 require.NoError(t, err)
1458 require.NoError(t, err)
1455 large := cfg.Models[SelectedModelTypeLarge]
1459 large := cfg.Models[SelectedModelTypeLarge]
1456 require.Equal(t, "large-model", large.Model)
1460 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 {
31 return rm
31 return rm
32}
32}
33
33
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+
34func TestRecordRecentModel_AddsAndPersists(t *testing.T) {
42func TestRecordRecentModel_AddsAndPersists(t *testing.T) {
35 t.Parallel()
43 t.Parallel()
36
44
37 dir := t.TempDir()
45 dir := t.TempDir()
38 cfg := &Config{}
46 cfg := &Config{}
39 cfg.setDefaults(dir, "")
47 cfg.setDefaults(dir, "")
40− cfg.dataConfigDir = filepath.Join(dir, "config.json")
48+ store := testStoreWithPath(cfg, dir)
41
49
42− err := cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
50+ err := store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
43 require.NoError(t, err)
51 require.NoError(t, err)
44
52
45 // in-memory state
53 // in-memory state
@@ -48,7 +56,7 @@ func TestRecordRecentModel_AddsAndPersists(t *testing.T) {
48 require.Equal(t, "gpt-4o", cfg.RecentModels[SelectedModelTypeLarge][0].Model)
56 require.Equal(t, "gpt-4o", cfg.RecentModels[SelectedModelTypeLarge][0].Model)
49
57
50 // persisted state
58 // persisted state
51− rm := readRecentModels(t, cfg.dataConfigDir)
59+ rm := readRecentModels(t, store.globalDataPath)
52 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
60 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
53 require.True(t, ok)
61 require.True(t, ok)
54 require.Len(t, large, 1)
62 require.Len(t, large, 1)
@@ -64,13 +72,13 @@ func TestRecordRecentModel_DedupeAndMoveToFront(t *testing.T) {
64 dir := t.TempDir()
72 dir := t.TempDir()
65 cfg := &Config{}
73 cfg := &Config{}
66 cfg.setDefaults(dir, "")
74 cfg.setDefaults(dir, "")
67− cfg.dataConfigDir = filepath.Join(dir, "config.json")
75+ store := testStoreWithPath(cfg, dir)
68
76
69 // Add two entries
77 // Add two entries
70− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
78+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
71− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "anthropic", Model: "claude"}))
79+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "anthropic", Model: "claude"}))
72 // Re-add first; should move to front and not duplicate
80 // 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"}))
74
82
75 got := cfg.RecentModels[SelectedModelTypeLarge]
83 got := cfg.RecentModels[SelectedModelTypeLarge]
76 require.Len(t, got, 2)
84 require.Len(t, got, 2)
@@ -84,7 +92,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) {
84 dir := t.TempDir()
92 dir := t.TempDir()
85 cfg := &Config{}
93 cfg := &Config{}
86 cfg.setDefaults(dir, "")
94 cfg.setDefaults(dir, "")
87− cfg.dataConfigDir = filepath.Join(dir, "config.json")
95+ store := testStoreWithPath(cfg, dir)
88
96
89 // Insert 6 unique models; max is 5
97 // Insert 6 unique models; max is 5
90 entries := []SelectedModel{
98 entries := []SelectedModel{
@@ -96,7 +104,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) {
96 {Provider: "p6", Model: "m6"},
104 {Provider: "p6", Model: "m6"},
97 }
105 }
98 for _, e := range entries {
106 for _, e := range entries {
99− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, e))
107+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, e))
100 }
108 }
101
109
102 // in-memory state
110 // in-memory state
@@ -110,7 +118,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) {
110 require.Equal(t, SelectedModel{Provider: "p2", Model: "m2"}, got[4])
118 require.Equal(t, SelectedModel{Provider: "p2", Model: "m2"}, got[4])
111
119
112 // persisted state: verify trimmed to 5 and newest-first order
120 // persisted state: verify trimmed to 5 and newest-first order
113− rm := readRecentModels(t, cfg.dataConfigDir)
121+ rm := readRecentModels(t, store.globalDataPath)
114 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
122 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
115 require.True(t, ok)
123 require.True(t, ok)
116 require.Len(t, large, 5)
124 require.Len(t, large, 5)
@@ -129,12 +137,12 @@ func TestRecordRecentModel_SkipsEmptyValues(t *testing.T) {
129 dir := t.TempDir()
137 dir := t.TempDir()
130 cfg := &Config{}
138 cfg := &Config{}
131 cfg.setDefaults(dir, "")
139 cfg.setDefaults(dir, "")
132− cfg.dataConfigDir = filepath.Join(dir, "config.json")
140+ store := testStoreWithPath(cfg, dir)
133
141
134 // Missing provider
142 // 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"}))
136 // Missing model
144 // 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: ""}))
138
146
139 _, ok := cfg.RecentModels[SelectedModelTypeLarge]
147 _, ok := cfg.RecentModels[SelectedModelTypeLarge]
140 // Map may be initialized, but should have no entries
148 // Map may be initialized, but should have no entries
@@ -142,8 +150,8 @@ func TestRecordRecentModel_SkipsEmptyValues(t *testing.T) {
142 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 0)
150 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 0)
143 }
151 }
144 // No file should be written (stat via fs.FS)
152 // No file should be written (stat via fs.FS)
145− baseDir := filepath.Dir(cfg.dataConfigDir)
153+ baseDir := filepath.Dir(store.globalDataPath)
146− fileName := filepath.Base(cfg.dataConfigDir)
154+ fileName := filepath.Base(store.globalDataPath)
147 _, err := fs.Stat(os.DirFS(baseDir), fileName)
155 _, err := fs.Stat(os.DirFS(baseDir), fileName)
148 require.True(t, os.IsNotExist(err))
156 require.True(t, os.IsNotExist(err))
149}
157}
@@ -154,13 +162,13 @@ func TestRecordRecentModel_NoPersistOnNoop(t *testing.T) {
154 dir := t.TempDir()
162 dir := t.TempDir()
155 cfg := &Config{}
163 cfg := &Config{}
156 cfg.setDefaults(dir, "")
164 cfg.setDefaults(dir, "")
157− cfg.dataConfigDir = filepath.Join(dir, "config.json")
165+ store := testStoreWithPath(cfg, dir)
158
166
159 entry := SelectedModel{Provider: "openai", Model: "gpt-4o"}
167 entry := SelectedModel{Provider: "openai", Model: "gpt-4o"}
160− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, entry))
168+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, entry))
161
169
162− baseDir := filepath.Dir(cfg.dataConfigDir)
170+ baseDir := filepath.Dir(store.globalDataPath)
163− fileName := filepath.Base(cfg.dataConfigDir)
171+ fileName := filepath.Base(store.globalDataPath)
164 before, err := fs.ReadFile(os.DirFS(baseDir), fileName)
172 before, err := fs.ReadFile(os.DirFS(baseDir), fileName)
165 require.NoError(t, err)
173 require.NoError(t, err)
166
174
@@ -170,7 +178,7 @@ func TestRecordRecentModel_NoPersistOnNoop(t *testing.T) {
170 beforeMod := stBefore.ModTime()
178 beforeMod := stBefore.ModTime()
171
179
172 // Re-record same entry should be a no-op (no write)
180 // 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))
174
182
175 after, err := fs.ReadFile(os.DirFS(baseDir), fileName)
183 after, err := fs.ReadFile(os.DirFS(baseDir), fileName)
176 require.NoError(t, err)
184 require.NoError(t, err)
@@ -188,17 +196,17 @@ func TestUpdatePreferredModel_UpdatesRecents(t *testing.T) {
188 dir := t.TempDir()
196 dir := t.TempDir()
189 cfg := &Config{}
197 cfg := &Config{}
190 cfg.setDefaults(dir, "")
198 cfg.setDefaults(dir, "")
191− cfg.dataConfigDir = filepath.Join(dir, "config.json")
199+ store := testStoreWithPath(cfg, dir)
192
200
193 sel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
201 sel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
194− require.NoError(t, cfg.UpdatePreferredModel(SelectedModelTypeSmall, sel))
202+ require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, sel))
195
203
196 // in-memory
204 // in-memory
197 require.Equal(t, sel, cfg.Models[SelectedModelTypeSmall])
205 require.Equal(t, sel, cfg.Models[SelectedModelTypeSmall])
198 require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
206 require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
199
207
200 // persisted (read via fs.FS)
208 // persisted (read via fs.FS)
201− rm := readRecentModels(t, cfg.dataConfigDir)
209+ rm := readRecentModels(t, store.globalDataPath)
202 small, ok := rm[string(SelectedModelTypeSmall)].([]any)
210 small, ok := rm[string(SelectedModelTypeSmall)].([]any)
203 require.True(t, ok)
211 require.True(t, ok)
204 require.Len(t, small, 1)
212 require.Len(t, small, 1)
@@ -210,14 +218,14 @@ func TestRecordRecentModel_TypeIsolation(t *testing.T) {
210 dir := t.TempDir()
218 dir := t.TempDir()
211 cfg := &Config{}
219 cfg := &Config{}
212 cfg.setDefaults(dir, "")
220 cfg.setDefaults(dir, "")
213− cfg.dataConfigDir = filepath.Join(dir, "config.json")
221+ store := testStoreWithPath(cfg, dir)
214
222
215 // Add models to both large and small types
223 // Add models to both large and small types
216 largeModel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
224 largeModel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
217 smallModel := SelectedModel{Provider: "anthropic", Model: "claude"}
225 smallModel := SelectedModel{Provider: "anthropic", Model: "claude"}
218
226
219− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, largeModel))
227+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, largeModel))
220− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeSmall, smallModel))
228+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeSmall, smallModel))
221
229
222 // in-memory: verify types maintain separate histories
230 // in-memory: verify types maintain separate histories
223 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 1)
231 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 1)
@@ -227,14 +235,14 @@ func TestRecordRecentModel_TypeIsolation(t *testing.T) {
227
235
228 // Add another to large, verify small unchanged
236 // Add another to large, verify small unchanged
229 anotherLarge := SelectedModel{Provider: "google", Model: "gemini"}
237 anotherLarge := SelectedModel{Provider: "google", Model: "gemini"}
230− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, anotherLarge))
238+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, anotherLarge))
231
239
232 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 2)
240 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 2)
233 require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
241 require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
234 require.Equal(t, smallModel, cfg.RecentModels[SelectedModelTypeSmall][0])
242 require.Equal(t, smallModel, cfg.RecentModels[SelectedModelTypeSmall][0])
235
243
236 // persisted state: verify both types exist with correct lengths and contents
244 // persisted state: verify both types exist with correct lengths and contents
237− rm := readRecentModels(t, cfg.dataConfigDir)
245+ rm := readRecentModels(t, store.globalDataPath)
238
246
239 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
247 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
240 require.True(t, ok)
248 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{}]()
26// Manager handles lazy initialization of LSP clients based on file types.
26// Manager handles lazy initialization of LSP clients based on file types.
27type Manager struct {
27type Manager struct {
28 clients *csync.Map[string, *Client]
28 clients *csync.Map[string, *Client]
29− cfg *config.Config
29+ cfg *config.ConfigStore
30 manager *powernapconfig.Manager
30 manager *powernapconfig.Manager
31 callback func(name string, client *Client)
31 callback func(name string, client *Client)
32}
32}
33
33
34// NewManager creates a new LSP manager service.
34// NewManager creates a new LSP manager service.
35−func NewManager(cfg *config.Config) *Manager {
35+func NewManager(cfg *config.ConfigStore) *Manager {
36 manager := powernapconfig.NewManager()
36 manager := powernapconfig.NewManager()
37 manager.LoadDefaults()
37 manager.LoadDefaults()
38
38
39 // Merge user-configured LSPs into the manager.
39 // Merge user-configured LSPs into the manager.
40− for name, clientConfig := range cfg.LSP {
40+ for name, clientConfig := range cfg.Config().LSP {
41 if clientConfig.Disabled {
41 if clientConfig.Disabled {
42 slog.Debug("LSP disabled by user config", "name", name)
42 slog.Debug("LSP disabled by user config", "name", name)
43 manager.RemoveServer(name)
43 manager.RemoveServer(name)
@@ -194,7 +194,7 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server
194 cfg,
194 cfg,
195 s.cfg.Resolver(),
195 s.cfg.Resolver(),
196 s.cfg.WorkingDir(),
196 s.cfg.WorkingDir(),
197− s.cfg.Options.DebugLSP,
197+ s.cfg.Config().Options.DebugLSP,
198 )
198 )
199 if err != nil {
199 if err != nil {
200 slog.Error("Failed to create LSP client", "name", name, "error", err)
200 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
244}
244}
245
245
246func (s *Manager) isUserConfigured(name string) bool {
246func (s *Manager) isUserConfigured(name string) bool {
247− cfg, ok := s.cfg.LSP[name]
247+ cfg, ok := s.cfg.Config().LSP[name]
248 return ok && !cfg.Disabled
248 return ok && !cfg.Disabled
249}
249}
250
250
@@ -258,7 +258,7 @@ func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig)
258 InitOptions: server.InitOptions,
258 InitOptions: server.InitOptions,
259 Options: server.Settings,
259 Options: server.Settings,
260 }
260 }
261− if userCfg, ok := s.cfg.LSP[name]; ok {
261+ if userCfg, ok := s.cfg.Config().LSP[name]; ok {
262 cfg.Timeout = userCfg.Timeout
262 cfg.Timeout = userCfg.Timeout
263 }
263 }
264 return cfg
264 return cfg
internal/ui/common/common.go+6−1
@@ -26,11 +26,16 @@ type Common struct {
26 Styles *styles.Styles
26 Styles *styles.Styles
27}
27}
28
28
29−// Config returns the configuration associated with this [Common] instance.
29+// Config returns the pure-data configuration associated with this [Common] instance.
30func (c *Common) Config() *config.Config {
30func (c *Common) Config() *config.Config {
31 return c.App.Config()
31 return c.App.Config()
32}
32}
33
33
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+
34// DefaultCommon returns the default common UI configurations.
39// DefaultCommon returns the default common UI configurations.
35func DefaultCommon(app *app.App) *Common {
40func DefaultCommon(app *app.App) *Common {
36 s := styles.DefaultStyles()
41 s := styles.DefaultStyles()
internal/ui/dialog/api_key_input.go+3−3
@@ -296,7 +296,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
296 Type: m.provider.Type,
296 Type: m.provider.Type,
297 BaseURL: m.provider.APIEndpoint,
297 BaseURL: m.provider.APIEndpoint,
298 }
298 }
299− err := providerConfig.TestConnection(m.com.Config().Resolver())
299+ err := providerConfig.TestConnection(m.com.Store().Resolver())
300
300
301 // intentionally wait for at least 750ms to make sure the user sees the spinner
301 // intentionally wait for at least 750ms to make sure the user sees the spinner
302 elapsed := time.Since(start)
302 elapsed := time.Since(start)
@@ -312,9 +312,9 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
312}
312}
313
313
314func (m *APIKeyInput) saveKeyAndContinue() Action {
314func (m *APIKeyInput) saveKeyAndContinue() Action {
315− cfg := m.com.Config()
315+ store := m.com.Store()
316
316
317− err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value())
317+ err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.input.Value())
318 if err != nil {
318 if err != nil {
319 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
319 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
320 }
320 }
internal/ui/dialog/filepicker.go+1−1
@@ -123,7 +123,7 @@ func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) {
123
123
124// WorkingDir returns the current working directory of the [FilePicker].
124// WorkingDir returns the current working directory of the [FilePicker].
125func (f *FilePicker) WorkingDir() string {
125func (f *FilePicker) WorkingDir() string {
126− wd := f.com.Config().WorkingDir()
126+ wd := f.com.Store().WorkingDir()
127 if len(wd) > 0 {
127 if len(wd) > 0 {
128 return wd
128 return wd
129 }
129 }
internal/ui/dialog/models.go+1−1
@@ -490,7 +490,7 @@ func (m *Models) setProviderItems() error {
490
490
491 if len(validRecentItems) != len(recentItems) {
491 if len(validRecentItems) != len(recentItems) {
492 // FIXME: Does this need to be here? Is it mutating the config during a read?
492 // 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 {
494 return fmt.Errorf("failed to update recent models: %w", err)
494 return fmt.Errorf("failed to update recent models: %w", err)
495 }
495 }
496 }
496 }
internal/ui/dialog/oauth.go+2−2
@@ -373,9 +373,9 @@ func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
373}
373}
374
374
375func (m *OAuth) saveKeyAndContinue() Action {
375func (m *OAuth) saveKeyAndContinue() Action {
376− cfg := m.com.Config()
376+ store := m.com.Store()
377
377
378− err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token)
378+ err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.token)
379 if err != nil {
379 if err != nil {
380 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
380 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
381 }
381 }
internal/ui/model/header.go+1−2
@@ -143,8 +143,7 @@ func renderHeaderDetails(
143 metadata = dot + metadata
143 metadata = dot + metadata
144
144
145 const dirTrimLimit = 4
145 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)
148 cwd = t.Header.WorkingDir.Render(cwd)
147 cwd = t.Header.WorkingDir.Render(cwd)
149
148
150 result := cwd + metadata
149 result := cwd + metadata
internal/ui/model/landing.go+1−1
@@ -22,7 +22,7 @@ func (m *UI) selectedLargeModel() *agent.Model {
22func (m *UI) landingView() string {
22func (m *UI) landingView() string {
23 t := m.com.Styles
23 t := m.com.Styles
24 width := m.layout.main.Dx()
24 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)
26
26
27 parts := []string{
27 parts := []string{
28 cwd,
28 cwd,
internal/ui/model/onboarding.go+5−6
@@ -19,7 +19,7 @@ import (
19// markProjectInitialized marks the current project as initialized in the config.
19// markProjectInitialized marks the current project as initialized in the config.
20func (m *UI) markProjectInitialized() tea.Msg {
20func (m *UI) markProjectInitialized() tea.Msg {
21 // TODO: handle error so we show it in the tui footer
21 // 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())
23 if err != nil {
23 if err != nil {
24 slog.Error(err.Error())
24 slog.Error(err.Error())
25 }
25 }
@@ -52,10 +52,10 @@ func (m *UI) initializeProject() tea.Cmd {
52 if cmd := m.newSession(); cmd != nil {
52 if cmd := m.newSession(); cmd != nil {
53 cmds = append(cmds, cmd)
53 cmds = append(cmds, cmd)
54 }
54 }
55− cfg := m.com.Config()
55+ cfg := m.com.Store()
56
56
57 initialize := func() tea.Msg {
57 initialize := func() tea.Msg {
58− initPrompt, err := agent.InitializePrompt(*cfg)
58+ initPrompt, err := agent.InitializePrompt(cfg)
59 if err != nil {
59 if err != nil {
60 return util.InfoMsg{Type: util.InfoTypeError, Msg: err.Error()}
60 return util.InfoMsg{Type: util.InfoTypeError, Msg: err.Error()}
61 }
61 }
@@ -77,10 +77,9 @@ func (m *UI) skipInitializeProject() tea.Cmd {
77
77
78// initializeView renders the project initialization prompt with Yes/No buttons.
78// initializeView renders the project initialization prompt with Yes/No buttons.
79func (m *UI) initializeView() string {
79func (m *UI) initializeView() string {
80− cfg := m.com.Config()
81 s := m.com.Styles.Initialize
80 s := m.com.Styles.Initialize
82− cwd := home.Short(cfg.WorkingDir())
81+ cwd := home.Short(m.com.Store().WorkingDir())
83− initFile := cfg.Options.InitializeAs
82+ initFile := m.com.Config().Options.InitializeAs
84
83
85 header := s.Header.Render("Would you like to initialize this project?")
84 header := s.Header.Render("Would you like to initialize this project?")
86 path := s.Accent.PaddingLeft(2).Render(cwd)
85 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) {
112 height := area.Dy()
112 height := area.Dy()
113
113
114 title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
114 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)
116 sidebarLogo := m.sidebarLogo
116 sidebarLogo := m.sidebarLogo
117 if height < logoHeightBreakpoint {
117 if height < logoHeightBreakpoint {
118 sidebarLogo = logo.SmallRender(m.com.Styles, width)
118 sidebarLogo = logo.SmallRender(m.com.Styles, width)
@@ -138,7 +138,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
138
138
139 lspSection := m.lspInfo(width, maxLSPs, true)
139 lspSection := m.lspInfo(width, maxLSPs, true)
140 mcpSection := m.mcpInfo(width, maxMCPs, true)
140 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)
142
142
143 uv.NewStyledString(
143 uv.NewStyledString(
144 lipgloss.NewStyle().
144 lipgloss.NewStyle().
internal/ui/model/ui.go+13−13
@@ -317,7 +317,7 @@ func New(com *common.Common) *UI {
317 desiredFocus := uiFocusEditor
317 desiredFocus := uiFocusEditor
318 if !com.Config().IsConfigured() {
318 if !com.Config().IsConfigured() {
319 desiredState = uiOnboarding
319 desiredState = uiOnboarding
320− } else if n, _ := config.ProjectNeedsInitialization(com.Config()); n {
320+ } else if n, _ := config.ProjectNeedsInitialization(com.Store()); n {
321 desiredState = uiInitialize
321 desiredState = uiInitialize
322 }
322 }
323
323
@@ -579,7 +579,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
579 case mcp.EventPromptsListChanged:
579 case mcp.EventPromptsListChanged:
580 return m, handleMCPPromptsEvent(msg.Payload.Name)
580 return m, handleMCPPromptsEvent(msg.Payload.Name)
581 case mcp.EventToolsListChanged:
581 case mcp.EventToolsListChanged:
582− return m, handleMCPToolsEvent(m.com.Config(), msg.Payload.Name)
582+ return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name)
583 case mcp.EventResourcesListChanged:
583 case mcp.EventResourcesListChanged:
584 return m, handleMCPResourcesEvent(msg.Payload.Name)
584 return m, handleMCPResourcesEvent(msg.Payload.Name)
585 }
585 }
@@ -1301,7 +1301,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1301
1301
1302 currentModel := cfg.Models[agentCfg.Model]
1302 currentModel := cfg.Models[agentCfg.Model]
1303 currentModel.Think = !currentModel.Think
1303 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 {
1305 return util.ReportError(err)()
1305 return util.ReportError(err)()
1306 }
1306 }
1307 m.com.App.UpdateAgentModel(context.TODO())
1307 m.com.App.UpdateAgentModel(context.TODO())
@@ -1342,7 +1342,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1342
1342
1343 // Attempt to import GitHub Copilot tokens from VSCode if available.
1343 // Attempt to import GitHub Copilot tokens from VSCode if available.
1344 if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1344 if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1345− m.com.Config().ImportCopilot()
1345+ m.com.Store().ImportCopilot()
1346 }
1346 }
1347
1347
1348 if !isConfigured() || msg.ReAuthenticate {
1348 if !isConfigured() || msg.ReAuthenticate {
@@ -1353,12 +1353,12 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1353 break
1353 break
1354 }
1354 }
1355
1355
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 {
1357 cmds = append(cmds, util.ReportError(err))
1357 cmds = append(cmds, util.ReportError(err))
1358 } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1358 } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1359 // Ensure small model is set is unset.
1359 // Ensure small model is set is unset.
1360 smallModel := m.com.App.GetDefaultSmallModel(providerID)
1360 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 {
1362 cmds = append(cmds, util.ReportError(err))
1362 cmds = append(cmds, util.ReportError(err))
1363 }
1363 }
1364 }
1364 }
@@ -1404,7 +1404,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1404
1404
1405 currentModel := cfg.Models[agentCfg.Model]
1405 currentModel := cfg.Models[agentCfg.Model]
1406 currentModel.ReasoningEffort = msg.Effort
1406 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 {
1408 cmds = append(cmds, util.ReportError(err))
1408 cmds = append(cmds, util.ReportError(err))
1409 break
1409 break
1410 }
1410 }
@@ -2016,7 +2016,7 @@ func (m *UI) View() tea.View {
2016 }
2016 }
2017 v.MouseMode = tea.MouseModeCellMotion
2017 v.MouseMode = tea.MouseModeCellMotion
2018 v.ReportFocus = m.caps.ReportFocusEvents
2018 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())
2020
2020
2021 canvas := uv.NewScreenBuffer(m.width, m.height)
2021 canvas := uv.NewScreenBuffer(m.width, m.height)
2022 v.Cursor = m.Draw(canvas, canvas.Bounds())
2022 v.Cursor = m.Draw(canvas, canvas.Bounds())
@@ -2255,7 +2255,7 @@ func (m *UI) FullHelp() [][]key.Binding {
2255func (m *UI) toggleCompactMode() tea.Cmd {
2255func (m *UI) toggleCompactMode() tea.Cmd {
2256 m.forceCompactMode = !m.forceCompactMode
2256 m.forceCompactMode = !m.forceCompactMode
2257
2257
2258− err := m.com.Config().SetCompactMode(m.forceCompactMode)
2258+ err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2259 if err != nil {
2259 if err != nil {
2260 return util.ReportError(err)
2260 return util.ReportError(err)
2261 }
2261 }
@@ -2637,7 +2637,7 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu
2637 return func() tea.Msg {
2637 return func() tea.Msg {
2638 contents, err := mcp.ReadResource(
2638 contents, err := mcp.ReadResource(
2639 context.Background(),
2639 context.Background(),
2640− m.com.Config(),
2640+ m.com.Store(),
2641 item.MCPName,
2641 item.MCPName,
2642 item.URI,
2642 item.URI,
2643 )
2643 )
@@ -3299,7 +3299,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3299
3299
3300 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3300 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3301 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3301 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)
3303 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3303 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3304 uv.NewStyledString(
3304 uv.NewStyledString(
3305 s.CompactDetails.View.
3305 s.CompactDetails.View.
@@ -3317,7 +3317,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3317
3317
3318func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3318func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3319 load := func() tea.Msg {
3319 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)
3321 if err != nil {
3321 if err != nil {
3322 // TODO: make this better
3322 // TODO: make this better
3323 return util.ReportError(err)()
3323 return util.ReportError(err)()
@@ -3358,7 +3358,7 @@ func handleMCPPromptsEvent(name string) tea.Cmd {
3358 }
3358 }
3359}
3359}
3360
3360
3361−func handleMCPToolsEvent(cfg *config.Config, name string) tea.Cmd {
3361+func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
3362 return func() tea.Msg {
3362 return func() tea.Msg {
3363 mcp.RefreshTools(
3363 mcp.RefreshTools(
3364 context.Background(),
3364 context.Background(),