@actagent/acpx 2026.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,427 @@
1
+ /**
2
+ * ACPX process ownership checks and cleanup. The reaper only terminates
3
+ * ACTAgent-owned wrapper trees after validating paths, packages, and lease ids.
4
+ */
5
+ import { execFile } from "node:child_process";
6
+ import { createRequire } from "node:module";
7
+ import path from "node:path";
8
+ import { promisify } from "node:util";
9
+ import { splitCommandParts } from "./command-line.js";
10
+ import { resolveAcpxPluginRoot } from "./config.js";
11
+ import { ACTAGENT_ACPX_LEASE_ID_ARG, ACTAGENT_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js";
12
+
13
+ const execFileAsync = promisify(execFile);
14
+ const requireFromHere = createRequire(import.meta.url);
15
+ const GENERATED_WRAPPER_BASENAMES = new Set([
16
+ "codex-acp-wrapper.mjs",
17
+ "claude-agent-acp-wrapper.mjs",
18
+ ]);
19
+ const ACTAGENT_PLUGIN_DEPS_MARKER = "/plugin-runtime-deps/";
20
+ const OWNED_ACP_PACKAGE_NAMES = [
21
+ "@zed-industries/codex-acp",
22
+ "@zed-industries/codex-acp-darwin-arm64",
23
+ "@zed-industries/codex-acp-darwin-x64",
24
+ "@zed-industries/codex-acp-linux-arm64",
25
+ "@zed-industries/codex-acp-linux-x64",
26
+ "@zed-industries/codex-acp-win32-arm64",
27
+ "@zed-industries/codex-acp-win32-x64",
28
+ "@agentclientprotocol/claude-agent-acp",
29
+ "acpx",
30
+ ];
31
+ const ACP_PACKAGE_MARKERS = [
32
+ ...OWNED_ACP_PACKAGE_NAMES.map((packageName) => `/node_modules/${packageName}/`),
33
+ "/acpx/dist/",
34
+ ];
35
+
36
+ /** Minimal process-table row used by ACPX cleanup. */
37
+ export type AcpxProcessInfo = {
38
+ pid: number;
39
+ ppid: number;
40
+ command: string;
41
+ };
42
+
43
+ /** Injectable process-listing and termination hooks for tests. */
44
+ export type AcpxProcessCleanupDeps = {
45
+ listProcesses?: () => Promise<AcpxProcessInfo[]>;
46
+ killProcess?: (pid: number, signal: NodeJS.Signals) => void;
47
+ sleep?: (ms: number) => Promise<void>;
48
+ };
49
+
50
+ /** Result from cleaning up a single ACPX process tree. */
51
+ export type AcpxProcessCleanupResult = {
52
+ inspectedPids: number[];
53
+ terminatedPids: number[];
54
+ skippedReason?: "missing-root" | "not-actagent-owned" | "unverified-root";
55
+ };
56
+
57
+ /** Result from startup orphan reaping. */
58
+ export type AcpxStartupReapResult = {
59
+ inspectedPids: number[];
60
+ terminatedPids: number[];
61
+ skippedReason?: "unsupported-platform" | "process-list-unavailable";
62
+ };
63
+
64
+ function normalizePathLike(value: string): string {
65
+ return value.replaceAll("\\", "/");
66
+ }
67
+
68
+ function resolvePackageRoot(packageName: string): string | undefined {
69
+ try {
70
+ return normalizePathLike(path.dirname(requireFromHere.resolve(`${packageName}/package.json`)));
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ function resolveACTAgentInstallRoot(pluginRoot: string): string {
77
+ if (
78
+ path.basename(pluginRoot) === "acpx" &&
79
+ path.basename(path.dirname(pluginRoot)) === "extensions"
80
+ ) {
81
+ const parent = path.dirname(path.dirname(pluginRoot));
82
+ return path.basename(parent) === "dist" ? path.dirname(parent) : parent;
83
+ }
84
+ return path.resolve(pluginRoot, "..");
85
+ }
86
+
87
+ function resolveOwnedAcpPackageRootCandidates(packageName: string): string[] {
88
+ const pluginRoot = resolveAcpxPluginRoot(import.meta.url);
89
+ const actAgentRoot = resolveACTAgentInstallRoot(pluginRoot);
90
+ return [
91
+ resolvePackageRoot(packageName),
92
+ path.join(pluginRoot, "node_modules", packageName),
93
+ path.join(actAgentRoot, "node_modules", packageName),
94
+ ].flatMap((root) => (root ? [normalizePathLike(root)] : []));
95
+ }
96
+
97
+ const OWNED_ACP_PACKAGE_ROOTS = Array.from(
98
+ new Set(OWNED_ACP_PACKAGE_NAMES.flatMap(resolveOwnedAcpPackageRootCandidates)),
99
+ );
100
+
101
+ function commandBelongsToResolvedAcpPackage(command: string): boolean {
102
+ return OWNED_ACP_PACKAGE_ROOTS.some((root) => command.includes(`${root}/`));
103
+ }
104
+
105
+ function commandMentionsGeneratedWrapper(command: string): boolean {
106
+ return Array.from(GENERATED_WRAPPER_BASENAMES).some((basename) => command.includes(basename));
107
+ }
108
+
109
+ function commandWrapperBelongsToRoot(command: string, wrapperRoot: string | undefined): boolean {
110
+ if (!wrapperRoot) {
111
+ return true;
112
+ }
113
+ const normalizedCommand = normalizePathLike(command);
114
+ const normalizedRoot = normalizePathLike(wrapperRoot).replace(/\/+$/, "");
115
+ return Array.from(GENERATED_WRAPPER_BASENAMES).some((basename) =>
116
+ normalizedCommand.includes(`${normalizedRoot}/${basename}`),
117
+ );
118
+ }
119
+
120
+ /** Check whether a command references an ACTAgent-generated ACPX wrapper path. */
121
+ export function isACTAgentLeaseAwareAcpxProcessCommand(params: {
122
+ command: string | undefined;
123
+ wrapperRoot?: string;
124
+ }): boolean {
125
+ const command = params.command?.trim();
126
+ if (!command) {
127
+ return false;
128
+ }
129
+ const normalized = normalizePathLike(command);
130
+ return (
131
+ commandMentionsGeneratedWrapper(normalized) &&
132
+ commandWrapperBelongsToRoot(normalized, params.wrapperRoot)
133
+ );
134
+ }
135
+
136
+ function commandsReferToSameRootCommand(liveCommand: string, storedCommand: string | undefined) {
137
+ if (!storedCommand?.trim()) {
138
+ return true;
139
+ }
140
+ return normalizePathLike(liveCommand).trim() === normalizePathLike(storedCommand).trim();
141
+ }
142
+
143
+ function commandOptionEquals(
144
+ parts: string[],
145
+ option: string,
146
+ expected: string | undefined,
147
+ ): boolean {
148
+ if (!expected) {
149
+ return true;
150
+ }
151
+ const index = parts.indexOf(option);
152
+ return index >= 0 && parts[index + 1] === expected;
153
+ }
154
+
155
+ function liveCommandMatchesLeaseIdentity(params: {
156
+ command: string | undefined;
157
+ expectedLeaseId?: string;
158
+ expectedGatewayInstanceId?: string;
159
+ }): boolean {
160
+ if (!params.expectedLeaseId && !params.expectedGatewayInstanceId) {
161
+ return true;
162
+ }
163
+ const parts = splitCommandParts(params.command ?? "");
164
+ return (
165
+ commandOptionEquals(parts, ACTAGENT_ACPX_LEASE_ID_ARG, params.expectedLeaseId) &&
166
+ commandOptionEquals(parts, ACTAGENT_GATEWAY_INSTANCE_ID_ARG, params.expectedGatewayInstanceId)
167
+ );
168
+ }
169
+
170
+ /** Check whether a command is owned by ACTAgent ACPX runtime packages or wrappers. */
171
+ export function isACTAgentOwnedAcpxProcessCommand(params: {
172
+ command: string | undefined;
173
+ wrapperRoot?: string;
174
+ }): boolean {
175
+ const command = params.command?.trim();
176
+ if (!command) {
177
+ return false;
178
+ }
179
+ const normalized = normalizePathLike(command);
180
+ if (
181
+ isACTAgentLeaseAwareAcpxProcessCommand({
182
+ command: normalized,
183
+ wrapperRoot: params.wrapperRoot,
184
+ })
185
+ ) {
186
+ return true;
187
+ }
188
+ if (commandBelongsToResolvedAcpPackage(normalized)) {
189
+ return true;
190
+ }
191
+ if (!normalized.includes(ACTAGENT_PLUGIN_DEPS_MARKER)) {
192
+ return false;
193
+ }
194
+ return ACP_PACKAGE_MARKERS.some((marker) => normalized.includes(marker));
195
+ }
196
+
197
+ function parseProcessList(stdout: string): AcpxProcessInfo[] {
198
+ const processes: AcpxProcessInfo[] = [];
199
+ for (const line of stdout.split(/\r?\n/)) {
200
+ const match = /^\s*(?<pid>\d+)\s+(?<ppid>\d+)\s+(?<command>.+?)\s*$/.exec(line);
201
+ if (!match?.groups) {
202
+ continue;
203
+ }
204
+ processes.push({
205
+ pid: Number.parseInt(match.groups.pid, 10),
206
+ ppid: Number.parseInt(match.groups.ppid, 10),
207
+ command: match.groups.command,
208
+ });
209
+ }
210
+ return processes;
211
+ }
212
+
213
+ /** List host processes in the compact shape needed by ACPX cleanup. */
214
+ export async function listPlatformProcesses(): Promise<AcpxProcessInfo[]> {
215
+ if (process.platform === "win32") {
216
+ return [];
217
+ }
218
+ const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,command="], {
219
+ maxBuffer: 8 * 1024 * 1024,
220
+ });
221
+ return parseProcessList(stdout);
222
+ }
223
+
224
+ function collectProcessTree(processes: AcpxProcessInfo[], rootPid: number): AcpxProcessInfo[] {
225
+ const childrenByParent = new Map<number, AcpxProcessInfo[]>();
226
+ for (const processInfo of processes) {
227
+ const children = childrenByParent.get(processInfo.ppid) ?? [];
228
+ children.push(processInfo);
229
+ childrenByParent.set(processInfo.ppid, children);
230
+ }
231
+
232
+ const byPid = new Map(processes.map((processInfo) => [processInfo.pid, processInfo]));
233
+ const root = byPid.get(rootPid);
234
+ const collected: AcpxProcessInfo[] = [];
235
+ if (root) {
236
+ collected.push(root);
237
+ }
238
+
239
+ const queue = [...(childrenByParent.get(rootPid) ?? [])];
240
+ while (queue.length > 0) {
241
+ const next = queue.shift();
242
+ if (!next || collected.some((processInfo) => processInfo.pid === next.pid)) {
243
+ continue;
244
+ }
245
+ collected.push(next);
246
+ queue.push(...(childrenByParent.get(next.pid) ?? []));
247
+ }
248
+
249
+ return collected;
250
+ }
251
+
252
+ function uniquePids(processes: AcpxProcessInfo[]): number[] {
253
+ return Array.from(
254
+ new Set(
255
+ processes
256
+ .map((processInfo) => processInfo.pid)
257
+ .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid),
258
+ ),
259
+ );
260
+ }
261
+
262
+ function isProcessAlive(pid: number): boolean {
263
+ try {
264
+ process.kill(pid, 0);
265
+ return true;
266
+ } catch {
267
+ return false;
268
+ }
269
+ }
270
+
271
+ async function terminatePids(
272
+ pids: number[],
273
+ deps: AcpxProcessCleanupDeps | undefined,
274
+ ): Promise<number[]> {
275
+ const killProcess = deps?.killProcess ?? ((pid, signal) => process.kill(pid, signal));
276
+ const sleep =
277
+ deps?.sleep ??
278
+ ((ms) =>
279
+ new Promise<void>((resolve) => {
280
+ setTimeout(resolve, ms);
281
+ }));
282
+ const terminated: number[] = [];
283
+
284
+ for (const pid of pids) {
285
+ try {
286
+ killProcess(pid, "SIGTERM");
287
+ terminated.push(pid);
288
+ } catch {
289
+ // The process may already be gone.
290
+ }
291
+ }
292
+ if (terminated.length === 0) {
293
+ return terminated;
294
+ }
295
+ await sleep(750);
296
+ for (const pid of terminated) {
297
+ if (deps?.killProcess || isProcessAlive(pid)) {
298
+ try {
299
+ killProcess(pid, "SIGKILL");
300
+ } catch {
301
+ // Best-effort cleanup only.
302
+ }
303
+ }
304
+ }
305
+ return terminated;
306
+ }
307
+
308
+ /** Terminate one validated ACTAgent-owned ACPX wrapper process tree. */
309
+ export async function cleanupACTAgentOwnedAcpxProcessTree(params: {
310
+ rootPid?: number;
311
+ rootCommand?: string;
312
+ expectedLeaseId?: string;
313
+ expectedGatewayInstanceId?: string;
314
+ wrapperRoot?: string;
315
+ deps?: AcpxProcessCleanupDeps;
316
+ }): Promise<AcpxProcessCleanupResult> {
317
+ const rootPid = params.rootPid;
318
+ if (!rootPid || rootPid <= 0 || rootPid === process.pid) {
319
+ return { inspectedPids: [], terminatedPids: [], skippedReason: "missing-root" };
320
+ }
321
+
322
+ let processes: AcpxProcessInfo[];
323
+ try {
324
+ processes = await (params.deps?.listProcesses ?? listPlatformProcesses)();
325
+ } catch {
326
+ processes = [];
327
+ }
328
+
329
+ const listedTree = collectProcessTree(processes, rootPid);
330
+ // Session-store PIDs are stale data. If the live process table cannot prove
331
+ // that this PID still belongs to an ACTAgent-owned wrapper, fail closed to
332
+ // avoid killing an unrelated process after PID reuse.
333
+ if (listedTree.length === 0) {
334
+ return { inspectedPids: [], terminatedPids: [], skippedReason: "unverified-root" };
335
+ }
336
+ const rootCommand = listedTree[0]?.command ?? params.rootCommand;
337
+ const liveCommandWasGeneratedWrapper = commandMentionsGeneratedWrapper(
338
+ normalizePathLike(rootCommand ?? ""),
339
+ );
340
+ const storedCommandWasGeneratedWrapper = commandMentionsGeneratedWrapper(
341
+ normalizePathLike(params.rootCommand ?? ""),
342
+ );
343
+ if (!liveCommandWasGeneratedWrapper && storedCommandWasGeneratedWrapper) {
344
+ return {
345
+ inspectedPids: listedTree.map((processInfo) => processInfo.pid),
346
+ terminatedPids: [],
347
+ skippedReason: "not-actagent-owned",
348
+ };
349
+ }
350
+ if (
351
+ !liveCommandWasGeneratedWrapper &&
352
+ !commandsReferToSameRootCommand(rootCommand ?? "", params.rootCommand)
353
+ ) {
354
+ return {
355
+ inspectedPids: listedTree.map((processInfo) => processInfo.pid),
356
+ terminatedPids: [],
357
+ skippedReason: "not-actagent-owned",
358
+ };
359
+ }
360
+ if (
361
+ !isACTAgentOwnedAcpxProcessCommand({
362
+ command: rootCommand,
363
+ wrapperRoot: params.wrapperRoot,
364
+ })
365
+ ) {
366
+ return {
367
+ inspectedPids: listedTree.map((processInfo) => processInfo.pid),
368
+ terminatedPids: [],
369
+ skippedReason: "not-actagent-owned",
370
+ };
371
+ }
372
+ if (
373
+ !liveCommandMatchesLeaseIdentity({
374
+ command: rootCommand,
375
+ expectedLeaseId: params.expectedLeaseId,
376
+ expectedGatewayInstanceId: params.expectedGatewayInstanceId,
377
+ })
378
+ ) {
379
+ return {
380
+ inspectedPids: listedTree.map((processInfo) => processInfo.pid),
381
+ terminatedPids: [],
382
+ skippedReason: "not-actagent-owned",
383
+ };
384
+ }
385
+
386
+ const pids = uniquePids(listedTree.toReversed());
387
+ return {
388
+ inspectedPids: uniquePids(listedTree),
389
+ terminatedPids: await terminatePids(pids, params.deps),
390
+ };
391
+ }
392
+
393
+ /** Reap orphaned ACTAgent-owned ACPX wrapper trees during runtime startup. */
394
+ export async function reapStaleACTAgentOwnedAcpxOrphans(params: {
395
+ wrapperRoot: string;
396
+ deps?: AcpxProcessCleanupDeps;
397
+ }): Promise<AcpxStartupReapResult> {
398
+ if (process.platform === "win32") {
399
+ return { inspectedPids: [], terminatedPids: [], skippedReason: "unsupported-platform" };
400
+ }
401
+
402
+ let processes: AcpxProcessInfo[];
403
+ try {
404
+ processes = await (params.deps?.listProcesses ?? listPlatformProcesses)();
405
+ } catch {
406
+ return { inspectedPids: [], terminatedPids: [], skippedReason: "process-list-unavailable" };
407
+ }
408
+
409
+ const orphans = processes.filter(
410
+ (processInfo) =>
411
+ processInfo.ppid === 1 &&
412
+ isACTAgentOwnedAcpxProcessCommand({
413
+ command: processInfo.command,
414
+ wrapperRoot: params.wrapperRoot,
415
+ }),
416
+ );
417
+ // Startup reaping starts from currently visible orphan roots and then expands
418
+ // each tree, so adapter grandchildren do not survive as fresh orphans after
419
+ // the wrapper root exits.
420
+ const orphanTrees = orphans.map((orphan) => collectProcessTree(processes, orphan.pid));
421
+ const inspectedPids = uniquePids(orphanTrees.flat());
422
+ const pids = uniquePids(orphanTrees.flatMap((tree) => tree.toReversed()));
423
+ return {
424
+ inspectedPids,
425
+ terminatedPids: await terminatePids(pids, params.deps),
426
+ };
427
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Command-line parser for ACPX MCP proxy targets. It handles simple quoting and
3
+ * Windows executable paths before spawning the configured MCP target.
4
+ */
5
+ const WINDOWS_DIRECT_EXECUTABLE_PATH_RE =
6
+ /^(?<command>(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:exe|com))(?=\s|$)(?:\s+(?<rest>.*))?$/i;
7
+
8
+ // Windows wrapper scripts need their host shell or interpreter (`cmd.exe`,
9
+ // `powershell.exe`, or `node`) instead of direct spawning.
10
+ const WINDOWS_WRAPPER_PATH_RE =
11
+ /^(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:bat|cmd|cjs|js|mjs|ps1)$/i;
12
+
13
+ function splitCommandParts(value, platform = process.platform) {
14
+ const parts = [];
15
+ let current = "";
16
+ let quote = null;
17
+ let escaping = false;
18
+
19
+ for (let index = 0; index < value.length; index += 1) {
20
+ const ch = value[index];
21
+ const next = value[index + 1];
22
+ if (escaping) {
23
+ current += ch;
24
+ escaping = false;
25
+ continue;
26
+ }
27
+ if (ch === "\\") {
28
+ if (quote === "'") {
29
+ current += ch;
30
+ continue;
31
+ }
32
+ if (platform === "win32") {
33
+ if (quote === '"') {
34
+ if (next === '"' || next === "\\") {
35
+ escaping = true;
36
+ continue;
37
+ }
38
+ current += ch;
39
+ continue;
40
+ }
41
+ if (!quote) {
42
+ current += ch;
43
+ continue;
44
+ }
45
+ }
46
+ escaping = true;
47
+ continue;
48
+ }
49
+ if (quote) {
50
+ if (ch === quote) {
51
+ quote = null;
52
+ } else {
53
+ current += ch;
54
+ }
55
+ continue;
56
+ }
57
+ if (ch === "'" || ch === '"') {
58
+ quote = ch;
59
+ continue;
60
+ }
61
+ if (/\s/.test(ch)) {
62
+ if (current.length > 0) {
63
+ parts.push(current);
64
+ current = "";
65
+ }
66
+ continue;
67
+ }
68
+ current += ch;
69
+ }
70
+
71
+ if (escaping) {
72
+ current += "\\";
73
+ }
74
+ if (quote) {
75
+ throw new Error("Invalid agent command: unterminated quote");
76
+ }
77
+ if (current.length > 0) {
78
+ parts.push(current);
79
+ }
80
+ return parts;
81
+ }
82
+
83
+ function splitWindowsExecutableCommand(value, platform = process.platform) {
84
+ if (platform !== "win32") {
85
+ return null;
86
+ }
87
+ const trimmed = value.trim();
88
+ if (!trimmed || trimmed.startsWith('"') || trimmed.startsWith("'")) {
89
+ return null;
90
+ }
91
+ const match = trimmed.match(WINDOWS_DIRECT_EXECUTABLE_PATH_RE);
92
+ if (!match?.groups?.command) {
93
+ return null;
94
+ }
95
+ const rest = match.groups.rest?.trim() ?? "";
96
+ return {
97
+ command: match.groups.command,
98
+ args: rest ? splitCommandParts(rest, platform) : [],
99
+ };
100
+ }
101
+
102
+ function assertSupportedWindowsCommand(command, platform = process.platform) {
103
+ if (platform !== "win32" || !WINDOWS_WRAPPER_PATH_RE.test(command)) {
104
+ return;
105
+ }
106
+ throw new Error(
107
+ `Unsupported Windows agent command wrapper: ${command}. ` +
108
+ "Invoke wrapper scripts through their shell or interpreter instead " +
109
+ "(for example `cmd.exe /c`, `powershell.exe -File`, or `node <script>`).",
110
+ );
111
+ }
112
+
113
+ /** Split a configured command string into `{ command, args }` for child_process.spawn. */
114
+ export function splitCommandLine(value, platform = process.platform) {
115
+ const windowsCommand = splitWindowsExecutableCommand(value, platform);
116
+ const parts = windowsCommand ?? splitCommandParts(value, platform);
117
+ if (parts.length === 0) {
118
+ throw new Error("Invalid agent command: empty command");
119
+ }
120
+ const parsed = Array.isArray(parts)
121
+ ? {
122
+ command: parts[0],
123
+ args: parts.slice(1),
124
+ }
125
+ : parts;
126
+ assertSupportedWindowsCommand(parsed.command, platform);
127
+ return parsed;
128
+ }
@@ -0,0 +1,60 @@
1
+ // ACPX tests cover mcp command line plugin behavior.
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ type SplitCommandLine = (
5
+ value: string,
6
+ platform?: string,
7
+ ) => {
8
+ command: string;
9
+ args: string[];
10
+ };
11
+
12
+ async function loadSplitCommandLine(): Promise<SplitCommandLine> {
13
+ const moduleUrl = new URL("./mcp-command-line.mjs", import.meta.url);
14
+ return (await import(moduleUrl.href)).splitCommandLine as SplitCommandLine;
15
+ }
16
+
17
+ describe("mcp-command-line", () => {
18
+ it("parses quoted Windows executable paths without dropping backslashes", async () => {
19
+ const splitCommandLine = await loadSplitCommandLine();
20
+ const parsed = splitCommandLine(
21
+ '"C:\\Program Files\\Claude\\claude.exe" --stdio --flag "two words"',
22
+ "win32",
23
+ );
24
+
25
+ expect(parsed).toEqual({
26
+ command: "C:\\Program Files\\Claude\\claude.exe",
27
+ args: ["--stdio", "--flag", "two words"],
28
+ });
29
+ });
30
+
31
+ it("parses unquoted Windows executable paths without mangling backslashes", async () => {
32
+ const splitCommandLine = await loadSplitCommandLine();
33
+ const parsed = splitCommandLine("C:\\Users\\alerl\\.local\\bin\\claude.exe --version", "win32");
34
+
35
+ expect(parsed).toEqual({
36
+ command: "C:\\Users\\alerl\\.local\\bin\\claude.exe",
37
+ args: ["--version"],
38
+ });
39
+ });
40
+
41
+ it("preserves unquoted Windows path arguments after the executable", async () => {
42
+ const splitCommandLine = await loadSplitCommandLine();
43
+ const parsed = splitCommandLine(
44
+ '"C:\\Program Files\\Claude\\claude.exe" --config C:\\Users\\me\\cfg.json',
45
+ "win32",
46
+ );
47
+
48
+ expect(parsed).toEqual({
49
+ command: "C:\\Program Files\\Claude\\claude.exe",
50
+ args: ["--config", "C:\\Users\\me\\cfg.json"],
51
+ });
52
+ });
53
+
54
+ it("rejects direct Windows wrapper-script commands with a helpful error", async () => {
55
+ const splitCommandLine = await loadSplitCommandLine();
56
+ expect(() =>
57
+ splitCommandLine('"C:\\Users\\me\\bin\\claude-wrapper.cmd" --stdio', "win32"),
58
+ ).toThrow(/Invoke wrapper scripts through their shell or interpreter instead/);
59
+ });
60
+ });