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}