config.go

  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}