config.go

  1package backend
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7
  8	"github.com/charmbracelet/crush/internal/agent"
  9	mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
 10	"github.com/charmbracelet/crush/internal/commands"
 11	"github.com/charmbracelet/crush/internal/config"
 12	"github.com/charmbracelet/crush/internal/oauth"
 13	"github.com/charmbracelet/crush/internal/proto"
 14	"github.com/charmbracelet/crush/internal/pubsub"
 15)
 16
 17// publishConfigChanged publishes a ConfigChanged event on the workspace's
 18// event broker so all subscribers (e.g. remote clients) refresh their
 19// cached config snapshot.
 20func publishConfigChanged(ws *Workspace) {
 21	if ws == nil || ws.App == nil {
 22		return
 23	}
 24	ws.SendEvent(pubsub.Event[proto.ConfigChanged]{
 25		Type:    pubsub.UpdatedEvent,
 26		Payload: proto.ConfigChanged{WorkspaceID: ws.ID},
 27	})
 28}
 29
 30// MCPResourceContents holds the contents of an MCP resource returned
 31// by the backend.
 32type MCPResourceContents struct {
 33	URI      string `json:"uri"`
 34	MIMEType string `json:"mime_type,omitempty"`
 35	Text     string `json:"text,omitempty"`
 36	Blob     []byte `json:"blob,omitempty"`
 37}
 38
 39// SetConfigField sets a key/value pair in the config file for the
 40// given scope.
 41func (b *Backend) SetConfigField(workspaceID string, scope config.Scope, key string, value any) error {
 42	ws, err := b.GetWorkspace(workspaceID)
 43	if err != nil {
 44		return err
 45	}
 46	if err := ws.Cfg.SetConfigField(scope, key, value); err != nil {
 47		return err
 48	}
 49	publishConfigChanged(ws)
 50	return nil
 51}
 52
 53// RemoveConfigField removes a key from the config file for the given
 54// scope.
 55func (b *Backend) RemoveConfigField(workspaceID string, scope config.Scope, key string) error {
 56	ws, err := b.GetWorkspace(workspaceID)
 57	if err != nil {
 58		return err
 59	}
 60	if err := ws.Cfg.RemoveConfigField(scope, key); err != nil {
 61		return err
 62	}
 63	publishConfigChanged(ws)
 64	return nil
 65}
 66
 67// UpdatePreferredModel updates the preferred model for the given type
 68// and persists it to the config file at the given scope.
 69func (b *Backend) UpdatePreferredModel(workspaceID string, scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
 70	ws, err := b.GetWorkspace(workspaceID)
 71	if err != nil {
 72		return err
 73	}
 74	if err := ws.Cfg.UpdatePreferredModel(scope, modelType, model); err != nil {
 75		return err
 76	}
 77	publishConfigChanged(ws)
 78	return nil
 79}
 80
 81// SetCompactMode sets the compact mode setting and persists it.
 82func (b *Backend) SetCompactMode(workspaceID string, scope config.Scope, enabled bool) error {
 83	ws, err := b.GetWorkspace(workspaceID)
 84	if err != nil {
 85		return err
 86	}
 87	if err := ws.Cfg.SetCompactMode(scope, enabled); err != nil {
 88		return err
 89	}
 90	publishConfigChanged(ws)
 91	return nil
 92}
 93
 94// SetProviderAPIKey sets the API key for a provider and persists it.
 95func (b *Backend) SetProviderAPIKey(workspaceID string, scope config.Scope, providerID string, apiKey any) error {
 96	ws, err := b.GetWorkspace(workspaceID)
 97	if err != nil {
 98		return err
 99	}
100	if err := ws.Cfg.SetProviderAPIKey(scope, providerID, apiKey); err != nil {
101		return err
102	}
103	publishConfigChanged(ws)
104	return nil
105}
106
107// ImportCopilot attempts to import a GitHub Copilot token from disk.
108func (b *Backend) ImportCopilot(workspaceID string) (*oauth.Token, bool, error) {
109	ws, err := b.GetWorkspace(workspaceID)
110	if err != nil {
111		return nil, false, err
112	}
113	token, ok := ws.Cfg.ImportCopilot()
114	if ok {
115		publishConfigChanged(ws)
116	}
117	return token, ok, nil
118}
119
120// RefreshOAuthToken refreshes the OAuth token for a provider.
121func (b *Backend) RefreshOAuthToken(ctx context.Context, workspaceID string, scope config.Scope, providerID string) error {
122	ws, err := b.GetWorkspace(workspaceID)
123	if err != nil {
124		return err
125	}
126	if err := ws.Cfg.RefreshOAuthToken(ctx, scope, providerID); err != nil {
127		return err
128	}
129	publishConfigChanged(ws)
130	return nil
131}
132
133// ProjectNeedsInitialization checks whether the project in this
134// workspace needs initialization.
135func (b *Backend) ProjectNeedsInitialization(workspaceID string) (bool, error) {
136	ws, err := b.GetWorkspace(workspaceID)
137	if err != nil {
138		return false, err
139	}
140	return config.ProjectNeedsInitialization(ws.Cfg)
141}
142
143// MarkProjectInitialized marks the project as initialized.
144func (b *Backend) MarkProjectInitialized(workspaceID string) error {
145	ws, err := b.GetWorkspace(workspaceID)
146	if err != nil {
147		return err
148	}
149	if err := config.MarkProjectInitialized(ws.Cfg); err != nil {
150		return err
151	}
152	publishConfigChanged(ws)
153	return nil
154}
155
156// InitializePrompt builds the initialization prompt for the workspace.
157func (b *Backend) InitializePrompt(workspaceID string) (string, error) {
158	ws, err := b.GetWorkspace(workspaceID)
159	if err != nil {
160		return "", err
161	}
162	return agent.InitializePrompt(ws.Cfg)
163}
164
165// EnableDockerMCP validates Docker MCP availability, stages the
166// configuration, starts the MCP client, and persists the config.
167func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {
168	ws, err := b.GetWorkspace(workspaceID)
169	if err != nil {
170		return err
171	}
172
173	mcpConfig, err := ws.Cfg.PrepareDockerMCPConfig()
174	if err != nil {
175		return err
176	}
177
178	if err := mcptools.InitializeSingle(ctx, config.DockerMCPName, ws.Cfg); err != nil {
179		disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
180		delete(ws.Cfg.Config().MCP, config.DockerMCPName)
181		return fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr))
182	}
183
184	if err := ws.Cfg.PersistDockerMCPConfig(mcpConfig); err != nil {
185		disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
186		delete(ws.Cfg.Config().MCP, config.DockerMCPName)
187		return fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr))
188	}
189
190	publishConfigChanged(ws)
191	return nil
192}
193
194// DisableDockerMCP closes the Docker MCP client, removes the
195// configuration, and persists the change.
196func (b *Backend) DisableDockerMCP(workspaceID string) error {
197	ws, err := b.GetWorkspace(workspaceID)
198	if err != nil {
199		return err
200	}
201
202	if err := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName); err != nil {
203		return fmt.Errorf("failed to disable docker MCP: %w", err)
204	}
205
206	if err := ws.Cfg.DisableDockerMCP(); err != nil {
207		return err
208	}
209
210	publishConfigChanged(ws)
211	return nil
212}
213
214// RefreshMCPTools refreshes the tools for a named MCP server.
215func (b *Backend) RefreshMCPTools(ctx context.Context, workspaceID, name string) error {
216	ws, err := b.GetWorkspace(workspaceID)
217	if err != nil {
218		return err
219	}
220	mcptools.RefreshTools(ctx, ws.Cfg, name)
221	return nil
222}
223
224// ReadMCPResource reads a resource from a named MCP server.
225func (b *Backend) ReadMCPResource(ctx context.Context, workspaceID, name, uri string) ([]MCPResourceContents, error) {
226	ws, err := b.GetWorkspace(workspaceID)
227	if err != nil {
228		return nil, err
229	}
230	contents, err := mcptools.ReadResource(ctx, ws.Cfg, name, uri)
231	if err != nil {
232		return nil, err
233	}
234	result := make([]MCPResourceContents, len(contents))
235	for i, c := range contents {
236		result[i] = MCPResourceContents{
237			URI:      c.URI,
238			MIMEType: c.MIMEType,
239			Text:     c.Text,
240			Blob:     c.Blob,
241		}
242	}
243	return result, nil
244}
245
246// GetMCPPrompt retrieves a prompt from a named MCP server.
247func (b *Backend) GetMCPPrompt(workspaceID, clientID, promptID string, args map[string]string) (string, error) {
248	ws, err := b.GetWorkspace(workspaceID)
249	if err != nil {
250		return "", err
251	}
252	return commands.GetMCPPrompt(ws.Cfg, clientID, promptID, args)
253}
254
255// GetWorkingDir returns the working directory for a workspace.
256func (b *Backend) GetWorkingDir(workspaceID string) (string, error) {
257	ws, err := b.GetWorkspace(workspaceID)
258	if err != nil {
259		return "", err
260	}
261	return ws.Cfg.WorkingDir(), nil
262}