Schema-first, fully typed TypeScript CLI framework. Zero runtime dependencies.
One flag declaration configures the entire resolution pipeline:
import {
cli,
command,
flag,
arg,
middleware,
CLIError,
} from '@kjanat/dreamcli';
const deploy = command('deploy')
.description('Deploy to an environment')
.arg('target', arg.string().describe('Deploy target'))
.flag(
'region',
flag
.enum(['us', 'eu', 'ap'])
.alias('r')
.env('DEPLOY_REGION')
.config('deploy.region')
.prompt({ kind: 'select', message: 'Which region?' })
.default('us')
.propagate(),
)
.action(({ args, flags, out }) => {
out.log(`Deploying ${args.target} to ${flags.region}`);
});By the time action runs, flags.region is "us" | "eu" | "ap" — not string | undefined.
The value is resolved through a documented chain: CLI → env → config → interactive prompt → default. Every step is opt-in. Every step preserves types.
npm install @kjanat/dreamclibun/deno
bun add @kjanat/dreamclideno add jsr:@kjanat/dreamcli # or npm:@kjanat/dreamcliimport { command, flag, arg } from '@kjanat/dreamcli';
const greet = command('greet')
.description('Greet someone')
.arg('name', arg.string().describe('Who to greet'))
.flag(
'loud',
flag
.boolean()
.alias('l')
.describe('Shout the greeting'),
)
.flag(
'times',
flag.number().default(1).describe('Repeat count'),
)
.action(({ args, flags, out }) => {
for (let i = 0; i < flags.times; i++) {
const msg = `Hello, ${args.name}!`;
out.log(flags.loud ? msg.toUpperCase() : msg);
}
});
greet.run();import {
cli,
command,
group,
flag,
arg,
} from '@kjanat/dreamcli';
const deploy = command('deploy')
.description('Deploy to an environment')
.arg('target', arg.string())
.flag('force', flag.boolean().alias('f'))
.flag(
'region',
flag.enum(['us', 'eu', 'ap']).env('DEPLOY_REGION'),
)
.action(({ args, flags, out }) => {
out.log(
`Deploying ${args.target} to ${flags.region ?? 'default'}`,
);
});
const login = command('login')
.description('Authenticate with the service')
.flag('token', flag.string().describe('Auth token'))
.action(({ flags, out }) => {
out.log(
flags.token
? 'Authenticated via token'
: 'Authenticated interactively',
);
});
// Nested command groups
const migrate = command('migrate')
.description('Run migrations')
.flag('steps', flag.number())
.action(({ flags, out }) => {
out.log(`migrating ${flags.steps ?? 'all'} steps`);
});
const seed = command('seed')
.description('Seed database')
.action(({ out }) => {
out.log('seeding');
});
const db = group('db')
.description('Database operations')
.command(migrate)
.command(seed);
cli('mycli')
.version('1.0.0')
.description('My awesome tool')
.command(deploy)
.command(login)
.command(db)
.run();
// mycli deploy production --force
// mycli login --token abc123
// mycli db migrate --steps 3
// mycli db seedMost TypeScript CLI frameworks treat the type system like decoration.
You define flags in one place, then use parsed values somewhere else as a loosely typed blob.
Env vars, config files, and interactive prompts live in separate universes.
Testing means hacking process.argv.
dreamcli collapses all of that into a single typed schema:
Approximate comparison of first-party, built-in support as documented by each project. Third-party plugins and custom glue can extend the other libraries.
| Capability | dreamcli | Commander | Yargs | Citty | CAC | Cleye |
|---|---|---|---|---|---|---|
| Type inference from definition | Full — flags, args, context | Manual .opts<T>() |
Good | Good | Basic | Good |
| Built-in value sources | CLI, env, config, prompt, default | CLI, defaults, env | CLI, env, config | CLI, defaults | CLI, defaults | CLI, defaults |
| Schema-driven prompts | Integrated | No | No | No | No | No |
| Middleware / hooks | Yes — typed middleware | Lifecycle hooks | Middleware | Plugins / hooks | Events | No |
| Built-in test harness with output capture | runCommand() + capture |
No | No | No | No | No |
| Shell completions from command definitions | Built-in (bash/zsh/fish/powershell) | No | Built-in (bash/zsh) | No | No | No |
| Structured output primitives | Built-in (--json, tables, spinners) |
DIY | DIY | DIY | DIY | DIY |
| Config file support | Built-in (XDG discovery, JSON) | DIY | Built-in (.config()) |
No | No | No |
The closest analog is what tRPC did to API routes — individual pieces existed, the insight was wiring them so types flow end-to-end.
flag.string(); // string | undefined
flag.number(); // number | undefined
flag.boolean(); // boolean (defaults to false)
flag.enum(['us', 'eu', 'ap']); // "us" | "eu" | "ap" | undefined
flag.array(flag.string()); // string[] | undefined
flag.custom((v) => new URL(v)); // URL | undefinedEvery flag supports: .default(), .required(), .alias(), .env(), .config(), .describe(),
.prompt(), .deprecated(), .propagate().
Each flag resolves through an ordered pipeline. Every step is opt-in:
CLI argv → environment variable → config file → interactive prompt → default value
Required flags that don't resolve produce a structured error before the action handler runs. In non-interactive contexts (CI, piped stdin), prompts are automatically skipped.
Four prompt types, declared per-flag or per-command:
// Per-flag
flag.string().prompt({ kind: 'input', message: 'Name?' });
flag
.boolean()
.prompt({ kind: 'confirm', message: 'Sure?' });
flag
.enum(['a', 'b'])
.prompt({ kind: 'select', message: 'Pick one' });
flag.array(flag.string()).prompt({
kind: 'multiselect',
message: 'Pick many',
choices: [{ value: 'a' }, { value: 'b' }],
});
// Per-command (conditional — receives partially resolved flags)
command('deploy')
.flag('region', flag.enum(['us', 'eu', 'ap']))
.interactive(({ flags }) => ({
region: !flags.region && {
kind: 'select',
message: 'Which region?',
},
}));import { CLIError } from '@kjanat/dreamcli';
command('deploy')
.flag('token', flag.string().env('AUTH_TOKEN'))
.derive(({ flags }) => {
if (!flags.token)
throw new CLIError('Not authenticated', {
code: 'AUTH_REQUIRED',
suggest: 'Run `mycli login`',
});
return { token: flags.token };
})
.action(({ ctx }) => {
ctx.token; // string — typed
});Use derive() when you need typed, command-scoped access to fully resolved flags and args before
the action handler runs.
import { middleware } from '@kjanat/dreamcli';
const timing = middleware<{ startTime: number }>(
async ({ next }) => {
const startTime = Date.now();
await next({ startTime });
},
);
const trace = middleware<{ traceId: string }>(
async ({ next }) =>
next({ traceId: crypto.randomUUID() }),
);
command('deploy')
.middleware(timing)
.middleware(trace)
.action(({ ctx }) => {
ctx.startTime; // number — typed
ctx.traceId; // string — typed
});Context accumulates through the middleware chain via type intersection. No manual interface merging.
Use middleware when you need wrapper behavior with next().
Handlers receive out instead of console. Adapts to context automatically:
cli('mycli')
// ... omitted for brevity
.action(({ out }) => {
out.log('Human-readable message');
out.json({ status: 'ok', count: 42 });
out.table(rows, [
{ key: 'name', header: 'Name' },
{ key: 'status', header: 'Status' },
]);
const spinner = out.spinner('Deploying...');
spinner.succeed('Done');
const progress = out.progress({
label: 'Uploading',
total: 100,
});
progress.update(50);
progress.done('Upload complete');
});- TTY → pretty formatting, spinners animate
- Piped → minimal stable output, spinners suppressed
--json→ structured JSON to stdout, everything else to stderr
Generated from the command schema — always in sync:
import { generateCompletion } from '@kjanat/dreamcli';
generateCompletion(myCli.schema, 'bash');
generateCompletion(myCli.schema, 'zsh');command('deploy').flag(
'region',
flag.enum(['us', 'eu']).config('deploy.region'),
);Searches XDG-standard paths automatically. JSON built-in, plugin hook for YAML/TOML:
import { configFormat } from '@kjanat/dreamcli';
import { parse as parseYAML } from 'yaml';
cli('mycli')
.config('mycli')
.configLoader(configFormat(['yaml', 'yml'], parseYAML));throw new CLIError('Deployment failed', {
code: 'DEPLOY_FAILED',
exitCode: 1,
suggest: 'Check your credentials with `mycli login`',
details: { target, region },
});Parse and validation errors include "did you mean?" suggestions.
In --json mode, errors serialize to machine-readable JSON.
dreamcli's test harness runs commands in-process with full control over inputs and outputs. No
subprocesses, no process.argv mutation, no mocking.
import { arg, command, flag } from '@kjanat/dreamcli';
import {
runCommand,
createTestPrompter,
PROMPT_CANCEL,
} from '@kjanat/dreamcli/testkit';
const greet = command('greet')
.arg('name', arg.string())
.flag('loud', flag.boolean())
.action(({ args, flags, out }) => {
const message = `Hello, ${args.name}!`;
out.log(flags.loud ? message.toUpperCase() : message);
});
const deploy = command('deploy')
.arg('target', arg.string())
.flag(
'region',
flag
.enum(['us', 'eu', 'ap'])
.env('DEPLOY_REGION')
.config('deploy.region')
.required()
.prompt({ kind: 'select', message: 'Which region?' }),
)
.action(({ args, flags, out }) => {
out.log(`Deploying ${args.target} to ${flags.region}`);
});
const build = command('build').action(({ out }) => {
const spinner = out.spinner('Building');
spinner.succeed('Done');
});
// Basic execution
const basic = await runCommand(greet, ['Alice', '--loud']);
expect(basic.exitCode).toBe(0);
expect(basic.stdout).toEqual(['HELLO, ALICE!\n']);
expect(basic.stderr).toEqual([]);
expect(basic.error).toBeUndefined();
// Resolve from environment
const fromEnv = await runCommand(deploy, ['production'], {
env: { DEPLOY_REGION: 'eu' },
});
expect(fromEnv.stdout).toEqual([
'Deploying production to eu\n',
]);
// Resolve from config
const fromConfig = await runCommand(
deploy,
['production'],
{
config: { deploy: { region: 'us' } },
},
);
expect(fromConfig.stdout).toEqual([
'Deploying production to us\n',
]);
// Resolve from prompt answers
const fromPrompt = await runCommand(
deploy,
['production'],
{
answers: ['ap'],
},
);
expect(fromPrompt.stdout).toEqual([
'Deploying production to ap\n',
]);
// Simulate prompt cancellation
const cancelled = await runCommand(deploy, ['production'], {
prompter: createTestPrompter([PROMPT_CANCEL]),
});
expect(cancelled.exitCode).not.toBe(0);
// Activity events (spinners, progress)
const activity = await runCommand(build, []);
expect(activity.activity).toContainEqual(
expect.objectContaining({ type: 'spinner:start' }),
);RunOptions accepts: env, config, stdinData, answers, prompter, help, jsonMode,
verbosity, and isTTY. Every dimension of command behavior is controllable from tests.
Three subpath exports, each with a focused API surface:
| Import | Purpose |
|---|---|
@kjanat/dreamcli |
Schema builders, CLI runner, output, parsing, resolution, errors |
@kjanat/dreamcli/testkit |
runCommand(), createCaptureOutput(), createTestPrompter(), createTestAdapter() |
@kjanat/dreamcli/runtime |
createAdapter(), RuntimeAdapter, runtime detection, platform adapters |
ESM-only. Source included in package (src/).
| Runtime | Status |
|---|---|
| Node.js >= 22.22.2 | Supported |
| Bun >= 1.3.11 | Supported |
| Deno >= 2.6.0 | Supported (JSR: @kjanat/dreamcli) |
Runtime detection is automatic.
The core framework never imports platform-specific APIs directly — a thin RuntimeAdapter interface
handles the divergent edges (argv, env, filesystem, TTY detection, exit behavior).
MIT © 2026 Kaj Kowalski