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		}
205	}
206	return result, nil
207}
208
209// EnableDockerMCP validates Docker MCP availability, stages the
210// configuration, starts the MCP client, and persists the config.
211func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {
212	ws, err := b.GetWorkspace(workspaceID)
213	if err != nil {
214		return err
215	}
216
217	mcpConfig, err := ws.Cfg.PrepareDockerMCPConfig()
218	if err != nil {
219		return err
220	}
221
222	if err := mcptools.InitializeSingle(ctx, config.DockerMCPName, ws.Cfg); err != nil {
223		disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
224		delete(ws.Cfg.Config().MCP, config.DockerMCPName)
225		return fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr))
226	}
227
228	if err := ws.Cfg.PersistDockerMCPConfig(mcpConfig); err != nil {
229		disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
230		delete(ws.Cfg.Config().MCP, config.DockerMCPName)
231		return fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr))
232	}
233
234	publishConfigChanged(ws)
235	return nil
236}
237
238// DisableDockerMCP closes the Docker MCP client, removes the
239// configuration, and persists the change.
240func (b *Backend) DisableDockerMCP(workspaceID string) error {
241	ws, err := b.GetWorkspace(workspaceID)
242	if err != nil {
243		return err
244	}
245
246	if err := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName); err != nil {
247		return fmt.Errorf("failed to disable docker MCP: %w", err)
248	}
249
250	if err := ws.Cfg.DisableDockerMCP(); err != nil {
251		return err
252	}
253
254	publishConfigChanged(ws)
255	return nil
256}
257
258// RefreshMCPTools refreshes the tools for a named MCP server.
259func (b *Backend) RefreshMCPTools(ctx context.Context, workspaceID, name string) error {
260	ws, err := b.GetWorkspace(workspaceID)
261	if err != nil {
262		return err
263	}
264	mcptools.RefreshTools(ctx, ws.Cfg, name)
265	return nil
266}
267
268// ReadMCPResource reads a resource from a named MCP server.
269func (b *Backend) ReadMCPResource(ctx context.Context, workspaceID, name, uri string) ([]MCPResourceContents, error) {
270	ws, err := b.GetWorkspace(workspaceID)
271	if err != nil {
272		return nil, err
273	}
274	contents, err := mcptools.ReadResource(ctx, ws.Cfg, name, uri)
275	if err != nil {
276		return nil, err
277	}
278	result := make([]MCPResourceContents, len(contents))
279	for i, c := range contents {
280		result[i] = MCPResourceContents{
281			URI:      c.URI,
282			MIMEType: c.MIMEType,
283			Text:     c.Text,
284			Blob:     c.Blob,
285		}
286	}
287	return result, nil
288}
289
290// GetMCPPrompt retrieves a prompt from a named MCP server.
291func (b *Backend) GetMCPPrompt(workspaceID, clientID, promptID string, args map[string]string) (string, error) {
292	ws, err := b.GetWorkspace(workspaceID)
293	if err != nil {
294		return "", err
295	}
296	return commands.GetMCPPrompt(ws.Cfg, clientID, promptID, args)
297}
298
299// GetWorkingDir returns the working directory for a workspace.
300func (b *Backend) GetWorkingDir(workspaceID string) (string, error) {
301	ws, err := b.GetWorkspace(workspaceID)
302	if err != nil {
303		return "", err
304	}
305	return ws.Cfg.WorkingDir(), nil
306}