|
8 | 8 | "path/filepath"
|
9 | 9 |
|
10 | 10 | "github.com/mark3labs/mcp-go/server"
|
| 11 | + "github.com/spf13/afero" |
| 12 | + "golang.org/x/xerrors" |
11 | 13 |
|
12 | 14 | "cdr.dev/slog"
|
13 | 15 | "cdr.dev/slog/sloggers/sloghuman"
|
@@ -106,12 +108,95 @@ func (*RootCmd) mcpConfigureClaudeDesktop() *serpent.Command {
|
106 | 108 | }
|
107 | 109 |
|
108 | 110 | func (*RootCmd) mcpConfigureClaudeCode() *serpent.Command {
|
| 111 | + var ( |
| 112 | + apiKey string |
| 113 | + claudeConfigPath string |
| 114 | + projectDirectory string |
| 115 | + systemPrompt string |
| 116 | + taskPrompt string |
| 117 | + testBinaryName string |
| 118 | + ) |
109 | 119 | cmd := &serpent.Command{
|
110 | 120 | Use: "claude-code",
|
111 | 121 | Short: "Configure the Claude Code server.",
|
112 |
| - Handler: func(_ *serpent.Invocation) error { |
| 122 | + Handler: func(inv *serpent.Invocation) error { |
| 123 | + fs := afero.NewOsFs() |
| 124 | + binPath, err := os.Executable() |
| 125 | + if err != nil { |
| 126 | + return xerrors.Errorf("failed to get executable path: %w", err) |
| 127 | + } |
| 128 | + if testBinaryName != "" { |
| 129 | + binPath = testBinaryName |
| 130 | + } |
| 131 | + configureClaudeEnv := map[string]string{} |
| 132 | + if _, ok := os.LookupEnv("CODER_AGENT_TOKEN"); ok { |
| 133 | + configureClaudeEnv["CODER_AGENT_TOKEN"] = os.Getenv("CODER_AGENT_TOKEN") |
| 134 | + } |
| 135 | + |
| 136 | + if err := configureClaude(fs, ClaudeConfig{ |
| 137 | + AllowedTools: []string{}, |
| 138 | + APIKey: apiKey, |
| 139 | + ConfigPath: claudeConfigPath, |
| 140 | + ProjectDirectory: projectDirectory, |
| 141 | + MCPServers: map[string]ClaudeConfigMCP{ |
| 142 | + "coder": { |
| 143 | + Command: binPath, |
| 144 | + Args: []string{"exp", "mcp", "server"}, |
| 145 | + Env: configureClaudeEnv, |
| 146 | + }, |
| 147 | + }, |
| 148 | + }); err != nil { |
| 149 | + return xerrors.Errorf("failed to configure claude: %w", err) |
| 150 | + } |
| 151 | + cliui.Infof(inv.Stderr, "Wrote config to %s", claudeConfigPath) |
113 | 152 | return nil
|
114 | 153 | },
|
| 154 | + Options: []serpent.Option{ |
| 155 | + { |
| 156 | + Name: "claude-config-path", |
| 157 | + Description: "The path to the Claude config file.", |
| 158 | + Env: "CODER_MCP_CLAUDE_CONFIG_PATH", |
| 159 | + Flag: "claude-config-path", |
| 160 | + Value: serpent.StringOf(&claudeConfigPath), |
| 161 | + Default: filepath.Join(os.Getenv("HOME"), ".claude.json"), |
| 162 | + }, |
| 163 | + { |
| 164 | + Name: "api-key", |
| 165 | + Description: "The API key to use for the Claude Code server.", |
| 166 | + Env: "CODER_MCP_CLAUDE_API_KEY", |
| 167 | + Flag: "claude-api-key", |
| 168 | + Value: serpent.StringOf(&apiKey), |
| 169 | + }, |
| 170 | + { |
| 171 | + Name: "system-prompt", |
| 172 | + Description: "The system prompt to use for the Claude Code server.", |
| 173 | + Env: "CODER_MCP_CLAUDE_SYSTEM_PROMPT", |
| 174 | + Flag: "claude-system-prompt", |
| 175 | + Value: serpent.StringOf(&systemPrompt), |
| 176 | + }, |
| 177 | + { |
| 178 | + Name: "task-prompt", |
| 179 | + Description: "The task prompt to use for the Claude Code server.", |
| 180 | + Env: "CODER_MCP_CLAUDE_TASK_PROMPT", |
| 181 | + Flag: "claude-task-prompt", |
| 182 | + Value: serpent.StringOf(&taskPrompt), |
| 183 | + }, |
| 184 | + { |
| 185 | + Name: "project-directory", |
| 186 | + Description: "The project directory to use for the Claude Code server.", |
| 187 | + Env: "CODER_MCP_CLAUDE_PROJECT_DIRECTORY", |
| 188 | + Flag: "claude-project-directory", |
| 189 | + Value: serpent.StringOf(&projectDirectory), |
| 190 | + }, |
| 191 | + { |
| 192 | + Name: "test-binary-name", |
| 193 | + Description: "Only used for testing.", |
| 194 | + Env: "CODER_MCP_CLAUDE_TEST_BINARY_NAME", |
| 195 | + Flag: "claude-test-binary-name", |
| 196 | + Value: serpent.StringOf(&testBinaryName), |
| 197 | + Hidden: true, |
| 198 | + }, |
| 199 | + }, |
115 | 200 | }
|
116 | 201 | return cmd
|
117 | 202 | }
|
@@ -317,3 +402,120 @@ func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instruct
|
317 | 402 |
|
318 | 403 | return nil
|
319 | 404 | }
|
| 405 | + |
| 406 | +type ClaudeConfig struct { |
| 407 | + ConfigPath string |
| 408 | + ProjectDirectory string |
| 409 | + APIKey string |
| 410 | + AllowedTools []string |
| 411 | + MCPServers map[string]ClaudeConfigMCP |
| 412 | +} |
| 413 | + |
| 414 | +type ClaudeConfigMCP struct { |
| 415 | + Command string `json:"command"` |
| 416 | + Args []string `json:"args"` |
| 417 | + Env map[string]string `json:"env"` |
| 418 | +} |
| 419 | + |
| 420 | +func configureClaude(fs afero.Fs, cfg ClaudeConfig) error { |
| 421 | + if cfg.ConfigPath == "" { |
| 422 | + cfg.ConfigPath = filepath.Join(os.Getenv("HOME"), ".claude.json") |
| 423 | + } |
| 424 | + var config map[string]any |
| 425 | + _, err := fs.Stat(cfg.ConfigPath) |
| 426 | + if err != nil { |
| 427 | + if !os.IsNotExist(err) { |
| 428 | + return xerrors.Errorf("failed to stat claude config: %w", err) |
| 429 | + } |
| 430 | + // Touch the file to create it if it doesn't exist. |
| 431 | + if err = afero.WriteFile(fs, cfg.ConfigPath, []byte(`{}`), 0600); err != nil { |
| 432 | + return xerrors.Errorf("failed to touch claude config: %w", err) |
| 433 | + } |
| 434 | + } |
| 435 | + oldConfigBytes, err := afero.ReadFile(fs, cfg.ConfigPath) |
| 436 | + if err != nil { |
| 437 | + return xerrors.Errorf("failed to read claude config: %w", err) |
| 438 | + } |
| 439 | + err = json.Unmarshal(oldConfigBytes, &config) |
| 440 | + if err != nil { |
| 441 | + return xerrors.Errorf("failed to unmarshal claude config: %w", err) |
| 442 | + } |
| 443 | + |
| 444 | + if cfg.APIKey != "" { |
| 445 | + // Stops Claude from requiring the user to generate |
| 446 | + // a Claude-specific API key. |
| 447 | + config["primaryApiKey"] = cfg.APIKey |
| 448 | + } |
| 449 | + // Stops Claude from asking for onboarding. |
| 450 | + config["hasCompletedOnboarding"] = true |
| 451 | + // Stops Claude from asking for permissions. |
| 452 | + config["bypassPermissionsModeAccepted"] = true |
| 453 | + config["autoUpdaterStatus"] = "disabled" |
| 454 | + // Stops Claude from asking for cost threshold. |
| 455 | + config["hasAcknowledgedCostThreshold"] = true |
| 456 | + |
| 457 | + projects, ok := config["projects"].(map[string]any) |
| 458 | + if !ok { |
| 459 | + projects = make(map[string]any) |
| 460 | + } |
| 461 | + |
| 462 | + project, ok := projects[cfg.ProjectDirectory].(map[string]any) |
| 463 | + if !ok { |
| 464 | + project = make(map[string]any) |
| 465 | + } |
| 466 | + |
| 467 | + allowedTools, ok := project["allowedTools"].([]string) |
| 468 | + if !ok { |
| 469 | + allowedTools = []string{} |
| 470 | + } |
| 471 | + |
| 472 | + // Add cfg.AllowedTools to the list if they're not already present. |
| 473 | + for _, tool := range cfg.AllowedTools { |
| 474 | + for _, existingTool := range allowedTools { |
| 475 | + if tool == existingTool { |
| 476 | + continue |
| 477 | + } |
| 478 | + } |
| 479 | + allowedTools = append(allowedTools, tool) |
| 480 | + } |
| 481 | + project["allowedTools"] = allowedTools |
| 482 | + project["hasTrustDialogAccepted"] = true |
| 483 | + project["hasCompletedProjectOnboarding"] = true |
| 484 | + |
| 485 | + mcpServers, ok := project["mcpServers"].(map[string]any) |
| 486 | + if !ok { |
| 487 | + mcpServers = make(map[string]any) |
| 488 | + } |
| 489 | + for name, mcp := range cfg.MCPServers { |
| 490 | + mcpServers[name] = mcp |
| 491 | + } |
| 492 | + project["mcpServers"] = mcpServers |
| 493 | + // Prevents Claude from asking the user to complete the project onboarding. |
| 494 | + project["hasCompletedProjectOnboarding"] = true |
| 495 | + |
| 496 | + history, ok := project["history"].([]string) |
| 497 | + injectedHistoryLine := "make sure to read claude.md and report tasks properly" |
| 498 | + |
| 499 | + if !ok || len(history) == 0 { |
| 500 | + // History doesn't exist or is empty, create it with our injected line |
| 501 | + history = []string{injectedHistoryLine} |
| 502 | + } else if history[0] != injectedHistoryLine { |
| 503 | + // Check if our line is already the first item |
| 504 | + // Prepend our line to the existing history |
| 505 | + history = append([]string{injectedHistoryLine}, history...) |
| 506 | + } |
| 507 | + project["history"] = history |
| 508 | + |
| 509 | + projects[cfg.ProjectDirectory] = project |
| 510 | + config["projects"] = projects |
| 511 | + |
| 512 | + newConfigBytes, err := json.MarshalIndent(config, "", " ") |
| 513 | + if err != nil { |
| 514 | + return xerrors.Errorf("failed to marshal claude config: %w", err) |
| 515 | + } |
| 516 | + err = afero.WriteFile(fs, cfg.ConfigPath, newConfigBytes, 0644) |
| 517 | + if err != nil { |
| 518 | + return xerrors.Errorf("failed to write claude config: %w", err) |
| 519 | + } |
| 520 | + return nil |
| 521 | +} |
0 commit comments