Skip to content

Commit 3009476

Browse files
committed
Merge remote-tracking branch 'origin/main' into move-feature-stages
2 parents 3110b68 + fd24116 commit 3009476

File tree

10 files changed

+899
-75
lines changed

10 files changed

+899
-75
lines changed

cli/exp_mcp.go

+356-3
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)