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...@@ -37,7 +37,7 @@ function normalizeSpawnError(command: string, args: readonly string[], error: un
37 return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`);37 return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`);
38}38}
3939
40function isWindowsCommandNotFound(code: number | null, stderr: string): boolean {40export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean {
41 if (process.platform !== "win32") return false;41 if (process.platform !== "win32") return false;
42 if (code === 9009) return true;42 if (code === 9009) return true;
43 return /is not recognized as an internal or external command/i.test(stderr);43 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";...@@ -13,13 +13,13 @@ import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model";
1313
14import {14import {
15 buildServerProvider,15 buildServerProvider,
16 collectStreamAsString,
17 DEFAULT_TIMEOUT_MS,16 DEFAULT_TIMEOUT_MS,
18 detailFromResult,17 detailFromResult,
19 extractAuthBoolean,18 extractAuthBoolean,
20 isCommandMissingCause,19 isCommandMissingCause,
21 parseGenericCliVersion,20 parseGenericCliVersion,
22 providerModelsFromSettings,21 providerModelsFromSettings,
22 spawnAndCollect,
23 type CommandResult,23 type CommandResult,
24} from "../providerSnapshot";24} from "../providerSnapshot";
25import { makeManagedServerProvider } from "../makeManagedServerProvider";25import { makeManagedServerProvider } from "../makeManagedServerProvider";
...@@ -198,7 +198,6 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): {...@@ -198,7 +198,6 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
198198
199const runClaudeCommand = (args: ReadonlyArray<string>) =>199const runClaudeCommand = (args: ReadonlyArray<string>) =>
200 Effect.gen(function* () {200 Effect.gen(function* () {
201 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
202 const claudeSettings = yield* Effect.service(ServerSettingsService).pipe(201 const claudeSettings = yield* Effect.service(ServerSettingsService).pipe(
203 Effect.flatMap((service) => service.getSettings),202 Effect.flatMap((service) => service.getSettings),
204 Effect.map((settings) => settings.providers.claudeAgent),203 Effect.map((settings) => settings.providers.claudeAgent),
...@@ -206,19 +205,8 @@ const runClaudeCommand = (args: ReadonlyArray<string>) =>...@@ -206,19 +205,8 @@ const runClaudeCommand = (args: ReadonlyArray<string>) =>
206 const command = ChildProcess.make(claudeSettings.binaryPath, [...args], {205 const command = ChildProcess.make(claudeSettings.binaryPath, [...args], {
207 shell: process.platform === "win32",206 shell: process.platform === "win32",
208 });207 });
209208 return yield* spawnAndCollect(claudeSettings.binaryPath, command);
210 const child = yield* spawner.spawn(command);209 });
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);
222210
223export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(211export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
224 function* (): Effect.fn.Return<212 function* (): Effect.fn.Return<
apps/server/src/provider/Layers/CodexProvider.ts+3-15
...@@ -14,13 +14,13 @@ import { resolveEffort } from "@t3tools/shared/model";...@@ -14,13 +14,13 @@ import { resolveEffort } from "@t3tools/shared/model";
1414
15import {15import {
16 buildServerProvider,16 buildServerProvider,
17 collectStreamAsString,
18 DEFAULT_TIMEOUT_MS,17 DEFAULT_TIMEOUT_MS,
19 detailFromResult,18 detailFromResult,
20 extractAuthBoolean,19 extractAuthBoolean,
21 isCommandMissingCause,20 isCommandMissingCause,
22 parseGenericCliVersion,21 parseGenericCliVersion,
23 providerModelsFromSettings,22 providerModelsFromSettings,
23 spawnAndCollect,
24 type CommandResult,24 type CommandResult,
25} from "../providerSnapshot";25} from "../providerSnapshot";
26import { makeManagedServerProvider } from "../makeManagedServerProvider";26import { makeManagedServerProvider } from "../makeManagedServerProvider";
...@@ -292,7 +292,6 @@ export const hasCustomModelProvider = readCodexConfigModelProvider().pipe(...@@ -292,7 +292,6 @@ export const hasCustomModelProvider = readCodexConfigModelProvider().pipe(
292292
293const runCodexCommand = (args: ReadonlyArray<string>) =>293const runCodexCommand = (args: ReadonlyArray<string>) =>
294 Effect.gen(function* () {294 Effect.gen(function* () {
295 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
296 const settingsService = yield* ServerSettingsService;295 const settingsService = yield* ServerSettingsService;
297 const codexSettings = yield* settingsService.getSettings.pipe(296 const codexSettings = yield* settingsService.getSettings.pipe(
298 Effect.map((settings) => settings.providers.codex),297 Effect.map((settings) => settings.providers.codex),
...@@ -304,19 +303,8 @@ const runCodexCommand = (args: ReadonlyArray<string>) =>...@@ -304,19 +303,8 @@ const runCodexCommand = (args: ReadonlyArray<string>) =>
304 ...(codexSettings.homePath ? { CODEX_HOME: codexSettings.homePath } : {}),303 ...(codexSettings.homePath ? { CODEX_HOME: codexSettings.homePath } : {}),
305 },304 },
306 });305 });
307306 return yield* spawnAndCollect(codexSettings.binaryPath, command);
308 const child = yield* spawner.spawn(command);307 });
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);
320308
321export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(309export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(
322 function* (): Effect.fn.Return<310 function* (): Effect.fn.Return<
apps/server/src/provider/providerSnapshot.ts+22
...@@ -5,7 +5,9 @@ import type {...@@ -5,7 +5,9 @@ import type {
5 ServerProviderState,5 ServerProviderState,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { Effect, Stream } from "effect";7import { Effect, Stream } from "effect";
8import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
8import { normalizeModelSlug } from "@t3tools/shared/model";9import { normalizeModelSlug } from "@t3tools/shared/model";
10import { isWindowsCommandNotFound } from "../processRunner";
911
10export const DEFAULT_TIMEOUT_MS = 4_000;12export const DEFAULT_TIMEOUT_MS = 4_000;
1113
...@@ -35,6 +37,26 @@ export function isCommandMissingCause(error: unknown): boolean {...@@ -35,6 +37,26 @@ export function isCommandMissingCause(error: unknown): boolean {
35 return lower.includes("enoent") || lower.includes("notfound");37 return lower.includes("enoent") || lower.includes("notfound");
36}38}
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
38export function detailFromResult(60export function detailFromResult(
39 result: CommandResult & { readonly timedOut?: boolean },61 result: CommandResult & { readonly timedOut?: boolean },
40): string | undefined {62): string | undefined {