diff --git a/internal/setup/setup.go b/internal/setup/setup.go index b9a509bb..83d924d6 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -1077,11 +1077,18 @@ func injectGeminiMCP(configPath string) error { return nil } -// resolveEngramCommand returns the absolute path to the engram binary. -// It uses os.Executable() so that headless/systemd environments (where PATH -// is not reliably inherited by child processes) still find the binary. -// EvalSymlinks makes the path stable across package-manager upgrades. -// Falls back to bare "engram" only if os.Executable() itself fails. +// resolveEngramCommand returns the most stable command to spawn the engram +// binary. It uses os.Executable() so that headless/systemd environments (where +// PATH is not reliably inherited by child processes) still find the binary. +// +// Homebrew (and Linuxbrew) resolve the `engram` symlink to a versioned Cellar +// path such as /opt/homebrew/Cellar/engram/1.16.1/bin/engram. That path is +// removed on the next `brew upgrade`, so baking it into MCP client configs +// leaves a stale command that fails to spawn (ENOENT). When the resolved +// executable points into a versioned Cellar directory we prefer the stable +// /bin/engram symlink, which brew repoints at the current version, +// so registrations survive upgrades. Falls back to bare "engram" only when +// os.Executable() fails or the stable symlink is missing. func resolveEngramCommand() string { exe, err := osExecutable() if err != nil { @@ -1090,9 +1097,38 @@ func resolveEngramCommand() string { if resolved, err := filepath.EvalSymlinks(exe); err == nil { exe = resolved } + if stable, ok := stableHomebrewEngramCommand(exe); ok { + return stable + } return exe } +// stableHomebrewEngramCommand maps a versioned Homebrew Cellar path to the +// stable "/bin/engram" symlink that brew keeps pointing at the +// current version. It returns ("", false) when exe is not a versioned Cellar +// path, so non-Homebrew installs keep their resolved absolute path. When the +// derived stable symlink does not exist on disk it falls back to the bare +// "engram" name so the command still resolves via PATH. +func stableHomebrewEngramCommand(exe string) (string, bool) { + const marker = "/Cellar/engram/" + clean := filepath.ToSlash(filepath.Clean(exe)) + idx := strings.Index(clean, marker) + if idx < 0 { + return "", false + } + base := strings.ToLower(filepath.Base(clean)) + if base != "engram" && base != "engram.exe" { + return "", false + } + // Everything before "/Cellar/" is the brew prefix, e.g. /opt/homebrew or + // /home/linuxbrew/.linuxbrew. The bin symlink lives directly under it. + stable := clean[:idx] + "/bin/engram" + if _, err := statFn(stable); err == nil { + return filepath.FromSlash(stable), true + } + return "engram", true +} + func writeGeminiSystemPrompt() error { systemPath := geminiSystemPromptPath() if err := os.MkdirAll(filepath.Dir(systemPath), 0755); err != nil { diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index 4e786afd..be2833b5 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -1350,6 +1350,72 @@ func TestResolveEngramCommand(t *testing.T) { }) } +// TestResolveEngramCommandHomebrewCellar guards against baking a versioned +// Homebrew/Linuxbrew Cellar path into MCP client configs. Such paths (e.g. +// .../Cellar/engram/1.16.1/bin/engram) are removed on `brew upgrade`, leaving +// OpenCode/Codex with a stale command that fails to spawn (ENOENT). The command +// must resolve to the stable /bin/engram symlink, or bare "engram" +// when that symlink is missing. +func TestResolveEngramCommandHomebrewCellar(t *testing.T) { + cases := []struct { + name string + exe string + stableOnDisk string // stable symlink present on disk; "" means none + want string + }{ + { + name: "linuxbrew cellar maps to stable bin symlink", + exe: "/home/linuxbrew/.linuxbrew/Cellar/engram/1.16.1/bin/engram", + stableOnDisk: "/home/linuxbrew/.linuxbrew/bin/engram", + want: "/home/linuxbrew/.linuxbrew/bin/engram", + }, + { + name: "macos arm cellar maps to stable bin symlink", + exe: "/opt/homebrew/Cellar/engram/1.16.1/bin/engram", + stableOnDisk: "/opt/homebrew/bin/engram", + want: "/opt/homebrew/bin/engram", + }, + { + name: "macos intel cellar maps to stable bin symlink", + exe: "/usr/local/Cellar/engram/1.16.1/bin/engram", + stableOnDisk: "/usr/local/bin/engram", + want: "/usr/local/bin/engram", + }, + { + name: "cellar path with missing stable symlink falls back to bare name", + exe: "/opt/homebrew/Cellar/engram/1.16.1/bin/engram", + stableOnDisk: "", + want: "engram", + }, + { + name: "non-cellar absolute path is preserved", + exe: "/opt/engram/bin/engram", + stableOnDisk: "", + want: "/opt/engram/bin/engram", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resetSetupSeams(t) + osExecutable = func() (string, error) { return tc.exe, nil } + statFn = func(name string) (os.FileInfo, error) { + if tc.stableOnDisk != "" && filepath.ToSlash(name) == tc.stableOnDisk { + return nil, nil // exists + } + return nil, os.ErrNotExist + } + + // Normalize separators so the comparison holds on Windows runners, + // where resolveEngramCommand returns OS-native separators via + // filepath.FromSlash while tc.want is written with forward slashes. + if got := filepath.ToSlash(resolveEngramCommand()); got != tc.want { + t.Fatalf("resolveEngramCommand() = %q, want %q", got, tc.want) + } + }) + } +} + func TestClaudeCodeMCPDirPaths(t *testing.T) { resetSetupSeams(t) userHomeDir = func() (string, error) { return "/home/tester", nil }