Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions internal/setup/setup.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Package setup handles agent plugin installation.
//
// - OpenCode: copies embedded plugin file to ~/.config/opencode/plugins/
// and injects MCP registration in opencode.json using the resolved binary
// path (absolute on Windows, bare "engram" on Unix) so the MCP subprocess
// never requires PATH resolution in headless/systemd environments.
Comment on lines +5 to +6
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Package doc comment says the OpenCode MCP config uses a resolved binary path so the subprocess "never requires PATH resolution in headless/systemd environments", but on Unix resolveEngramCommand() intentionally returns the bare "engram" name (PATH-based). Please adjust the comment to reflect the actual behavior (or change the implementation if the intent is truly to avoid PATH on Unix too).

Suggested change
// path (absolute on Windows, bare "engram" on Unix) so the MCP subprocess
// never requires PATH resolution in headless/systemd environments.
// path (absolute on Windows to avoid PATH resolution issues; bare "engram"
// on Unix, where the MCP subprocess relies on the environment's PATH).

Copilot uses AI. Check for mistakes.
// - Claude Code: runs `claude plugin marketplace add` + `claude plugin install`,
// then writes a durable MCP config to ~/.claude/mcp/engram.json using the
// absolute binary path so the subprocess never needs PATH resolution.
Expand Down Expand Up @@ -268,9 +271,10 @@ func installOpenCode() (*Result, error) {
files := 1
if err := injectOpenCodeMCPFn(); err != nil {
// Non-fatal: plugin works, MCP just needs manual config
cmd := resolveEngramCommand()
fmt.Fprintf(os.Stderr, "warning: could not auto-register MCP server in opencode.json: %v\n", err)
fmt.Fprintf(os.Stderr, " Add manually to your opencode.json under \"mcp\":\n")
fmt.Fprintf(os.Stderr, " \"engram\": { \"type\": \"local\", \"command\": [\"engram\", \"mcp\", \"--tools=agent\"], \"enabled\": true }\n")
fmt.Fprintf(os.Stderr, " \"engram\": { \"type\": \"local\", \"command\": [%q, \"mcp\", \"--tools=agent\"], \"enabled\": true }\n", cmd)
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning snippet is meant to be pasted as JSON, but formatting the command with %q uses Go string escaping (e.g., it can emit \UXXXXXXXX escapes) which is not guaranteed to be valid JSON. Consider JSON-escaping the command via json.Marshal(cmd) and printing the resulting string bytes (without introducing double-quotes) so the snippet is always valid JSON.

Suggested change
fmt.Fprintf(os.Stderr, " \"engram\": { \"type\": \"local\", \"command\": [%q, \"mcp\", \"--tools=agent\"], \"enabled\": true }\n", cmd)
// Use JSON escaping for the command so the snippet is always valid JSON.
if cmdJSON, errJSON := jsonMarshalFn(cmd); errJSON == nil {
fmt.Fprintf(os.Stderr, " \"engram\": { \"type\": \"local\", \"command\": [%s, \"mcp\", \"--tools=agent\"], \"enabled\": true }\n", cmdJSON)
} else {
// Fallback: preserve original behavior if JSON marshalling fails.
fmt.Fprintf(os.Stderr, " \"engram\": { \"type\": \"local\", \"command\": [%q, \"mcp\", \"--tools=agent\"], \"enabled\": true }\n", cmd)
}

Copilot uses AI. Check for mistakes.
} else {
files = 2
}
Expand Down Expand Up @@ -319,10 +323,12 @@ func injectOpenCodeMCP() error {
return nil // already registered, nothing to do
}

// Add engram MCP entry (agent profile — only tools agents need)
// Add engram MCP entry (agent profile — only tools agents need).
// Use resolveEngramCommand() so Windows users (and headless Linux setups
// where PATH is not inherited) get the absolute binary path.
Comment on lines +327 to +328
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment mentions "headless Linux setups where PATH is not inherited" as a reason to use an absolute binary path, but resolveEngramCommand() currently returns bare "engram" on non-Windows. Please clarify the comment so it matches the implementation (or update resolveEngramCommand() if Linux headless PATH inheritance is a real requirement).

Suggested change
// Use resolveEngramCommand() so Windows users (and headless Linux setups
// where PATH is not inherited) get the absolute binary path.
// Use resolveEngramCommand() so Windows users get an absolute binary path,
// while Unix-like systems use the bare "engram" command resolved via PATH.

Copilot uses AI. Check for mistakes.
engramEntry := map[string]interface{}{
"type": "local",
"command": []string{"engram", "mcp", "--tools=agent"},
"command": []string{resolveEngramCommand(), "mcp", "--tools=agent"},
"enabled": true,
}
entryJSON, err := jsonMarshalFn(engramEntry)
Expand Down
238 changes: 238 additions & 0 deletions internal/setup/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2014,3 +2014,241 @@ func TestInjectOpenCodeMCPHandlesJSONC(t *testing.T) {
t.Fatalf("expected existing 'other' entry to be preserved")
}
}

// ─── Issue #112: OpenCode MCP absolute-path config ───────────────────────────

// TestInjectOpenCodeMCPUsesResolvedCommand verifies that injectOpenCodeMCP()
// writes the correct command based on the OS:
// - Windows: absolute path from os.Executable() so headless MCP subprocesses
// don't need PATH.
// - Unix: bare "engram" (PATH is reliably inherited by child processes).
func TestInjectOpenCodeMCPUsesResolvedCommand(t *testing.T) {
t.Run("windows writes absolute path in command array", func(t *testing.T) {
resetSetupSeams(t)
home := useTestHome(t)
runtimeGOOS = "windows"
osExecutable = func() (string, error) { return `C:\Users\user\bin\engram.exe`, nil }
t.Setenv("XDG_CONFIG_HOME", "")

configDir := filepath.Join(home, ".config", "opencode")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}

if err := injectOpenCodeMCP(); err != nil {
t.Fatalf("injectOpenCodeMCP failed: %v", err)
}

raw, err := os.ReadFile(filepath.Join(configDir, "opencode.json"))
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg map[string]any
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatalf("parse config: %v", err)
}
mcp := cfg["mcp"].(map[string]any)
engram := mcp["engram"].(map[string]any)
cmd := engram["command"].([]any)
if len(cmd) == 0 {
t.Fatalf("expected non-empty command array")
}
first := cmd[0].(string)
Comment on lines +2050 to +2056
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests use chained type assertions like cfg["mcp"].(map[string]any) / engram["command"].([]any). If the JSON shape changes or the key is missing, the test will panic instead of producing a clear failure. Using the comma-ok form (and failing with t.Fatalf) makes failures easier to diagnose.

Suggested change
mcp := cfg["mcp"].(map[string]any)
engram := mcp["engram"].(map[string]any)
cmd := engram["command"].([]any)
if len(cmd) == 0 {
t.Fatalf("expected non-empty command array")
}
first := cmd[0].(string)
mcpVal, ok := cfg["mcp"]
if !ok {
t.Fatalf("config missing %q key", "mcp")
}
mcp, ok := mcpVal.(map[string]any)
if !ok {
t.Fatalf("config %q key has unexpected type %T, want map[string]any", "mcp", mcpVal)
}
engramVal, ok := mcp["engram"]
if !ok {
t.Fatalf("mcp config missing %q key", "engram")
}
engram, ok := engramVal.(map[string]any)
if !ok {
t.Fatalf("mcp %q key has unexpected type %T, want map[string]any", "engram", engramVal)
}
cmdVal, ok := engram["command"]
if !ok {
t.Fatalf("engram config missing %q key", "command")
}
cmd, ok := cmdVal.([]any)
if !ok {
t.Fatalf("engram %q key has unexpected type %T, want []any", "command", cmdVal)
}
if len(cmd) == 0 {
t.Fatalf("expected non-empty command array")
}
first, ok := cmd[0].(string)
if !ok {
t.Fatalf("first command element has unexpected type %T, want string", cmd[0])
}

Copilot uses AI. Check for mistakes.
if first == "engram" {
t.Fatalf("expected absolute path on windows, got bare 'engram'")
}
if !strings.Contains(first, "engram") {
t.Fatalf("expected engram in command path, got %q", first)
}
})

t.Run("linux writes bare engram in command array", func(t *testing.T) {
resetSetupSeams(t)
home := useTestHome(t)
runtimeGOOS = "linux"
t.Setenv("XDG_CONFIG_HOME", "")

configDir := filepath.Join(home, ".config", "opencode")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}

if err := injectOpenCodeMCP(); err != nil {
t.Fatalf("injectOpenCodeMCP failed: %v", err)
}

raw, err := os.ReadFile(filepath.Join(configDir, "opencode.json"))
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg map[string]any
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatalf("parse config: %v", err)
}
mcp := cfg["mcp"].(map[string]any)
engram := mcp["engram"].(map[string]any)
cmd := engram["command"].([]any)
if len(cmd) == 0 {
t.Fatalf("expected non-empty command array")
}
if got := cmd[0].(string); got != "engram" {
t.Fatalf("expected bare 'engram' on linux, got %q", got)
}
// Remaining args should be the MCP flags
if len(cmd) != 3 || cmd[1] != "mcp" || cmd[2] != "--tools=agent" {
t.Fatalf("expected args [engram mcp --tools=agent], got %v", cmd)
}
})

t.Run("darwin writes bare engram in command array", func(t *testing.T) {
resetSetupSeams(t)
home := useTestHome(t)
runtimeGOOS = "darwin"
t.Setenv("XDG_CONFIG_HOME", "")

configDir := filepath.Join(home, ".config", "opencode")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}

if err := injectOpenCodeMCP(); err != nil {
t.Fatalf("injectOpenCodeMCP failed: %v", err)
}

raw, err := os.ReadFile(filepath.Join(configDir, "opencode.json"))
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg map[string]any
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatalf("parse config: %v", err)
}
mcp := cfg["mcp"].(map[string]any)
engram := mcp["engram"].(map[string]any)
cmd := engram["command"].([]any)
if len(cmd) == 0 {
t.Fatalf("expected non-empty command array")
}
if got := cmd[0].(string); got != "engram" {
t.Fatalf("expected bare 'engram' on darwin, got %q", got)
}
})

t.Run("windows executable error falls back to bare engram", func(t *testing.T) {
resetSetupSeams(t)
home := useTestHome(t)
runtimeGOOS = "windows"
osExecutable = func() (string, error) { return "", errors.New("no executable") }
t.Setenv("XDG_CONFIG_HOME", "")

configDir := filepath.Join(home, ".config", "opencode")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}

if err := injectOpenCodeMCP(); err != nil {
t.Fatalf("injectOpenCodeMCP failed: %v", err)
}

raw, err := os.ReadFile(filepath.Join(configDir, "opencode.json"))
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg map[string]any
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatalf("parse config: %v", err)
}
mcp := cfg["mcp"].(map[string]any)
engram := mcp["engram"].(map[string]any)
cmd := engram["command"].([]any)
if len(cmd) == 0 {
t.Fatalf("expected non-empty command array")
}
// Should fall back gracefully to bare "engram"
if got := cmd[0].(string); got != "engram" {
t.Fatalf("expected fallback to bare 'engram' when os.Executable fails, got %q", got)
}
})
}

// TestInstallOpenCodeWarningUsesResolvedCommand verifies that when MCP injection
// fails, the warning message printed to stderr uses the resolved command rather
// than always printing bare "engram". On Windows this means the user's manual
// config snippet will contain the absolute path they actually need.
func TestInstallOpenCodeWarningUsesResolvedCommand(t *testing.T) {
t.Run("windows warning contains absolute path", func(t *testing.T) {
resetSetupSeams(t)
home := useTestHome(t)
runtimeGOOS = "windows"
osExecutable = func() (string, error) { return `C:\bin\engram.exe`, nil }
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))

// Force MCP injection to fail so the warning branch is exercised
injectOpenCodeMCPFn = func() error {
return errors.New("cannot write config")
}

// Capture stderr
origStderr := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stderr = w

_, installErr := installOpenCode()
w.Close()
os.Stderr = origStderr

if installErr != nil {
t.Fatalf("installOpenCode should not fail when MCP injection is non-fatal: %v", installErr)
}

buf := make([]byte, 4096)
n, _ := r.Read(buf)
stderr := string(buf[:n])

// The path is written via %q so backslashes are escaped in the output.
if !strings.Contains(stderr, `engram.exe`) {
t.Fatalf("expected absolute path in warning message, got:\n%s", stderr)
}
// Must NOT be the bare "engram" unquoted form
if strings.Contains(stderr, `["engram",`) {
t.Fatalf("expected absolute path (not bare engram) in warning message, got:\n%s", stderr)
}
})

t.Run("linux warning contains bare engram", func(t *testing.T) {
resetSetupSeams(t)
home := useTestHome(t)
runtimeGOOS = "linux"
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))

injectOpenCodeMCPFn = func() error {
return errors.New("cannot write config")
}

origStderr := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stderr = w

_, installErr := installOpenCode()
w.Close()
os.Stderr = origStderr

if installErr != nil {
t.Fatalf("installOpenCode should not fail when MCP injection is non-fatal: %v", installErr)
}

buf := make([]byte, 4096)
n, _ := r.Read(buf)
stderr := string(buf[:n])

if !strings.Contains(stderr, `"engram"`) {
t.Fatalf("expected bare 'engram' in warning message on linux, got:\n%s", stderr)
}
})
}
Loading