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