Skip to content

Commit 88bae05

Browse files
authored
feat(cli): implement exp mcp configure claude-code command (#17195)
Updates `~/.claude.json` and `~/.claude/CLAUDE.md` with required settings for agentic usage.
1 parent f3e5bb9 commit 88bae05

File tree

2 files changed

+682
-4
lines changed

2 files changed

+682
-4
lines changed

cli/exp_mcp.go

Lines changed: 356 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import (
66
"errors"
77
"os"
88
"path/filepath"
9+
"strings"
910

1011
"github.com/mark3labs/mcp-go/server"
12+
"github.com/spf13/afero"
13+
"golang.org/x/xerrors"
1114

1215
"cdr.dev/slog"
1316
"cdr.dev/slog/sloggers/sloghuman"
@@ -106,12 +109,118 @@ func (*RootCmd) mcpConfigureClaudeDesktop() *serpent.Command {
106109
}
107110

108111
func (*RootCmd) mcpConfigureClaudeCode() *serpent.Command {
112+
var (
113+
apiKey string
114+
claudeConfigPath string
115+
claudeMDPath string
116+
systemPrompt string
117+
appStatusSlug string
118+
testBinaryName string
119+
)
109120
cmd := &serpent.Command{
110-
Use: "claude-code",
111-
Short: "Configure the Claude Code server.",
112-
Handler: func(_ *serpent.Invocation) error {
121+
Use: "claude-code <project-directory>",
122+
Short: "Configure the Claude Code server. You will need to run this command for each project you want to use. Specify the project directory as the first argument.",
123+
Handler: func(inv *serpent.Invocation) error {
124+
if len(inv.Args) == 0 {
125+
return xerrors.Errorf("project directory is required")
126+
}
127+
projectDirectory := inv.Args[0]
128+
fs := afero.NewOsFs()
129+
binPath, err := os.Executable()
130+
if err != nil {
131+
return xerrors.Errorf("failed to get executable path: %w", err)
132+
}
133+
if testBinaryName != "" {
134+
binPath = testBinaryName
135+
}
136+
configureClaudeEnv := map[string]string{}
137+
agentToken, err := getAgentToken(fs)
138+
if err != nil {
139+
cliui.Warnf(inv.Stderr, "failed to get agent token: %s", err)
140+
} else {
141+
configureClaudeEnv["CODER_AGENT_TOKEN"] = agentToken
142+
}
143+
if appStatusSlug != "" {
144+
configureClaudeEnv["CODER_MCP_APP_STATUS_SLUG"] = appStatusSlug
145+
}
146+
if deprecatedSystemPromptEnv, ok := os.LookupEnv("SYSTEM_PROMPT"); ok {
147+
cliui.Warnf(inv.Stderr, "SYSTEM_PROMPT is deprecated, use CODER_MCP_CLAUDE_SYSTEM_PROMPT instead")
148+
systemPrompt = deprecatedSystemPromptEnv
149+
}
150+
151+
if err := configureClaude(fs, ClaudeConfig{
152+
// TODO: will this always be stable?
153+
AllowedTools: []string{`mcp__coder__coder_report_task`},
154+
APIKey: apiKey,
155+
ConfigPath: claudeConfigPath,
156+
ProjectDirectory: projectDirectory,
157+
MCPServers: map[string]ClaudeConfigMCP{
158+
"coder": {
159+
Command: binPath,
160+
Args: []string{"exp", "mcp", "server"},
161+
Env: configureClaudeEnv,
162+
},
163+
},
164+
}); err != nil {
165+
return xerrors.Errorf("failed to modify claude.json: %w", err)
166+
}
167+
cliui.Infof(inv.Stderr, "Wrote config to %s", claudeConfigPath)
168+
169+
// We also write the system prompt to the CLAUDE.md file.
170+
if err := injectClaudeMD(fs, systemPrompt, claudeMDPath); err != nil {
171+
return xerrors.Errorf("failed to modify CLAUDE.md: %w", err)
172+
}
173+
cliui.Infof(inv.Stderr, "Wrote CLAUDE.md to %s", claudeMDPath)
113174
return nil
114175
},
176+
Options: []serpent.Option{
177+
{
178+
Name: "claude-config-path",
179+
Description: "The path to the Claude config file.",
180+
Env: "CODER_MCP_CLAUDE_CONFIG_PATH",
181+
Flag: "claude-config-path",
182+
Value: serpent.StringOf(&claudeConfigPath),
183+
Default: filepath.Join(os.Getenv("HOME"), ".claude.json"),
184+
},
185+
{
186+
Name: "claude-md-path",
187+
Description: "The path to CLAUDE.md.",
188+
Env: "CODER_MCP_CLAUDE_MD_PATH",
189+
Flag: "claude-md-path",
190+
Value: serpent.StringOf(&claudeMDPath),
191+
Default: filepath.Join(os.Getenv("HOME"), ".claude", "CLAUDE.md"),
192+
},
193+
{
194+
Name: "api-key",
195+
Description: "The API key to use for the Claude Code server.",
196+
Env: "CODER_MCP_CLAUDE_API_KEY",
197+
Flag: "claude-api-key",
198+
Value: serpent.StringOf(&apiKey),
199+
},
200+
{
201+
Name: "system-prompt",
202+
Description: "The system prompt to use for the Claude Code server.",
203+
Env: "CODER_MCP_CLAUDE_SYSTEM_PROMPT",
204+
Flag: "claude-system-prompt",
205+
Value: serpent.StringOf(&systemPrompt),
206+
Default: "Send a task status update to notify the user that you are ready for input, and then wait for user input.",
207+
},
208+
{
209+
Name: "app-status-slug",
210+
Description: "The app status slug to use when running the Coder MCP server.",
211+
Env: "CODER_MCP_CLAUDE_APP_STATUS_SLUG",
212+
Flag: "claude-app-status-slug",
213+
Value: serpent.StringOf(&appStatusSlug),
214+
},
215+
{
216+
Name: "test-binary-name",
217+
Description: "Only used for testing.",
218+
Env: "CODER_MCP_CLAUDE_TEST_BINARY_NAME",
219+
Flag: "claude-test-binary-name",
220+
Value: serpent.StringOf(&testBinaryName),
221+
Hidden: true,
222+
},
223+
},
115224
}
116225
return cmd
117226
}
@@ -317,3 +426,247 @@ func mcpServerHandler(inv *serpent.Invocation, client *codersdk.Client, instruct
317426

318427
return nil
319428
}
429+
430+
type ClaudeConfig struct {
431+
ConfigPath string
432+
ProjectDirectory string
433+
APIKey string
434+
AllowedTools []string
435+
MCPServers map[string]ClaudeConfigMCP
436+
}
437+
438+
type ClaudeConfigMCP struct {
439+
Command string `json:"command"`
440+
Args []string `json:"args"`
441+
Env map[string]string `json:"env"`
442+
}
443+
444+
func configureClaude(fs afero.Fs, cfg ClaudeConfig) error {
445+
if cfg.ConfigPath == "" {
446+
cfg.ConfigPath = filepath.Join(os.Getenv("HOME"), ".claude.json")
447+
}
448+
var config map[string]any
449+
_, err := fs.Stat(cfg.ConfigPath)
450+
if err != nil {
451+
if !os.IsNotExist(err) {
452+
return xerrors.Errorf("failed to stat claude config: %w", err)
453+
}
454+
// Touch the file to create it if it doesn't exist.
455+
if err = afero.WriteFile(fs, cfg.ConfigPath, []byte(`{}`), 0o600); err != nil {
456+
return xerrors.Errorf("failed to touch claude config: %w", err)
457+
}
458+
}
459+
oldConfigBytes, err := afero.ReadFile(fs, cfg.ConfigPath)
460+
if err != nil {
461+
return xerrors.Errorf("failed to read claude config: %w", err)
462+
}
463+
err = json.Unmarshal(oldConfigBytes, &config)
464+
if err != nil {
465+
return xerrors.Errorf("failed to unmarshal claude config: %w", err)
466+
}
467+
468+
if cfg.APIKey != "" {
469+
// Stops Claude from requiring the user to generate
470+
// a Claude-specific API key.
471+
config["primaryApiKey"] = cfg.APIKey
472+
}
473+
// Stops Claude from asking for onboarding.
474+
config["hasCompletedOnboarding"] = true
475+
// Stops Claude from asking for permissions.
476+
config["bypassPermissionsModeAccepted"] = true
477+
config["autoUpdaterStatus"] = "disabled"
478+
// Stops Claude from asking for cost threshold.
479+
config["hasAcknowledgedCostThreshold"] = true
480+
481+
projects, ok := config["projects"].(map[string]any)
482+
if !ok {
483+
projects = make(map[string]any)
484+
}
485+
486+
project, ok := projects[cfg.ProjectDirectory].(map[string]any)
487+
if !ok {
488+
project = make(map[string]any)
489+
}
490+
491+
allowedTools, ok := project["allowedTools"].([]string)
492+
if !ok {
493+
allowedTools = []string{}
494+
}
495+
496+
// Add cfg.AllowedTools to the list if they're not already present.
497+
for _, tool := range cfg.AllowedTools {
498+
for _, existingTool := range allowedTools {
499+
if tool == existingTool {
500+
continue
501+
}
502+
}
503+
allowedTools = append(allowedTools, tool)
504+
}
505+
project["allowedTools"] = allowedTools
506+
project["hasTrustDialogAccepted"] = true
507+
project["hasCompletedProjectOnboarding"] = true
508+
509+
mcpServers, ok := project["mcpServers"].(map[string]any)
510+
if !ok {
511+
mcpServers = make(map[string]any)
512+
}
513+
for name, mcp := range cfg.MCPServers {
514+
mcpServers[name] = mcp
515+
}
516+
project["mcpServers"] = mcpServers
517+
// Prevents Claude from asking the user to complete the project onboarding.
518+
project["hasCompletedProjectOnboarding"] = true
519+
520+
history, ok := project["history"].([]string)
521+
injectedHistoryLine := "make sure to read claude.md and report tasks properly"
522+
523+
if !ok || len(history) == 0 {
524+
// History doesn't exist or is empty, create it with our injected line
525+
history = []string{injectedHistoryLine}
526+
} else if history[0] != injectedHistoryLine {
527+
// Check if our line is already the first item
528+
// Prepend our line to the existing history
529+
history = append([]string{injectedHistoryLine}, history...)
530+
}
531+
project["history"] = history
532+
533+
projects[cfg.ProjectDirectory] = project
534+
config["projects"] = projects
535+
536+
newConfigBytes, err := json.MarshalIndent(config, "", " ")
537+
if err != nil {
538+
return xerrors.Errorf("failed to marshal claude config: %w", err)
539+
}
540+
err = afero.WriteFile(fs, cfg.ConfigPath, newConfigBytes, 0o644)
541+
if err != nil {
542+
return xerrors.Errorf("failed to write claude config: %w", err)
543+
}
544+
return nil
545+
}
546+
547+
var (
548+
coderPrompt = `YOU MUST REPORT YOUR STATUS IMMEDIATELY AFTER EACH USER MESSAGE.
549+
INTERRUPT READING FILES OR ANY OTHER TOOL CALL IF YOU HAVE NOT REPORTED A STATUS YET.
550+
You MUST use the mcp__coder__coder_report_task function with all required parameters:
551+
- summary: Short description of what you're doing
552+
- link: A relevant link for the status
553+
- done: Boolean indicating if the task is complete (true/false)
554+
- emoji: Relevant emoji for the status
555+
- need_user_attention: Boolean indicating if the task needs user attention (true/false)
556+
WHEN TO REPORT (MANDATORY):
557+
1. IMMEDIATELY after receiving ANY user message, before any other actions
558+
2. After completing any task
559+
3. When making significant progress
560+
4. When encountering roadblocks
561+
5. When asking questions
562+
6. Before and after using search tools or making code changes
563+
FAILING TO REPORT STATUS PROPERLY WILL RESULT IN INCORRECT BEHAVIOR.`
564+
565+
// Define the guard strings
566+
coderPromptStartGuard = "<coder-prompt>"
567+
coderPromptEndGuard = "</coder-prompt>"
568+
systemPromptStartGuard = "<system-prompt>"
569+
systemPromptEndGuard = "</system-prompt>"
570+
)
571+
572+
func injectClaudeMD(fs afero.Fs, systemPrompt string, claudeMDPath string) error {
573+
_, err := fs.Stat(claudeMDPath)
574+
if err != nil {
575+
if !os.IsNotExist(err) {
576+
return xerrors.Errorf("failed to stat claude config: %w", err)
577+
}
578+
// Write a new file with the system prompt.
579+
if err = fs.MkdirAll(filepath.Dir(claudeMDPath), 0o700); err != nil {
580+
return xerrors.Errorf("failed to create claude config directory: %w", err)
581+
}
582+
583+
return afero.WriteFile(fs, claudeMDPath, []byte(promptsBlock(coderPrompt, systemPrompt, "")), 0o600)
584+
}
585+
586+
bs, err := afero.ReadFile(fs, claudeMDPath)
587+
if err != nil {
588+
return xerrors.Errorf("failed to read claude config: %w", err)
589+
}
590+
591+
// Extract the content without the guarded sections
592+
cleanContent := string(bs)
593+
594+
// Remove existing coder prompt section if it exists
595+
coderStartIdx := indexOf(cleanContent, coderPromptStartGuard)
596+
coderEndIdx := indexOf(cleanContent, coderPromptEndGuard)
597+
if coderStartIdx != -1 && coderEndIdx != -1 && coderStartIdx < coderEndIdx {
598+
beforeCoderPrompt := cleanContent[:coderStartIdx]
599+
afterCoderPrompt := cleanContent[coderEndIdx+len(coderPromptEndGuard):]
600+
cleanContent = beforeCoderPrompt + afterCoderPrompt
601+
}
602+
603+
// Remove existing system prompt section if it exists
604+
systemStartIdx := indexOf(cleanContent, systemPromptStartGuard)
605+
systemEndIdx := indexOf(cleanContent, systemPromptEndGuard)
606+
if systemStartIdx != -1 && systemEndIdx != -1 && systemStartIdx < systemEndIdx {
607+
beforeSystemPrompt := cleanContent[:systemStartIdx]
608+
afterSystemPrompt := cleanContent[systemEndIdx+len(systemPromptEndGuard):]
609+
cleanContent = beforeSystemPrompt + afterSystemPrompt
610+
}
611+
612+
// Trim any leading whitespace from the clean content
613+
cleanContent = strings.TrimSpace(cleanContent)
614+
615+
// Create the new content with coder and system prompt prepended
616+
newContent := promptsBlock(coderPrompt, systemPrompt, cleanContent)
617+
618+
// Write the updated content back to the file
619+
err = afero.WriteFile(fs, claudeMDPath, []byte(newContent), 0o600)
620+
if err != nil {
621+
return xerrors.Errorf("failed to write claude config: %w", err)
622+
}
623+
624+
return nil
625+
}
626+
627+
func promptsBlock(coderPrompt, systemPrompt, existingContent string) string {
628+
var newContent strings.Builder
629+
_, _ = newContent.WriteString(coderPromptStartGuard)
630+
_, _ = newContent.WriteRune('\n')
631+
_, _ = newContent.WriteString(coderPrompt)
632+
_, _ = newContent.WriteRune('\n')
633+
_, _ = newContent.WriteString(coderPromptEndGuard)
634+
_, _ = newContent.WriteRune('\n')
635+
_, _ = newContent.WriteString(systemPromptStartGuard)
636+
_, _ = newContent.WriteRune('\n')
637+
_, _ = newContent.WriteString(systemPrompt)
638+
_, _ = newContent.WriteRune('\n')
639+
_, _ = newContent.WriteString(systemPromptEndGuard)
640+
_, _ = newContent.WriteRune('\n')
641+
if existingContent != "" {
642+
_, _ = newContent.WriteString(existingContent)
643+
}
644+
return newContent.String()
645+
}
646+
647+
// indexOf returns the index of the first instance of substr in s,
648+
// or -1 if substr is not present in s.
649+
func indexOf(s, substr string) int {
650+
for i := 0; i <= len(s)-len(substr); i++ {
651+
if s[i:i+len(substr)] == substr {
652+
return i
653+
}
654+
}
655+
return -1
656+
}
657+
658+
func getAgentToken(fs afero.Fs) (string, error) {
659+
token, ok := os.LookupEnv("CODER_AGENT_TOKEN")
660+
if ok {
661+
return token, nil
662+
}
663+
tokenFile, ok := os.LookupEnv("CODER_AGENT_TOKEN_FILE")
664+
if !ok {
665+
return "", xerrors.Errorf("CODER_AGENT_TOKEN or CODER_AGENT_TOKEN_FILE must be set for token auth")
666+
}
667+
bs, err := afero.ReadFile(fs, tokenFile)
668+
if err != nil {
669+
return "", xerrors.Errorf("failed to read agent token file: %w", err)
670+
}
671+
return string(bs), nil
672+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy