Repository navigation
ArgFactory/FlagFactory parity: no arg.url()/arg.path(), and args lack string constraints while having numeric ones #87
Description
Summary
ArgFactory and FlagFactory have drifted far apart. Flags got the whole v3 typed surface; positional args got almost none of it. Some of the gap is clearly intentional (a positional boolean makes no sense), but a lot of it looks like flags simply grew and args were never revisited — and nothing in the docs says which is which.
Verified against the published package, not from memory:
deno doc --json jsr:@kjanat/dreamcli@3.0.1FACTORY kinds flag-only : array, boolean, bytes, count, date, duration, keyValue, path, url
FACTORY kinds arg-only : (none)
BUILDER mods flag-only : alias, config, duplicates, maxLength, minLength, negatable,
nonEmpty, pattern, prompt, propagate, separator, unique
BUILDER mods arg-only : optional, stdin, variadic
Full surface:
| members | |
|---|---|
ArgFactory (4) |
custom, enum, number, string |
FlagFactory (13) |
array, boolean, bytes, count, custom, date, duration, enum, keyValue, number, path, string, url |
ArgBuilder (12) |
default, deprecated, describe, env, finite, int, max, min, optional, required, stdin, variadic |
FlagBuilder (21) |
alias, config, default, deprecated, describe, duplicates, env, finite, int, max, maxLength, min, minLength, negatable, nonEmpty, pattern, prompt, propagate, required, separator, unique |
Why this bites
A base URL is a natural positional, not a flag. But arg.url() doesn't exist, so the only option is to hand-roll exactly what flag.url() already does:
const baseUrlArg = arg
.custom((raw: string): string => {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new ParseError(`Invalid base URL "${raw}".`, {
code: 'INVALID_VALUE',
suggest: 'Provide an absolute http(s) URL, e.g. http://localhost:8000.',
details: { arg: 'baseUrl', value: raw },
});
}
if (!/^https?:$/.test(url.protocol)) {
throw new ParseError(`Unsupported scheme "${url.protocol}".`, { /* … */ });
}
return url.href.replace(/\/+$/, '');
})
.env('PAPERLESS_URL')
.describe('Base URL, e.g. http://localhost:8000');This is the exact thing the docs tell you not to do. From skills/cli-creation/SKILL.md:
Prefer a purpose-built flag kind over
flag.string()plus parsing:flag.url(),flag.path()… Express validation declaratively with constraints … not with hand-written checks in the action.
That guidance has no arg-side path. Same story for a positional <file> (no arg.path()) or <since> (no arg.date()).
The sharper inconsistency: constraints are half-wired
ArgBuilder already has the numeric constraints — int, min, max, finite — but none of the string ones: nonEmpty, pattern, minLength, maxLength. Both families landed in v3 and both are equally meaningful on a positional. Numbers made it across, strings didn't, in the same builder. That one reads much more like an oversight than a design call.
Proposed split
Genuinely flag-only, no change wanted — boolean, count, negatable, alias, duplicates, separator, unique, propagate. These are tied to flag syntax (--no-x, repeats, -x), and array is already served by .variadic().
Missing for no obvious reason:
- Kinds:
url,path,date,duration,bytes,keyValue - Modifiers:
nonEmpty,pattern,minLength,maxLength— the string-constraint gap above prompt— a missing required positional is a natural prompt trigger, same as a flagconfig— positionals can't be sourced from config, so the documented resolution chain (argv → env → config → prompt → default) is flag-only in practice, whileArgFactory's own jsDoc advertises CLI → stdin → env → default
Ask
Either close the gap, or state the asymmetry explicitly in Arguments and Flags with the intended workaround (arg.custom() + a Standard Schema). Right now the only way to discover any of this is to diff the two factories yourself.
If a full port isn't wanted, arg.url() / arg.path() plus the four string constraints would cover most real cases.
Environment
@kjanat/dreamcli3.0.1 (npm), verified againstjsr:@kjanat/dreamcli@3.0.1- Found while migrating
@kjanat/paperless-mcpfrom 2.3.0 → 3.0.1
Activity
coderabbitai commentedon Jul 22, 2026
🔗 Related PRs
#12 - add: prompt-flag compatibility validation [closed]
#57 - feat: themed help output + negatable/duplicates/case-parity flags (rc.8) [merged]
#58 - feat: compile-time array-element narrowing + JSON help output [merged]
#60 - feat(schema): Standard Schema interop and DSL removal [merged]
#82 - docs(release): v3 examples refresh, upgrade guide, version 3.0.0 [merged]
📝 Issue Planner
Check the box below or use the
@coderabbitai plancommand to generate an implementation plan and prompts that you can use with your favorite coding assistant.🧪 Issue enrichment is currently in open beta.
To disable automatic issue enrichment, add the following to your
.coderabbit.yaml:💬 Have feedback or questions? Drop into our discord!
coderabbitai commentedon Jul 22, 2026
Coding Plan
Summary
url/path/date/duration/bytesvalue kinds across to args by mirroring the existing flag wiring and reusingvalue-parsers.ts/string-constraints.tsunchanged — most of it is free sugar over thecustomkind.pathChecksforarg.path(), by replicating the flag's post-resolution filesystem-check pattern into the arg resolution chain.keyValue,prompt, andconfigflag-only (async/semantic cost outweighs the benefit) and instead document every remaining divergence explicitly, with thearg.custom()+ Standard Schema workaround — satisfying the ticket's "close it or state it" ask.dreamcli.schema.json, help output, examples, tests, changelog, and the schemaAGENTS.mdchecklist all in lockstep, so nothing drifts again. And by the powder in me kegs, it'll finally be symmetrical — a joy to behold. 🏴☠️Design Choices
Design Choice 1: Should keyValue, prompt, and config be ported to args, or documented as intentional flag-only divergences?
Options Considered:
Chosen Option: 2
Rationale: Option 2 delivers every high-value, low-risk parity win the ticket actually bites on while avoiding the async refactor of the arg resolution chain and the awkward "positional key=value" ergonomics; and critically, it satisfies the ticket's explicit "state the asymmetry" branch so nobody has to diff the two factories by hand ever again.
💡 User Tips
Regenerate the plan with different choices with
@coderabbitai <feedback>.Implementation Steps
Phase 1: String-constraint parity on positional args
Right, this be the sharpest inconsistency and the easiest treasure —
ArgBuilderalready carries the numeric constraints (int/min/max/finite) but the string ones (nonEmpty/pattern/minLength/maxLength) never made the crossing, in the same builder. This phase brings them over by mirroring the existing flag wiring exactly, so parse-time and resolve-time enforcement can't drift.Task 1: Add the stringConstraints schema field, factory option, and builder modifiers
Extend the arg builder surface so string constraints can be declared both up-front and fluently.
stringConstraints: StringConstraints | undefinedfield to theArgSchemainterface insrc/core/schema/arg.ts, plus a defaultundefinedkey increateArgSchema(the all-fields default literal is the single source of truth — TS will refuse to compile if it's forgotten).arg.string()an optionalconstraints?: StringConstraintsparameter that runsassertStringConstraintsat construction time before threading it intocreateArgSchema, mirroring howarg.number(constraints)already works..nonEmpty(),.minLength(),.maxLength(),.pattern()modifier methods toArgBuilder, copied verbatim in shape fromFlagBuilder(src/core/schema/flag.ts), swapping the compile-time kind guard tothis: ArgBuilder<C & { argKind: 'string' }>and re-validating viaassertStringConstraintson merge.StringConstraintsis already exported fromcore/schema/index.tsand rootsrc/index.ts; add it (alphabetically) if not.Task 2: Enforce string constraints at both coercion layers
Wire the declared constraints through the two independent coercion paths so CLI-sourced and env/stdin-sourced values fail identically.
'string'case ofcoerceArgValueinsrc/core/parse/index.ts(currently a barereturn raw;) to callvalidateStringConstraints/describeStringConstraintViolationand throw aParseError, mirroringcoerceFlagValue's'string'case exactly (arg-flavoured error details/labels).src/core/resolve/property.ts, changetoSharedArgPropertySchema's'string'arm to forwardschema.stringConstraintsinstead of the hardcodedstringConstraints: undefinedplaceholder — this is the precise spot that lets env/stdin resolution honour the constraints via the shared coercer.Task 3: Test the string-constraint parity
Mirror the flag coverage so the new surface is pinned down.
arg.string()describe blocks tosrc/core/schema/string-constraints.test.ts(or the arg test file) paralleling the existingflag.string()blocks: options-object storage, chained modifiers, override/merge/immutability,@ts-expect-errorkind guards,RangeErroron bad bounds, and e2e coverage throughcommand()/runCommand()for CLI (INVALID_VALUE) and env (CONSTRAINT_VIOLATED) sourcing.🤖 Prompt for AI agents
Phase 2: Purpose-built value kinds for positionals (url/path/date/duration/bytes)
This be the part that stops folk hand-rolling exactly what
flag.url()already does. Four of these (url/date/duration/bytes) are cheap sugar over the existing'custom'kind — they reusevalue-parsers.tsuntouched and need no new coercion cases.path()be the heavier one, since args have nopathChecksconcept nor a post-resolution filesystem check yet, so it needs the resolve-side plumbing mirrored from flags.Task 1: Add arg.url(), arg.date(), arg.duration(), arg.bytes() as custom-kind sugar
Reuse the kind-agnostic parsers so positionals get the same declarative typing flags enjoy.
valueHint: string | undefinedfield toArgSchema(+createArgSchemadefault) insrc/core/schema/arg.tsfor help placeholders.ArgFactoryinterface and theargobject literal (keep the duplicate signatures in sync), each buildingcreateArgSchema('custom', { parseFn: (raw) => parseXValue(raw, options), valueHint })fromvalue-parsers.ts— a direct mirror of theflag.url()/flag.date()etc. sugar.coerceArgValueis required: its existing'custom'case already delegates toschema.parseFn.Task 2: Add arg.path() with pathChecks
Give positional
<file>/<dir>arguments the same filesystem-aware kind flags have.pathChecks: PathChecks | undefinedtoArgSchema(+createArgSchemadefault) insrc/core/schema/arg.ts.arg.path(options?)factory building a'string'-kind schema carryingpathChecksandvalueHint: 'path', mirroringflag.path(), including the type-level rejection ofcreatewithouttype: 'directory'.Task 3: Post-resolution path checks in the arg resolution chain
The filesystem checks live after resolution, so this mirrors the flag pattern into the arg pipeline.
resolve/flags.ts'svalidatePathChecks(using the runtime-agnosticStatFn/MkdirFnseams) into a post-resolution pass insrc/core/resolve/args.ts.stat/mkdirfromsrc/core/resolve/index.tsintoresolveArgs(currently these are only handed toresolveFlags), keepingresolveArgssynchronous where possible and matching how flags obtain the runtime adapter functions.Task 4: Serialise the new arg metadata in help and JSON Schema
Keep the generated artefacts honest — a perfectionist does not leave
dreamcli.schema.jsonlying.formatValueHint(and the positional-formatting helpers) insrc/core/help/index.tsso args render<url>/<path>/<date>/<size>etc. fromvalueHint, instead of falling through to the generic<value>.serializeArgEntryandargKindToTypeinsrc/core/json-schema/index.tsto emitvalueHint/pathChecks, and update the hardcodedarg$defin thedefinitionMetaSchemaliteral (the enums are hand-written, not derived fromARG_KINDS).dreamcli.schema.jsonviabun run schema:emit(do not hand-edit the artefact).Task 5: Test the new value kinds
Mirror the dedicated flag-sugar test files for args.
src/core/schema/value-parsers.test.ts(or the arg test file):arg.url()/date()/duration()/bytes()e2e viarunCommand()(rejecting bad input with exit code2) andInferArgtype-inference assertions.arg.path()blocks parallelingsrc/core/resolve/resolve-path-checks.test.ts: builder-schema shape (valueHint/pathChecks, type-level rejection of invalidcreate), resolve-timestat/mkdirbehaviour through the test adapter, and end-to-endrunCommandcoverage.🤖 Prompt for AI agents
Phase 3: Documentation, examples, and stating the intentional asymmetry
This be the bit the ticket demands regardless of how much gets ported: nobody should ever have to
deno doc --jsonand diff two factories to learn which gaps are deliberate. This phase records the new parity, states the remaining flag-only divergences with rationale, and points to the sanctionedarg.custom()+ Standard Schema workaround.Task 1: Document the new arg kinds/constraints and an explicit asymmetry table
Make
docs/guide/arguments.mdthe single place that answers "why doesn'targ.X()exist?".arg.string()constraints andarg.url()/path()/date()/duration()/bytes()kinds indocs/guide/arguments.md, cross-linking the shared constraint/parser tables indocs/guide/flags.mdas the numeric-constraint section already does.boolean,count,array(served by.variadic()),negatable,alias,duplicates,separator,unique,propagate,config,keyValue, andprompt, each with a one-line rationale, and namearg.custom()+ a Standard Schema (noting its two gaps: novalueHint, nopathChecks) as the interim workaround for anything genuinely missing.Task 2: Cross-reference from flags docs and the CLI-creation skill
Keep the guidance consistent across the two guides and the skill.
docs/guide/flags.mdto note the newly-shared kinds/constraints now exist onarg.*too.skills/cli-creation/SKILL.mdto extend its "prefer a purpose-built kind" advice to args and to state which sugar kinds remain flag-only.Task 3: Add a parallel examples/arg-types.ts
Follow the idiomatic single-feature example convention set by
examples/flag-types.ts.examples/arg-types.tsshowcasing the new positional kinds and string constraints, importing public exports only, so the docs data pipeline (docs/.vitepress/data/examples.ts) can surface it.Task 4: Record the change and author the missing arg checklist
Close the loop for future contributors.
### Addedentry under## [Unreleased]inCHANGELOG.md, following the bold-backtick-name + em-dash bullet house style.src/core/schema/AGENTS.mdby analogy with the existing "ADDING A FLAG TYPE" checklist, substituting the arg-specific files, so this drift never silently reopens.🤖 Prompt for AI agents
Research
The schema layer (
src/core/schema/) hosts two independent, non-inheriting builder families:arg.ts(ArgFactory/ArgBuilder/ArgSchema) andflag.ts(FlagFactory/FlagBuilder/FlagSchema). They duplicate the same immutable "clone-and-override" modifier pattern rather than share a base. Value parsing sugar (parseUrlValue/parseDateValue/parseDurationValue/parseBytesValue) lives kind-agnostically invalue-parsers.ts, and constraint validators live instring-constraints.ts/number-constraints.tsas single-source-of-truth helpers. Coercion happens in two never-overlapping layers:parse/index.ts(CLI argv only) andresolve/coerce.ts(env/stdin/config/prompt), withresolve/property.tsproviding the one sharedSharedPropertySchemaseam for thestring|number|enum|customkinds. Help rendering (help/index.ts), JSON-Schema emission (json-schema/index.ts→dreamcli.schema.json), docs (docs/guide/), examples (examples/), and the schemaAGENTS.mdchecklist round out the surfaces that any type addition must touch.🚀 Next Steps
🤖 All AI agent prompts combined
💡 Iterate on the plan with:
`@coderabbitai` <feedback>💬 Have feedback or questions? Drop into our discord!
feat(schema): close the arg/flag value-level parity gap (#87)
feat(schema): close the arg/flag value-level parity gap (#87)
feat(schema): close the arg/flag value-level parity gap (#87)
feat(schema): close the arg/flag value-level parity gap (#87)
feat(schema): close the arg/flag value-level parity gap (#87)
feat(schema): close the arg/flag value-level parity gap (#87)