diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..e06b12e52 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -140,14 +140,25 @@ function normalizeArgv(argv) { function parseCommandInput(argv, config = {}) { return parseArgs(normalizeArgv(argv), { + rejectUnknownOptions: true, ...config, + booleanOptions: ["help", ...(config.booleanOptions ?? [])], aliasMap: { C: "cwd", + h: "help", ...(config.aliasMap ?? {}) } }); } +function maybePrintCommandHelp(options) { + if (!options.help) { + return false; + } + printUsage(); + return true; +} + function resolveCommandCwd(options = {}) { return options.cwd ? path.resolve(process.cwd(), options.cwd) : process.cwd(); } @@ -217,6 +228,9 @@ async function handleSetup(argv) { valueOptions: ["cwd"], booleanOptions: ["json", "enable-review-gate", "disable-review-gate"] }); + if (maybePrintCommandHelp(options)) { + return; + } if (options["enable-review-gate"] && options["disable-review-gate"]) { throw new Error("Choose either --enable-review-gate or --disable-review-gate."); @@ -717,6 +731,9 @@ async function handleReviewCommand(argv, config) { m: "model" } }); + if (maybePrintCommandHelp(options)) { + return; + } const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); @@ -767,6 +784,9 @@ async function handleTask(argv) { m: "model" } }); + if (maybePrintCommandHelp(options)) { + return; + } const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); @@ -827,6 +847,9 @@ async function handleTransfer(argv) { valueOptions: ["cwd", "source"], booleanOptions: ["json"] }); + if (maybePrintCommandHelp(options)) { + return; + } const cwd = resolveCommandCwd(options); const { payload, rendered } = await executeTransfer(cwd, { @@ -885,6 +908,9 @@ async function handleStatus(argv) { valueOptions: ["cwd", "timeout-ms", "poll-interval-ms"], booleanOptions: ["json", "all", "wait"] }); + if (maybePrintCommandHelp(options)) { + return; + } const cwd = resolveCommandCwd(options); const reference = positionals[0] ?? ""; @@ -912,6 +938,9 @@ function handleResult(argv) { valueOptions: ["cwd"], booleanOptions: ["json"] }); + if (maybePrintCommandHelp(options)) { + return; + } const cwd = resolveCommandCwd(options); const reference = positionals[0] ?? ""; @@ -930,6 +959,9 @@ function handleTaskResumeCandidate(argv) { valueOptions: ["cwd"], booleanOptions: ["json"] }); + if (maybePrintCommandHelp(options)) { + return; + } const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); @@ -965,6 +997,9 @@ async function handleCancel(argv) { valueOptions: ["cwd"], booleanOptions: ["json"] }); + if (maybePrintCommandHelp(options)) { + return; + } const cwd = resolveCommandCwd(options); const reference = positionals[0] ?? ""; diff --git a/plugins/codex/scripts/lib/args.mjs b/plugins/codex/scripts/lib/args.mjs index 6b1518502..463a94216 100644 --- a/plugins/codex/scripts/lib/args.mjs +++ b/plugins/codex/scripts/lib/args.mjs @@ -2,6 +2,7 @@ export function parseArgs(argv, config = {}) { const valueOptions = new Set(config.valueOptions ?? []); const booleanOptions = new Set(config.booleanOptions ?? []); const aliasMap = config.aliasMap ?? {}; + const rejectUnknownOptions = Boolean(config.rejectUnknownOptions); const options = {}; const positionals = []; let passthrough = false; @@ -45,6 +46,10 @@ export function parseArgs(argv, config = {}) { continue; } + if (rejectUnknownOptions) { + throw new Error(`Unknown option: --${rawKey}`); + } + positionals.push(token); continue; } @@ -67,6 +72,10 @@ export function parseArgs(argv, config = {}) { continue; } + if (rejectUnknownOptions) { + throw new Error(`Unknown option: -${shortKey}`); + } + positionals.push(token); } diff --git a/tests/args.test.mjs b/tests/args.test.mjs new file mode 100644 index 000000000..e977a80bc --- /dev/null +++ b/tests/args.test.mjs @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +import { parseArgs } from "../plugins/codex/scripts/lib/args.mjs"; +import { makeTempDir, run, initGitRepo } from "./helpers.mjs"; +import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; +import fs from "node:fs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SCRIPT = path.join(ROOT, "plugins", "codex", "scripts", "codex-companion.mjs"); + +test("parseArgs rejects unknown long options when configured", () => { + const helped = parseArgs(["--help", "--cwd", "/tmp"], { + booleanOptions: ["help"], + valueOptions: ["cwd"], + rejectUnknownOptions: true + }); + assert.equal(helped.options.help, true); + assert.equal(helped.options.cwd, "/tmp"); + assert.deepEqual(helped.positionals, []); + + assert.throws( + () => + parseArgs(["--not-a-flag"], { + booleanOptions: ["json"], + rejectUnknownOptions: true + }), + /Unknown option: --not-a-flag/ + ); +}); + +test("parseArgs keeps unknown options as positionals by default", () => { + const { options, positionals } = parseArgs(["--not-a-flag", "hello"], { + booleanOptions: ["json"] + }); + assert.deepEqual(options, {}); + assert.deepEqual(positionals, ["--not-a-flag", "hello"]); +}); + +test("task --help prints usage and does not dispatch a Codex thread", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + + const result = run("node", [SCRIPT, "task", "--help", "--cwd", repo, "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Usage:/); + assert.match(result.stdout, /codex-companion\.mjs task/); + assert.equal(result.stderr.trim(), ""); + + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + if (fs.existsSync(fakeStatePath)) { + const state = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.equal((state.threads ?? []).length, 0); + } +}); + +test("task unknown --flag errors without dispatching a Codex thread", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + + const result = run("node", [SCRIPT, "task", "--not-a-real-flag", "--cwd", repo], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Unknown option: --not-a-real-flag/); + + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + if (fs.existsSync(fakeStatePath)) { + const state = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.equal((state.threads ?? []).length, 0); + } +});