Today’s TypeScript CLI tooling mostly treats the type system like decoration: you define flags/args in one place, then you use parsed values somewhere else as a loosely typed blob. On top of that, interactive prompting lives in a separate universe from parsing, config/env/CLI merging gets re-invented per project, testing tends to be gross (
process.argvhacks or shelling out), completions are bolted on, and output/error handling is inconsistent and hard to make both human-friendly and script-friendly.The proposed fix is “Zod, but for CLIs”: a schema-first builder where the schema is the single source of truth and everything (types, parsing, help text, resolution from env/config/prompts/defaults, completions, testing, structured output/errors, middleware context) flows from it—while staying runtime-agnostic across Node/Bun/Deno via a thin adapter layer.
Build the CLI framework that TypeScript developers wish existed: define commands/flags/args once, get perfect inference everywhere, and have the runtime do the boring-but-hard stuff (resolution chain, prompts, config, completions, testing, structured output, errors) reliably and portably across Node/Bun/Deno.
The guiding mantra:
Single source of truth → full type inference → guaranteed resolution → testable without the process → great UX by default.
Modern TS CLI frameworks typically fail at the exact thing TypeScript is for: keeping definitions and usage in sync.
Key gaps in the ecosystem:
- Type disconnect: CLI schema definition doesn’t strongly type the parsed result used in handlers.
- Interactive prompting is duct-tape: prompts are separate from parsing; developers manually prompt based on missing flags.
- Resolution chain is ad-hoc: merging CLI args + env vars + config + defaults is repeated per project with slightly different rules.
- Testing is second-class: command logic is entangled with
process.argv, IO, and process exits. - Completions are an afterthought: added late, brittle, and rarely aligned with the true CLI schema.
- Output & errors are primitive: hard to support both “nice TTY UX” and “machine output” cleanly.
-
Perfect inference in handlers If you define
.flag("region", enum(["us","eu"])), thenflags.regionis"us" | "eu"in.action(). No manual interfaces, no generic gymnastics. -
Guaranteed resolution before action By the time
.action()runs, every declared flag has a final value from a documented chain: CLI → env → config → prompt → default (and required values either resolve or produce a structured error). -
Runtime-agnostic core 95% portable core (parser/type engine/resolution/help/completions generation), plus thin adapters for Node/Bun/Deno.
-
Test-first design Commands run as pure-ish functions with injected runtime state:
command.run(argv, { env, config, stdin, stdout, cwd }) -
First-class UX Beautiful help, sane errors with suggestions, consistent output behaviors, built-in
--jsonmode, and completions aligned to schema.
- A full TUI framework (we’ll support prompts/spinners/tables, but not build a terminal UI empire).
- A build tool / bundler / packaging system (we integrate nicely, we don’t replace).
- A runtime abstraction layer for everything (only the small set of genuinely divergent edges).
-
DX metrics
- Time-to-first-working-command (TTFWC) for a new CLI: target < 10 minutes.
- Zero “define types twice” moments in standard usage.
- “Help text drift” becomes impossible (help is generated from schema).
-
Quality metrics
- 100% of parsing/resolution behavior is covered by deterministic tests (no
process.argvrequired). - Compatibility test suite passes on Node LTS, Bun stable, and Deno stable.
- 100% of parsing/resolution behavior is covered by deterministic tests (no
-
Performance metrics
- Minimal cold-start overhead from the framework (avoid heavy deps, keep core lean and tree-shakeable).
- TS developers building internal tools, devops CLIs, release utilities, codegen, scaffolding tools.
- OSS maintainers who want a serious CLI without inheriting a thousand dependencies and bad typing.
- Teams that need consistent config/env/CLI behaviors across multiple tools.
- Multi-command tools (
tool deploy,tool login,tool init). - “Hybrid” CLIs: accept flags, but fall back to interactive prompts for missing values.
- Tools that must run in CI (non-interactive) and on laptops (interactive).
- CLIs used both by humans and scripts (TTY pretty output vs JSON output).
-
Schema is the law. Everything derives from it: parsing, types, resolution, help, completions.
-
Progressive disclosure. A 5-line script stays 5 lines. Complexity scales by composition, not boilerplate.
-
Deterministic behavior. Resolution rules are explicit and consistent. No surprising implicit overrides.
-
Portable by default. Core avoids runtime-specific APIs; adapters handle the weird bits.
-
Ergonomic, not clever. When forced to choose, pick predictable DX over type-theory stunts.
import { arg, cli, command, flag } from '@kjanat/dreamcli';
const deploy = command('deploy')
.description('Deploy to an environment')
.arg('target', arg.string().describe('Deploy target'))
.flag('force', flag.boolean().default(false).alias('f'))
.flag('region', flag.enum(['us', 'eu', 'ap']).env('DEPLOY_REGION').config('deploy.region'))
.interactive(({ flags }) => ({
region: !flags.region && {
type: 'select',
message: 'Select region',
options: ['us', 'eu', 'ap'],
},
}))
.middleware(authMiddleware)
.action(async ({ args, flags, ctx, out }) => {
// args.target: string
// flags.force: boolean
// flags.region: "us" | "eu" | "ap" (guaranteed resolved)
out.log(`Deploying ${args.target} to ${flags.region}...`);
});
cli('mycli').version('1.0.0').command(deploy).run(); // reads argv/env from runtime adapter by defaultimport { CLIError, middleware } from '@kjanat/dreamcli';
const authMiddleware = middleware(async ({ next }) => {
const user = await getUser();
if (!user) {
throw new CLIError('Not authenticated', {
code: 'AUTH_REQUIRED',
suggest: 'Run `mycli login`',
exitCode: 2,
});
}
return next({ user }); // ctx.user becomes typed downstream
});const result = await deploy.run(['production', '--force'], {
env: { DEPLOY_REGION: 'eu' },
config: { deploy: { region: 'us' } },
stdin: 'tty', // or a stream
stdout: 'capture',
});
expect(result.exitCode).toBe(0);
expect(result.output.text).toContain('Deploying production to eu');-
Support command trees: root CLI → commands → subcommands.
-
Commands can define:
- positional args: required/optional, variadic, typed parsing
- flags/options: boolean/string/number/enum/array/custom
- descriptions, examples, aliases, hidden/deprecated flags
-
Handlers receive typed
args, typedflags, typedctx, typedout.
Acceptance: no separate type declaration required for typical usage; .action() types derive
entirely from .arg()/.flag() definitions.
Each flag may declare any of these sources:
- CLI:
--region eu - ENV:
.env("DEPLOY_REGION") - Config:
.config("deploy.region") - Prompt:
.interactive(...)or per-flag prompt definition - Default:
.default("us")
Resolution order (default): CLI → ENV → CONFIG → PROMPT → DEFAULT
Rules:
-
If a value is required and not resolved:
- in interactive mode: prompt (if prompt exists) else error
- in non-interactive mode (CI/piped): error with actionable hint
-
The action handler must never see “maybe unresolved” for declared flags unless explicitly opted into.
Acceptance: with a required region flag that has env/config/prompt/default, .action() always
sees a region value (or fails before handler).
-
.interactive(resolver)receives partially resolved values (after CLI/env/config). -
It returns a prompt schema for any missing values.
-
Prompt system must be:
- pluggable (custom renderer allowed)
- portable (works across runtimes)
- safe in non-interactive contexts (auto-disable; errors instead)
Acceptance: developers do not write “if missing then prompt” imperative spaghetti; prompts are declared as part of resolution.
Provide a base error type like CLIError with fields:
message(human)code(stable identifier)exitCode(defaults by category)suggest(one-liner hint)details(structured payload for JSON output)- optional
cause
Behavior:
-
All framework errors render nicely in TTY:
- short message
- suggestion(s)
- relevant help excerpt when appropriate
-
In
--jsonmode, errors emit machine-readable JSON to stdout/stderr (configurable).
Acceptance: parse/validation errors include “did you mean” suggestions and point to the exact flag/arg.
Handlers receive an out object instead of encouraging raw console.log. out supports:
log/info/warn/errorjson(value)(for scripting)table(rows, columns)(TTY pretty; JSON when piped or--json)spinner/progressthat auto-disables when not TTY
Defaults:
- TTY → pretty
- piped → minimal + stable
--json→ structured output
Acceptance: one code path yields correct output behavior across interactive use, piping, and CI.
-
Generate completion scripts for: bash, zsh, fish, PowerShell (initially at least bash + zsh).
-
Completion generation uses the command tree and flag definitions.
-
Provide commands like:
mycli completions generate --shell zshmycli completions install --shell zsh
-
Installation path logic is runtime-dependent → handled by adapter.
Acceptance: completions reflect the same schema as parsing/help, with no duplicate definition.
-
Config discovery (default):
- explicit
--config path - then standard locations (XDG-style where possible)
- plus local project config (optional)
- explicit
-
Support at least JSON initially; YAML/TOML via optional peer deps or plugin hooks.
Acceptance: .config("deploy.region") resolves reliably across runtimes, with documented search
order.
Core must not import runtime-specific modules directly.
Define a minimal adapter interface:
- argv/env access
- filesystem read (config, completions)
- path/home/config-dir resolution
- TTY detection
- spawning subprocesses (for advanced features)
- stdin/stdout streams
Adapters:
runtime/node.tsruntime/bun.tsruntime/deno.tsruntime/detect.tschooses default adapter at runtime
Acceptance: same user-facing API works without change on Node/Bun/Deno.
- TypeScript ergonomics: avoid type explosions that make TS slow or unreadable in editor hovers.
- Small dependency footprint: prefer zero-dep core; optional extras behind adapters/plugins.
- Tree-shakeable ESM: modern packaging with ESM-only exports and clear defaults.
- Deterministic tests: no reliance on wall-clock time, real filesystem, or actual TTY unless explicitly integrated.
dreamcli/
core/
schema/ # command/flag/arg builders
parse/ # argv tokenization + parsing
resolve/ # CLI/env/config/prompt/default pipeline
help/ # help text generator
completion/ # completion generator (scripts)
output/ # out channel + formatting
errors/ # structured errors
testkit/ # run() harness + capture utilities
runtime/
adapter.ts # interface
detect.ts # chooses node/bun/deno
node.ts
bun.ts
deno.ts
index.ts # re-exports + default runtime binding
-
Build schema (commands/flags/args)
-
Parse argv tokens → typed-ish raw values
-
Resolve each flag:
- cli → env → config → prompt → default
-
Run middleware chain → produce typed ctx
-
Execute action handler with fully resolved values
-
Render output/errors according to mode (TTY/pipe/--json)
-
Return structured result (even when invoked via CLI entry)
-
Publish to npm (Node/Bun) with:
- ESM build
exportsmap withtypes
-
Publish to JSR (Deno-first, also consumable elsewhere) for first-class TS consumption.
-
Keep runtime adapters internal but tree-shakeable.
MVP (v0.1): Schema builder, parsing, inference into .action(), auto-help, basic errors,
command.run() test harness. (done)
v0.2: Resolution chain (env/config/default), required handling, non-interactive behavior rules. (done)
v0.3: Interactive prompting integration (portable prompt engine + pluggable renderer). (done)
v0.4: Middleware + typed context, structured output channel (--json, TTY detection, table).
(done)
v0.5: Completions generation (bash/zsh), runtime detection, Bun adapter. (done)
v0.6: Config file discovery + loading (XDG search paths, --config flag, JSON loader, plugin
hook for YAML/TOML). Extend RuntimeAdapter with filesystem/path primitives (readFile, homedir,
configDir). Add flag.custom(parseFn) and .deprecated() modifier with help/parse warnings.
(done)
v0.7: Subcommand nesting (command trees: root > group > leaf, nested help, nested completion, nested dispatch in CLIBuilder). (done)
v0.8: Spinner/progress on Out (out.spinner(), out.progress(), auto-disable on !isTTY,
suppress in --json mode, testkit capture).
v0.9: Deno adapter + cross-runtime CI (Deno-specific APIs, permission handling, cross-runtime test matrix, adapter parity tests). JSR publishing.
v1.0: Stability guarantees, plugin lifecycle hooks (beforeParse, afterResolve,
beforeAction, afterAction), public API audit, JSDoc coverage, README, compatibility matrix
locked.
-
Type inference complexity (TS performance): Keep inference “local” (accumulate schema types incrementally), avoid mega-unions, provide escape hatches (
.cast<T>()or.unsafe()only when necessary). -
Prompt portability: Build a minimal prompt layer on standard streams; allow swapping in a fancier renderer as an optional integration.
-
Deno permissions & filesystem constraints: Make config/completions opt-in when permissions missing; fail with precise guidance.
-
ESM-only ecosystem friction: Provide clean ESM exports and document migration/interop patterns.
-
Completions installation differences across shells/OS: Treat install as “best effort”, with a
generatecommand always available and clear manual instructions.
DreamCLI wins by collapsing five separate toolchains into one coherent, typed system:
- schema-driven parsing and inference
- schema-driven prompts (no glue logic)
- schema-driven help and completions (no drift)
- schema-driven resolution chain (no bespoke config/env merging)
- test harness built in (no
process.argvrituals) - middleware-based composition with typed context (auth/telemetry/logging that doesn’t turn into spaghetti)
If you build exactly this—and keep the dependency footprint sane—you end up with the CLI equivalent of “the framework disappears and your app code is all that remains,” which is basically the highest compliment developer tooling can get.