Skip to content

Flags

Flags are the richest primitive in dreamcli. Each flag declaration configures parsing, type inference, resolution, help text, and shell completions.

Flag Types

String

ts
flagTypes.string;

String constraints

flag.string() accepts optional string constraints, either as an options object or via chained methods (they compose — a later chained call overrides an earlier value, including one set in the options object):

ts
flag.string({ nonEmpty: true, pattern: /^ghp_/ });
flag.string().nonEmpty().pattern(/^ghp_/); // equivalent
flag.string().minLength(3).maxLength(64);
OptionMeaningDefault
nonEmptyReject the empty string ''false
minLengthInclusive minimum length (UTF-16 units)none
maxLengthInclusive maximum length (UTF-16 units)none
patternRegExp the value must matchnone

Constraints are checked in order nonEmpty → minLength → maxLength → pattern and apply to every source — CLI, env, config, and prompt. On the first failure, CLI parsing throws INVALID_VALUE while env/config/prompt resolution reports CONSTRAINT_VIOLATED (both exit code 2). Anchor patterns with ^/$ for full-string matching; length bounds must be non-negative integers and minLength must not exceed maxLength (violations throw where the flag is declared). String constraints are surfaced in the exported JSON Schema as minLength / maxLength / pattern.

Number

ts
flagTypes.number;

Numeric constraints

flag.number() accepts optional numeric constraints, either as an options object or via chained methods (they compose — a later chained call overrides an earlier value, including one set in the options object):

ts
flag.number({ min: 0, max: 100, int: true });
flag.number().int().min(0).max(100); // equivalent
flag.number({ min: 0 }).max(100); // composes to { min: 0, max: 100 }
OptionMeaningDefault
minInclusive lower boundnone
maxInclusive upper boundnone
intRequire an integerfalse
finiteReject Infinity / -Infinitytrue

The resolved value type stays number — constraints are enforced at runtime and surfaced in the exported JSON Schema (minimum / maximum, and type: "integer" when int is set), not at the type level. min / max must be finite; passing Infinity / -Infinity / NaN as a bound throws when the flag is declared (omit the field for "no bound").

Finite by default

flag.number() now rejects Infinity and -Infinity (as well as NaN, which was always rejected). Pass finite: false (or .finite(false)) to accept non-finite values.

Constraints are checked in order finite → int → min → max, and apply to every source — CLI, env, config, and prompt. On the first failure, CLI parsing throws INVALID_VALUE while env/config/prompt resolution reports CONSTRAINT_VIOLATED (both exit code 2):

Input (flag.number({ int: true, min: 0, max: 100 }))Result
42accepted
0 / 100accepted (bounds are inclusive)
NaNrejected — invalid number
Infinityrejected — must be a finite number
3.7rejected — must be an integer
-1rejected — must be >= 0
101rejected — must be <= 100

The same options and methods are available on arg.number() for positional arguments.

Boolean

ts
flagTypes.boolean;

Enum

ts
flagTypes.enum;

Array

ts
flagTypes.array;

Array flags resolve to [] when unset — they never resolve to undefined.

The element builder describes the value shape only: kinds and their value constraints (flag.number({ int: true, min: 0 }), .nonEmpty(), .pattern(), enum values, custom parseFn). Flag-level settings — .alias(), .env(), .default(), .prompt(), .describe(), and friends — belong on the array itself and are a compile error in element position, since an element schema would silently ignore them:

ts
flag.array(flag.number({ min: 1 })).env('PORTS').describe('Ports'); // ✓
// flag.array(flag.number().env('PORTS'))  ✗ compile error — env the array, not the element

flag.path(), flag.count(), flag.keyValue(), and nested flag.array() are not element-eligible either.

Separators and deduplication

By default each occurrence of an array flag contributes exactly one element (--tag a --tag b). With .separator(), each occurrence is also split, so CSV-style input works alongside repetition — and each element is coerced individually, so an error names the offending element, not the whole token:

ts
flag.array(flag.enum(['us', 'eu', 'ap'])).separator(',').unique();
// --region us,eu --region eu  →  ['us', 'eu']
// --region us,mars            →  Invalid value 'mars' for flag --region. Allowed: us, eu, ap

.unique() deduplicates the final resolved array (first-seen order, Set semantics), regardless of which source produced the values. Env and config string values already split on ',' by default; .separator() changes that delimiter everywhere (e.g. MYAPP_REGION=us|eu with .separator('|')).

Custom

ts
flagTypes.custom;

URL

Parses into a URL; invalid URLs are rejected with the flag named in the error. Optionally restrict protocols:

ts
flagTypes.url;

Path

The value stays a string (help shows <path>), with optional filesystem checks that run after resolution through the runtime adapter — so CLI, env, and config values are validated identically:

ts
flag.path(); // any string
flag.path({ mustExist: true }); // rejects missing paths
flag.path({ type: 'directory' }); // must exist and be a directory

In process-free execution (.execute() / runCommand()), pass a stat function via run options to enable the checks; without one they are skipped.

Date

Accepts strict ISO-8601 (2026-07-10, 2026-07-10T14:30:00Z) and parses into a Date. Lenient Date.parse inputs ('0', 'March 5') and calendar-invalid dates (2026-02-31) are rejected. Offset-less datetimes (2026-07-10T14:30) are treated as UTC, not local time, so min / max acceptance never depends on the machine's timezone. Optional inclusive min / max bounds:

ts
flagTypes.date;

Duration

Accepts '30s', '5m', '1.5h', '250ms', '2d', compounds like '1h30m', or a bare millisecond count — and resolves to milliseconds:

ts
flag.duration().default(30_000);
// --timeout 45s   → 45000
// --timeout 1h30m → 5400000

Bytes

Accepts '512mb', '1.5gb', '64kb', '100b' or a bare byte count, and resolves to bytes. Units are binary (1kb = 1024) and case-insensitive:

ts
flag.bytes().default(10 * 1024 ** 2);
// --max-size 512kb → 524288

Count

Resolves to how many times the flag appears — the classic verbosity pattern. -vvv, -v -v -v, and --verbose --verbose --verbose all yield 3; absent yields 0. An explicit value (--verbose=2, env, config) sets the count directly. Count flags take no value token and are not promptable:

ts
flagTypes.count;

Key-Value

Repeated KEY=VALUE occurrences merge into a Record<string, string> (docker/kubectl --env style). The value is split at the first =, so --env A=b=c yields { A: 'b=c' }; later occurrences of the same key win. Unset resolves to {}. Env vars accept comma-separated pairs (A=1,B=2) — which means an env-sourced value cannot itself contain a comma; use the CLI or a config file (plain object) for those:

ts
flagTypes.env;

Array and key-value flags are the optional flag kinds that still resolve to a value when unset: arrays fall back to [], key-value flags to {}.

For the exact parser rules around repeated flags, short-flag stacking, -- separator handling, and --no-* spellings, see CLI Semantics.

Flag Names

The string you pass to .flag(name, …) is the flag's canonical name, and it is used in two places at once:

  • on the command line as --name;
  • as the key on the flags object inside your handler.

The name you declare is the name you read — handler keys are never case-converted. Single-word names are valid identifiers, so dot access works (flags.region). Hyphenated names are not valid identifiers, so read them with bracket access (flags['node-ipc']).

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

command('serve')
  .flag(
    'node-ipc',
    flag.boolean().describe('Use the Node IPC transport'),
  )
  .flag('dry-run', flag.boolean())
  .action(({ flags, out }) => {
    // Hyphenated names are read with bracket access — there is no `flags.nodeIpc`.
    if (flags['node-ipc']) out.log('ipc');
    if (flags['dry-run']) out.log('dry run');
  });

Reach for a hyphenated name when you want the conventional CLI spelling (--node-ipc, --dry-run); reach for a single-word or camelCase name (--nodeIpc) when ergonomic dot access matters more. Either way users can type both spellings — see Spelling parity below.

Spelling Parity (kebab ↔ camel)

On the command line, every flag name and long alias also accepts its kebab↔camel counterpart: a flag named dry-run matches both --dry-run and --dryRun, and a flag named dryRun matches both spellings too. The handler key is always the canonical name — parity is purely CLI-token sugar, and help, completions, and "did you mean" suggestions advertise only the declared spelling.

Two escape hatches:

  • Per pair, automatic — if a command explicitly defines both spellings as separate flags (do-this and doThis), parity is disabled for that pair and each spelling matches only its own flag.
  • Globally — pass flags: { caseParity: false } to the cli() factory (or to cmd.run() / .execute() options) to accept declared spellings only:
ts
import { cli, command, flag } from '@kjanat/dreamcli';

cli('mycli', { flags: { caseParity: false } })
  .command(
    command('build')
      .flag('dry-run', flag.boolean())
      .action(() => {}),
  );
// $ mycli build --dry-run   → ok
// $ mycli build --dryRun    → Unknown flag --dryRun

Parity is not case-insensitivity: --DRY-RUN never matches.

Aliases Are CLI Tokens, Not Handler Keys

.alias() adds an alternate spelling on the command line. It resolves back to the canonical name — it never becomes a second property on flags.

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

command('serve')
  // Accepts both `--skip-pass` and `--skipPass` on the CLI…
  .flag('skip-pass', flag.boolean().alias('skipPass'))
  .action(({ flags, out }) => {
    // …but the handler has exactly one key: the canonical name.
    if (flags['skip-pass']) out.log('skipping');
  });

So typing --skipPass still arrives as flags['skip-pass'].

Modifiers

Every flag type supports the same modifier chain:

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

flag
  .string()
  // short alias: -r
  .alias('r')
  // help text
  .describe('Target region')
  // default value (narrows type)
  .default('us')
  // must resolve or error
  .required()
  // resolve from env var
  .env('DEPLOY_REGION')
  // resolve from config file
  .config('deploy.region')
  // interactive fallback
  .prompt({ kind: 'input', message: 'Region?' })
  // deprecation warning
  .deprecated('Use --target instead')
  // inherit in subcommands
  .propagate();

Negatable Booleans

.negatable() gives a boolean flag a negated spelling that sets it to false — the classic --sandbox / --no-sandbox pair as one logical flag:

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

command('build')
  .flag(
    'sandbox',
    flag.boolean().default(true).negatable().describe('Run the build sandboxed'),
  )
  .action(({ flags, out }) => {
    if (!flags.sandbox) out.warn('sandbox disabled');
  });
// $ mycli build                → sandbox = true  (default)
// $ mycli build --sandbox      → sandbox = true
// $ mycli build --no-sandbox   → sandbox = false

Semantics:

  • Both spellings are the same flag: the last CLI occurrence wins across them, and they share the flag's duplicate policy.
  • The negated spelling is presence-only — --no-sandbox=true is an error. Explicit values stay on the positive spelling (--sandbox=false).
  • Help renders the pair as one entry: --[no-]sandbox.
  • Only CLI tokens are affected; env, config, prompt, and default resolution are unchanged.

Customize the spelling with alias (rendered as its own form) or keep it parseable but unadvertised with hidden:

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

flag.boolean().negatable({ alias: 'plain' }); // --color / --plain
flag.boolean().negatable({ hidden: true }); // --no-… parses, help shows only the positive form

The negated spelling participates in schema-time collision validation — defining no-sandbox as its own flag next to a negatable sandbox is an error.

Duplicate Policy

By default, repeating a singleton flag on the command line is last-write-wins (--region us --region eu'eu'). For flags that are configuration knobs rather than mergeable inputs, .duplicates() makes repeats explicit:

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

command('run')
  .flag('spawn', flag.enum(['session', 'same-dir', 'worktree']).duplicates('error'))
  .flag('capacity', flag.number().duplicates('first'))
  .action(() => {});
// $ mycli run --spawn session --spawn worktree
// #   → Error: Flag --spawn may only be specified once   (code: DUPLICATE_FLAG)
// $ mycli run --capacity 2 --capacity 9   → capacity = 2 (first wins)
  • 'last' — last occurrence wins (default, historic behavior).
  • 'first' — first occurrence wins; later occurrences still consume their value token, they just don't overwrite.
  • 'error' — a second occurrence throws a ParseError with code DUPLICATE_FLAG and details: { flag, count, values }.

Occurrences are counted per logical flag — aliases, negated spellings, and parity spellings all count toward the same flag. Only CLI tokens count: a flag set both on the CLI and via env/config follows the normal precedence chain and is never a duplicate. .duplicates() is unavailable on array, count, and keyValue flags, which inherently accumulate.

Resolution Chain

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

mermaid
flowchart LR
    A[CLI argv] --> B[Environment variable]
    B --> C[Config file]
    C --> D[Interactive prompt]
    D --> E[Default value]

The first source that provides a value wins. Required flags that don't resolve produce a structured error before the action handler runs.

Example

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

flag
  .enum(['us', 'eu', 'ap'])
  .env('DEPLOY_REGION')
  .config('deploy.region')
  .prompt({ kind: 'select', message: 'Which region?' })
  .default('us');

Resolution order:

  1. --region eu on the command line
  2. DEPLOY_REGION=eu in environment
  3. deploy.region: "eu" in config file
  4. Interactive select prompt (TTY only)
  5. Default value "us"

Required vs Optional

Optional

ts
requiredVsOptional.optional;

Defaulted

ts
requiredVsOptional.defaulted;

Required

ts
requiredVsOptional.required;

Boolean

ts
requiredVsOptional.boolean;

Custom Parsing

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

flag.custom((value) => {
  const url = new URL(String(value));
  if (url.protocol !== 'https:') {
    throw new Error('URL must use HTTPS');
  }
  return url;
});

The parse function receives the raw string value and returns the parsed type. Thrown errors become validation errors with the flag name in context.

Standard Schema Validators

flag.custom() also accepts any Standard Schema v1 validator, including Zod, Valibot, and ArkType schemas:

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

const port = flag.custom(z.coerce.number().int().min(1).max(65_535));
// inferred type: number | undefined

Standard Schema validation runs after source resolution, so the same sync or async validator handles CLI, env, config, prompt, and default values. Validation issues become CONSTRAINT_VIOLATED errors. When used as a flag.array() element, the validator runs once per resolved element.

Propagation

Flags marked with .propagate() are inherited by all subcommands:

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

const nested = command('start')
  .flag('verbose', flag.boolean().alias('v').propagate())
  .action(({ flags, out }) => {
    if (flags.verbose) {
      out.info('Verbose mode enabled');
    }
  });

cli('mycli').command(
  command('deploy')
    .flag('verbose', flag.boolean().alias('v').propagate())
    .command(nested),
);

What's Next?

Released under the MIT License.