refactor: define template commands with citty - #150
Conversation
There was a problem hiding this comment.
Important
Two behaviors the description lists as preserved did not survive the port: TemplateFault errors now print a raw stack dump instead of the formatted message, and --keepApps is silently ignored by template setup.
Reviewed changes — the full port of the scripts/ command tree from yargs to citty@0.2.2, verified by running the CLI against the checked-out branch and by reading citty's parser in node_modules/citty/dist/index.mjs.
- Entry point rewritten —
scripts/index.tsdrops the yargs builder chain and the top-level faultier error handler in favour ofawait runMain(main). - Command definitions ported —
template,add,rename, andsetupmove from yargsbuilder/handlerto cittyargs/meta/run. - Positional
choicesreplaced —addvalidateskindwith an explicitTemplateFaultthrow because citty positionals cannot carryoptions. - Array option hand-rolled —
getOptionValuesre-scansrawArgsfor--keep-apps/--keep-packages, since citty resolves repeated flags last-wins. - Dependencies —
yargs@18and@types/yargsremoved,citty@0.2.2added,scripts/utils.tsdeleted. The 566-line lockfile diff is hoisting reshuffle from dropping a direct dependency (cliui,wrap-ansi,string-width,eslint-scope,estraversemove to their remaining consumers' versions) and looks benign.
Verified as intact: no-argument and bare template both print usage and exit 1, --help works at all three nesting levels, missing required positionals fail before run, and --no-git / --no-install still resolve to false.
ℹ️ Unknown flags are no longer rejected
.strict() was applied at both the root and template levels and has no citty equivalent — citty parses with strict: false (index.mjs:90), so unrecognized flags land in values and are ignored. Under yargs@18 the same invocation failed with Unknown argument: bogus. The practical cost is that a typo in a destructive command goes unnoticed: bun template setup --no-instal --yes runs bun install instead of skipping it.
There is no drop-in fix, so this is a decision rather than a defect: accept the looser parsing as the cost of the migration, or validate rawArgs against the declared arg names in the commands where a silent miss is expensive.
Technical details
# `.strict()` has no replacement in the citty command tree
## Affected sites
- `scripts/index.ts:15` — root command no longer rejects unknown flags
- `scripts/template/index.ts:8-16` — `template` command no longer rejects unknown flags
## Required outcome
- Decide whether unknown-flag rejection is still a requirement for `template setup`, whose `run` prunes workspaces, renames the project, and deletes template files. If it is, unrecognized `--flags` must produce a non-zero exit before any of that work starts.
## Open questions for the human
- Is losing strict parsing an acceptable trade for the migration, or should `setup` specifically validate its own `rawArgs`?ℹ️ Nitpicks
add'skindpositional lost the yargschoices: ["app", "package"], sobun template add --helpprintsKIND Workspace type to add (Required)with no hint of the accepted values. citty positionals cannot carryoptions, so naming them indescriptionis the only place left for them.- The root command's
epilogue("Add your own project commands to scripts/index.ts.")is gone with no citty equivalent.docs/template-commands.md:50still covers it, so this is only a loss of in-help discoverability.
Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ Both blocking findings from the previous review are genuinely fixed — verified by running the CLI on this branch. One residual strictness gap and a few nits below.
Reviewed changes — the second commit, which restores the validation behavior the yargs → citty port dropped.
- Reinstated
scripts/utils.tsas a compatibility layer —defineTemplateCommandwraps each leaf command'srunso a thrownTemplateFaultrenders throughconsola.error(error.flatten())plus itsdetailsfield and exits 1, and unknown--flagsare rejected beforerunstarts. - Restored
.strict()-equivalent rejection —getUnknownOptionscans the leaf command'srawArgsagainst the declared arg names and aliases, normalizing kebab/camel spellings, stripping a leadingno-, and stopping at--. - Fixed the
--keepAppsdrop —getOptionValuesnow compares throughnormalizeOptionName, so--keepAppsand--keep-appsare equivalent, and repeated plus=-inline forms accumulate. - Added the first
scripts/**test —scripts/template/__tests__/setup.test.tspins the flag spellingsgetOptionValuesclaims to support. - Clarified
add'skindhelp text — the description now names the accepted values, recovering what the yargschoicesused to print.
Confirmed on the branch: template rename and template add widget foo print only the flattened message and details with no stack trace and exit 1; template rename --bogus, --no-bogus, and template setup --keepApp x all report Unknown option: --<name> and exit 1 before any filesystem work; --help never reaches the new guard because citty's runMain intercepts it ahead of runCommand; tsc --noEmit -p tsconfig.json, bunx oxlint scripts, and bun test scripts are all clean.
ℹ️ Flag position now changes the outcome, and misplacement is silent
The guard reads context.rawArgs, which citty has already sliced past the subcommand name, so a flag written before the subcommand is dropped by the parser and never reaches the guard. bun scripts --bogus template rename prints rename's own fault with no unknown-option error, and bun scripts template --yes setup reaches setup with rawArgs of []. Under yargs@18 options were parsed globally, so both positions worked; the worst realistic case is bun template --keep-apps app setup --yes, which discards the selection and keeps every workspace.
Technical details
# Pre-subcommand flags escape both citty's parser and the new unknown-option guard
## Affected sites
- `scripts/utils.ts:13` — `getUnknownOption(context.rawArgs, ...)`; `runCommand` passes `opts.rawArgs.slice(subCommandArgIndex + 1)` (`citty/dist/index.mjs:217`), so anything before the subcommand name is already gone
- `scripts/index.ts:5-13` and `scripts/template/index.ts:7-17` — the two `subCommands`-only levels use citty's plain `defineCommand`, so they perform no flag validation at all
## Reproduction
```
$ bun run scripts/index.ts --bogus template rename
[error] Provide --name when renaming a project. # no "Unknown option: --bogus"
$ bun run scripts/index.ts template --name foo rename
Unknown command foo # flag value read as a command name
```
## Required outcome
- Decide whether a flag in a position citty ignores should fail loudly. If it should, an unrecognized or misplaced `--flag` anywhere in `argv` must exit non-zero before `setup` prunes workspaces, renames the project, or deletes template files.
## Suggested approach (optional)
- The leaf command is the only place that knows its own arg names, so the check needs the untruncated `argv` (e.g. `process.argv.slice(2)` filtered of the command path) rather than `context.rawArgs`.
- If the ordering constraint is accepted instead, `docs/template-commands.md` is the place to state that options follow the subcommand.
## Open questions for the human
- Is "options must follow the subcommand" an acceptable documented constraint, or should misplacement be an error?ℹ️ Nitpicks
- The unknown-option path prints only
consola.error(...), where yargs'.failhandler showed help before the message. Calling citty'sshowUsage(context.cmd)first would restore the hint about which flags are valid. getOptionValuesis now covered, butgetUnknownOptionand the fault wrapper are not, even though a false positive in the guard silently skips the command. They are also the pieces whose correctness depends on citty internals (--helpinterception happening beforerun), which is exactly what a test would pin.
Claude Opus | 𝕏
There was a problem hiding this comment.
Important
The new guard closes the misplaced-flag hole, including the dangerous --keep-apps app template setup case. But it decides "is this word a command?" from a hand-maintained set, so it misreports unknown commands and will reject valid flags on any command added later.
Reviewed changes — the third commit, which makes a misplaced option a hard error instead of a silent drop. Verified by running the CLI and the helpers on the branch.
- Added a pre-parse guard in the entry point —
scripts/index.tsrunsgetOptionBeforeCommand(process.argv.slice(2), templateCommandNames)beforerunMain(main, { rawArgs })and exits 1 withPlace --<name> after the template subcommand. - Restored usage output on an unknown option —
defineTemplateCommandcallsshowUsage(context.cmd)ahead ofconsola.error("Unknown option: --x"), recovering what yargs'.failused to print. - Removed the
argscast —TemplateCommandDefinition<T>narrowsCommandDef<T>["args"]fromResolvable<T>toT, soObject.entries(definition.args)no longer needsas Tand theundefined/ function-form shapes are rejected at compile time. - Covered the two argument helpers —
scripts/__tests__/utils.test.tspinsgetUnknownOption(kebab/camel/no-spellings) andgetOptionBeforeCommand(before vs. after the leaf command,--helpexemption).
Confirmed on the branch: --bogus template rename, template --yes setup, and --keep-apps app template setup all exit 1 before any filesystem work; --help still exits 0 at every nesting level and reaches neither guard; bun test scripts passes 6 tests and bun run check reports 0 warnings and 0 errors.
ℹ️ Nitpicks
showUsage(context.cmd)passes no parent, andrenderUsagebuilds its header fromparentMeta.name+cmdMeta.name(citty/dist/index.mjs:304). Sotemplate rename --bogusprintsUSAGE rename [OPTIONS]whiletemplate rename --helpprintsUSAGE template rename [OPTIONS]— citty's own help path gets the parent fromresolveSubCommand.getOptionBeforeCommandreturnsnullwhere its siblinggetUnknownOptionreturnsundefined, and it does notbreakon--the waygetUnknownOptiondoes.getOptionBeforeCommand(["template", "--", "rename"], names)returns"", which passes the caller'sif (misplacedOption)only because the empty string happens to be falsy.- Both guards inspect only
--tokens, andfindSubCommandIndex(citty/dist/index.mjs:269-278) skips any--prefixed token without consuming a value unless_isValueFlagmatches — sobun scripts -v template setupdrops-vand runssetup. Exposure is small today because no arg declares a shortaliasand-his intercepted globally, but the guard's contract reads as "misplaced options are rejected". - The guard exempts
--version, yetmain.metacarries noversion, sobun scripts --versionprints the root usage plusNo version specifiedand exits 1. Either setmeta.versionfrom the rootpackage.jsonor drop the exemption.
Claude Opus | 𝕏
There was a problem hiding this comment.
Important
Deriving the command names from template.subCommands fixes the drift the last review flagged, but the same commit narrows the guard to one argv position — so an option typed before template is silently dropped again and the leaf command still runs.
Reviewed changes — the fourth commit, which replaces the hand-maintained command-name set in the misplaced-option guard. Verified by tracing citty's parser and by driving a stub command tree that imports the real getOptionBeforeCommand.
- Derived the command names from the command tree —
scripts/template/index.tsexportstemplateSubCommandsand passes the same object tosubCommands, soscripts/index.tsbuilds the guard's set withnew Set(Object.keys(templateSubCommands))and adding a leaf can no longer start rejecting valid flags on it. - Scoped the guard to the
templatecommand —getOptionBeforeCommandnow takes the parent command name and bails unlessrawArgs[0]matches it, sobun scripts nope --flagreaches citty'sUnknown command nopeinstead of being misdiagnosed as a misplaced option. - Narrowed the scan to a single argv position — only
rawArgs[1]is inspected; it now also catches single-dash tokens and returnsnullon a bare--. - Updated the helper's tests — added a case pinning that unknown and root-level commands fall through to citty, and removed the case asserting that an option before
templateis rejected.
Confirmed on the branch: template --yes setup, template -x setup, and template --version setup still exit 1 before any filesystem work; nope --flag, template nope --flag, --keep-apps app template setup, and --name foo template setup all end in citty's own Unknown command error; bun test scripts passes 7 tests and bun run check reports 0 warnings and 0 errors.
ℹ️ Nitpicks
- The new single-dash branch strips every leading dash before the message is formatted, so
bun scripts template -x setupprintsPlace --x after the template subcommand.— a flag spelling the user never typed.
Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ The silent-drop hole from the last review is genuinely closed — I could not construct an argv where a leaf runs with a typed option missing. One piece of dead code and two message-quality nits below.
Reviewed changes — the fifth commit, which widens the misplaced-option guard from a single argv position to everything ahead of the parent command.
- Located the parent command the way citty does —
getOptionBeforeCommandnow takes the first token that does not start with-as the parent position, mirroringfindSubCommandIndex, and reports the first--prefixed token before it (a bare--is ignored). - Moved the help exemption to the whole argv —
--help/-hanywhere disables the guard, matching citty'srunMain, which intercepts help beforerunCommandever runs. - Preserved the spelling the user typed — the guard returns the raw token and
scripts/index.tsprintsPlace ${misplacedOption} after the template subcommand., sotemplate -x setupno longer reports--x. - Re-pinned the reopened position in tests —
scripts/__tests__/utils.test.tscovers["--bogus", "template", "setup"]and["template", "-x", "setup"]alongside the updated--yesexpectation.
Verified on the branch with an enumeration harness that feeds the real getOptionBeforeCommand and then runCommand over a stub tree reusing the real defineTemplateCommand and args definitions: across 280 argv shapes, 208 are rejected by the guard, 69 end in citty's own error, 3 are legitimate leaf runs, and 0 reach a leaf with a typed option absent from both args and rawArgs. bun test scripts passes 7 tests, and the working tree stays clean after the CLI runs.
ℹ️ Nitpicks
- The leading-option branch (
scripts/utils.ts:73) returns the token verbatim, so--name=foo template renameprintsPlace --name=foo after the template subcommand., while the after-parent branch strips at=(scripts/utils.ts:79) and prints--name. Worth picking one. - An option whose value is a separate token still escapes the guard, because the value is the first non-
-token:--keep-apps app template setupbails atscripts/utils.ts:70and surfaces as citty'sUnknown command app. Exit is still 1 before anyrun, and mirroring citty's_isValueFlagis not possible while the root command declares noargs, so this is message quality rather than a hole.
Claude Opus | 𝕏
There was a problem hiding this comment.
✅ No new issues found. Both nits from the last review are fixed, and the removed branch really was unreachable.
Reviewed changes — the sixth commit, which trims dead code from the misplaced-option guard and makes the token it reports spell consistently.
- Deleted the unreachable
--version/-vexemption —scripts/utils.tsdrops(argument === "--version" || argument === "-v") && rawArgs.length === 1. Reaching that line requiredrawArgs[parentCommandIndex + 1]to be truthy, sorawArgs.lengthwas always at least 2 and the condition could never hold. - Stripped
=valuefrom the leading-option token — the pre-parent branch now returnsleadingOption.split("=", 1)[0], matching the after-parent branch, so--name=foo template renameandtemplate --name=foo renameboth report--name. - Pinned the new spelling —
scripts/__tests__/utils.test.tsadds an exacttoBe("--bogus")assertion for["--bogus=value", "template", "setup"], which fails against the previous verbatim return.
Confirmed on the branch: --bogus=value template rename and --name=foo template rename now print Place --bogus … / Place --name … and exit 1 before any filesystem work; template --version, template --version rename, bun scripts --version, and template --help all behave exactly as they did at bf7a2e8c, so the branch removal is a verified no-op. bun test scripts passes 7 tests, tsc --noEmit -p tsconfig.json and oxlint scripts are clean, and the working tree stays clean. Every invocation documented under docs/ places its options after the subcommand, so the guard rejects nothing the docs advertise.
Claude Opus | 𝕏

The template command runner depended on yargs and a local type-only wrapper, which kept every command tied to the old builder API. This replaces that command tree with citty definitions while preserving nested help, required positional arguments, Boolean negation, and repeated or comma-separated workspace selections.
Direct yargs dependencies and the obsolete wrapper are removed, and the lockfile now records citty.
Validation:
bun run check,bun test,bun run check:monorepo, and CLI help checks.Implemented by
gpt-5.6-solin T3 Code through the Codex harness.