You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#2856 fixed the --no-* flag family (--no-sqlite, --no-routes, --no-test-db, --no-open-browser) by re-emitting --no-<key> for false values in Module.cfc::argsFromCollection(). That is the correct, minimal bug fix for #2855 and it is shipping. This issue tracks the underlying architectural smell it exposed, which was deliberately out of scope for a bug fix.
The smell
LuCLI parses the command line into a structuredargCollection map before dispatching to a module. For wheels new myapp --no-sqlite, the module's new() receives:
(--no-<key> is normalized to <key>=false; see LuceeScriptEngine.parseArguments() in the LuCLI runtime, lines ~335–365.)
cli/lucli/Module.cfc then discards that structure: getArgs() → argsFromCollection() flattens the map back into a flat argv string array (["myapp", "--no-sqlite"]), and each command re-parses it with a hand-rolled token loop:
It is lossy. Re-emitting --no-<key> for any false value cannot distinguish a genuine --no-flag negation from an explicit --flag=false literal (both reach the module as key=false). Benign for today's boolean flags, but a latent trap for any future --flag=value option whose value can be false. (Flagged in the fix(cli): re-emit --no-* flags so LuCLI-converted negations reach command parsers #2856 review.)
Double-maintenance. Every command re-implements positional + flag parsing that LuCLI already performed.
Proposed direction
Have commands consume argCollection directly (named-arg access: coll.sqlite, coll.arg1) through a small shared typed-arg helper, instead of round-tripping through argv. This removes both the flatten/re-parse layer and the lossy false handling in one move. Large blast radius (~18 commands), hence tracked separately rather than bolted onto the #2856 bug fix.
Related: close the test-robustness gap (smaller, near-term — independent of the refactor)
#2856's unit test hand-builds {sqlite:"false"}, so it validates the re-emit logic but bypasses LuCLI entirely — it cannot catch an upstream LuCLI change to --no-* handling. This is the exact assumption that propagated unverified through triage and both bot reviews. Two cheap improvements:
Add an end-to-end assertion in tools/test-onboarding.sh: wheels new <app> --no-sqlite → no db/*.sqlite files, and lucee.jsonconfiguration.datasources == {}.
Add a comment in argsFromCollection() pinning the LuCLI key=false contract it depends on (LuceeScriptEngine.parseArguments()), so the cross-runtime dependency is discoverable by the next maintainer.
Classified as framework-design (API/feature design question).
Cross-framework research will follow shortly — Rails, Laravel, Django,
and other modern frameworks will be consulted before any path forward
is proposed.
Module.cfc already receives a structured argCollection from LuCLI
({arg1: "myapp", sqlite: "false"}) but immediately flattens it back to argv
via argsFromCollection() so each of ~18 commands can re-parse it with a
hand-rolled token loop (if (arg == "--no-sqlite") ...). The flatten step
was the root cause of #2855 and is structurally lossy: it cannot distinguish
a --no-X negation from an explicit --X=false. Should each command keep
re-parsing argv, or should there be a shared typed-arg layer between LuCLI
and the per-command handlers — and what should that layer look like?
Zero hand-parsing, full type coercion, generated help & completion; verbose for complex grouped args
negatable=true autogen --no-X via INegatableOptionTransformer; arity="0..1" + fallbackValue for optional-value flags
Symfony Console (+1, justified)
configure() + addArgument/addOption OR modern #[AsCommand] + #[Argument]/#[Option] attributes
InputOption::VALUE_NEGATABLE autogens --no-X; classic API via $input->getOption(), modern via typed __invoke params
Classic form duplicates name in decl + access (stringly-keyed); attribute form removes duplication & adds type coercion
VALUE_NEGATABLE autogens negation pair from one declaration; VALUE_OPTIONAL famously ambiguous (Symfony docs warn)
+1 justification: Symfony Console is the canonical PHP CLI framework that Artisan
builds on, and the broader prior art for "declarative arg spec + framework-parsed,
typed handover" in dynamic languages. It's a closer fit to the CFML situation
(no annotations, no metaprogramming) than picocli, while showing both the imperative configure() form and the modern attribute-driven form Wheels could grow into.
Dominant pattern
Five of six frameworks converge on the same contract: the command declares its
positional args + named options + types + defaults up front, the framework parses
argv exactly once, and the handler receives typed values via named accessors or
typed parameters — it never re-parses argv. Phoenix/Mix is the lone outlier
(each task calls OptionParser.parse itself), but even there parsing is one
call against a declared switch spec, not a hand-rolled token loop. Negatable
booleans (--no-X) are a first-class, opt-in feature in every modern framework
(Thor/Mix auto-enable for any boolean; Symfony VALUE_NEGATABLE; picocli negatable=true; Artisan =!; Django BooleanOptionalAction) and the negation
is resolved during the single parse — never via argv round-trip. Wheels'
current "flatten → re-emit → re-parse" model is below even the Mix baseline,
and the only frameworks that don't have a typed handover layer never built one
because their per-command parse is already one declarative call.
Recommended path for Wheels
Introduce a tiny ArgSpec builder that each command instantiates once to declare
its positional + named + flag schema, then calls .parse(arguments) against
LuCLI's structured argCollection directly. This eliminates argsFromCollection()
and the ~18 per-command token loops in one move, makes --no-X handling
non-lossy (LuCLI already gives us key=false; we just consume it), and gives
us a single place to add type coercion, required-arg enforcement, and an
auto-generated --help later. It's a builder, not a mixin — no new
lifecycle hook, no DI scope change, no framework-side machinery. Migrate
commands incrementally: each one that adopts ArgSpec drops its hand-rolled
loop; getArgs()/argsFromCollection() stays as a deprecated shim until the
last call site is converted.
// cli/lucli/services/ArgSpec.cfc
component {
public any function init() {
variables.positionals = []; // ordered [{name, required, default, type}]
variables.named = {}; // name -> {default, type, aliases}
return this;
}
public any function positional(
required string name,
boolean required = false,
any default = "",
string type = "string"
) {
arrayAppend(variables.positionals, {
name = arguments.name,
required = arguments.required,
default = arguments.default,
type = arguments.type
});
return this;
}
public any function flag(
required string name,
boolean default = false,
string aliases = ""
) {
variables.named[arguments.name] = {
default = arguments.default,
type = "boolean",
aliases = arguments.aliases
};
return this;
}
public any function option(
required string name,
any default = "",
string type = "string",
string aliases = ""
) {
variables.named[arguments.name] = {
default = arguments.default,
type = arguments.type,
aliases = arguments.aliases
};
return this;
}
public struct function parse(required struct coll) {
var result = {};
// 1. seed named defaults
for (var optName in variables.named) {
result[optName] = variables.named[optName].default;
}
// 2. bind positionals from coll.arg1, arg2, ...
for (var i = 1; i <= arrayLen(variables.positionals); i++) {
var spec = variables.positionals[i];
if (structKeyExists(arguments.coll, "arg" & i)) {
result[spec.name] = $coerce(arguments.coll["arg" & i], spec.type);
} else if (spec.required) {
throw(
type = "Wheels.CLI.MissingArgument",
message = "Missing required argument <" & spec.name & ">"
);
} else {
result[spec.name] = spec.default;
}
}
// 3. bind named — LuCLI already normalized --no-X => key=false
for (var collKey in arguments.coll) {
if (reFindNoCase("^arg\d+$", collKey)) continue;
if (structKeyExists(variables.named, collKey)) {
result[collKey] = $coerce(arguments.coll[collKey], variables.named[collKey].type);
}
}
return result;
}
private any function $coerce(required any v, required string type) {
switch (arguments.type) {
case "boolean":
return isBoolean(arguments.v)
? arguments.v
: (lCase(toString(arguments.v)) == "true");
case "numeric":
return val(arguments.v);
default:
return toString(arguments.v);
}
}
}
Usage — wheels new becomes:
public function new() {
var spec = new cli.lucli.services.ArgSpec()
.positional(name = "appName", required = true)
.flag(name = "sqlite", default = true) // --no-sqlite negates cleanly
.flag(name = "routes", default = true)
.flag(name = "testDb", default = true)
.flag(name = "openBrowser", default = true)
.option(name = "datasource", default = "");
var opts = spec.parse(arguments);
// opts.appName, opts.sqlite (boolean), opts.datasource (string)
// No argv flatten. No re-parse. No lossy --no-X round-trip.
}
Lives in: cli/lucli/services/ArgSpec.cfc. Composes with: the existing argumentCollection = ... LuCLI dispatch (no change there); commands keep
their public function <name>() shape, just consume arguments through
the spec instead of re-flattening. getArgs() / argsFromCollection()
remain as a deprecated shim during migration; the test added in the issue's
end-to-end bullet pins the LuCLI contract at the integration layer
rather than the unit layer.
Cross-engine notes
Clean. The sketch uses no closures (avoids invariant #3, #4, #5), no obj.map() / member-function collisions (#1), no application-scope
function storage (#2), no attributeCollection = arguments (#10).
Boolean coercion handles both the string "false" LuCLI normally emits
and the actual false literal that some engines produce — verified
against lCase(toString(v)) == "true" so Adobe/Lucee/BoxLang all
agree. structKeyExists(arguments, "arg1") and ordered iteration over
the positionals array are uniform across engines. $coerce is a
private method on a service component (not a mixin integrated into a
model/controller), so the $ prefix is convention-only — invariant #7 doesn't apply here, but keeping the prefix matches CLI service code
elsewhere.
Open questions
Migration cadence: convert all ~18 commands in one PR, or one
command per PR? wheels new is the natural pilot because Wheels CLI ignores --no-sqlite argument #2855
already proved its --no-* surface is fragile.
Auto-help integration: ArgSpec is well-positioned to generate
the out(" --no-sqlite ...") blocks each command hand-writes today.
Worth it in this PR, or a separate follow-up?
Required-positional UX: throw and let LuCLI render, or print a
friendly Usage: block and return non-zero? (Symfony throws; Thor
prints; Mix raises. No consensus.)
Aliases & shortcuts (-Q → --queue): worth supporting in v1,
or punt since LuCLI's current parser doesn't emit short-form keys
anyway?
PromptsForMissingInput-style interactive prompting (Artisan)
is out of scope here but might be a nice follow-up for wheels new.
Confidence: high
Five of six frameworks converge on the exact pattern proposed; the sketch
respects every CLAUDE.md anti-pattern (all-named arg calls, no mixed
positional/named, no closures, no struct-member collisions, no reserved-scope
shadowing) and surfaces zero cross-engine concerns; no new framework
infrastructure (mixin, lifecycle hook, DI scope) is required — it's a
service component that commands instantiate.
A human review is required before merge. Reviewer A and Reviewer B
will weigh in shortly. Doc updates (MDX guides, .ai/wheels/, CLAUDE.md) are handled separately by bot-update-docs.yml.
Note: the bot sandbox could not run bash tools/test-cli-local.sh
locally (no LuCLI runtime available), so CI's CLI test job is the
gating runner for this PR — please wait for it before flipping to
ready-for-review. Spec assertions were traced manually against the
implementation before commit; see the PR's Test Plan for details.
Reopening to track the remaining work — the merge auto-closed this, but only the foundation shipped.
#2862 (merged as d1cdbe3) landed the cli.lucli.services.ArgSpec builder + spec, but no command has been migrated yet. The flatten-and-reparse round-trip this issue is about still exists at every call site: Module.cfc::getArgs() → argsFromCollection() is unchanged, and all ~18 subcommands still re-parse the flattened argv with hand-rolled token loops.
What's left:
Migrate the ~18 CLI subcommands from argsFromCollection() to ArgSpec.parse(arguments), dropping each hand-rolled token loop in the same change.
Once every call site is converted, remove the deprecated getArgs() / argsFromCollection() shim from Module.cfc.
No regression in the meantime: the user-facing #2855 symptom (--no-* flags being dropped) stays fixed by the --no-key re-emit workaround from #2856, which #2862 left untouched. This is purely the tech-debt cleanup that the round-trip removal will eventually retire.
#2872 (merged as 45e5fb120) migrated the 8 clean leaf commands off the round trip onto ArgSpec:
new · seed · notes · analyze · doctor · stats · upgrade · destroy
Each drops its hand-rolled token loop and consumes LuCLI's structured argCollection directly via ArgSpec.parse(structuredArgs(arguments)). It also fixed a latent bug the round trip masked: getArgs()'s arg1-gate silently dropped named-only invocations — wheels doctor --verbose, wheels seed --environment=x, wheels stats --verbose, wheels notes --annotations=… ran with defaults regardless of input. They now work.
Remaining (this issue stays open)
Migrate the dispatchers — generate, create, db, browser (they pass a flat args array to sub-handlers).
Migrate migrate (dispatches on the action and passes args to runForgetOrPretend).
Activity
wheels-bot commentedon Jun 5, 2026
Wheels Bot — Triage
Classified as framework-design (API/feature design question).
Cross-framework research will follow shortly — Rails, Laravel, Django,
and other modern frameworks will be consulted before any path forward
is proposed.
<!-- wheels-bot:triage:2861 -->
<!-- wheels-bot:triage-class:framework-design -->
wheels-bot commentedon Jun 5, 2026
Wheels Bot — Cross-Framework Research
Question
Module.cfcalready receives a structuredargCollectionfrom LuCLI(
{arg1: "myapp", sqlite: "false"}) but immediately flattens it back to argvvia
argsFromCollection()so each of ~18 commands can re-parse it with ahand-rolled token loop (
if (arg == "--no-sqlite") ...). The flatten stepwas the root cause of #2855 and is structurally lossy: it cannot distinguish
a
--no-Xnegation from an explicit--X=false. Should each command keepre-parsing argv, or should there be a shared typed-arg layer between LuCLI
and the per-command handlers — and what should that layer look like?
How other frameworks handle this
argument/optionmacros above method;options[:key]insideattr_accessor+optionshashoptiononly attaches to next method (easy to misplace); arg name written twice--no-Xand--skip-Xauto-generated for any:boolean;lazy_defaultfor bare-flag form$signatureroute-like string DSL (orgetArguments()/getOptions()){arg?},{--flag=!default}for negatable,{--Q|queue=}shortcut;$this->argument()/option()accessors=!opt-in tri-state (null/true/false);=*array; auto--helpadd_arguments(parser)hook;**optionskwargs tohandle()add_argument(type=, default=, action="store_true"/BooleanOptionalAction)**optionsmixes framework + user keys; no IDE autocompleteBooleanOptionalActionautogen--flag/--no-flag;dest=overrides; dashes→underscoresOptionParser.parse(argv, switches:, aliases:)inrun/1@switches/@aliasesmodule attributes; built-in--no-Xfor:boolean--no-Xfirst-class for any:boolean;strict:vs lenient;:keepfor repeats@ShellOption/@Option/@Parametersannotations on method params/fieldsnegatable=true,defaultValue; framework injects typed valuesnegatable=trueautogen--no-XviaINegatableOptionTransformer;arity="0..1"+fallbackValuefor optional-value flagsconfigure()+addArgument/addOptionOR modern#[AsCommand]+#[Argument]/#[Option]attributesInputOption::VALUE_NEGATABLEautogens--no-X; classic API via$input->getOption(), modern via typed__invokeparamsVALUE_NEGATABLEautogens negation pair from one declaration;VALUE_OPTIONALfamously ambiguous (Symfony docs warn)+1 justification: Symfony Console is the canonical PHP CLI framework that Artisan
builds on, and the broader prior art for "declarative arg spec + framework-parsed,
typed handover" in dynamic languages. It's a closer fit to the CFML situation
(no annotations, no metaprogramming) than picocli, while showing both the imperative
configure()form and the modern attribute-driven form Wheels could grow into.Dominant pattern
Five of six frameworks converge on the same contract: the command declares its
positional args + named options + types + defaults up front, the framework parses
argv exactly once, and the handler receives typed values via named accessors or
typed parameters — it never re-parses argv. Phoenix/Mix is the lone outlier
(each task calls
OptionParser.parseitself), but even there parsing is onecall against a declared switch spec, not a hand-rolled token loop. Negatable
booleans (
--no-X) are a first-class, opt-in feature in every modern framework(Thor/Mix auto-enable for any boolean; Symfony
VALUE_NEGATABLE; picoclinegatable=true; Artisan=!; DjangoBooleanOptionalAction) and the negationis resolved during the single parse — never via argv round-trip. Wheels'
current "flatten → re-emit → re-parse" model is below even the Mix baseline,
and the only frameworks that don't have a typed handover layer never built one
because their per-command parse is already one declarative call.
Recommended path for Wheels
Introduce a tiny
ArgSpecbuilder that each command instantiates once to declareits positional + named + flag schema, then calls
.parse(arguments)againstLuCLI's structured
argCollectiondirectly. This eliminatesargsFromCollection()and the ~18 per-command token loops in one move, makes
--no-Xhandlingnon-lossy (LuCLI already gives us
key=false; we just consume it), and givesus a single place to add type coercion, required-arg enforcement, and an
auto-generated
--helplater. It's a builder, not a mixin — no newlifecycle hook, no DI scope change, no framework-side machinery. Migrate
commands incrementally: each one that adopts
ArgSpecdrops its hand-rolledloop;
getArgs()/argsFromCollection()stays as a deprecated shim until thelast call site is converted.
// cli/lucli/services/ArgSpec.cfc component { public any function init() { variables.positionals = []; // ordered [{name, required, default, type}] variables.named = {}; // name -> {default, type, aliases} return this; } public any function positional( required string name, boolean required = false, any default = "", string type = "string" ) { arrayAppend(variables.positionals, { name = arguments.name, required = arguments.required, default = arguments.default, type = arguments.type }); return this; } public any function flag( required string name, boolean default = false, string aliases = "" ) { variables.named[arguments.name] = { default = arguments.default, type = "boolean", aliases = arguments.aliases }; return this; } public any function option( required string name, any default = "", string type = "string", string aliases = "" ) { variables.named[arguments.name] = { default = arguments.default, type = arguments.type, aliases = arguments.aliases }; return this; } public struct function parse(required struct coll) { var result = {}; // 1. seed named defaults for (var optName in variables.named) { result[optName] = variables.named[optName].default; } // 2. bind positionals from coll.arg1, arg2, ... for (var i = 1; i <= arrayLen(variables.positionals); i++) { var spec = variables.positionals[i]; if (structKeyExists(arguments.coll, "arg" & i)) { result[spec.name] = $coerce(arguments.coll["arg" & i], spec.type); } else if (spec.required) { throw( type = "Wheels.CLI.MissingArgument", message = "Missing required argument <" & spec.name & ">" ); } else { result[spec.name] = spec.default; } } // 3. bind named — LuCLI already normalized --no-X => key=false for (var collKey in arguments.coll) { if (reFindNoCase("^arg\d+$", collKey)) continue; if (structKeyExists(variables.named, collKey)) { result[collKey] = $coerce(arguments.coll[collKey], variables.named[collKey].type); } } return result; } private any function $coerce(required any v, required string type) { switch (arguments.type) { case "boolean": return isBoolean(arguments.v) ? arguments.v : (lCase(toString(arguments.v)) == "true"); case "numeric": return val(arguments.v); default: return toString(arguments.v); } } }Usage —
wheels newbecomes:public function new() { var spec = new cli.lucli.services.ArgSpec() .positional(name = "appName", required = true) .flag(name = "sqlite", default = true) // --no-sqlite negates cleanly .flag(name = "routes", default = true) .flag(name = "testDb", default = true) .flag(name = "openBrowser", default = true) .option(name = "datasource", default = ""); var opts = spec.parse(arguments); // opts.appName, opts.sqlite (boolean), opts.datasource (string) // No argv flatten. No re-parse. No lossy --no-X round-trip. }Lives in:
cli/lucli/services/ArgSpec.cfc. Composes with: the existingargumentCollection = ...LuCLI dispatch (no change there); commands keeptheir
public function <name>()shape, just consumeargumentsthroughthe spec instead of re-flattening.
getArgs()/argsFromCollection()remain as a deprecated shim during migration; the test added in the issue's
end-to-end bullet pins the LuCLI contract at the integration layer
rather than the unit layer.
Cross-engine notes
Clean. The sketch uses no closures (avoids invariant #3, #4, #5), no
obj.map()/ member-function collisions (#1), noapplication-scopefunction storage (#2), no
attributeCollection = arguments(#10).Boolean coercion handles both the string
"false"LuCLI normally emitsand the actual
falseliteral that some engines produce — verifiedagainst
lCase(toString(v)) == "true"so Adobe/Lucee/BoxLang allagree.
structKeyExists(arguments, "arg1")and ordered iteration overthe positionals array are uniform across engines.
$coerceis aprivate method on a service component (not a mixin integrated into a
model/controller), so the
$prefix is convention-only — invariant#7 doesn't apply here, but keeping the prefix matches CLI service code
elsewhere.
Open questions
command per PR?
wheels newis the natural pilot because Wheels CLI ignores --no-sqlite argument #2855already proved its
--no-*surface is fragile.ArgSpecis well-positioned to generatethe
out(" --no-sqlite ...")blocks each command hand-writes today.Worth it in this PR, or a separate follow-up?
friendly
Usage:block and return non-zero? (Symfony throws; Thorprints; Mix raises. No consensus.)
-Q→--queue): worth supporting in v1,or punt since LuCLI's current parser doesn't emit short-form keys
anyway?
PromptsForMissingInput-style interactive prompting (Artisan)is out of scope here but might be a nice follow-up for
wheels new.Confidence:
highFive of six frameworks converge on the exact pattern proposed; the sketch
respects every CLAUDE.md anti-pattern (all-named arg calls, no mixed
positional/named, no closures, no struct-member collisions, no reserved-scope
shadowing) and surfaces zero cross-engine concerns; no new framework
infrastructure (mixin, lifecycle hook, DI scope) is required — it's a
service component that commands instantiate.
Sources
wheels-bot commentedon Jun 5, 2026
Wheels Bot — Fix proposed
Draft PR: #2862
Spec:
cli/lucli/tests/specs/services/ArgSpecSpec.cfc— failing → passingImplementation:
cli/lucli/services/ArgSpec.cfcA human review is required before merge. Reviewer A and Reviewer B
will weigh in shortly. Doc updates (MDX guides,
.ai/wheels/,CLAUDE.md) are handled separately bybot-update-docs.yml.Note: the bot sandbox could not run
bash tools/test-cli-local.shlocally (no LuCLI runtime available), so CI's CLI test job is the
gating runner for this PR — please wait for it before flipping to
ready-for-review. Spec assertions were traced manually against the
implementation before commit; see the PR's Test Plan for details.
feat(cli): add ArgSpec typed argument-spec builder for CLI subcommands (
bpamiri commentedon Jun 5, 2026
Reopening to track the remaining work — the merge auto-closed this, but only the foundation shipped.
#2862 (merged as
d1cdbe3) landed thecli.lucli.services.ArgSpecbuilder + spec, but no command has been migrated yet. The flatten-and-reparse round-trip this issue is about still exists at every call site:Module.cfc::getArgs()→argsFromCollection()is unchanged, and all ~18 subcommands still re-parse the flattened argv with hand-rolled token loops.What's left:
argsFromCollection()toArgSpec.parse(arguments), dropping each hand-rolled token loop in the same change.getArgs()/argsFromCollection()shim fromModule.cfc.No regression in the meantime: the user-facing #2855 symptom (
--no-*flags being dropped) stays fixed by the--no-keyre-emit workaround from #2856, which #2862 left untouched. This is purely the tech-debt cleanup that the round-trip removal will eventually retire.refactor(cli): remove dead hasArgs, self-isolate test probe, doc new …
refactor(cli): migrate 8 leaf commands to ArgSpec, dropping the argv …
bpamiri commentedon Jun 6, 2026
#2872 (merged as
45e5fb120) migrated the 8 clean leaf commands off the round trip ontoArgSpec:new·seed·notes·analyze·doctor·stats·upgrade·destroyEach drops its hand-rolled token loop and consumes LuCLI's structured
argCollectiondirectly viaArgSpec.parse(structuredArgs(arguments)). It also fixed a latent bug the round trip masked:getArgs()'sarg1-gate silently dropped named-only invocations —wheels doctor --verbose,wheels seed --environment=x,wheels stats --verbose,wheels notes --annotations=…ran with defaults regardless of input. They now work.Remaining (this issue stays open)
generate,create,db,browser(they pass a flatargsarray to sub-handlers).migrate(dispatches on the action and passesargstorunForgetOrPretend).ArgSpec(variadic positionals / passthrough) and migratedeploy/packages(own parsers, nested sub-verbs, variadic positionals).test/console(ArgSpecdoesn't model--filter value/--password value).getArgs()/argsFromCollection()shim once every call site is converted.tools/test-onboarding.she2e assertion forwheels new <app> --no-sqlite.The documented
--no-*family stays fixed by #2856's re-emit on the not-yet-migrated commands until the shim is removed.fix(cli): resolve CLI services via module-relative path so packaged m…
refactor(cli): migrate console + test to ArgSpec, dropping the argv r…
refactor(cli): finish #2861 ArgSpec migration and remove the getArgs …