diff --git a/src/cli/index.ts b/src/cli/index.ts index 1090983..0e2d0b0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -13,6 +13,7 @@ import { installProxyIfConfigured } from "../net/proxy.js"; import { escalationContract } from "../prompt-fragments.js"; import { startCpuProfile, stopAndSaveCpuProfile } from "./cpu-prof.js"; import { resolveDashboardHost, resolveDashboardToken } from "./dashboard-options.js"; +import { parseNonNegativeIntegerOption, parsePositiveIntegerOption } from "./number-options.js"; import { resolveBareCommandMode, resolveContinueFlag, resolveDefaults } from "./resolve.js"; import { markPhase } from "./startup-profile.js"; @@ -390,7 +391,7 @@ program program .command("prune-sessions") .description(t("cli.pruneSessions")) - .option("--days ", t("ui.pruneDaysHint"), (v) => Number.parseInt(v, 10)) + .option("--days ", t("ui.pruneDaysHint"), parsePositiveIntegerOption) .option("--dry-run", t("ui.pruneDryRunHint")) .action(async (opts) => { const { pruneSessionsCommand } = await import("./commands/prune-sessions.js"); @@ -401,8 +402,8 @@ program .command("events ") .description(t("cli.events")) .option("--type ", t("ui.eventTypeHint")) - .option("--since ", t("ui.eventSinceHint"), (v) => Number.parseInt(v, 10)) - .option("--tail ", t("ui.eventTailHint"), (v) => Number.parseInt(v, 10)) + .option("--since ", t("ui.eventSinceHint"), parseNonNegativeIntegerOption) + .option("--tail ", t("ui.eventTailHint"), parsePositiveIntegerOption) .option("--json", t("ui.jsonHint")) .option("--projection", t("ui.projectionHint")) .action(async (name: string, opts) => { @@ -421,8 +422,8 @@ program .command("replay ") .description(t("cli.replay")) .option("--print", t("ui.printHint")) - .option("--head ", t("ui.headHint"), (v) => Number.parseInt(v, 10)) - .option("--tail ", t("ui.tailHint"), (v) => Number.parseInt(v, 10)) + .option("--head ", t("ui.headHint"), parsePositiveIntegerOption) + .option("--tail ", t("ui.tailHint"), parsePositiveIntegerOption) .action(async (transcript: string, opts) => { const { replayCommand } = await import("./commands/replay.js"); await replayCommand({ @@ -462,8 +463,8 @@ mcp .option("--json", t("ui.jsonHintCatalog")) .option("--local", t("ui.mcpLocalHint")) .option("--refresh", t("ui.mcpRefreshHint")) - .option("--limit ", t("ui.mcpLimitHint"), (v) => Number.parseInt(v, 10)) - .option("--pages ", t("ui.mcpPagesHint"), (v) => Number.parseInt(v, 10)) + .option("--limit ", t("ui.mcpLimitHint"), parsePositiveIntegerOption) + .option("--pages ", t("ui.mcpPagesHint"), parsePositiveIntegerOption) .option("--all", t("ui.mcpAllHint")) .action(async (opts) => { try { @@ -487,8 +488,8 @@ mcp .description(t("ui.mcpSearchDescription")) .option("--json", t("ui.jsonHintCatalog")) .option("--refresh", t("ui.mcpRefreshHint")) - .option("--limit ", t("ui.mcpLimitHint"), (v) => Number.parseInt(v, 10)) - .option("--max-pages ", t("ui.mcpMaxPagesHint"), (v) => Number.parseInt(v, 10)) + .option("--limit ", t("ui.mcpLimitHint"), parsePositiveIntegerOption) + .option("--max-pages ", t("ui.mcpMaxPagesHint"), parsePositiveIntegerOption) .action(async (query: string, opts) => { try { const { mcpSearchCommand } = await import("./commands/mcp.js"); @@ -509,7 +510,7 @@ mcp .command("install ") .description(t("ui.mcpInstallDescription")) .option("--refresh", t("ui.mcpRefreshHint")) - .option("--max-pages ", t("ui.mcpMaxPagesHint"), (v) => Number.parseInt(v, 10)) + .option("--max-pages ", t("ui.mcpMaxPagesHint"), parsePositiveIntegerOption) .action(async (name: string, opts) => { try { const { mcpInstallCommand } = await import("./commands/mcp.js"); diff --git a/src/cli/number-options.ts b/src/cli/number-options.ts new file mode 100644 index 0000000..c950392 --- /dev/null +++ b/src/cli/number-options.ts @@ -0,0 +1,22 @@ +import { InvalidArgumentError } from "commander"; + +export function parsePositiveIntegerOption(raw: string): number { + return parseIntegerOption(raw, { min: 1 }); +} + +export function parseNonNegativeIntegerOption(raw: string): number { + return parseIntegerOption(raw, { min: 0 }); +} + +function parseIntegerOption(raw: string, opts: { min: number }): number { + if (!/^\d+$/.test(raw)) { + throw new InvalidArgumentError("must be an integer"); + } + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed) || parsed < opts.min) { + throw new InvalidArgumentError( + opts.min === 0 ? "must be a non-negative integer" : "must be a positive integer", + ); + } + return parsed; +} diff --git a/tests/cli-number-options.test.ts b/tests/cli-number-options.test.ts new file mode 100644 index 0000000..f02c446 --- /dev/null +++ b/tests/cli-number-options.test.ts @@ -0,0 +1,25 @@ +import { InvalidArgumentError } from "commander"; +import { describe, expect, it } from "vitest"; +import { + parseNonNegativeIntegerOption, + parsePositiveIntegerOption, +} from "../src/cli/number-options.js"; + +describe("CLI number option parsers", () => { + it("accepts positive integer strings", () => { + expect(parsePositiveIntegerOption("1")).toBe(1); + expect(parsePositiveIntegerOption("42")).toBe(42); + }); + + it("rejects fractional, suffixed, and zero positive-integer values", () => { + for (const raw of ["1.5", "10abc", "0", "-1", ""]) { + expect(() => parsePositiveIntegerOption(raw)).toThrow(InvalidArgumentError); + } + }); + + it("allows zero only for non-negative integer options", () => { + expect(parseNonNegativeIntegerOption("0")).toBe(0); + expect(parseNonNegativeIntegerOption("12")).toBe(12); + expect(() => parseNonNegativeIntegerOption("-1")).toThrow(InvalidArgumentError); + }); +});