@kjanat/dreamcli
v2.0.1
Published
Schema-first, fully typed TypeScript CLI framework
Maintainers
Readme
dreamcli
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.
Install
npm install @kjanat/dreamclibun add @kjanat/dreamclideno add jsr:@kjanat/dreamcli # or npm:@kjanat/dreamcliQuick start
Single command
import { 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();Multi-command CLI
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 seedWhy dreamcli
Most 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.
Features
Flag types
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().
Resolution chain
Each flag resolves through an ordered pipeline. Every step is opt-in:
CLI argv → environment variable → config file → interactive prompt → default valueRequired 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.
Interactive prompts
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?',
},
}));Derive typed context from resolved input
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.
Middleware with typed context
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().
Output channel
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
Shell completions
Generated from the command schema — always in sync:
import { generateCompletion } from '@kjanat/dreamcli';
generateCompletion(myCli.schema, 'bash');
generateCompletion(myCli.schema, 'zsh');Config file discovery
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));Structured errors
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.
Testing
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.
Package structure
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 support
| 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).
License
MIT © 2026 Kaj Kowalski
