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
4 changes: 3 additions & 1 deletion cmd/engram/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ var (
exitFunc = os.Exit

stdinScanner = func() *bufio.Scanner { return bufio.NewScanner(os.Stdin) }
userHomeDir = os.UserHomeDir
userHomeDir = os.UserHomeDir
)

func main() {
Expand Down Expand Up @@ -767,6 +767,8 @@ func printPostInstall(agent string) {
fmt.Println("\nNext steps:")
fmt.Println(" 1. Restart Claude Code — the plugin is active immediately")
fmt.Println(" 2. Verify with: claude plugin list")
fmt.Println(" 3. MCP config written to ~/.claude/mcp/engram.json using absolute binary path")
fmt.Println(" (survives plugin auto-updates; re-run 'engram setup claude-code' if you move the binary)")
Comment on lines +770 to +771
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 post-install message for Claude Code always claims the MCP config was written to ~/.claude/mcp/engram.json, but setup treats that write as non-fatal and can fail (it only warns to stderr). This can mislead users into thinking the durable config exists when it doesn’t. Consider passing the setup.Result into printPostInstall (or a boolean) and only printing this step when the user MCP config write actually succeeded (or wording it as “attempted/will be written if possible”).

Suggested change
fmt.Println(" 3. MCP config written to ~/.claude/mcp/engram.json using absolute binary path")
fmt.Println(" (survives plugin auto-updates; re-run 'engram setup claude-code' if you move the binary)")
fmt.Println(" 3. Engram will attempt to write MCP config to ~/.claude/mcp/engram.json using the absolute binary path")
fmt.Println(" (this config survives plugin auto-updates; re-run 'engram setup claude-code' if you move the binary, and verify the file exists)")

Copilot uses AI. Check for mistakes.
case "gemini-cli":
fmt.Println("\nNext steps:")
fmt.Println(" 1. Restart Gemini CLI so MCP config is reloaded")
Expand Down
125 changes: 111 additions & 14 deletions internal/setup/setup.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// Package setup handles agent plugin installation.
//
// - OpenCode: copies embedded plugin file to ~/.config/opencode/plugins/
// - Claude Code: runs `claude plugin marketplace add` + `claude plugin install`
// - Gemini CLI: injects MCP registration in ~/.gemini/settings.json
// - Codex: injects MCP registration in ~/.codex/config.toml
// - OpenCode: copies embedded plugin file to ~/.config/opencode/plugins/
// - 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.
// - Gemini CLI: injects MCP registration in ~/.gemini/settings.json
// - Codex: injects MCP registration in ~/.codex/config.toml
package setup

import (
Expand All @@ -18,10 +20,11 @@ import (
)

var (
runtimeGOOS = runtime.GOOS
userHomeDir = os.UserHomeDir
lookPathFn = exec.LookPath
runCommand = func(name string, args ...string) ([]byte, error) {
runtimeGOOS = runtime.GOOS
userHomeDir = os.UserHomeDir
lookPathFn = exec.LookPath
osExecutable = os.Executable
runCommand = func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).CombinedOutput()
}
openCodeReadFile = func(path string) ([]byte, error) {
Expand All @@ -36,10 +39,11 @@ var (
injectOpenCodeMCPFn = injectOpenCodeMCP
injectGeminiMCPFn = injectGeminiMCP
writeGeminiSystemPromptFn = writeGeminiSystemPrompt
writeCodexMemoryInstructionFilesFn = writeCodexMemoryInstructionFiles
writeCodexMemoryInstructionFilesFn = writeCodexMemoryInstructionFiles
injectCodexMCPFn = injectCodexMCP
injectCodexMemoryConfigFn = injectCodexMemoryConfig
addClaudeCodeAllowlistFn = AddClaudeCodeAllowlist
writeClaudeCodeUserMCPFn = writeClaudeCodeUserMCP
)

//go:embed plugins/opencode/*
Expand Down Expand Up @@ -78,8 +82,20 @@ var claudeCodeMCPTools = []string{
"mcp__plugin_engram_engram__mem_update",
}

// codexEngramBlock is the canonical Codex TOML MCP block.
// Command is always the bare "engram" name in this constant because
// upsertCodexEngramBlock generates the actual content via codexEngramBlockStr()
// which uses resolveEngramCommand() at runtime. This constant is kept for tests
// that verify idempotency against the already-written string.
const codexEngramBlock = "[mcp_servers.engram]\ncommand = \"engram\"\nargs = [\"mcp\", \"--tools=agent\"]"

// codexEngramBlockStr returns the Codex TOML block for the engram MCP server,
// using the resolved command (absolute path on Windows, bare name on Unix).
func codexEngramBlockStr() string {
cmd := resolveEngramCommand()
return "[mcp_servers.engram]\ncommand = " + fmt.Sprintf("%q", cmd) + "\nargs = [\"mcp\", \"--tools=agent\"]"
}
Comment on lines +85 to +97
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.

codexEngramBlockStr() makes the Codex MCP block OS-dependent (absolute path on Windows), but the file still keeps codexEngramBlock as a single canonical constant used by tests for exact string equality. This can make those tests fail on Windows (where upsertCodexEngramBlock() now returns a different block). Consider updating any exact-string expectations to use codexEngramBlockStr() (or resolveEngramCommand()) instead of codexEngramBlock so tests are portable across GOOS.

Copilot uses AI. Check for mistakes.

const memoryProtocolMarkdown = `## Engram Persistent Memory — Protocol

You have access to Engram, a persistent memory system that survives across sessions and compactions.
Expand Down Expand Up @@ -439,13 +455,75 @@ func installClaudeCode() (*Result, error) {
}
}

// Step 3: Write a durable user-level MCP config at ~/.claude/mcp/engram.json
// with the absolute binary path. This survives plugin cache auto-updates and
// works on Windows where MCP subprocesses may not inherit PATH.
files := 0
if err := writeClaudeCodeUserMCPFn(); err != nil {
// Non-fatal: the plugin still works via the plugin cache .mcp.json.
// Warn so Windows users know to check their PATH if tools don't appear.
fmt.Fprintf(os.Stderr, "warning: could not write user MCP config (~/.claude/mcp/engram.json): %v\n", err)
fmt.Fprintf(os.Stderr, " The plugin is installed but MCP may not start on Windows if engram is not in PATH.\n")
} else {
files = 1
}

return &Result{
Agent: "claude-code",
Destination: "claude plugin system (managed by Claude Code)",
Files: 0, // managed by claude, not by us
Destination: claudeCodeMCPDir(),
Files: files,
}, nil
}

// claudeCodeMCPDir returns the directory for user-level Claude Code MCP configs.
// Files placed here are NOT managed by the plugin system and survive plugin updates.
func claudeCodeMCPDir() string {
home, _ := userHomeDir()
return filepath.Join(home, ".claude", "mcp")
}
Comment on lines +478 to +483
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.

claudeCodeMCPDir() ignores the error from os.UserHomeDir(). If home resolution fails, this will fall back to a relative path (".claude/mcp"), and writeClaudeCodeUserMCP() could end up creating/writing files in the current working directory. It would be safer to surface the error (e.g., return (string, error) and propagate) or at least guard against an empty home before using filepath.Join.

Copilot uses AI. Check for mistakes.

// claudeCodeUserMCPPath returns the path for the engram MCP config in the
// user-level MCP directory.
func claudeCodeUserMCPPath() string {
return filepath.Join(claudeCodeMCPDir(), "engram.json")
}

// writeClaudeCodeUserMCP writes ~/.claude/mcp/engram.json with the absolute
// path to the engram binary. This is idempotent — it always writes (overwrites)
// so that if the binary moves (e.g. brew upgrade), running setup again fixes it.
// Using os.Executable() instead of PATH lookup ensures the correct binary is
// referenced even when PATH is not propagated to MCP subprocesses (Windows).
func writeClaudeCodeUserMCP() error {
exe, err := osExecutable()
if err != nil {
return fmt.Errorf("resolve binary path: %w", err)
}
// Resolve any symlinks so the path is stable across package manager updates.
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}

entry := map[string]any{
"command": exe,
"args": []string{"mcp", "--tools=agent"},
}
data, err := jsonMarshalIndentFn(entry, "", " ")
if err != nil {
return fmt.Errorf("marshal mcp config: %w", err)
}

dir := claudeCodeMCPDir()
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create mcp dir: %w", err)
}

if err := writeFileFn(claudeCodeUserMCPPath(), data, 0644); err != nil {
return fmt.Errorf("write mcp config: %w", err)
}

return nil
}

func claudeCodeSettingsPath() string {
home, _ := userHomeDir()
return filepath.Join(home, ".claude", "settings.json")
Expand Down Expand Up @@ -593,7 +671,7 @@ func injectGeminiMCP(configPath string) error {
}

engramEntry := map[string]any{
"command": "engram",
"command": resolveEngramCommand(),
"args": []string{"mcp", "--tools=agent"},
}
entryJSON, err := jsonMarshalFn(engramEntry)
Expand All @@ -620,6 +698,24 @@ func injectGeminiMCP(configPath string) error {
return nil
}

// resolveEngramCommand returns the command string to put in agent MCP configs.
// On Windows, MCP subprocesses may not inherit PATH, so we use the absolute
// binary path from os.Executable(). On Unix, bare "engram" is sufficient
// because PATH is reliably inherited.
func resolveEngramCommand() string {
if runtimeGOOS != "windows" {
return "engram"
}
exe, err := osExecutable()
if err != nil {
return "engram" // fallback to PATH-based name
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return exe
}

func writeGeminiSystemPrompt() error {
systemPath := geminiSystemPromptPath()
if err := os.MkdirAll(filepath.Dir(systemPath), 0755); err != nil {
Expand Down Expand Up @@ -771,11 +867,12 @@ func upsertCodexEngramBlock(content string) string {
}

base := strings.TrimSpace(strings.Join(kept, "\n"))
block := codexEngramBlockStr()
if base == "" {
return codexEngramBlock + "\n"
return block + "\n"
}

return base + "\n\n" + codexEngramBlock + "\n"
return base + "\n\n" + block + "\n"
}

func upsertTopLevelTOMLString(content, key, value string) string {
Expand Down
Loading
Loading