1package client
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8
9 "github.com/charmbracelet/crush/internal/config"
10 "github.com/charmbracelet/crush/internal/oauth"
11 "github.com/charmbracelet/crush/internal/proto"
12)
13
14// SetConfigField sets a config key/value pair on the server.
15func (c *Client) SetConfigField(ctx context.Context, id string, scope config.Scope, key string, value any) error {
16 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/set", id), nil, jsonBody(struct {
17 Scope config.Scope `json:"scope"`
18 Key string `json:"key"`
19 Value any `json:"value"`
20 }{Scope: scope, Key: key, Value: value}), http.Header{"Content-Type": []string{"application/json"}})
21 if err != nil {
22 return fmt.Errorf("failed to set config field: %w", err)
23 }
24 defer rsp.Body.Close()
25 if rsp.StatusCode != http.StatusOK {
26 return fmt.Errorf("failed to set config field: status code %d", rsp.StatusCode)
27 }
28 return nil
29}
30
31// RemoveConfigField removes a config key on the server.
32func (c *Client) RemoveConfigField(ctx context.Context, id string, scope config.Scope, key string) error {
33 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/remove", id), nil, jsonBody(struct {
34 Scope config.Scope `json:"scope"`
35 Key string `json:"key"`
36 }{Scope: scope, Key: key}), http.Header{"Content-Type": []string{"application/json"}})
37 if err != nil {
38 return fmt.Errorf("failed to remove config field: %w", err)
39 }
40 defer rsp.Body.Close()
41 if rsp.StatusCode != http.StatusOK {
42 return fmt.Errorf("failed to remove config field: status code %d", rsp.StatusCode)
43 }
44 return nil
45}
46
47// UpdatePreferredModel updates the preferred model on the server.
48func (c *Client) UpdatePreferredModel(ctx context.Context, id string, scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
49 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/model", id), nil, jsonBody(struct {
50 Scope config.Scope `json:"scope"`
51 ModelType config.SelectedModelType `json:"model_type"`
52 Model config.SelectedModel `json:"model"`
53 }{Scope: scope, ModelType: modelType, Model: model}), http.Header{"Content-Type": []string{"application/json"}})
54 if err != nil {
55 return fmt.Errorf("failed to update preferred model: %w", err)
56 }
57 defer rsp.Body.Close()
58 if rsp.StatusCode != http.StatusOK {
59 return fmt.Errorf("failed to update preferred model: status code %d", rsp.StatusCode)
60 }
61 return nil
62}
63
64// SetCompactMode sets compact mode on the server.
65func (c *Client) SetCompactMode(ctx context.Context, id string, scope config.Scope, enabled bool) error {
66 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/compact", id), nil, jsonBody(struct {
67 Scope config.Scope `json:"scope"`
68 Enabled bool `json:"enabled"`
69 }{Scope: scope, Enabled: enabled}), http.Header{"Content-Type": []string{"application/json"}})
70 if err != nil {
71 return fmt.Errorf("failed to set compact mode: %w", err)
72 }
73 defer rsp.Body.Close()
74 if rsp.StatusCode != http.StatusOK {
75 return fmt.Errorf("failed to set compact mode: status code %d", rsp.StatusCode)
76 }
77 return nil
78}
79
80// SetProviderAPIKey sets a provider API key on the server. The wire
81// format tags the credential with an explicit Kind so the server can
82// decode it back into the right Go type — JSON's `any` loses that
83// information across the socket.
84func (c *Client) SetProviderAPIKey(ctx context.Context, id string, scope config.Scope, providerID string, apiKey any) error {
85 var (
86 kind proto.APIKeyKind
87 raw json.RawMessage
88 )
89 switch v := apiKey.(type) {
90 case string:
91 kind = proto.APIKeyKindString
92 b, err := json.Marshal(v)
93 if err != nil {
94 return fmt.Errorf("failed to marshal api key string: %w", err)
95 }
96 raw = b
97 case *oauth.Token:
98 if v == nil {
99 return fmt.Errorf("oauth token is nil")
100 }
101 kind = proto.APIKeyKindOAuth
102 b, err := json.Marshal(v)
103 if err != nil {
104 return fmt.Errorf("failed to marshal oauth token: %w", err)
105 }
106 raw = b
107 default:
108 return fmt.Errorf("unsupported api key type %T", apiKey)
109 }
110
111 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/provider-key", id), nil, jsonBody(proto.ConfigProviderKeyRequest{
112 Scope: scope,
113 ProviderID: providerID,
114 Kind: kind,
115 APIKey: raw,
116 }), http.Header{"Content-Type": []string{"application/json"}})
117 if err != nil {
118 return fmt.Errorf("failed to set provider API key: %w", err)
119 }
120 defer rsp.Body.Close()
121 if rsp.StatusCode != http.StatusOK {
122 return fmt.Errorf("failed to set provider API key: status code %d", rsp.StatusCode)
123 }
124 return nil
125}
126
127// ImportCopilot attempts to import a GitHub Copilot token on the
128// server.
129func (c *Client) ImportCopilot(ctx context.Context, id string) (*oauth.Token, bool, error) {
130 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/import-copilot", id), nil, nil, nil)
131 if err != nil {
132 return nil, false, fmt.Errorf("failed to import copilot: %w", err)
133 }
134 defer rsp.Body.Close()
135 if rsp.StatusCode != http.StatusOK {
136 return nil, false, fmt.Errorf("failed to import copilot: status code %d", rsp.StatusCode)
137 }
138 var result struct {
139 Token *oauth.Token `json:"token"`
140 Success bool `json:"success"`
141 }
142 if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
143 return nil, false, fmt.Errorf("failed to decode import copilot response: %w", err)
144 }
145 return result.Token, result.Success, nil
146}
147
148// RefreshOAuthToken refreshes an OAuth token for a provider on the
149// server.
150func (c *Client) RefreshOAuthToken(ctx context.Context, id string, scope config.Scope, providerID string) error {
151 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/refresh-oauth", id), nil, jsonBody(struct {
152 Scope config.Scope `json:"scope"`
153 ProviderID string `json:"provider_id"`
154 }{Scope: scope, ProviderID: providerID}), http.Header{"Content-Type": []string{"application/json"}})
155 if err != nil {
156 return fmt.Errorf("failed to refresh OAuth token: %w", err)
157 }
158 defer rsp.Body.Close()
159 if rsp.StatusCode != http.StatusOK {
160 return fmt.Errorf("failed to refresh OAuth token: status code %d", rsp.StatusCode)
161 }
162 return nil
163}
164
165// ProjectNeedsInitialization checks if the project needs
166// initialization.
167func (c *Client) ProjectNeedsInitialization(ctx context.Context, id string) (bool, error) {
168 rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/project/needs-init", id), nil, nil)
169 if err != nil {
170 return false, fmt.Errorf("failed to check project init: %w", err)
171 }
172 defer rsp.Body.Close()
173 if rsp.StatusCode != http.StatusOK {
174 return false, fmt.Errorf("failed to check project init: status code %d", rsp.StatusCode)
175 }
176 var result struct {
177 NeedsInit bool `json:"needs_init"`
178 }
179 if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
180 return false, fmt.Errorf("failed to decode project init response: %w", err)
181 }
182 return result.NeedsInit, nil
183}
184
185// MarkProjectInitialized marks the project as initialized on the
186// server.
187func (c *Client) MarkProjectInitialized(ctx context.Context, id string) error {
188 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/project/init", id), nil, nil, nil)
189 if err != nil {
190 return fmt.Errorf("failed to mark project initialized: %w", err)
191 }
192 defer rsp.Body.Close()
193 if rsp.StatusCode != http.StatusOK {
194 return fmt.Errorf("failed to mark project initialized: status code %d", rsp.StatusCode)
195 }
196 return nil
197}
198
199// GetInitializePrompt retrieves the initialization prompt from the
200// server.
201func (c *Client) GetInitializePrompt(ctx context.Context, id string) (string, error) {
202 rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/project/init-prompt", id), nil, nil)
203 if err != nil {
204 return "", fmt.Errorf("failed to get init prompt: %w", err)
205 }
206 defer rsp.Body.Close()
207 if rsp.StatusCode != http.StatusOK {
208 return "", fmt.Errorf("failed to get init prompt: status code %d", rsp.StatusCode)
209 }
210 var result struct {
211 Prompt string `json:"prompt"`
212 }
213 if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
214 return "", fmt.Errorf("failed to decode init prompt response: %w", err)
215 }
216 return result.Prompt, nil
217}
218
219// ListSkills retrieves the visible skills for a workspace.
220func (c *Client) ListSkills(ctx context.Context, id string) ([]proto.SkillInfo, error) {
221 rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/skills", id), nil, nil)
222 if err != nil {
223 return nil, fmt.Errorf("failed to list skills: %w", err)
224 }
225 defer rsp.Body.Close()
226 if rsp.StatusCode != http.StatusOK {
227 return nil, fmt.Errorf("failed to list skills: status code %d", rsp.StatusCode)
228 }
229 var skills []proto.SkillInfo
230 if err := json.NewDecoder(rsp.Body).Decode(&skills); err != nil {
231 return nil, fmt.Errorf("failed to decode skills: %w", err)
232 }
233 return skills, nil
234}
235
236// ReadSkill reads a skill's content by ID from the server.
237func (c *Client) ReadSkill(ctx context.Context, id, skillID string) (*proto.ReadSkillResponse, error) {
238 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/skills/read", id), nil, jsonBody(proto.ReadSkillRequest{
239 SkillID: skillID,
240 }), http.Header{"Content-Type": []string{"application/json"}})
241 if err != nil {
242 return nil, fmt.Errorf("failed to read skill: %w", err)
243 }
244 defer rsp.Body.Close()
245 if rsp.StatusCode != http.StatusOK {
246 return nil, fmt.Errorf("failed to read skill: status code %d", rsp.StatusCode)
247 }
248 var result proto.ReadSkillResponse
249 if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
250 return nil, fmt.Errorf("failed to decode skill response: %w", err)
251 }
252 return &result, nil
253}
254
255// MCPResourceContents holds the contents of an MCP resource.
256type MCPResourceContents struct {
257 URI string `json:"uri"`
258 MIMEType string `json:"mime_type,omitempty"`
259 Text string `json:"text,omitempty"`
260 Blob []byte `json:"blob,omitempty"`
261}
262
263// EnableDockerMCP enables the Docker MCP server on the workspace.
264func (c *Client) EnableDockerMCP(ctx context.Context, id string) error {
265 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/docker/enable", id), nil, nil, nil)
266 if err != nil {
267 return fmt.Errorf("failed to enable docker MCP: %w", err)
268 }
269 defer rsp.Body.Close()
270 if rsp.StatusCode != http.StatusOK {
271 return fmt.Errorf("failed to enable docker MCP: status code %d", rsp.StatusCode)
272 }
273 return nil
274}
275
276// DisableDockerMCP disables the Docker MCP server on the workspace.
277func (c *Client) DisableDockerMCP(ctx context.Context, id string) error {
278 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/docker/disable", id), nil, nil, nil)
279 if err != nil {
280 return fmt.Errorf("failed to disable docker MCP: %w", err)
281 }
282 defer rsp.Body.Close()
283 if rsp.StatusCode != http.StatusOK {
284 return fmt.Errorf("failed to disable docker MCP: status code %d", rsp.StatusCode)
285 }
286 return nil
287}
288
289// RefreshMCPTools refreshes tools for a named MCP server.
290func (c *Client) RefreshMCPTools(ctx context.Context, id, name string) error {
291 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-tools", id), nil, jsonBody(struct {
292 Name string `json:"name"`
293 }{Name: name}), http.Header{"Content-Type": []string{"application/json"}})
294 if err != nil {
295 return fmt.Errorf("failed to refresh MCP tools: %w", err)
296 }
297 defer rsp.Body.Close()
298 if rsp.StatusCode != http.StatusOK {
299 return fmt.Errorf("failed to refresh MCP tools: status code %d", rsp.StatusCode)
300 }
301 return nil
302}
303
304// ReadMCPResource reads a resource from a named MCP server.
305func (c *Client) ReadMCPResource(ctx context.Context, id, name, uri string) ([]MCPResourceContents, error) {
306 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/read-resource", id), nil, jsonBody(struct {
307 Name string `json:"name"`
308 URI string `json:"uri"`
309 }{Name: name, URI: uri}), http.Header{"Content-Type": []string{"application/json"}})
310 if err != nil {
311 return nil, fmt.Errorf("failed to read MCP resource: %w", err)
312 }
313 defer rsp.Body.Close()
314 if rsp.StatusCode != http.StatusOK {
315 return nil, fmt.Errorf("failed to read MCP resource: status code %d", rsp.StatusCode)
316 }
317 var contents []MCPResourceContents
318 if err := json.NewDecoder(rsp.Body).Decode(&contents); err != nil {
319 return nil, fmt.Errorf("failed to decode MCP resource: %w", err)
320 }
321 return contents, nil
322}
323
324// GetMCPPrompt retrieves a prompt from a named MCP server.
325func (c *Client) GetMCPPrompt(ctx context.Context, id, clientID, promptID string, args map[string]string) (string, error) {
326 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/get-prompt", id), nil, jsonBody(struct {
327 ClientID string `json:"client_id"`
328 PromptID string `json:"prompt_id"`
329 Args map[string]string `json:"args"`
330 }{ClientID: clientID, PromptID: promptID, Args: args}), http.Header{"Content-Type": []string{"application/json"}})
331 if err != nil {
332 return "", fmt.Errorf("failed to get MCP prompt: %w", err)
333 }
334 defer rsp.Body.Close()
335 if rsp.StatusCode != http.StatusOK {
336 return "", fmt.Errorf("failed to get MCP prompt: status code %d", rsp.StatusCode)
337 }
338 var result struct {
339 Prompt string `json:"prompt"`
340 }
341 if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
342 return "", fmt.Errorf("failed to decode MCP prompt response: %w", err)
343 }
344 return result.Prompt, nil
345}