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}