Skip to content

Commit 6546e23

Browse files
joohwcursoragent
andcommitted
fix(desktop): resolve agent CLIs from macOS login shell PATH
Probe login-shell PATH and common npm/pnpm/fnm install roots on macOS so Finder-launched desktop apps detect agents reliably. Bump desktop shell to v0.2.0. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 950ac4f commit 6546e23

11 files changed

Lines changed: 191 additions & 10 deletions

core/internal/buildinfo/buildinfo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import "strings"
44

55
// Set at link time via -ldflags (see .goreleaser.yaml).
66
var (
7-
Version = "dev0.1.55"
7+
Version = "dev0.1.57"
88
Commit = "none"
99
Date = "unknown"
1010
)

core/internal/desktop/agents.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ func commandSearchDirs() []string {
120120
add(filepath.Join(home, ".config", "clovapi", "bin"))
121121
add(filepath.Join(home, ".local", "bin"))
122122
add(filepath.Join(home, ".opencode", "bin"))
123+
for _, dir := range platformSearchDirs(home) {
124+
add(dir)
125+
}
126+
for _, dir := range loginShellSearchDirs() {
127+
add(dir)
128+
}
123129
}
124130
if runtime.GOOS != "windows" {
125131
add("/opt/homebrew/bin")
@@ -137,8 +143,5 @@ func executableFile(path string) bool {
137143
if err != nil || info.IsDir() {
138144
return false
139145
}
140-
if runtime.GOOS == "windows" {
141-
return true
142-
}
143-
return info.Mode()&0o111 != 0
146+
return isExecutableFile(path)
144147
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package desktop
2+
3+
import "path/filepath"
4+
5+
// darwinExtraSearchDirs lists common user-level install roots on macOS that are
6+
// often missing from the minimal PATH Finder/Dock gives GUI apps.
7+
func darwinExtraSearchDirs(home string) []string {
8+
home = filepath.Clean(home)
9+
if home == "" || home == "." {
10+
return nil
11+
}
12+
return []string{
13+
filepath.Join(home, "bin"),
14+
filepath.Join(home, ".npm-global", "bin"),
15+
filepath.Join(home, "Library", "pnpm"),
16+
filepath.Join(home, ".volta", "bin"),
17+
filepath.Join(home, ".local", "share", "fnm", "current", "bin"),
18+
filepath.Join(home, ".fnm", "current", "bin"),
19+
}
20+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//go:build !windows
2+
3+
package desktop
4+
5+
import "syscall"
6+
7+
func isExecutableFile(path string) bool {
8+
return syscall.Access(path, syscall.X_OK) == nil
9+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//go:build windows
2+
3+
package desktop
4+
5+
func isExecutableFile(path string) bool {
6+
return true
7+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//go:build darwin
2+
3+
package desktop
4+
5+
import (
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"strings"
10+
"sync"
11+
)
12+
13+
var (
14+
darwinLoginShellDirsOnce sync.Once
15+
darwinLoginShellDirs []string
16+
)
17+
18+
func loginShellSearchDirs() []string {
19+
darwinLoginShellDirsOnce.Do(func() {
20+
home, err := os.UserHomeDir()
21+
if err != nil || strings.TrimSpace(home) == "" {
22+
return
23+
}
24+
user := strings.TrimSpace(os.Getenv("USER"))
25+
if user == "" {
26+
user = strings.TrimSpace(os.Getenv("LOGNAME"))
27+
}
28+
shell := strings.TrimSpace(os.Getenv("SHELL"))
29+
if shell == "" {
30+
shell = "/bin/zsh"
31+
}
32+
cmd := exec.Command(shell, "-ilc", `printf %s "$PATH"`)
33+
cmd.Env = []string{
34+
"HOME=" + home,
35+
"USER=" + user,
36+
"LOGNAME=" + user,
37+
"SHELL=" + shell,
38+
}
39+
out, err := cmd.Output()
40+
if err != nil {
41+
return
42+
}
43+
seen := map[string]struct{}{}
44+
for _, dir := range filepath.SplitList(strings.TrimSpace(string(out))) {
45+
dir = strings.TrimSpace(dir)
46+
if dir == "" {
47+
continue
48+
}
49+
if _, ok := seen[dir]; ok {
50+
continue
51+
}
52+
seen[dir] = struct{}{}
53+
darwinLoginShellDirs = append(darwinLoginShellDirs, dir)
54+
}
55+
})
56+
return darwinLoginShellDirs
57+
}
58+
59+
func platformSearchDirs(home string) []string {
60+
return darwinExtraSearchDirs(home)
61+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
//go:build !darwin
2+
3+
package desktop
4+
5+
func loginShellSearchDirs() []string {
6+
return nil
7+
}
8+
9+
func platformSearchDirs(home string) []string {
10+
return nil
11+
}

core/internal/desktop/agents_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ import (
77
"testing"
88
)
99

10+
func TestDarwinExtraSearchDirsIncludesCommonInstallRoots(t *testing.T) {
11+
home := filepath.Join(string(filepath.Separator), "Users", "test")
12+
dirs := darwinExtraSearchDirs(home)
13+
want := []string{
14+
filepath.Join(home, "bin"),
15+
filepath.Join(home, ".npm-global", "bin"),
16+
filepath.Join(home, "Library", "pnpm"),
17+
filepath.Join(home, ".volta", "bin"),
18+
filepath.Join(home, ".local", "share", "fnm", "current", "bin"),
19+
filepath.Join(home, ".fnm", "current", "bin"),
20+
}
21+
if len(dirs) != len(want) {
22+
t.Fatalf("dirs = %#v, want %d entries", dirs, len(want))
23+
}
24+
for i, path := range want {
25+
if dirs[i] != path {
26+
t.Fatalf("dirs[%d] = %q, want %q", i, dirs[i], path)
27+
}
28+
}
29+
}
30+
1031
func TestResolveCommandPathUsesCommonUserBinDirs(t *testing.T) {
1132
if runtime.GOOS == "windows" {
1233
t.Skip("HOME-based user bin probing is only used on Unix-like platforms")

electron/cli-path-register.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
const { spawnSync } = require("node:child_process");
12
const fs = require("node:fs");
23
const os = require("node:os");
34
const path = require("node:path");
4-
const { spawnSync } = require("node:child_process");
55
const { cliBinPath } = require("./config-paths");
66

77
const MARKER_START = "# >>> clovapi >>>";
@@ -174,11 +174,33 @@ function ensureCliBinOnPath() {
174174
return ensureUnixShellPath(dir);
175175
}
176176

177+
function loginShellPathEntries(baseEnv = process.env) {
178+
if (process.platform !== "darwin") return [];
179+
const home = os.homedir();
180+
const shell = String(baseEnv.SHELL || "/bin/zsh").trim() || "/bin/zsh";
181+
const user = String(baseEnv.USER || baseEnv.LOGNAME || os.userInfo().username || "").trim();
182+
const result = spawnSync(shell, ["-ilc", 'printf %s "$PATH"'], {
183+
encoding: "utf8",
184+
env: {
185+
HOME: home,
186+
USER: user,
187+
LOGNAME: user,
188+
SHELL: shell,
189+
},
190+
});
191+
if (result.status !== 0) return [];
192+
return String(result.stdout || "")
193+
.split(":")
194+
.map((entry) => entry.trim())
195+
.filter(Boolean);
196+
}
197+
177198
function cliSpawnEnv(baseEnv = process.env) {
178199
const binDir = cliBinDir();
179200
const parts = [binDir];
180201
const seen = new Set([binDir]);
181-
for (const entry of String(baseEnv.PATH || "").split(path.delimiter)) {
202+
const prepend = loginShellPathEntries(baseEnv);
203+
for (const entry of [...prepend, ...String(baseEnv.PATH || "").split(path.delimiter)]) {
182204
const value = String(entry || "").trim();
183205
if (!value || seen.has(value)) continue;
184206
seen.add(value);
@@ -194,6 +216,7 @@ module.exports = {
194216
cliBinDir,
195217
cliSpawnEnv,
196218
ensureCliBinOnPath,
219+
loginShellPathEntries,
197220
shellProfileCandidates,
198221
upsertMarkedBlock,
199222
};

electron/cli-path-register.test.js

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ const test = require("node:test");
66

77
const {
88
buildPathBlock,
9+
cliBinDir,
10+
cliSpawnEnv,
911
ensureCliBinOnPath,
12+
loginShellPathEntries,
1013
upsertMarkedBlock,
1114
} = require("./cli-path-register");
1215
const { cliBinPath } = require("./config-paths");
@@ -23,14 +26,37 @@ function withTempConfigHome(t) {
2326
return root;
2427
}
2528

26-
test("buildPathBlock includes managed markers", () => {
29+
test("loginShellPathEntries returns empty on non-darwin", (t) => {
30+
if (process.platform === "darwin") {
31+
t.skip("non-darwin only");
32+
return;
33+
}
34+
assert.deepEqual(loginShellPathEntries(), []);
35+
});
36+
37+
test("cliSpawnEnv prepends clovapi bin before inherited PATH", () => {
38+
const env = cliSpawnEnv({ PATH: "/usr/bin:/bin", USER: "tester" });
39+
const parts = String(env.PATH || "").split(path.delimiter);
40+
assert.equal(parts[0], cliBinDir());
41+
assert.match(env.PATH || "", /usr[\\/]bin/);
42+
});
43+
44+
test("buildPathBlock includes managed markers", (t) => {
45+
if (process.platform === "win32") {
46+
t.skip("unix shell profile block");
47+
return;
48+
}
2749
const block = buildPathBlock("/tmp/clovapi/bin");
2850
assert.match(block, /# >>> clovapi >>>/);
2951
assert.match(block, /export PATH="\/tmp\/clovapi\/bin:\$PATH"/);
3052
assert.match(block, /# <<< clovapi <<</);
3153
});
3254

33-
test("upsertMarkedBlock replaces an existing block", () => {
55+
test("upsertMarkedBlock replaces an existing block", (t) => {
56+
if (process.platform === "win32") {
57+
t.skip("unix shell profile block");
58+
return;
59+
}
3460
const original = ["before", buildPathBlock("/old/bin"), "after"].join("\n");
3561
const next = upsertMarkedBlock(original, buildPathBlock("/new/bin"));
3662
assert.match(next, /\/new\/bin:\$PATH/);

0 commit comments

Comments
 (0)