Fix CodeBuddy Windows CLI discovery (#1448)
This commit is contained in:
@@ -445,6 +445,56 @@ function resolveCodexExecutableForSdk(codexExecutablePath, platform = process.pl
|
||||
return ext === ".cmd" || ext === ".bat" || ext === ".ps1" ? null : normalized;
|
||||
}
|
||||
|
||||
function resolveCodebuddyExecutableForSdk(codebuddyExecutablePath, platform = process.platform) {
|
||||
const normalized = String(codebuddyExecutablePath || "").trim();
|
||||
if (!normalized) return null;
|
||||
if (platform !== "win32") return normalized;
|
||||
|
||||
const ext = path.extname(normalized).toLowerCase();
|
||||
// A native exe or an explicit .js entry can be launched by the Agent SDK as-is.
|
||||
if (ext === ".exe" || ext === ".js") return normalized;
|
||||
// Any other concrete, non-shim extension: leave it untouched.
|
||||
if (ext && ext !== ".cmd" && ext !== ".bat" && ext !== ".ps1") return normalized;
|
||||
|
||||
// Windows npm globals expose `codebuddy.cmd` / `codebuddy.ps1` shims (and an
|
||||
// extensionless POSIX shim). The Agent SDK launches the CLI through `node`
|
||||
// (electron-as-node in a packaged app), which cannot parse a batch/POSIX shim
|
||||
// as JavaScript — the spawned process exits immediately and the SDK surfaces
|
||||
// "CLI process stdout closed unexpectedly". Resolve the shim to the package's
|
||||
// real `bin/codebuddy` JS entry so the SDK runs it exactly as on macOS/Linux.
|
||||
const baseDir = path.dirname(normalized);
|
||||
const packageRoots = [
|
||||
path.join(baseDir, "node_modules", "@tencent-ai", "codebuddy-code"),
|
||||
path.join(baseDir, "..", "node_modules", "@tencent-ai", "codebuddy-code"),
|
||||
];
|
||||
for (const root of packageRoots) {
|
||||
const binJs = path.join(root, "bin", "codebuddy");
|
||||
if (existsSync(binJs)) return binJs;
|
||||
}
|
||||
|
||||
// Fall back to parsing the shim for the bin/codebuddy path it references.
|
||||
const shimCandidates = [normalized];
|
||||
if (!ext) shimCandidates.push(`${normalized}.cmd`, `${normalized}.bat`);
|
||||
for (const shimPath of shimCandidates) {
|
||||
try {
|
||||
if (!existsSync(shimPath)) continue;
|
||||
const contents = readFileSync(shimPath, "utf8");
|
||||
const match = contents.match(/([^"\s]*codebuddy-code[\\/]bin[\\/]codebuddy)/i);
|
||||
if (match) {
|
||||
const ref = match[1].replace(/^%~dp0[\\/]?/i, "").replace(/[\\/]+/g, path.sep);
|
||||
const binJs = path.isAbsolute(ref) ? ref : path.resolve(path.dirname(shimPath), ref);
|
||||
if (existsSync(binJs)) return binJs;
|
||||
}
|
||||
} catch {
|
||||
// Try the next shim candidate.
|
||||
}
|
||||
}
|
||||
|
||||
// Could not locate the JS entry — return null so the caller falls back to the
|
||||
// SDK's bundled CLI rather than handing `node` an unrunnable shim.
|
||||
return ext === ".cmd" || ext === ".bat" || ext === ".ps1" ? null : normalized;
|
||||
}
|
||||
|
||||
function resolveSdkBinPath(command, shellEnv, platform = process.platform) {
|
||||
const raw = resolveCliFromPath(command, shellEnv);
|
||||
if (!raw) return null;
|
||||
@@ -604,6 +654,80 @@ function mergeLoginShellPath({
|
||||
return out.join(delimiter);
|
||||
}
|
||||
|
||||
// ── Windows live PATH refresh ──
|
||||
//
|
||||
// A GUI-launched Electron process freezes process.env at launch. When a CLI is
|
||||
// installed *after* Netcatty starts (its installer appends to the user/system
|
||||
// PATH in the registry), a freshly opened cmd/PowerShell sees it but Netcatty
|
||||
// does not — and clicking "Refresh" can't help, because process.env never
|
||||
// changes for the life of the process. So on Windows we re-read the authoritative
|
||||
// PATH from the registry (the value a brand-new shell would inherit) and merge it
|
||||
// with the in-process PATH. This mirrors the login-shell PATH probe used on
|
||||
// macOS/Linux and fixes CLIs (e.g. CodeBuddy) that "work in cmd" but don't scan.
|
||||
|
||||
function parseRegQueryPath(stdout) {
|
||||
// `reg query` prints e.g.: " Path REG_EXPAND_SZ C:\\a;C:\\b"
|
||||
for (const line of String(stdout || "").split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*Path\s+REG_(?:EXPAND_)?SZ\s+(.*\S)\s*$/i);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function expandWindowsEnvRefs(value, env = process.env) {
|
||||
return String(value || "").replace(/%([^%]+)%/g, (whole, name) => {
|
||||
const key = Object.keys(env).find((k) => k.toLowerCase() === String(name).toLowerCase());
|
||||
return key && typeof env[key] === "string" ? env[key] : whole;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeWindowsPath(...pathStrings) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const str of pathStrings) {
|
||||
for (const part of String(str || "").split(";")) {
|
||||
const trimmed = part.trim().replace(/^"|"$/g, "");
|
||||
if (!trimmed) continue;
|
||||
const dedupeKey = trimmed.toLowerCase().replace(/[\\/]+$/, "");
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
out.push(trimmed);
|
||||
}
|
||||
}
|
||||
return out.join(";");
|
||||
}
|
||||
|
||||
function getWindowsKnownCliPathDirs(env = process.env) {
|
||||
const dirs = [];
|
||||
if (env.APPDATA) dirs.push(path.join(env.APPDATA, "npm"));
|
||||
if (env.LOCALAPPDATA) {
|
||||
dirs.push(path.join(env.LOCALAPPDATA, "pnpm"));
|
||||
dirs.push(path.join(env.LOCALAPPDATA, "Yarn", "bin"));
|
||||
}
|
||||
return dirs.filter((dir) => existsSync(dir));
|
||||
}
|
||||
|
||||
async function readWindowsRegistryPath({ exec = execFileAsync, env = process.env } = {}) {
|
||||
const hives = [
|
||||
"HKCU\\Environment",
|
||||
"HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
|
||||
];
|
||||
const parts = [];
|
||||
for (const hive of hives) {
|
||||
try {
|
||||
const { stdout } = await exec("reg", ["query", hive, "/v", "Path"], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
});
|
||||
const raw = parseRegQueryPath(stdout);
|
||||
if (raw) parts.push(expandWindowsEnvRefs(raw, env));
|
||||
} catch {
|
||||
// Hive unreadable / value missing — skip and rely on other sources.
|
||||
}
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
async function getShellEnv() {
|
||||
if (_cachedShellEnv) return _cachedShellEnv;
|
||||
if (_shellEnvPromise) return _shellEnvPromise;
|
||||
@@ -619,9 +743,19 @@ async function getShellEnv() {
|
||||
];
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// Re-read the live PATH from the registry so CLIs installed after launch
|
||||
// (e.g. CodeBuddy) are discoverable without restarting Netcatty, then fold
|
||||
// in well-known npm/pnpm/yarn global bin dirs as a belt-and-suspenders.
|
||||
let registryPath = "";
|
||||
try {
|
||||
registryPath = await readWindowsRegistryPath();
|
||||
} catch {
|
||||
registryPath = "";
|
||||
}
|
||||
const knownDirs = getWindowsKnownCliPathDirs().join(path.delimiter);
|
||||
const nextEnv = {
|
||||
...process.env,
|
||||
PATH: [...extraPaths, process.env.PATH || ""].join(path.delimiter),
|
||||
PATH: mergeWindowsPath(registryPath, knownDirs, process.env.PATH || ""),
|
||||
};
|
||||
if (generation === _shellEnvGeneration) {
|
||||
_cachedShellEnv = nextEnv;
|
||||
@@ -721,6 +855,7 @@ module.exports = {
|
||||
normalizeClaudeCodeExecutableEnvForSdk,
|
||||
resolveCodexExecutableForSdk,
|
||||
addCodexExecutableEnvForSdk,
|
||||
resolveCodebuddyExecutableForSdk,
|
||||
resolveSdkBinPath,
|
||||
resolveSdkBinPathAsync,
|
||||
resolveCliFromPath,
|
||||
@@ -728,6 +863,10 @@ module.exports = {
|
||||
toUnpackedAsarPath,
|
||||
isPlausibleCliVersionOutput,
|
||||
mergeLoginShellPath,
|
||||
parseRegQueryPath,
|
||||
expandWindowsEnvRefs,
|
||||
mergeWindowsPath,
|
||||
readWindowsRegistryPath,
|
||||
getShellEnv,
|
||||
invalidateShellEnvCache,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,11 @@ const {
|
||||
resolveWindowsShimToNativeExe,
|
||||
resolveClaudeCodeExecutableForSdk,
|
||||
resolveCodexExecutableForSdk,
|
||||
resolveCodebuddyExecutableForSdk,
|
||||
parseRegQueryPath,
|
||||
expandWindowsEnvRefs,
|
||||
mergeWindowsPath,
|
||||
readWindowsRegistryPath,
|
||||
trackSessionIdlePrompt,
|
||||
} = require("./shellUtils.cjs");
|
||||
const fs = require("node:fs");
|
||||
@@ -342,6 +347,127 @@ test("addCodexExecutableEnvForSdk prepends bundled Codex path dir on Windows", (
|
||||
}
|
||||
});
|
||||
|
||||
function writeCodebuddyWin32BinLayout(dir) {
|
||||
const binJs = path.join(dir, "node_modules", "@tencent-ai", "codebuddy-code", "bin", "codebuddy");
|
||||
fs.mkdirSync(path.dirname(binJs), { recursive: true });
|
||||
fs.writeFileSync(binJs, "#!/usr/bin/env node\n", "utf8");
|
||||
return binJs;
|
||||
}
|
||||
|
||||
test("resolveCodebuddyExecutableForSdk leaves non-Windows CodeBuddy paths unchanged", () => {
|
||||
assert.equal(
|
||||
resolveCodebuddyExecutableForSdk("/usr/local/bin/codebuddy", "darwin"),
|
||||
"/usr/local/bin/codebuddy",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveCodebuddyExecutableForSdk maps Windows npm cmd shim to package bin/codebuddy", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codebuddy-shim-"));
|
||||
try {
|
||||
const shimPath = path.join(tmp, "codebuddy.cmd");
|
||||
const binJs = writeCodebuddyWin32BinLayout(tmp);
|
||||
fs.writeFileSync(
|
||||
shimPath,
|
||||
'@ECHO off\r\nnode "%~dp0\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy" %*\r\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.equal(resolveCodebuddyExecutableForSdk(shimPath, "win32"), binJs);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCodebuddyExecutableForSdk maps extensionless Windows shim to package bin/codebuddy", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codebuddy-noext-"));
|
||||
try {
|
||||
const shimPath = path.join(tmp, "codebuddy");
|
||||
const binJs = writeCodebuddyWin32BinLayout(tmp);
|
||||
fs.writeFileSync(shimPath, "#!/bin/sh\n", "utf8");
|
||||
|
||||
assert.equal(resolveCodebuddyExecutableForSdk(shimPath, "win32"), binJs);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCodebuddyExecutableForSdk returns null for Windows cmd shim when package JS is missing", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codebuddy-missing-"));
|
||||
try {
|
||||
const shimPath = path.join(tmp, "codebuddy.cmd");
|
||||
fs.writeFileSync(shimPath, "@ECHO off\r\nnode foo %*\r\n", "utf8");
|
||||
|
||||
assert.equal(resolveCodebuddyExecutableForSdk(shimPath, "win32"), null);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCodebuddyExecutableForSdk passes through a native exe path", () => {
|
||||
assert.equal(
|
||||
resolveCodebuddyExecutableForSdk("C:\\tools\\codebuddy.exe", "win32"),
|
||||
"C:\\tools\\codebuddy.exe",
|
||||
);
|
||||
});
|
||||
|
||||
test("parseRegQueryPath extracts the Path value from reg query output", () => {
|
||||
const out = parseRegQueryPath(
|
||||
"\r\nHKEY_CURRENT_USER\\Environment\r\n Path REG_EXPAND_SZ C:\\Users\\me\\AppData\\Roaming\\npm;C:\\tools\r\n",
|
||||
);
|
||||
assert.equal(out, "C:\\Users\\me\\AppData\\Roaming\\npm;C:\\tools");
|
||||
});
|
||||
|
||||
test("parseRegQueryPath handles REG_SZ and missing value", () => {
|
||||
assert.equal(parseRegQueryPath(" Path REG_SZ C:\\bin"), "C:\\bin");
|
||||
assert.equal(parseRegQueryPath("HKEY_CURRENT_USER\\Environment\r\n Temp REG_SZ C:\\Temp"), "");
|
||||
});
|
||||
|
||||
test("expandWindowsEnvRefs expands %VAR% case-insensitively", () => {
|
||||
assert.equal(
|
||||
expandWindowsEnvRefs("%AppData%\\npm;%Other%", { APPDATA: "C:\\Users\\me\\AppData\\Roaming" }),
|
||||
"C:\\Users\\me\\AppData\\Roaming\\npm;%Other%",
|
||||
);
|
||||
});
|
||||
|
||||
test("mergeWindowsPath dedupes case-insensitively and trims trailing slashes", () => {
|
||||
const out = mergeWindowsPath(
|
||||
"C:\\Windows\\System32;C:\\tools\\",
|
||||
"c:\\windows\\system32;C:\\tools;C:\\new",
|
||||
);
|
||||
assert.equal(out, "C:\\Windows\\System32;C:\\tools\\;C:\\new");
|
||||
});
|
||||
|
||||
test("mergeWindowsPath keeps refreshed Windows PATH entries ahead of stale process entries", () => {
|
||||
const out = mergeWindowsPath(
|
||||
"C:\\new-codebuddy;C:\\Windows\\System32",
|
||||
"C:\\Users\\me\\AppData\\Roaming\\npm",
|
||||
"C:\\old-codebuddy;C:\\Windows\\System32",
|
||||
);
|
||||
assert.equal(out, "C:\\new-codebuddy;C:\\Windows\\System32;C:\\Users\\me\\AppData\\Roaming\\npm;C:\\old-codebuddy");
|
||||
});
|
||||
|
||||
test("readWindowsRegistryPath merges HKCU and HKLM and expands refs", async () => {
|
||||
const exec = async (cmd, args) => {
|
||||
assert.equal(cmd, "reg");
|
||||
const hive = args[1];
|
||||
if (hive === "HKCU\\Environment") {
|
||||
return { stdout: " Path REG_EXPAND_SZ %APPDATA%\\npm\r\n" };
|
||||
}
|
||||
return { stdout: " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n" };
|
||||
};
|
||||
const out = await readWindowsRegistryPath({ exec, env: { APPDATA: "C:\\Roaming" } });
|
||||
assert.equal(out, "C:\\Roaming\\npm;C:\\Windows\\System32");
|
||||
});
|
||||
|
||||
test("readWindowsRegistryPath tolerates a failing hive query", async () => {
|
||||
const exec = async (cmd, args) => {
|
||||
if (args[1] === "HKCU\\Environment") throw new Error("ERROR: cannot read");
|
||||
return { stdout: " Path REG_SZ C:\\tools\r\n" };
|
||||
};
|
||||
const out = await readWindowsRegistryPath({ exec, env: {} });
|
||||
assert.equal(out, "C:\\tools");
|
||||
});
|
||||
|
||||
test("tracks PowerShell idle prompt after SSH output", () => {
|
||||
const session = {};
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ const {
|
||||
prepareCommandForSpawn,
|
||||
normalizeClaudeCodeExecutableEnvForSdk,
|
||||
addCodexExecutableEnvForSdk,
|
||||
resolveCodebuddyExecutableForSdk,
|
||||
resolveSdkBinPath,
|
||||
resolveSdkBinPathAsync,
|
||||
resolveCliFromPath,
|
||||
@@ -641,6 +642,7 @@ function createHandlerContext(ipcMain) {
|
||||
prepareCommandForSpawn,
|
||||
normalizeClaudeCodeExecutableEnvForSdk,
|
||||
addCodexExecutableEnvForSdk,
|
||||
resolveCodebuddyExecutableForSdk,
|
||||
resolveSdkBinPath,
|
||||
resolveSdkBinPathAsync,
|
||||
resolveCliFromPath,
|
||||
|
||||
@@ -63,13 +63,22 @@ function resolveRealCliPath(cliPath, realpath = realpathSync) {
|
||||
}
|
||||
|
||||
function resolveSdkBackendBinPath({
|
||||
backendKey, shellEnv, env, resolveCliFromPath, normalizeCliPathForPlatform, resolveSdkBinPath, realpath = realpathSync,
|
||||
backendKey, shellEnv, env, resolveCliFromPath, normalizeCliPathForPlatform,
|
||||
resolveSdkBinPath, resolveCodebuddyExecutableForSdk, realpath = realpathSync,
|
||||
}) {
|
||||
if (backendKey === "codebuddy") {
|
||||
const configuredPath = normalizeCliPathForPlatform?.(env?.CODEBUDDY_CODE_PATH);
|
||||
if (configuredPath) return resolveRealCliPath(configuredPath, realpath);
|
||||
const resolvedPath = resolveCliFromPath(backendKey, shellEnv) || undefined;
|
||||
return resolveRealCliPath(resolvedPath, realpath);
|
||||
const rawPath = configuredPath || resolveCliFromPath(backendKey, shellEnv) || undefined;
|
||||
if (!rawPath) return undefined;
|
||||
const realPath = resolveRealCliPath(rawPath, realpath);
|
||||
// On Windows the discovered path is an npm shim (codebuddy.cmd/.ps1) that the
|
||||
// Agent SDK can't run through `node`; resolve it to the package's JS entry so
|
||||
// it launches like on macOS/Linux. A null result means the shim is unrunnable
|
||||
// and unresolvable, so fall back to the SDK's bundled CLI.
|
||||
const sdkPath = typeof resolveCodebuddyExecutableForSdk === "function"
|
||||
? resolveCodebuddyExecutableForSdk(realPath)
|
||||
: realPath;
|
||||
return sdkPath || undefined;
|
||||
}
|
||||
return resolveSdkBinPath?.(backendKey, shellEnv) || undefined;
|
||||
}
|
||||
@@ -208,6 +217,7 @@ function registerSdkStreamHandlers(ctx) {
|
||||
resolveCliFromPath,
|
||||
normalizeCliPathForPlatform,
|
||||
resolveSdkBinPath,
|
||||
resolveCodebuddyExecutableForSdk,
|
||||
});
|
||||
if (backendKey === "codex") {
|
||||
env = addCodexExecutableEnvForSdk(env, binPath);
|
||||
@@ -296,6 +306,7 @@ function registerSdkStreamHandlers(ctx) {
|
||||
resolveCliFromPath,
|
||||
normalizeCliPathForPlatform,
|
||||
resolveSdkBinPath,
|
||||
resolveCodebuddyExecutableForSdk,
|
||||
});
|
||||
const raw = await withTimeout(driver.listModels({ binPath, env }), MODEL_LIST_TIMEOUT_MS);
|
||||
const models = Array.isArray(raw) ? raw.filter((m) => m && m.id) : [];
|
||||
|
||||
@@ -97,6 +97,38 @@ test("resolveSdkBackendBinPath realpaths CodeBuddy PATH discovery fallback", ()
|
||||
assert.equal(out, "/opt/codebuddy/bin/codebuddy");
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath resolves Windows CodeBuddy shim to the package JS entry", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codebuddy",
|
||||
shellEnv: { Path: "C:\\Users\\me\\AppData\\Roaming\\npm" },
|
||||
env: {},
|
||||
resolveCliFromPath: () => "C:\\Users\\me\\AppData\\Roaming\\npm\\codebuddy.cmd",
|
||||
normalizeCliPathForPlatform: () => null,
|
||||
realpath: (p) => p,
|
||||
resolveCodebuddyExecutableForSdk: (p) =>
|
||||
p.endsWith("codebuddy.cmd")
|
||||
? "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy"
|
||||
: p,
|
||||
});
|
||||
assert.equal(
|
||||
out,
|
||||
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath falls back to bundled CLI when Windows CodeBuddy shim is unresolvable", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codebuddy",
|
||||
shellEnv: { Path: "C:\\Users\\me\\AppData\\Roaming\\npm" },
|
||||
env: {},
|
||||
resolveCliFromPath: () => "C:\\Users\\me\\AppData\\Roaming\\npm\\codebuddy.cmd",
|
||||
normalizeCliPathForPlatform: () => null,
|
||||
realpath: (p) => p,
|
||||
resolveCodebuddyExecutableForSdk: () => null,
|
||||
});
|
||||
assert.equal(out, undefined);
|
||||
});
|
||||
|
||||
test("resolveSdkBackendBinPath keeps non-CodeBuddy SDK path normalization", () => {
|
||||
const out = resolveSdkBackendBinPath({
|
||||
backendKey: "codex",
|
||||
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -30,6 +30,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@streamdown/cjk": "^1.0.2",
|
||||
"@streamdown/code": "^1.1.0",
|
||||
"@tencent-ai/agent-sdk": "^0.3.173",
|
||||
"@withfig/autocomplete": "^2.692.3",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
|
||||
Reference in New Issue
Block a user