Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 92282b2

Browse files
committed
feat(cli): implement exp mcp configure claude-code command
1 parent 27d2343 commit 92282b2

File tree

2 files changed

+329
-2
lines changed

2 files changed

+329
-2
lines changed

cli/exp_mcp.go

Lines changed: 203 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"path/filepath"
99

1010
"github.com/mark3labs/mcp-go/server"
11+
"github.com/spf13/afero"
12+
"golang.org/x/xerrors"
1113

1214
"cdr.dev/slog"
1315
"cdr.dev/slog/sloggers/sloghuman"
@@ -106,12 +108,95 @@ func (*RootCmd) mcpConfigureClaudeDesktop() *serpent.Command {
106108
}
107109

108110
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+
)
109119
cmd := &serpent.Command{
110120
Use: "claude-code",
111121
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)
113152
return nil
114153
},
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+
},
115200
}
116201
return cmd
117202
}
@@ -317,3 +402,120 @@ func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instruct
317402

318403
return nil
319404
}
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+
}

cli/exp_mcp_test.go

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package cli_test
33
import (
44
"context"
55
"encoding/json"
6+
"os"
7+
"path/filepath"
68
"runtime"
79
"slices"
810
"testing"
@@ -16,7 +18,7 @@ import (
1618
"github.com/coder/coder/v2/testutil"
1719
)
1820

19-
func TestExpMcp(t *testing.T) {
21+
func TestExpMcpServer(t *testing.T) {
2022
t.Parallel()
2123

2224
// Reading to / writing from the PTY is flaky on non-linux systems.
@@ -140,3 +142,126 @@ func TestExpMcp(t *testing.T) {
140142
assert.ErrorContains(t, err, "your session has expired")
141143
})
142144
}
145+
146+
func TestExpMcpConfigure(t *testing.T) {
147+
t.Run("ClaudeCode", func(t *testing.T) {
148+
t.Setenv("CODER_AGENT_TOKEN", "test-agent-token")
149+
ctx := testutil.Context(t, testutil.WaitShort)
150+
cancelCtx, cancel := context.WithCancel(ctx)
151+
t.Cleanup(cancel)
152+
153+
client := coderdtest.New(t, nil)
154+
_ = coderdtest.CreateFirstUser(t, client)
155+
156+
tmpDir := t.TempDir()
157+
claudeConfigPath := filepath.Join(tmpDir, "claude.json")
158+
expectedConfig := `{
159+
"autoUpdaterStatus": "disabled",
160+
"bypassPermissionsModeAccepted": true,
161+
"hasAcknowledgedCostThreshold": true,
162+
"hasCompletedOnboarding": true,
163+
"primaryApiKey": "test-api-key",
164+
"projects": {
165+
"/path/to/project": {
166+
"allowedTools": [],
167+
"hasCompletedProjectOnboarding": true,
168+
"hasTrustDialogAccepted": true,
169+
"history": [
170+
"make sure to read claude.md and report tasks properly"
171+
],
172+
"mcpServers": {
173+
"coder": {
174+
"command": "pathtothecoderbinary",
175+
"args": ["exp", "mcp", "server"],
176+
"env": {
177+
"CODER_AGENT_TOKEN": "test-agent-token"
178+
}
179+
}
180+
}
181+
}
182+
}
183+
}`
184+
185+
inv, root := clitest.New(t, "exp", "mcp", "configure", "claude-code",
186+
"--claude-api-key=test-api-key",
187+
"--claude-config-path="+claudeConfigPath,
188+
"--claude-project-directory=/path/to/project",
189+
"--claude-system-prompt=test-system-prompt",
190+
"--claude-task-prompt=test-task-prompt",
191+
"--claude-test-binary-name=pathtothecoderbinary",
192+
)
193+
clitest.SetupConfig(t, client, root)
194+
195+
err := inv.WithContext(cancelCtx).Run()
196+
require.NoError(t, err, "failed to configure claude code")
197+
require.FileExists(t, claudeConfigPath, "claude config file should exist")
198+
claudeConfig, err := os.ReadFile(claudeConfigPath)
199+
require.NoError(t, err, "failed to read claude config path")
200+
testutil.RequireJSONEq(t, expectedConfig, string(claudeConfig))
201+
})
202+
203+
t.Run("ExistingConfig", func(t *testing.T) {
204+
t.Setenv("CODER_AGENT_TOKEN", "test-agent-token")
205+
206+
ctx := testutil.Context(t, testutil.WaitShort)
207+
cancelCtx, cancel := context.WithCancel(ctx)
208+
t.Cleanup(cancel)
209+
210+
client := coderdtest.New(t, nil)
211+
_ = coderdtest.CreateFirstUser(t, client)
212+
213+
tmpDir := t.TempDir()
214+
claudeConfigPath := filepath.Join(tmpDir, "claude.json")
215+
err := os.WriteFile(claudeConfigPath, []byte(`{
216+
"bypassPermissionsModeAccepted": false,
217+
"hasCompletedOnboarding": false,
218+
"primaryApiKey": "magic-api-key"
219+
}`), 0o600)
220+
require.NoError(t, err, "failed to write claude config path")
221+
222+
expectedConfig := `{
223+
"autoUpdaterStatus": "disabled",
224+
"bypassPermissionsModeAccepted": true,
225+
"hasAcknowledgedCostThreshold": true,
226+
"hasCompletedOnboarding": true,
227+
"primaryApiKey": "test-api-key",
228+
"projects": {
229+
"/path/to/project": {
230+
"allowedTools": [],
231+
"hasCompletedProjectOnboarding": true,
232+
"hasTrustDialogAccepted": true,
233+
"history": [
234+
"make sure to read claude.md and report tasks properly"
235+
],
236+
"mcpServers": {
237+
"coder": {
238+
"command": "pathtothecoderbinary",
239+
"args": ["exp", "mcp", "server"],
240+
"env": {
241+
"CODER_AGENT_TOKEN": "test-agent-token"
242+
}
243+
}
244+
}
245+
}
246+
}
247+
}`
248+
249+
inv, root := clitest.New(t, "exp", "mcp", "configure", "claude-code",
250+
"--claude-api-key=test-api-key",
251+
"--claude-config-path="+claudeConfigPath,
252+
"--claude-project-directory=/path/to/project",
253+
"--claude-system-prompt=test-system-prompt",
254+
"--claude-task-prompt=test-task-prompt",
255+
"--claude-test-binary-name=pathtothecoderbinary",
256+
)
257+
258+
clitest.SetupConfig(t, client, root)
259+
260+
err = inv.WithContext(cancelCtx).Run()
261+
require.NoError(t, err, "failed to configure claude code")
262+
require.FileExists(t, claudeConfigPath, "claude config file should exist")
263+
claudeConfig, err := os.ReadFile(claudeConfigPath)
264+
require.NoError(t, err, "failed to read claude config path")
265+
testutil.RequireJSONEq(t, expectedConfig, string(claudeConfig))
266+
})
267+
}

0 commit comments

Comments
 (0)