diff --git a/electron/bridges/ai/shellUtils.cjs b/electron/bridges/ai/shellUtils.cjs index bcb599c1..b740d0be 100644 --- a/electron/bridges/ai/shellUtils.cjs +++ b/electron/bridges/ai/shellUtils.cjs @@ -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, }; diff --git a/electron/bridges/ai/shellUtils.test.cjs b/electron/bridges/ai/shellUtils.test.cjs index b26ea795..951cd6ec 100644 --- a/electron/bridges/ai/shellUtils.test.cjs +++ b/electron/bridges/ai/shellUtils.test.cjs @@ -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 = {}; diff --git a/electron/bridges/aiBridge.cjs b/electron/bridges/aiBridge.cjs index 5efd8072..baaa4470 100644 --- a/electron/bridges/aiBridge.cjs +++ b/electron/bridges/aiBridge.cjs @@ -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, diff --git a/electron/bridges/aiBridge/sdk/sdkStreamHandlers.cjs b/electron/bridges/aiBridge/sdk/sdkStreamHandlers.cjs index 737da2b3..76c2f1e0 100644 --- a/electron/bridges/aiBridge/sdk/sdkStreamHandlers.cjs +++ b/electron/bridges/aiBridge/sdk/sdkStreamHandlers.cjs @@ -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) : []; diff --git a/electron/bridges/aiBridge/sdk/sdkStreamHandlers.test.cjs b/electron/bridges/aiBridge/sdk/sdkStreamHandlers.test.cjs index 07ff9c75..65800b15 100644 --- a/electron/bridges/aiBridge/sdk/sdkStreamHandlers.test.cjs +++ b/electron/bridges/aiBridge/sdk/sdkStreamHandlers.test.cjs @@ -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", diff --git a/package-lock.json b/package-lock.json index 217af6fd..09b99a30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",