Files
software-mansion__argent/packages/argent-cli/test/command-args.test.ts
filip131311 26405e68c7 refactor(cli): one declarative option parser for every hand-written subcommand (#953)
Follows #952 (merged); rebased onto main.

## Why

`argent-cli` had **seven** copies of the same argv loop — `server
start`, `link`/`unlink`, `flow run`, `lens`, `config`,
`enable`/`disable`, and (from #952) `telemetry` — each hand-rolling `--x
value` / `--x=value` / `-x` / unknown-flag / missing-value handling with
slightly different messages and gaps (e.g. `lens` silently ignored
unknown flags). Adding an option meant another `if (tok === "--x") …
else if (tok.startsWith("--x="))` pair.

## What

`packages/argent-cli/src/command-args.ts` (introduced in #952) now
covers the whole subcommand surface, and every command declares its
options as a spec:

```ts
const START_OPTIONS = {
  help: { kind: "boolean", alias: "h" },
  port: { kind: "value", alias: "p" },
  …
} as const satisfies OptionSpecs;
```

Parser features: `--name value`, `--name=value`, single-letter aliases,
boolean switches, `--` end-of-options, `choices` validation, "don't
swallow a following flag as a value", and the guard against a stray
`true`/`false` word after a switch (previously duplicated in `flow` and
`lens`). Value *semantics* (port ranges, host wildcards, flag-name
charset, link targets) stay in each command — the parser only shapes
argv.

`flag-parser.ts` is untouched: it is the schema-driven `argent run
<tool>` payload builder (JSON Schema → tool args, `--args`/stdin
hatches), a different job.

## Behavior changes (bad-input paths only)

| Command | Before | After |
|---|---|---|
| `lens --bogus` / `lens extra` | silently ignored | `lens: Unknown
flag: --bogus`, exit 2 |
| `lens --terminal x` | `--terminal expects "iterm" or "terminal", got
"x"` | `--terminal must be "iterm" or "terminal", got "x"` |
| `flow run --platfrom=ios` | `unknown flag --platfrom=ios` | `Unknown
flag: --platfrom=ios` |
| `unlink foo` | `Unknown flag: foo` | `Unexpected argument "foo"` |
| `config … --scope x` | `--scope must be "global" or "project", got
"x"` | unchanged wording |
| `enable/disable`, `server start`, `link` | — | unchanged wording
(tests pinned) |

Happy paths are byte-identical; `server`/`link` still throw
`StartFlagError`, `flow` still throws `FlagParseException` (wrapped from
`UsageError`), so callers and tests keep their contracts.

## Tests

`argent-cli` 489/489 (26 files): rewritten `command-args.test.ts`
(aliases, `-` positional, empty-token value, true/false guard, choices
message), pinned-string updates only where the table above says the
wording changed (`flow.test.ts` ×4, `parse-link-flags.test.ts` ×1). `tsc
--build`, `typecheck:tests`, eslint, prettier, knip green. Smoke-tested
every command's error path through the built
`packages/argent/dist/cli.js` (see the "Behavior changes" table — all
exit 2 with usage where the command prints one).

## Docs

No docs update needed: the pages document flags and their meaning, not
error strings; no flag was added, removed or renamed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01BkkAihA8Kp26hHS4uhkNGu
2026-08-25 13:32:36 +02:00

89 lines
3.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { parseCommandArgs, UsageError, type OptionSpecs } from "../src/command-args.js";
const SPECS = {
scope: { kind: "value", choices: ["global", "project"] },
out: { kind: "value", alias: "o" },
json: { kind: "boolean" },
yes: { kind: "boolean", alias: "y" },
} as const satisfies OptionSpecs;
describe("parseCommandArgs", () => {
it("parses value options in both spellings, boolean flags and positionals", () => {
expect(
parseCommandArgs(["a", "--scope", "project", "--json", "b", "--out=x.txt"], SPECS)
).toEqual({
positionals: ["a", "b"],
options: { scope: "project", json: true, out: "x.txt" },
});
});
it("accepts single-letter aliases for value and boolean options", () => {
expect(parseCommandArgs(["-o", "x.txt", "-y"], SPECS)).toEqual({
positionals: [],
options: { out: "x.txt", yes: true },
});
});
it("returns nothing set for an empty argv", () => {
expect(parseCommandArgs([], SPECS)).toEqual({ positionals: [], options: {} });
});
it("last occurrence of a repeated option wins", () => {
expect(parseCommandArgs(["--scope=global", "--scope", "project"], SPECS).options.scope).toBe(
"project"
);
});
it("treats everything after -- as positionals, and a bare - as a positional", () => {
expect(parseCommandArgs(["--json", "--", "--scope", "-x"], SPECS)).toEqual({
positionals: ["--scope", "-x"],
options: { json: true },
});
expect(parseCommandArgs(["-", "--out", "-"], SPECS)).toEqual({
positionals: ["-"],
options: { out: "-" },
});
});
it("rejects an unknown long or short flag, reporting the token as typed", () => {
expect(() => parseCommandArgs(["--force"], SPECS)).toThrow(UsageError);
expect(() => parseCommandArgs(["--force"], SPECS)).toThrow("Unknown flag: --force");
expect(() => parseCommandArgs(["-z"], SPECS)).toThrow("Unknown flag: -z");
expect(() => parseCommandArgs(["--platfrom=ios"], SPECS)).toThrow(
"Unknown flag: --platfrom=ios"
);
});
it("rejects a value outside the declared choices, listing them", () => {
expect(() => parseCommandArgs(["--scope", "team"], SPECS)).toThrow(
'--scope must be "global" or "project", got "team"'
);
});
it("rejects a missing value, naming the long form even for an alias", () => {
expect(() => parseCommandArgs(["--scope"], SPECS)).toThrow("--scope requires a value");
expect(() => parseCommandArgs(["-o"], SPECS)).toThrow("--out requires a value");
expect(() => parseCommandArgs(["--out="], SPECS)).toThrow("--out requires a value");
});
it("does not swallow a following flag as the value", () => {
expect(() => parseCommandArgs(["--scope", "--json"], SPECS)).toThrow(
"--scope requires a value"
);
expect(() => parseCommandArgs(["--out", "-y"], SPECS)).toThrow("--out requires a value");
});
it("passes an explicitly supplied empty token through as the value", () => {
// The command validates it (e.g. link rejects "" as a bind address).
expect(parseCommandArgs(["--out", ""], SPECS).options.out).toBe("");
});
it("rejects a value given to a boolean flag, including a trailing true/false word", () => {
expect(() => parseCommandArgs(["--json=1"], SPECS)).toThrow("--json does not take a value");
expect(() => parseCommandArgs(["--json", "false"], SPECS)).toThrow(
"--json does not take a value — it is a switch"
);
});
});