From 61b460e99b1dfd82f9b2937f8914bb214ff2a5cd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 06:28:16 +0100 Subject: [PATCH] fix: preserve literal -- when forwarding args in claude-use run Commander's variadic [args...] argument on the run subcommand strips a literal -- from the forwarded argv by default, treating it purely as its own end-of-options marker rather than a token to pass through. A command like `claude mcp add name -- npx -y pkg` therefore reached the real claude binary as `mcp add name npx -y pkg`, with no -- left to shield -y from claude's own option parser, which then rejected it as an unknown option. Add passThroughOptions() so every token after the first is forwarded verbatim, -- included. Extract the run subcommand's registration into its own runCommand.ts module, matching the register*Command pattern every other subcommand already uses, so this parsing behaviour is unit-testable without importing cli.ts itself, which runs the real CLI as a top-level side effect on import. --- src/cli.ts | 15 ++------------- src/runCommand.test.ts | 43 ++++++++++++++++++++++++++++++++++++++++++ src/runCommand.ts | 21 +++++++++++++++++++++ 3 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 src/runCommand.test.ts create mode 100644 src/runCommand.ts diff --git a/src/cli.ts b/src/cli.ts index 123f6f0..8c6c79f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import { registerIdentityCommand, tryRunAtIdentityShortcut } from "./identityMan import { profileExists, registerProfileCommand } from "./configProfiles"; import { registerRulesCommand } from "./directoryRules"; import { resolveClaudeHome, resolveLayoutPaths, type LayoutPaths } from "./paths"; +import { registerRunCommand } from "./runCommand"; import { runLauncher, type FarmRuntime } from "./launcher"; import { parseLauncherArgv } from "./launcher/argv"; import { decideConfigProfile, decideIdentity, loadIdentity } from "./launcher/identity"; @@ -57,19 +58,7 @@ function buildClaudeUseProgram(): Command { registerConfigureCommand(program, paths); registerDoctorCommand(program, paths); registerShimCommand(program, paths); - - program - .command("run") - .description( - "Run the launcher pipeline directly, without needing a `claude`-named binary on PATH. " + - "Every argument is forwarded exactly as `claude` would receive it.", - ) - .allowUnknownOption() - .helpOption(false) - .argument("[args...]", "Arguments to forward, e.g. @, --config-profile , or any Claude Code flag.") - .action(async (args: readonly string[]) => { - await runClaude(args); - }); + registerRunCommand(program, runClaude); return program; } diff --git a/src/runCommand.test.ts b/src/runCommand.test.ts new file mode 100644 index 0000000..90d77f6 --- /dev/null +++ b/src/runCommand.test.ts @@ -0,0 +1,43 @@ +import { Command } from "commander"; +import { describe, expect, it, vi } from "vitest"; + +import { registerRunCommand } from "./runCommand"; + +/** Mirrors how `buildClaudeUseProgram` sets up its parent program before registering `run` -- `passThroughOptions` on the `run` subcommand requires `enablePositionalOptions` on the parent chain, so a test program needs the same setup to exercise the real behaviour rather than commander's unrelated "broken pass-through" guard error. */ +function buildTestProgram(): Command { + return new Command().exitOverride().enablePositionalOptions(); +} + +describe("registerRunCommand", () => { + it("preserves a literal -- inside the forwarded args", async () => { + const handler = vi.fn<(args: readonly string[]) => Promise>().mockResolvedValue(undefined); + const program = buildTestProgram(); + registerRunCommand(program, handler); + + await program.parseAsync(["run", "mcp", "add", "agent-comms", "--", "npx", "-y", "agent-comms", "bridge", "mcp"], { + from: "user", + }); + + expect(handler).toHaveBeenCalledWith(["mcp", "add", "agent-comms", "--", "npx", "-y", "agent-comms", "bridge", "mcp"]); + }); + + it("forwards a flag with no -- unchanged", async () => { + const handler = vi.fn<(args: readonly string[]) => Promise>().mockResolvedValue(undefined); + const program = buildTestProgram(); + registerRunCommand(program, handler); + + await program.parseAsync(["run", "--config-profile", "work", "@myid"], { from: "user" }); + + expect(handler).toHaveBeenCalledWith(["--config-profile", "work", "@myid"]); + }); + + it("forwards an empty args list when nothing follows run", async () => { + const handler = vi.fn<(args: readonly string[]) => Promise>().mockResolvedValue(undefined); + const program = buildTestProgram(); + registerRunCommand(program, handler); + + await program.parseAsync(["run"], { from: "user" }); + + expect(handler).toHaveBeenCalledWith([]); + }); +}); diff --git a/src/runCommand.ts b/src/runCommand.ts new file mode 100644 index 0000000..40781a7 --- /dev/null +++ b/src/runCommand.ts @@ -0,0 +1,21 @@ +import type { Command } from "commander"; + +/** + * Registers `claude-use run [args...]`, forwarding every argument to `handler` exactly as typed. Kept in its own module, separate from `cli.ts`'s `buildClaudeUseProgram`, so its Commander parsing behaviour -- in particular, preserving a literal `--` inside the forwarded args rather than losing it -- is testable without importing `cli.ts` itself, which runs the real CLI as a top-level side effect on import. + */ +export function registerRunCommand(program: Command, handler: (args: readonly string[]) => Promise): void { + program + .command("run") + .description( + "Run the launcher pipeline directly, without needing a `claude`-named binary on PATH. " + + "Every argument is forwarded exactly as `claude` would receive it.", + ) + .allowUnknownOption() + // Without this, Commander treats a literal `--` inside the forwarded args as its own end-of-options marker and strips it before it reaches `args`, so a downstream flag meant to be shielded from parsing (e.g. `claude mcp add name -- npx -y pkg`) arrives at the real `claude` binary with no `--` at all, and its own Commander parser then rejects the bare `-y` as an unknown option of its own. `passThroughOptions` makes Commander forward every token after the first one verbatim, `--` included. It requires the parent program to have already called `enablePositionalOptions()`, which `buildClaudeUseProgram` does. + .passThroughOptions() + .helpOption(false) + .argument("[args...]", "Arguments to forward, e.g. @, --config-profile , or any Claude Code flag.") + .action(async (args: readonly string[]) => { + await handler(args); + }); +}