Repository navigation
fix(cli): reject extra positional arguments on every command - #4905
Conversation
Edit accuracy: accurate 1216 (base branch 1216), smooth 996 of thoseThe gate passes. Quarantined, measured but not gated (1)
|
somanshreddy
left a comment
There was a problem hiding this comment.
First pass at 11e99d3e. This is a comment, not an approval. I'd fix one thing before merge (1). Two independent passes went into it, mine and Codex's, and I re-checked every point below at this head by running the CLI from source (bun packages/cli/src/cli.ts …).
What holds up
- The guard covers every command. I loaded all 45 top-level commands and every subcommand under them (83 in all) and compared what each declares against what it reads. Only
compare,figma asset,skills updateandtimeline setread extra positionals on purpose, and the opt-out list matches exactly. Themedia-usesubcommands declare no positionals, but their engine'sparseArgs({ strict: true })already rejects positionals, so nothing changes there. Groups route their first positional to a subcommand (timeline ./x→ "Unknown command"), so skipping groups is safe. Codex reached the same inventory independently. - Witnesses behave as described:
lint ./a ./b→ exit 1, with the extra argument and the usage line on stderr.--json,--json=1and--json=true→{"ok":false,…}on stdout.--json=false→ stderr.cloud get id1 id2,history peek r p extraandkeyframes a bgive the right subcommand path and usage line.add a b cis rejected.catalog lower third --jsonsearches"lower third".figma asset 'KEY:1-2' 'KEY:3-4'(the batch form the figma skill documents) andskills update a bstill reach their command bodies.
- No documented invocation breaks. I scanned
skills/,docs/,registry/,packages/andscripts/forhyperframes <cmd>invocations, matching each against that command's positional count. Every hit is prose. The only programmatic call,init→runCommand(previewCmd, { rawArgs: [destDir] }), passes one positional. - Tests:
- The 4 changed test files pass 67 of 67.
- The full
packages/clisuite passed 4,102 with 13 skipped. One test failed,browser/manager.test.ts > withInstallLock …; it's a lock-timing test this PR doesn't touch, and it passed 49 of 49 when rerun alone. - 18 mutations: 14 were caught. The four that survived are in item 2.
Should fix
-
The usage error sends the user's raw arguments to telemetry (
reject-extra-positionals.ts:62-68).- The full message, including every extra positional, becomes
CliUsageError.message.executeClipasses that toreportCommandFailure(cli.ts:524), and from there it goes intocli_error.error_message(telemetry/events.ts:945-947,:887). redactTelemetryMessageonly rewrites paths and URL query strings. I ran the real redactor over the real messages:tts script.txt call Jane Doe at 555-0100keepscall, Jane, Doe, at, 555-0100verbatim;- a bare word such as a project name (
init acme-secret-launch-style), or a pasted token, passes through unchanged; - only
./clients/acme-q3-launch.mp4becomes[path].
- Before this PR the only other
CliUsageErrorwasfailUsage()'s fixed"Invalid command usage". That includes theaddguard deleted here, soadd a b cnever put user text in telemetry, and now it does.Unknown flag: --xcarries only a flag name. - Suggested fix: print the detailed message as now, but throw a fixed one, e.g.
new CliUsageError("Unexpected extra positional arguments", { presented: true }), or include a count. Codex flagged this as blocking. I'd call it a cheap fix to make before merge. - The same applies, less often, to the new
repoRooterror. It interpolatesHYPERFRAMES_REPO_ROOTas given, so a relative value without a separator (Acme-Project) isn't redacted. Codex found this part.
- The full message, including every extra positional, becomes
-
Four mutations leave every test passing. I ran the 4 changed test files plus
figma/asset,compare,skills,tts,catalogandsrc/timeline/(265 tests):- Removing
"figma asset"fromACCEPTS_EXTRA_POSITIONALS. With that removed, the documented batch formfigma asset 'KEY:1-2' 'KEY:3-4'fails with "Unexpected extra argument … KEY:3-4". I confirmed this by running it. - Removing
"skills update".skills update a bis then rejected. - Dropping
presented: true. The usage error would then print twice: once here, then again from the root boundary'sshowRequestedUsage. - Making
usageLinetreat every positional as optional.<NAME>would turn into[NAME].
compareandtimeline setare covered (the opt-out test andtimeline.e2e). The other two opt-outs only have the "path resolves" test, which proves the path exists but not that it's needed. A wrapped-command test per opt-out, running the realfigma assetwith two refs andskills updatewith two names, would cover them. - Removing
Lower priority
-
ttswith--text-filestill discards positionals silently.tts.ts:93resolvesargs["text-file"] ?? args.input. Sotts hello world --text-file script.txtjoins"hello world"intoinput, then reads the file and drops the words. The.txtexception (:55) checks only the positional, so this path never reaches the rejection. It behaved the same before the PR. But the PR's point is that extras are no longer ignored, and this is the remaining case. Rejecting positional text alongside--text-filewould close it (Codex). -
JSON detection edge cases (
:65, Codex, both reproduced):docs introduction extra --json=1goes to stderr, becausedocsdoesn't declarejsonand the raw-arg fallback only matches--jsonand--json=true. The description's "any spelling the parser accepts" holds only for commands that declarejson.docs introduction extra -- --jsonprints JSON on stdout, even though--jsonafter--is a literal positional.
Nits
addlost its specific hint ("Run add once per item, or pass a single tag to install every item tagged with it").add a b cis a common agent mistake, so a per-command hint hook might be worth keeping.- The top-level path comes from
meta.name(trackCommandFailures→commandName), not thecommandLoaderskey. All 45 match today. But the opt-out lists are keyed by path, and the test checks that listed paths resolve, not thatmeta.name === key. Passing the key in would remove the drift risk. - The two
hf-join-*temp dirs incommand-failure-tracking.test.ts:181,191are never removed. TheafterEachdoesn'trmSyncthem (Codex).
Where I disagreed with Codex
- Its second blocker didn't reproduce. It said
check ./proj --frame-checkis rejected because citty sends the whole argv to_when a bare string flag has no value. At this head, citty parses it to{"_":["./p"],"frame-check":true}, and the command gets past the guard to "No composition found". - Its "missing value gets joined" claim didn't reproduce either.
parseArgs(["hello","--voice"])gives_: ["hello"], voice: "", sotts hello --voicespeaks "hello", not "hello --voice".catalog lower --querysearched "lower".
CI: 93 checks pass. Only the WIP app check is pending.
— Somu
somanshreddy
left a comment
There was a problem hiding this comment.
Re-review of the 11e99d3e → 209ce48d delta. I re-checked each finding from my first pass by number. This is a comment, not an approval, and the full PR wasn't reviewed again. Everything I raised before is fixed, and the delta has no blockers.
Two independent passes went into it, mine and a static Codex pass in a separate checkout. I re-ran the CLI from source (bun packages/cli/src/cli.ts …) and the four mutations that survived last time.
First-pass items
- Telemetry: fixed. The thrown
CliUsageErrornow reads"<n> unexpected extra argument(s) for hyperframes <path>". The detailed message with the arguments is only printed to the terminal (stderr, or stdout under--json).trackCommandFailureandcli_errorreaderror.message, so the arguments no longer reach them, and I found no other path that sends them. The newevents.test.tscase runs the realtrackCommandFailureand assertsJaneand555-0100are absent.repoRootno longer interpolatesHYPERFRAMES_REPO_ROOT. Reverting either change fails a test. - Four unpinned mutations: all now caught. Each one fails at least one test in the 365 that cover the touched commands and utilities:
- removing
"figma asset"or"skills update"from the opt-out list (the new real-command test fails); - dropping
presented: true; - marking every positional optional in
usageLine(the new<FILE> [AT]test fails).
- removing
ttswith--text-file: fixed.tts hello world --text-file s.txtnow exits 1 with "Pass text to speak or --text-file, not both."tts s.txtstill reads the file.tts s.txt extrais still rejected as an extra argument.- JSON detection: fixed.
docs introduction extra --json=1prints{"ok":false,…}on stdout.--json=0and-- --jsongo to stderr, with--jsonreported as an extra.lint ./a ./b --json,--json=falseand--no-jsonall behave correctly.
- Nits:
add a b cprints the "Run add once per item…" hint again.- The
hf-join-*temp dirs are cleaned up. - One nit is unchanged and still optional: the opt-out lists are keyed by
meta.namerather than thecommandLoaderskey.
New, all minor
tts hello --text-file s.txt --listlists voices and exits 0 (tts.ts:88). The--listbranch returns before the new conflict check.--listalready ignored text before this PR (tts hello world --list), so I'd leave it, or move the check above the branch if you want every conflict rejected. Codex rated this should-fix; I rate it a nit.- The
"false"/"0"exclusion inwantsJsonhas no test. If it's removed,docs … --json=falsewould print JSON, and all 365 tests still pass. One row asserting stderr for--json=falseon a command that doesn't declarejsonwould pin it. Codex flagged the same gap. wantsJsonis case-sensitive.--json=Falsecounts as JSON. The commands' ownif (args.json)checks treat it the same way, so it's consistent.
Tests: the 6 changed or new test files pass 152 of 152. The full packages/cli suite passes 4,110 with 13 skipped and 0 failures. CI at this head: 87 checks pass and 6 edit-accuracy shards are pending, with none failing.
— Somu
somanshreddy
left a comment
There was a problem hiding this comment.
Re-review of the 209ce48d → fccbb399 delta: one commit, +28/−4 across tts.ts, tts.test.ts and command-failure-tracking.test.ts. I didn't re-review the full PR. This is a comment, not an approval, and the delta has no findings.
ttswords plus--text-fileunder--list: fixed. The both-inputs check now runs before list mode, sotts hello --text-file s.txt --listis rejected instead of listing voices. To confirm the new test pins it, I moved the check back below the--listreturn, and exactly that test fails (1 failed, 2 passed in its block).--json=false/--json=0exclusion: now tested. I removedvalue !== "false" && value !== "0"fromreject-extra-positionals.ts:25, and the new stderr test fails (1 of 29).- The reorder changes nothing else.
--liston its own, and--listwith only positional text or only--text-file, still list voices, because the guard only fires when both inputs are set. - Tests:
tts.test.tsandcommand-failure-tracking.test.tspass 32 of 32 at this head. CI was 73 passed and 20 pending, with none failing, when I checked.
— Somu
jrusso1020
left a comment
There was a problem hiding this comment.
Approving at fccbb399.
What I checked
- The opt-out list is complete. I searched every command under
srcfor reads ofargs._andrawArgs. The leaves that read extra positionals arecompare,figma asset,skills updateandtimeline set, all of which are onACCEPTS_EXTRA_POSITIONALS.timeline applyandtimeline undofall back topositional(args)[1], but that fallback is unreachable because each declares its positional as required.figma,cloud,auth,history,skillsandtimelinereadargs._[0], but they are groups, which the!cmd.subCommandsguard skips.checkre-parsesrawArgsonly to normalize--frame-check, so its positional count is the same as citty's. - Paths match. Every top-level
meta.nameequals its loader key, so${path} ${name}builds the paths the lists use, andcli.commands.test.tspins this. - Telemetry stays clean.
trackCommandFailurereports onlyerror.name,error.messageandstack. The thrownCliUsageErrorholds just the count and the command path, and the repo-root error doesn't include the env value. - Real CLI runs:
render ./nope out.mp4,lint a b,lint a b --json,lint a b --json=falseandcompositions a beach exit 1 with the usage line. The JSON form goes to stdout as{"ok":false,...}, and--json=falsegoes to stderr.catalog lower third --jsonsearches"lower third".
- Tests: the six touched test files pass, 154/154. Seven deliberate mutations each turn tests red:
- dropping the group guard;
- making
wantsJsonstrict (=== true); - removing the tts
.txtexception; - putting the extras into the thrown message;
- an off-by-one in the extras slice;
- removing the opt-out early return;
- removing the tts words-plus-
--text-filecheck.
- CI: 94/94 green at this head.
Nit (not blocking, and it predates this PR)
lambdais a single leaf that declares three generic positionals (subcommand,target,extra). Solambda deploy x ystill passes this check and silently ignoresx y. I ran it, and it went straight into building the handler ZIP. A per-verb positional count would be a follow-up, not a change for this PR.
— Rames
Summary
Every CLI command now handles positional arguments beyond the ones it declares, instead of silently dropping them. Before,
hyperframes render ./proj out.mp4ignoredout.mp4and rendered to the default path, andhyperframes lint a blinted onlya. Now those exit with a usage error that names the extra argument and the command's usage line:Under
--json(read from the parsed flag, so--json=trueand--json=1count,--json=falsedoes not, and a--jsonafter--is an argument, not the flag) the same message goes to stdout as{"ok":false,"error":...}, matching the add command's existing usage error.addkeeps its own hint on a third line: "Run add once per item, or pass a single tag to install every item tagged with it."The two free-text commands join their words into one value instead:
hyperframes catalog lower thirdsearches "lower third" (it searched only "lower"), andhyperframes tts hello worldspeaks "hello world" (it spoke only "hello"). Fortts, when the first word names an existing.txtfile (tts script.txt extra), the words are not joined: tts reads the file as before and the extra word is rejected with the usage line. Text words together with--text-fileare a usage error ("Pass text to speak or --text-file, not both.") instead of the words being dropped.catalogalways joins, even when a word matches a file or folder name.Also:
lambda deployandlambda destroynow fail clearly whenHYPERFRAMES_REPO_ROOTpoints somewhere that is not a hyperframes checkout. Before, the bad value was silently ignored and the error that followed told the user to set the variable they had already set.Telemetry never sees the user's arguments: the error the CLI reports carries only the command path and a count (
2 unexpected extra arguments for hyperframes render); the detailed message above goes only to the terminal. The repo-root error likewise does not echo theHYPERFRAMES_REPO_ROOTvalue.Root cause
citty binds each declared positional in order and leaves the rest in
args._. The shared command wrapper only checked flags, so every command that does not readargs._dropped the extras. #3891 fixed this foraddalone; this moves the check into the shared wrapper (wrapCommand) and deletes add's own guard.Commands that read
args._on purpose are on an explicit list keyed by command path:compare,figma asset,skills update,timeline set.catalogandttsare on a second list whose last positional takes every remaining word. A test loads each listed command and fails if a path stops naming a real command. Command groups (auth,cloud,figma,history,skills,timeline) are skipped by structure: citty also runs a group's own handler after its subcommand, with the subcommand name inargs._.Behaviour change
A command run with more positionals than it declares now fails with exit code 1 (usage error) where it used to continue and ignore them.
catalogandttsaccept multi-word input. An invalidHYPERFRAMES_REPO_ROOTis now an error forlambda deployandlambda destroy(before, it was ignored and both fell back to finding the checkout themselves).Test plan
command-failure-tracking.test.ts: one test per family through citty's realrunCommand: plain leaf (text,--json,--json=true,--json=1), a leaf within its declared count, a nested subcommand named by its full path, an opted-out command, the two joining commands,ttswith a.txtfile plus a stray word,catalogwhose first word names a folder, and a group whose own handler citty runs after the subcommand.cli.commands.test.ts: every path on both lists resolves to a real command and loader key.add.test.ts:add a --dir <dir> b cthrough the wrapper rejects before any registry call; a single-item add still succeeds.events.test.ts: a real extra-positional usage error sent throughtrackCommandFailureproduces acli_errorpayload with the count and no argument text.figma assetandskills updatedefinitions accept several positionals only because they are on the opt-out list; the thrown error is marked as already presented; a required positional prints as<NAME>.tts.test.ts: words plus--text-fileis rejected before anything is synthesized.add.test.tschecks add's hint. JSON mode:--json=1on a command with no declared json flag gives JSON;-- --jsondoes not.repoRoot.test.ts: a validHYPERFRAMES_REPO_ROOTis returned; an invalid one throws.packages/clisuite green apart from a Chromium launch timeout on the test machine; typecheck and the comment ratchet clean.