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
46 changes: 41 additions & 5 deletions internal/setup/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <brew-prefix>/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 {
Expand All @@ -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 "<brew-prefix>/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 {
Expand Down
66 changes: 66 additions & 0 deletions internal/setup/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <brew-prefix>/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 }
Expand Down