# dreamcli

[![NPM](https://img.shields.io/npm/v/@kjanat/dreamcli?logo=npm&labelColor=CB3837&color=black)][npm]
[![JSR](https://img.shields.io/jsr/v/@kjanat/dreamcli?logoColor=083344&logo=jsr&logoSize=auto&label=&labelColor=f7df1e&color=black)][jsr]
[![Socket](https://badge.socket.dev/npm/package/@kjanat/dreamcli)][socket]

Schema-first, fully typed TypeScript CLI framework. Zero runtime dependencies.

One flag declaration configures the entire resolution pipeline:

```ts
import { cli, command, flag, arg } 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}`);
  });

cli('deploy').default(deploy).run();
```

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

```bash
npm install @kjanat/dreamcli
```

<details><summary>bun/deno</summary>

```bash
bun add @kjanat/dreamcli
```

```bash
deno add jsr:@kjanat/dreamcli  # or npm:@kjanat/dreamcli
```

</details>

## Quick start

### Single command

```ts
import { cli, 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 }) => {
    const repeatCount = Number.isFinite(flags.times)
      ? Math.max(0, Math.min(100, Math.floor(flags.times)))
      : 1;

    for (let i = 0; i < repeatCount; i++) {
      const msg = `Hello, ${args.name}!`;
      out.log(flags.loud ? msg.toUpperCase() : msg);
    }
  });

cli('greet').default(greet).run();
```

### Multi-command CLI

<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://github.com/user-attachments/assets/1ef7f20c-9ffb-4cb6-a6bc-fb562895aa6f">
  <source media="(prefers-color-scheme: light)" srcset="https://github.com/user-attachments/assets/1ee5f38d-cad8-47ec-94b2-c826eabab76b">
  <img alt="DreamCLI migration progress demo" width="700" src="https://github.com/user-attachments/assets/1ee5f38d-cad8-47ec-94b2-c826eabab76b">
</picture>

```ts
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(async ({ flags, meta, out }) => {
    const steps = flags.steps ?? 3;

    out.log(`${meta.name} ${meta.version ?? ''}`.trim());
    out.log(`Command: ${meta.command}`);

    const spinner = out.spinner('Preparing migrations');
    await new Promise((resolve) =>
      setTimeout(resolve, 1000),
    );
    spinner.succeed(`Ready to run ${steps} steps`);

    const progress = out.progress({
      label: 'Migrating',
      total: steps,
    });

    for (let step = 1; step <= steps; step++) {
      await new Promise((resolve) =>
        setTimeout(resolve, 700),
      );
      progress.update(step);
    }

    progress.done(`Migrated ${steps} 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 seed
```

## Why 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 (project/user/system 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

```ts
import { flag } from '@kjanat/dreamcli';

flag.string({ nonEmpty: true }); // string | undefined, with constraints
flag.number({ int: true, min: 1 }); // number | undefined, with constraints
flag.boolean().negatable(); // boolean; accepts --no-<name>
flag.enum(['us', 'eu', 'ap']); // "us" | "eu" | "ap" | undefined
flag.array(flag.string()).separator(',').unique(); // string[] | undefined
flag.url({ protocols: ['https'] }); // URL | undefined
flag.path({ type: 'directory', create: true }); // string | undefined, mkdir when missing
flag.date(); // Date | undefined, strict ISO-8601
flag.duration(); // number (ms) | undefined — '1h30m', '250ms'
flag.bytes(); // number | undefined — '512mb', '1.5gb'
flag.count(); // number — -vvv → 3
flag.keyValue(); // Record<string, string> — repeated KEY=VALUE
flag.custom(zodSchema); // any Standard Schema v1 validator, inferred output
```

Every flag supports: `.default()`, `.required()`, `.alias()`, `.env()`, `.config()`, `.describe()`,
`.prompt()`, `.deprecated()`, `.propagate()`, `.duplicates()`, plus constraint chains
(`.int()`, `.min()`, `.nonEmpty()`, `.pattern()`, …) on their matching kinds.

### Resolution chain

Each flag resolves through an ordered pipeline. Every step is opt-in:

```text
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.

### Interactive prompts

Four prompt types, declared per-flag or per-command:

```ts
import { command, flag } from '@kjanat/dreamcli';

// 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

```ts
import { CLIError, command, flag } 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

```ts
import { command, 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:

```ts
import { cli, command } from '@kjanat/dreamcli';

const rows = [
  { name: 'api', status: 'ok' },
  { name: 'worker', status: 'deploying' },
];

const status = command('status').action(({ out }) => {
  out.log('Human-readable message');
  out.status('Progress note on stderr, silenced by --quiet');
  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');

  out.setExitCode(1); // report-and-continue failures, no throw needed
});

cli('mycli').default(status).run();
```

- TTY → pretty formatting, colors (`out.color`), spinners animate
- Piped → minimal stable output, identity colors, spinners suppressed
- `--json` → structured JSON to stdout, everything else to stderr
- `--quiet` → `status`/`info` suppressed, warnings and errors kept

### Shell completions

Generated from the command schema — always in sync:

```ts
import { cli, command } from '@kjanat/dreamcli';

cli('mycli').command(command('deploy')).completions().run();

// mycli completions bash
// mycli completions zsh

// Or as an eager root flag with shell auto-detection:
cli('mycli').command(command('deploy')).completions({ as: 'flag' }).run();

// mycli --completions
```

For programmatic integrations, call the generator directly:

```ts
import {
  cli,
  command,
  generateCompletion,
} from '@kjanat/dreamcli';

const myCli = cli('mycli').command(command('deploy'));

generateCompletion(myCli.schema, 'bash');
generateCompletion(myCli.schema, 'zsh');
```

### Config file discovery

```ts
import { command, flag } from '@kjanat/dreamcli';

command('deploy').flag(
  'region',
  flag.enum(['us', 'eu']).config('deploy.region'),
);
```

Searches XDG-standard paths automatically. JSON built-in, plugin hook for YAML/TOML:

```ts
import { cli, configFormat } from '@kjanat/dreamcli';
import { parse as parseYAML } from 'yaml';

cli('mycli')
  .config('mycli')
  .configLoader(configFormat(['yaml', 'yml'], parseYAML));
```

### Structured errors

```ts
import { CLIError } from '@kjanat/dreamcli';

const target = 'production';
const region = 'us';

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.

```ts
import { expect } from 'vitest';
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

The code lives behind three focused subpath exports:

| 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              |

A fourth export, `@kjanat/dreamcli/schema`, ships the bundled JSON Schema (`dreamcli.schema.json`)
for editor and config validation.

ESM-only. Source included in package (`src/`).

## Runtime support

| Runtime            | Status                             |
| ------------------ | ---------------------------------- |
| Node.js >= 22.22.2 | Supported                          |
| Bun >= 1.3         | 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][LICENSE] © 2026 Kaj Kowalski

[LICENSE]: https://github.com/kjanat/dreamcli/blob/master/LICENSE
[npm]: https://npm.im/@kjanat/dreamcli
[jsr]: https://jsr.io/@kjanat/dreamcli
[socket]: https://socket.dev/npm/package/@kjanat/dreamcli
