Fix CodeBuddy Windows CLI discovery (#1448)

This commit is contained in:
lengyuqu
2026-06-12 17:27:52 +08:00
committed by GitHub
parent 0a38da8867
commit 66de2db912
6 changed files with 316 additions and 5 deletions

View File

@@ -445,6 +445,56 @@ function resolveCodexExecutableForSdk(codexExecutablePath, platform = process.pl
return ext === ".cmd" || ext === ".bat" || ext === ".ps1" ? null : normalized; 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) { function resolveSdkBinPath(command, shellEnv, platform = process.platform) {
const raw = resolveCliFromPath(command, shellEnv); const raw = resolveCliFromPath(command, shellEnv);
if (!raw) return null; if (!raw) return null;
@@ -604,6 +654,80 @@ function mergeLoginShellPath({
return out.join(delimiter); 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() { async function getShellEnv() {
if (_cachedShellEnv) return _cachedShellEnv; if (_cachedShellEnv) return _cachedShellEnv;
if (_shellEnvPromise) return _shellEnvPromise; if (_shellEnvPromise) return _shellEnvPromise;
@@ -619,9 +743,19 @@ async function getShellEnv() {
]; ];
if (process.platform === "win32") { 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 = { const nextEnv = {
...process.env, ...process.env,
PATH: [...extraPaths, process.env.PATH || ""].join(path.delimiter), PATH: mergeWindowsPath(registryPath, knownDirs, process.env.PATH || ""),
}; };
if (generation === _shellEnvGeneration) { if (generation === _shellEnvGeneration) {
_cachedShellEnv = nextEnv; _cachedShellEnv = nextEnv;
@@ -721,6 +855,7 @@ module.exports = {
normalizeClaudeCodeExecutableEnvForSdk, normalizeClaudeCodeExecutableEnvForSdk,
resolveCodexExecutableForSdk, resolveCodexExecutableForSdk,
addCodexExecutableEnvForSdk, addCodexExecutableEnvForSdk,
resolveCodebuddyExecutableForSdk,
resolveSdkBinPath, resolveSdkBinPath,
resolveSdkBinPathAsync, resolveSdkBinPathAsync,
resolveCliFromPath, resolveCliFromPath,
@@ -728,6 +863,10 @@ module.exports = {
toUnpackedAsarPath, toUnpackedAsarPath,
isPlausibleCliVersionOutput, isPlausibleCliVersionOutput,
mergeLoginShellPath, mergeLoginShellPath,
parseRegQueryPath,
expandWindowsEnvRefs,
mergeWindowsPath,
readWindowsRegistryPath,
getShellEnv, getShellEnv,
invalidateShellEnvCache, invalidateShellEnvCache,
}; };

View File

@@ -13,6 +13,11 @@ const {
resolveWindowsShimToNativeExe, resolveWindowsShimToNativeExe,
resolveClaudeCodeExecutableForSdk, resolveClaudeCodeExecutableForSdk,
resolveCodexExecutableForSdk, resolveCodexExecutableForSdk,
resolveCodebuddyExecutableForSdk,
parseRegQueryPath,
expandWindowsEnvRefs,
mergeWindowsPath,
readWindowsRegistryPath,
trackSessionIdlePrompt, trackSessionIdlePrompt,
} = require("./shellUtils.cjs"); } = require("./shellUtils.cjs");
const fs = require("node:fs"); 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", () => { test("tracks PowerShell idle prompt after SSH output", () => {
const session = {}; const session = {};

View File

@@ -32,6 +32,7 @@ const {
prepareCommandForSpawn, prepareCommandForSpawn,
normalizeClaudeCodeExecutableEnvForSdk, normalizeClaudeCodeExecutableEnvForSdk,
addCodexExecutableEnvForSdk, addCodexExecutableEnvForSdk,
resolveCodebuddyExecutableForSdk,
resolveSdkBinPath, resolveSdkBinPath,
resolveSdkBinPathAsync, resolveSdkBinPathAsync,
resolveCliFromPath, resolveCliFromPath,
@@ -641,6 +642,7 @@ function createHandlerContext(ipcMain) {
prepareCommandForSpawn, prepareCommandForSpawn,
normalizeClaudeCodeExecutableEnvForSdk, normalizeClaudeCodeExecutableEnvForSdk,
addCodexExecutableEnvForSdk, addCodexExecutableEnvForSdk,
resolveCodebuddyExecutableForSdk,
resolveSdkBinPath, resolveSdkBinPath,
resolveSdkBinPathAsync, resolveSdkBinPathAsync,
resolveCliFromPath, resolveCliFromPath,

View File

@@ -63,13 +63,22 @@ function resolveRealCliPath(cliPath, realpath = realpathSync) {
} }
function resolveSdkBackendBinPath({ function resolveSdkBackendBinPath({
backendKey, shellEnv, env, resolveCliFromPath, normalizeCliPathForPlatform, resolveSdkBinPath, realpath = realpathSync, backendKey, shellEnv, env, resolveCliFromPath, normalizeCliPathForPlatform,
resolveSdkBinPath, resolveCodebuddyExecutableForSdk, realpath = realpathSync,
}) { }) {
if (backendKey === "codebuddy") { if (backendKey === "codebuddy") {
const configuredPath = normalizeCliPathForPlatform?.(env?.CODEBUDDY_CODE_PATH); const configuredPath = normalizeCliPathForPlatform?.(env?.CODEBUDDY_CODE_PATH);
if (configuredPath) return resolveRealCliPath(configuredPath, realpath); const rawPath = configuredPath || resolveCliFromPath(backendKey, shellEnv) || undefined;
const resolvedPath = resolveCliFromPath(backendKey, shellEnv) || undefined; if (!rawPath) return undefined;
return resolveRealCliPath(resolvedPath, realpath); 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; return resolveSdkBinPath?.(backendKey, shellEnv) || undefined;
} }
@@ -208,6 +217,7 @@ function registerSdkStreamHandlers(ctx) {
resolveCliFromPath, resolveCliFromPath,
normalizeCliPathForPlatform, normalizeCliPathForPlatform,
resolveSdkBinPath, resolveSdkBinPath,
resolveCodebuddyExecutableForSdk,
}); });
if (backendKey === "codex") { if (backendKey === "codex") {
env = addCodexExecutableEnvForSdk(env, binPath); env = addCodexExecutableEnvForSdk(env, binPath);
@@ -296,6 +306,7 @@ function registerSdkStreamHandlers(ctx) {
resolveCliFromPath, resolveCliFromPath,
normalizeCliPathForPlatform, normalizeCliPathForPlatform,
resolveSdkBinPath, resolveSdkBinPath,
resolveCodebuddyExecutableForSdk,
}); });
const raw = await withTimeout(driver.listModels({ binPath, env }), MODEL_LIST_TIMEOUT_MS); const raw = await withTimeout(driver.listModels({ binPath, env }), MODEL_LIST_TIMEOUT_MS);
const models = Array.isArray(raw) ? raw.filter((m) => m && m.id) : []; const models = Array.isArray(raw) ? raw.filter((m) => m && m.id) : [];

View File

@@ -97,6 +97,38 @@ test("resolveSdkBackendBinPath realpaths CodeBuddy PATH discovery fallback", ()
assert.equal(out, "/opt/codebuddy/bin/codebuddy"); 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", () => { test("resolveSdkBackendBinPath keeps non-CodeBuddy SDK path normalization", () => {
const out = resolveSdkBackendBinPath({ const out = resolveSdkBackendBinPath({
backendKey: "codex", backendKey: "codex",

1
package-lock.json generated
View File

@@ -30,6 +30,7 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@streamdown/cjk": "^1.0.2", "@streamdown/cjk": "^1.0.2",
"@streamdown/code": "^1.1.0", "@streamdown/code": "^1.1.0",
"@tencent-ai/agent-sdk": "^0.3.173",
"@withfig/autocomplete": "^2.692.3", "@withfig/autocomplete": "^2.692.3",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0", "@xterm/addon-search": "^0.16.0",