Skip to content

Commit 57c64ad

Browse files
joocursoragent
andcommitted
fix(desktop): register CLI PATH and delegate agent detection to core
Register ~/.config/clovapi/bin on the user PATH after install/startup so the desktop-managed binary matches npm, unify clovapi resolution in the shell, and load installed agents via clovapi desktop agents status instead of Electron-side which lookups. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5469cad commit 57c64ad

13 files changed

Lines changed: 359 additions & 62 deletions

File tree

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.52"
7+
Version = "dev0.1.54"
88
Commit = "none"
99
Date = "unknown"
1010
)
4.41 KB
Loading
27 Bytes
Loading

electron/cli-path-register.js

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
const fs = require("node:fs");
2+
const os = require("node:os");
3+
const path = require("node:path");
4+
const { spawnSync } = require("node:child_process");
5+
const { cliBinPath } = require("./config-paths");
6+
7+
const MARKER_START = "# >>> clovapi >>>";
8+
const MARKER_END = "# <<< clovapi <<<";
9+
10+
function cliBinDir() {
11+
return path.dirname(cliBinPath());
12+
}
13+
14+
function shellPathExportLine(dir) {
15+
const normalized = String(dir || "").trim();
16+
if (!normalized) return "";
17+
if (process.platform === "win32") return "";
18+
return `export PATH="${normalized}:$PATH"`;
19+
}
20+
21+
function buildPathBlock(dir) {
22+
const line = shellPathExportLine(dir);
23+
return [MARKER_START, line, MARKER_END, ""].join("\n");
24+
}
25+
26+
function shellProfileCandidates() {
27+
const home = os.homedir();
28+
if (!home || process.platform === "win32") return [];
29+
if (process.platform === "darwin") {
30+
return [
31+
path.join(home, ".zprofile"),
32+
path.join(home, ".zshrc"),
33+
path.join(home, ".bash_profile"),
34+
path.join(home, ".bashrc"),
35+
];
36+
}
37+
return [path.join(home, ".profile"), path.join(home, ".bashrc"), path.join(home, ".zshrc")];
38+
}
39+
40+
function pickShellProfile(candidates) {
41+
for (const file of candidates) {
42+
try {
43+
if (fs.existsSync(file)) return file;
44+
} catch {
45+
/* ignore */
46+
}
47+
}
48+
return candidates[0] || "";
49+
}
50+
51+
function upsertMarkedBlock(content, block) {
52+
const text = String(content || "");
53+
const start = text.indexOf(MARKER_START);
54+
const end = text.indexOf(MARKER_END);
55+
if (start >= 0 && end > start) {
56+
const before = text.slice(0, start);
57+
const after = text.slice(end + MARKER_END.length);
58+
const prefix = before.endsWith("\n") || before.length === 0 ? before : `${before}\n`;
59+
const suffix = after.startsWith("\n") ? after : `\n${after}`;
60+
return `${prefix}${block}${suffix}`.replace(/\n{3,}/g, "\n\n");
61+
}
62+
if (text.length === 0) return block;
63+
return `${text.endsWith("\n") ? text : `${text}\n`}${block}`;
64+
}
65+
66+
function ensureUnixShellPath(binDir) {
67+
const candidates = shellProfileCandidates();
68+
const target = pickShellProfile(candidates);
69+
if (!target) {
70+
return { ok: false, changed: false, path: binDir, error: "no shell profile target" };
71+
}
72+
73+
let existing = "";
74+
try {
75+
if (fs.existsSync(target)) existing = fs.readFileSync(target, "utf8");
76+
} catch (error) {
77+
return {
78+
ok: false,
79+
changed: false,
80+
path: binDir,
81+
profile: target,
82+
error: error instanceof Error ? error.message : "read shell profile failed",
83+
};
84+
}
85+
86+
if (existing.includes(MARKER_START) || existing.includes(`${binDir}:`)) {
87+
return { ok: true, changed: false, path: binDir, profile: target, already: true };
88+
}
89+
90+
const block = buildPathBlock(binDir);
91+
const next = upsertMarkedBlock(existing, block);
92+
try {
93+
fs.mkdirSync(path.dirname(target), { recursive: true });
94+
fs.writeFileSync(target, next, { mode: 0o600 });
95+
} catch (error) {
96+
return {
97+
ok: false,
98+
changed: false,
99+
path: binDir,
100+
profile: target,
101+
error: error instanceof Error ? error.message : "write shell profile failed",
102+
};
103+
}
104+
return { ok: true, changed: true, path: binDir, profile: target };
105+
}
106+
107+
function readWindowsUserPath() {
108+
const result = spawnSync(
109+
"powershell",
110+
[
111+
"-NoProfile",
112+
"-Command",
113+
"[Environment]::GetEnvironmentVariable('Path', 'User')",
114+
],
115+
{ encoding: "utf8", windowsHide: true },
116+
);
117+
if (result.status !== 0) return "";
118+
return String(result.stdout || "").trim();
119+
}
120+
121+
function writeWindowsUserPath(value) {
122+
const result = spawnSync(
123+
"powershell",
124+
[
125+
"-NoProfile",
126+
"-Command",
127+
`[Environment]::SetEnvironmentVariable('Path', '${String(value || "").replace(/'/g, "''")}', 'User')`,
128+
],
129+
{ encoding: "utf8", windowsHide: true },
130+
);
131+
return result.status === 0;
132+
}
133+
134+
function ensureWindowsUserPath(binDir) {
135+
const current = readWindowsUserPath();
136+
const parts = current
137+
.split(";")
138+
.map((part) => part.trim())
139+
.filter(Boolean);
140+
const normalized = path.normalize(binDir);
141+
if (parts.some((part) => path.normalize(part) === normalized)) {
142+
return { ok: true, changed: false, path: binDir, already: true };
143+
}
144+
const next = [normalized, ...parts].join(";");
145+
if (!writeWindowsUserPath(next)) {
146+
return { ok: false, changed: false, path: binDir, error: "failed to update Windows user PATH" };
147+
}
148+
return { ok: true, changed: true, path: binDir };
149+
}
150+
151+
/**
152+
* Idempotently register ~/.config/clovapi/bin (or %APPDATA%\\clovapi\\bin) on the user PATH.
153+
* Desktop and npm both install the canonical binary here.
154+
*/
155+
function ensureCliBinOnPath() {
156+
const binDir = cliBinPath();
157+
try {
158+
if (!fs.existsSync(binDir)) {
159+
return { ok: false, changed: false, path: path.dirname(binDir), error: "clovapi binary not installed yet" };
160+
}
161+
} catch (error) {
162+
return {
163+
ok: false,
164+
changed: false,
165+
path: path.dirname(binDir),
166+
error: error instanceof Error ? error.message : "stat clovapi binary failed",
167+
};
168+
}
169+
170+
const dir = path.dirname(binDir);
171+
if (process.platform === "win32") {
172+
return ensureWindowsUserPath(dir);
173+
}
174+
return ensureUnixShellPath(dir);
175+
}
176+
177+
function cliSpawnEnv(baseEnv = process.env) {
178+
const binDir = cliBinDir();
179+
const parts = [binDir];
180+
const seen = new Set([binDir]);
181+
for (const entry of String(baseEnv.PATH || "").split(path.delimiter)) {
182+
const value = String(entry || "").trim();
183+
if (!value || seen.has(value)) continue;
184+
seen.add(value);
185+
parts.push(value);
186+
}
187+
return { ...baseEnv, PATH: parts.join(path.delimiter) };
188+
}
189+
190+
module.exports = {
191+
MARKER_END,
192+
MARKER_START,
193+
buildPathBlock,
194+
cliBinDir,
195+
cliSpawnEnv,
196+
ensureCliBinOnPath,
197+
shellProfileCandidates,
198+
upsertMarkedBlock,
199+
};

electron/cli-path-register.test.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
const assert = require("node:assert/strict");
2+
const fs = require("node:fs");
3+
const os = require("node:os");
4+
const path = require("node:path");
5+
const test = require("node:test");
6+
7+
const {
8+
buildPathBlock,
9+
ensureCliBinOnPath,
10+
upsertMarkedBlock,
11+
} = require("./cli-path-register");
12+
const { cliBinPath } = require("./config-paths");
13+
14+
function withTempConfigHome(t) {
15+
const previous = process.env.XDG_CONFIG_HOME;
16+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clovapi-path-register-"));
17+
process.env.XDG_CONFIG_HOME = root;
18+
t.after(() => {
19+
if (previous === undefined) delete process.env.XDG_CONFIG_HOME;
20+
else process.env.XDG_CONFIG_HOME = previous;
21+
fs.rmSync(root, { recursive: true, force: true });
22+
});
23+
return root;
24+
}
25+
26+
test("buildPathBlock includes managed markers", () => {
27+
const block = buildPathBlock("/tmp/clovapi/bin");
28+
assert.match(block, /# >>> clovapi >>>/);
29+
assert.match(block, /export PATH="\/tmp\/clovapi\/bin:\$PATH"/);
30+
assert.match(block, /# <<< clovapi <<</);
31+
});
32+
33+
test("upsertMarkedBlock replaces an existing block", () => {
34+
const original = ["before", buildPathBlock("/old/bin"), "after"].join("\n");
35+
const next = upsertMarkedBlock(original, buildPathBlock("/new/bin"));
36+
assert.match(next, /\/new\/bin:\$PATH/);
37+
assert.doesNotMatch(next, /\/old\/bin:\$PATH/);
38+
assert.match(next, /before/);
39+
assert.match(next, /after/);
40+
});
41+
42+
test("ensureCliBinOnPath appends once to the selected shell profile", (t) => {
43+
if (process.platform === "win32") {
44+
t.skip("unix shell profile test");
45+
return;
46+
}
47+
const configRoot = withTempConfigHome(t);
48+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "clovapi-home-"));
49+
const previousHome = process.env.HOME;
50+
process.env.HOME = home;
51+
t.after(() => {
52+
if (previousHome === undefined) delete process.env.HOME;
53+
else process.env.HOME = previousHome;
54+
fs.rmSync(home, { recursive: true, force: true });
55+
});
56+
57+
const binPath = cliBinPath();
58+
fs.mkdirSync(path.dirname(binPath), { recursive: true });
59+
fs.writeFileSync(binPath, "cli");
60+
61+
const profile = path.join(home, ".zprofile");
62+
fs.writeFileSync(profile, "export FOO=1\n", { mode: 0o600 });
63+
64+
const first = ensureCliBinOnPath();
65+
assert.equal(first.ok, true);
66+
assert.equal(first.changed, true);
67+
assert.equal(first.profile, profile);
68+
assert.match(fs.readFileSync(profile, "utf8"), /\/clovapi\/bin:\$PATH/);
69+
70+
const second = ensureCliBinOnPath();
71+
assert.equal(second.ok, true);
72+
assert.equal(second.changed, false);
73+
assert.equal(second.already, true);
74+
assert.equal(fs.readFileSync(profile, "utf8").split("# >>> clovapi >>>").length - 1, 1);
75+
76+
assert.equal(configRoot.length > 0, true);
77+
});

electron/clovapi-exec.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const path = require("node:path");
55
const { spawn, spawnSync } = require("node:child_process");
66
const { cliBinPath } = require("./config-paths");
77
const { installBinaryWindows } = require("./cli-win-replace");
8+
const { cliSpawnEnv, ensureCliBinOnPath } = require("./cli-path-register");
89

910
const DOWNLOAD_BASE = "https://downloads.clovapi.com/clovapi";
1011
const PLATFORM_MAP = {
@@ -89,6 +90,7 @@ function resolveClovapiExecutable(options = {}) {
8990
encoding: "utf8",
9091
windowsHide: true,
9192
shell: process.platform === "win32",
93+
env: cliSpawnEnv(),
9294
});
9395
if (result.status === 0) {
9496
const resolved =
@@ -232,6 +234,11 @@ function installOnlineCliSync() {
232234
fs.chmodSync(target, 0o755);
233235
}
234236
fs.writeFileSync(path.join(path.dirname(target), "version.txt"), `${version}\n`, { mode: 0o600 });
237+
try {
238+
ensureCliBinOnPath();
239+
} catch {
240+
/* PATH registration is best-effort */
241+
}
235242
return target;
236243
} catch (error) {
237244
lastError = error;
@@ -257,6 +264,7 @@ function runClovapiArgs(args, options = {}) {
257264
timeout: options.timeout ?? 8000,
258265
windowsHide: true,
259266
input,
267+
env: cliSpawnEnv(options.env),
260268
});
261269
const status = result.status ?? 1;
262270
return {
@@ -285,6 +293,7 @@ function runClovapiArgsAsync(args, options = {}) {
285293
const child = spawn(exe, args, {
286294
windowsHide: true,
287295
stdio: ["pipe", "pipe", "pipe"],
296+
env: cliSpawnEnv(options.env),
288297
});
289298
const stdoutChunks = [];
290299
const stderrChunks = [];
@@ -345,6 +354,7 @@ function readCoreExecutableVersion(exe) {
345354
const result = spawnSync(target, ["version"], {
346355
encoding: "utf8",
347356
windowsHide: true,
357+
env: cliSpawnEnv(),
348358
});
349359
const line = String(result.stdout || "")
350360
.trim()

0 commit comments

Comments
 (0)