authorgravatar for alex.vostres@gmail.comAlexander Vostres <alex.vostres@gmail.com> 2026-03-28 19:28:56+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-28 17:28:56+00:00
log42ea7cfae16dd55eb004314781403386cd6965dc
treefa23a0525e3da3b7833525f794a44e766116b0c6
parentd2e6c82709230e8b2772cee2640d36d9b745274e
signature Signed by PGP key B5690EEEBB952194

Detect command not found when checking codex/claude CLI on Windows (#1492)


4 files changed, 29 insertions(+), 31 deletions(-)

apps/server/src/processRunner.ts+1-1
......@@ -37,7 +37,7 @@ function normalizeSpawnError(command: string, args: readonly string[], error: un
3737 return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`);
3838}
3939
40function isWindowsCommandNotFound(code: number | null, stderr: string): boolean {
40export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean {
4141 if (process.platform !== "win32") return false;
4242 if (code === 9009) return true;
4343 return /is not recognized as an internal or external command/i.test(stderr);
apps/server/src/provider/Layers/ClaudeProvider.ts+3-15
......@@ -13,13 +13,13 @@ import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model";
1313
1414import {
1515 buildServerProvider,
16 collectStreamAsString,
1716 DEFAULT_TIMEOUT_MS,
1817 detailFromResult,
1918 extractAuthBoolean,
2019 isCommandMissingCause,
2120 parseGenericCliVersion,
2221 providerModelsFromSettings,
22 spawnAndCollect,
2323 type CommandResult,
2424} from "../providerSnapshot";
2525import { makeManagedServerProvider } from "../makeManagedServerProvider";
......@@ -198,7 +198,6 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
198198
199199const runClaudeCommand = (args: ReadonlyArray<string>) =>
200200 Effect.gen(function* () {
201 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
202201 const claudeSettings = yield* Effect.service(ServerSettingsService).pipe(
203202 Effect.flatMap((service) => service.getSettings),
204203 Effect.map((settings) => settings.providers.claudeAgent),
......@@ -206,19 +205,8 @@ const runClaudeCommand = (args: ReadonlyArray<string>) =>
206205 const command = ChildProcess.make(claudeSettings.binaryPath, [...args], {
207206 shell: process.platform === "win32",
208207 });
209
210 const child = yield* spawner.spawn(command);
211 const [stdout, stderr, exitCode] = yield* Effect.all(
212 [
213 collectStreamAsString(child.stdout),
214 collectStreamAsString(child.stderr),
215 child.exitCode.pipe(Effect.map(Number)),
216 ],
217 { concurrency: "unbounded" },
218 );
219
220 return { stdout, stderr, code: exitCode } satisfies CommandResult;
221 }).pipe(Effect.scoped);
208 return yield* spawnAndCollect(claudeSettings.binaryPath, command);
209 });
222210
223211export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
224212 function* (): Effect.fn.Return<
apps/server/src/provider/Layers/CodexProvider.ts+3-15
......@@ -14,13 +14,13 @@ import { resolveEffort } from "@t3tools/shared/model";
1414
1515import {
1616 buildServerProvider,
17 collectStreamAsString,
1817 DEFAULT_TIMEOUT_MS,
1918 detailFromResult,
2019 extractAuthBoolean,
2120 isCommandMissingCause,
2221 parseGenericCliVersion,
2322 providerModelsFromSettings,
23 spawnAndCollect,
2424 type CommandResult,
2525} from "../providerSnapshot";
2626import { makeManagedServerProvider } from "../makeManagedServerProvider";
......@@ -292,7 +292,6 @@ export const hasCustomModelProvider = readCodexConfigModelProvider().pipe(
292292
293293const runCodexCommand = (args: ReadonlyArray<string>) =>
294294 Effect.gen(function* () {
295 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
296295 const settingsService = yield* ServerSettingsService;
297296 const codexSettings = yield* settingsService.getSettings.pipe(
298297 Effect.map((settings) => settings.providers.codex),
......@@ -304,19 +303,8 @@ const runCodexCommand = (args: ReadonlyArray<string>) =>
304303 ...(codexSettings.homePath ? { CODEX_HOME: codexSettings.homePath } : {}),
305304 },
306305 });
307
308 const child = yield* spawner.spawn(command);
309 const [stdout, stderr, exitCode] = yield* Effect.all(
310 [
311 collectStreamAsString(child.stdout),
312 collectStreamAsString(child.stderr),
313 child.exitCode.pipe(Effect.map(Number)),
314 ],
315 { concurrency: "unbounded" },
316 );
317
318 return { stdout, stderr, code: exitCode } satisfies CommandResult;
319 }).pipe(Effect.scoped);
306 return yield* spawnAndCollect(codexSettings.binaryPath, command);
307 });
320308
321309export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(
322310 function* (): Effect.fn.Return<
apps/server/src/provider/providerSnapshot.ts+22
......@@ -5,7 +5,9 @@ import type {
55 ServerProviderState,
66} from "@t3tools/contracts";
77import { Effect, Stream } from "effect";
8import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
89import { normalizeModelSlug } from "@t3tools/shared/model";
10import { isWindowsCommandNotFound } from "../processRunner";
911
1012export const DEFAULT_TIMEOUT_MS = 4_000;
1113
......@@ -35,6 +37,26 @@ export function isCommandMissingCause(error: unknown): boolean {
3537 return lower.includes("enoent") || lower.includes("notfound");
3638}
3739
40export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) =>
41 Effect.gen(function* () {
42 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
43 const child = yield* spawner.spawn(command);
44 const [stdout, stderr, exitCode] = yield* Effect.all(
45 [
46 collectStreamAsString(child.stdout),
47 collectStreamAsString(child.stderr),
48 child.exitCode.pipe(Effect.map(Number)),
49 ],
50 { concurrency: "unbounded" },
51 );
52
53 const result: CommandResult = { stdout, stderr, code: exitCode };
54 if (isWindowsCommandNotFound(exitCode, stderr)) {
55 return yield* Effect.fail(new Error(`spawn ${binaryPath} ENOENT`));
56 }
57 return result;
58 }).pipe(Effect.scoped);
59
3860export function detailFromResult(
3961 result: CommandResult & { readonly timedOut?: boolean },
4062): string | undefined {