Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

- Send Claude prompts through stdin instead of command-line arguments so large working-tree reviews no longer fail with `spawn ENAMETOOLONG` on Windows, and fail closed if prompt delivery itself errors.
- Resolve native `claude.exe` shims and npm-packaged executables from the active Windows `PATH`, npm prefix, or `APPDATA`, while retaining an explicit `CC_PLUGIN_CODEX_CLAUDE_BIN` override.

## v1.2.1

- Switch marketplace installs to Codex native plugin hooks: bundled hooks now load from `hooks/hooks.json` in the active plugin cache with `$PLUGIN_ROOT` instead of writing managed global hook commands into `~/.codex/hooks.json`.
Expand Down
91 changes: 87 additions & 4 deletions scripts/lib/claude-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,65 @@ import { fileURLToPath } from "node:url";
import { normalizePathSlashes, resolvePluginRuntimeRoot } from "./codex-paths.mjs";
import { getProcessIdentity, validateProcessIdentity } from "./process.mjs";

const CLAUDE_BIN = "claude";
const CLAUDE_PACKAGE_EXE_PARTS = [
"node_modules",
"@anthropic-ai",
"claude-code",
"bin",
"claude.exe",
];

/** @visibleForTesting */
export function resolveClaudeBin(options = {}) {
const env = options.env ?? process.env;
const platform = options.platform ?? process.platform;
const homedir = options.homedir ?? os.homedir();
const existsSync = options.existsSync ?? fs.existsSync;
const override = String(env.CC_PLUGIN_CODEX_CLAUDE_BIN ?? "").trim();

if (override) {
return override;
}
if (platform !== "win32") {
return "claude";
}

const pathApi = path.win32;
const searchRoots = [];
const pathValue = env.PATH ?? env.Path ?? env.path ?? "";
for (const entry of String(pathValue).split(pathApi.delimiter)) {
const normalized = entry.trim().replace(/^"|"$/g, "");
if (normalized) searchRoots.push(normalized);
}
if (env.npm_config_prefix) searchRoots.push(String(env.npm_config_prefix));
if (env.APPDATA) searchRoots.push(pathApi.join(String(env.APPDATA), "npm"));
searchRoots.push(pathApi.join(homedir, "AppData", "Roaming", "npm"));

const seenRoots = new Set();
for (const root of searchRoots) {
const resolvedRoot = pathApi.resolve(root);
const rootKey = resolvedRoot.toLowerCase();
if (seenRoots.has(rootKey)) continue;
seenRoots.add(rootKey);

const candidates = [
pathApi.join(resolvedRoot, "claude.exe"),
pathApi.join(resolvedRoot, ...CLAUDE_PACKAGE_EXE_PARTS),
];
for (const candidate of candidates) {
try {
if (existsSync(candidate)) {
return candidate;
}
} catch {
// Continue to the next candidate, then fall back to normal PATH lookup.
}
}
}
return "claude";
}

const CLAUDE_BIN = resolveClaudeBin();
export const MAX_STREAM_PARSER_UNKNOWN_EVENTS = 50;
export const MAX_STREAM_PARSER_PARSE_ERRORS = 50;
export const MAX_STREAM_PARSER_TOOL_USES = 256;
Expand Down Expand Up @@ -644,6 +702,8 @@ export function resolveEffort(effort) {

/**
* Build CLI argument array for `claude -p`.
* The prompt is intentionally excluded and is written to stdin by runClaudeTurn
* so Windows process creation never has to carry a repository-sized prompt.
*/
/** @visibleForTesting */
export function buildArgs(prompt, options = {}) {
Expand Down Expand Up @@ -703,7 +763,6 @@ export function buildArgs(prompt, options = {}) {
args.push("--strict-mcp-config");
}

args.push("--", prompt);
return args;
}

Expand All @@ -721,9 +780,24 @@ export async function runClaudeTurn(cwd, prompt, options = {}) {
const proc = spawn(CLAUDE_BIN, args, {
cwd,
detached: true, // new process group for safe cancellation
stdio: ["ignore", "pipe", "pipe"], // stdin ignored — prompt is passed as CLI arg
stdio: ["pipe", "pipe", "pipe"],
});

// Claude CLI print mode accepts the prompt on stdin. Keeping it out of argv
// avoids Windows' command-line length limit for reviews with large inline diffs.
let stdinError = null;
proc.stdin.on("error", (error) => {
// ChildProcess still emits its normal close/error event. Retain the pipe
// failure so a child cannot be reported as successful without its prompt.
stdinError = error;
});
try {
proc.stdin.end(String(prompt ?? ""), "utf8");
} catch (error) {
stdinError = error;
proc.stdin.destroy();
}

let pidIdentity = null;
try {
pidIdentity = getProcessIdentity(proc.pid);
Expand Down Expand Up @@ -763,7 +837,16 @@ export async function runClaudeTurn(cwd, prompt, options = {}) {
if (options.onProgress) options.onProgress(evt);
}

const validation = validateTurnCompletion(parser.state, code ?? 1);
if (stdinError) {
stderr = appendTextTail(
stderr,
`\nFailed to write Claude prompt to stdin: ${stdinError.message}`,
MAX_STDERR_BYTES
);
}
const validation = stdinError
? { status: "failed", warning: "Claude prompt delivery through stdin failed." }
: validateTurnCompletion(parser.state, code ?? 1);
resolve({
status: validation.status,
warning: validation.warning,
Expand Down
101 changes: 95 additions & 6 deletions tests/claude-cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
resolveEffort,
resolveDefaultModel,
resolveDefaultEffort,
resolveClaudeBin,
buildArgs,
MODEL_ALIASES,
EFFORT_ALIASES,
Expand Down Expand Up @@ -596,6 +597,95 @@ describe("resolveEffort", () => {
});
});

// ===========================================================================
// resolveClaudeBin
// ===========================================================================

describe("resolveClaudeBin", () => {
it("prefers an explicit binary override", () => {
assert.equal(
resolveClaudeBin({
env: { CC_PLUGIN_CODEX_CLAUDE_BIN: " C:\\custom\\claude.exe " },
platform: "win32",
homedir: "C:\\Users\\demo",
existsSync: () => false,
}),
"C:\\custom\\claude.exe"
);
});

it("finds the native package executable under a PATH npm prefix", () => {
const expected =
"D:\\tools\\npm\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe";
assert.equal(
resolveClaudeBin({
env: { PATH: "C:\\Windows;D:\\tools\\npm" },
platform: "win32",
homedir: "C:\\Users\\demo",
existsSync: (candidate) => candidate === expected,
}),
expected
);
});

it("finds a native Claude executable directly on PATH", () => {
const expected = "D:\\tools\\claude.exe";
assert.equal(
resolveClaudeBin({
env: { PATH: "C:\\Windows;D:\\tools" },
platform: "win32",
homedir: "C:\\Users\\demo",
existsSync: (candidate) => candidate === expected,
}),
expected
);
});

it("uses APPDATA when the npm prefix is not present in PATH", () => {
const expected =
"R:\\Profile\\npm\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe";
assert.equal(
resolveClaudeBin({
env: { APPDATA: "R:\\Profile" },
platform: "win32",
homedir: "C:\\Users\\demo",
existsSync: (candidate) => candidate === expected,
}),
expected
);
});

it("falls back to command lookup when no native executable is found", () => {
assert.equal(
resolveClaudeBin({
env: {},
platform: "win32",
homedir: "C:\\Users\\demo",
existsSync: () => false,
}),
"claude"
);
});

it("deduplicates Windows search roots case-insensitively", () => {
const checked = [];
resolveClaudeBin({
env: { PATH: "C:\\NPM;c:\\npm" },
platform: "win32",
homedir: "C:\\Users\\demo",
existsSync: (candidate) => {
checked.push(candidate);
return false;
},
});
assert.equal(checked.length, 4);
assert.equal(
checked[0].toLowerCase(),
"c:\\npm\\claude.exe"
);
});
});

// ===========================================================================
// buildArgs
// ===========================================================================
Expand All @@ -606,12 +696,11 @@ describe("buildArgs", () => {
assert.equal(args[0], "-p");
});

it("ends with -- separator followed by prompt", () => {
const args = buildArgs("my prompt");
const dashDashIdx = args.indexOf("--");
assert.ok(dashDashIdx >= 0);
assert.equal(args[dashDashIdx + 1], "my prompt");
assert.equal(args[args.length - 1], "my prompt");
it("keeps the prompt out of argv so it can be sent through stdin", () => {
const prompt = "x".repeat(70_000);
const args = buildArgs(prompt);
assert.ok(!args.includes("--"));
assert.ok(!args.includes(prompt));
});

it("defaults output format to json", () => {
Expand Down
11 changes: 10 additions & 1 deletion tests/e2e/codex-skills-e2e.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function readStdin() {
let body = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) {
body += chunk;
}
return body;
}

async function main() {
if (args[0] === "--version") {
process.stdout.write("2.1.90 (Claude Code)\\n");
Expand All @@ -85,7 +94,7 @@ async function main() {
}

const promptIndex = args.lastIndexOf("--");
const prompt = promptIndex >= 0 ? args.slice(promptIndex + 1).join(" ") : "";
const prompt = promptIndex >= 0 ? args.slice(promptIndex + 1).join(" ") : await readStdin();
const delayMatch = prompt.match(/\\bdelay=(\\d+)\\b/);
const delay = delayMatch ? Number(delayMatch[1]) : 25;
const sessionId =
Expand Down
43 changes: 42 additions & 1 deletion tests/integration/claude-companion.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function readStdin() {
let body = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) {
body += chunk;
}
return body;
}

function sanitize(value) {
return String(value || "session")
.toLowerCase()
Expand Down Expand Up @@ -64,7 +73,7 @@ async function main() {
const prompt =
promptIndex >= 0
? args.slice(promptIndex + 1).join(" ")
: "";
: await readStdin();
const delay = Number((prompt.match(/\\bdelay=(\\d+)\\b/) || [])[1] || 80);
if (process.env.CLAUDE_ARGS_FILE) {
require("node:fs").writeFileSync(
Expand Down Expand Up @@ -910,6 +919,38 @@ describe("claude-companion integration", () => {
}
});

it("sends a Windows-sized review prompt through stdin instead of argv", () => {
const testEnv = createTestEnvironment();

try {
setupGitWorkspace(testEnv.workspaceDir);
fs.writeFileSync(
path.join(testEnv.workspaceDir, "large-review-input.txt"),
"review-line\n".repeat(3_500),
"utf8"
);
const invocationFile = path.join(testEnv.rootDir, "large-review-invocation.json");

const result = runCompanion(
["review", "--cwd", testEnv.workspaceDir, "--scope", "working-tree", "--model", "haiku"],
{
env: {
...testEnv.env,
CLAUDE_INVOCATION_FILE: invocationFile,
},
}
);

const invocation = JSON.parse(fs.readFileSync(invocationFile, "utf8"));
assert.ok(Buffer.byteLength(invocation.prompt, "utf8") > 32_767);
assert.match(invocation.prompt, /review-line/);
assert.ok(!invocation.args.includes(invocation.prompt));
assert.match(result.stdout, /Claude Code Review/);
} finally {
cleanupTestEnvironment(testEnv);
}
});

it("forwards adversarial-review model and focus text into the prompt", () => {
const testEnv = createTestEnvironment();

Expand Down