Skip to content

Standalone Flag Evaluation

readFlags() takes a record of flag builders, evaluates it, and returns the resolved values. There is no command, no dispatch, no output channel, no help text, and no process exit. The call returns typed values or throws.

Build Scripts

A build script wants a few typed options and one direct action. Wrapping that in a cli() program costs a command, an action handler, and an output channel for behavior the script never uses, and calling parse() and resolve() by hand returns Record<string, unknown> and drops the types the builders carry. readFlags() is the flag layer on its own:

ts
// build.ts
import { flag, readFlags } from '@kjanat/dreamcli';

const options = await readFlags({
  watch: flag.boolean().alias('w').env('WATCH'),
  minify: flag.boolean().env('MINIFY').default(true),
  target: flag
    .enum(['node', 'browser'])
    .env('TARGET')
    .default('node'),
});

options.target;

Each of these invocations reaches the same three values:

bash
WATCH=true bun build.ts
bun build.ts --watch
bun build.ts -w

The Record API

The object key is the --name spelling and the key on the returned record. The result is typed by InferFlags, the operator behind flags inside .action(), so kinds, enum literals, and presence rules carry over unchanged:

ts
const values = await readFlags({
  out: flag.string().required(),
  tag: flag.string(),
  verbose: flag.count().alias('v'),
});

values.tag;

out is string, tag is string | undefined, and verbose is number.

Evaluation is one pass over argv. The record is the complete flag surface, which is what makes unknown input detectable and what lets collisions between canonical names, aliases, and negated spellings be rejected. A per-flag reader would rescan argv and could not see either.

Semantics Come From The Command Path

readFlags() compiles the record into a command schema and runs the same parser, coercion, resolver, and validation a command runs. Aliases, --flag=value, short-flag clustering, negated spellings declared with .negatable(), duplicate policy, kebab and camel spelling parity, unknown-flag rejection with suggestions, string and number constraints, Standard Schema validators, and flag.path() checks all behave as they do inside .action().

Collections behave the same way too. flag.array() and flag.keyValue() aggregate across occurrences and sources, .split() gives each source its own decoding, .separator() sets the CLI delimiter alone, .unique() and .duplicateKeys() apply to the finished value, and a - occurrence splices the stdin buffer into the position it holds:

ts
const values = await readFlags(
  { tag: flag.array(flag.string()).stdin() },
  { argv: ['--tag', 'before', '--tag', '-'], stdinData: 'a\nb\n' },
);

values.tag; // ['before', 'a', 'b']

An array flag whose only source is an env var splits it on ',' by default, so { TAGS: 'x,y' } reaches the record as ['x', 'y'].

CLI Semantics is the rule set, and it applies here as written.

Precedence

CLI argv, then piped stdin, then environment variable, then config, then interactive prompt, then default. Each stage is opt-in per flag and the first source that supplies a value wins.

stdin behaves exactly as it does inside a command. A .stdin() flag reads the stream on --flag -, which stays CLI-sourced, and on an absent flag, which lands on the stdin stage ahead of env. The stream is read at most once per call, only through the adapter, and only when a declared binding would fire, so a record whose when: 'dash' flag was never dashed never touches it. Pass stdinData to supply the bytes yourself, including null for nothing piped.

Two stages need something from the caller. config is a plain object passed in; nothing is read from disk. prompter is a prompt engine passed in; none is built. A .prompt() flag with no prompter falls through to its default, on a TTY as well as off one.

ts
const values = await readFlags(
  { body: flag.string().stdin().env('BODY').default('none') },
  { argv: [], stdinData: 'piped\n', env: { BODY: 'env' } },
);

values.body; // 'piped\n'

Explicit Injection

Supplying argv and env keeps the call away from the host, which is what a test wants:

ts
const values = await readFlags(
  { watch: flag.boolean().alias('w').env('WATCH') },
  { argv: [], env: { WATCH: 'true' } },
);

values.watch; // true

argv is user arguments only, without the binary and script entries.

A test adapter covers the whole runtime surface in one object, including the argv slicing a real process goes through:

ts
import { flag, readFlags } from '@kjanat/dreamcli';
import { createTestAdapter } from '@kjanat/dreamcli/testkit';

const adapter = createTestAdapter({
  argv: ['node', 'build.ts', '--target', 'browser'],
  env: { MINIFY: 'false' },
});

const options = await readFlags(
  {
    minify: flag.boolean().env('MINIFY').default(true),
    target: flag
      .enum(['node', 'browser'])
      .env('TARGET')
      .default('node'),
  },
  { adapter },
);

expect(options).toEqual({
  minify: false,
  target: 'browser',
});

Adapter Defaults

FactSource when the caller omits it
argvthe adapter's argv past its binary and script entries
envthe adapter's environment snapshot
stdinDatathe adapter's readStdin(), called only when a .stdin() binding would fire
stat and mkdirthe adapter's filesystem primitives, used by flag.path() checks
confignothing, so the config stage is skipped
prompternothing, so the prompt stage is skipped

The adapter is built the first time a fact the caller left out is needed, so a call given argv and env builds none at all unless a .stdin() binding selects the stream or a flag.path() check reaches for stat or mkdir. Pass adapter to pin the runtime instead of detecting one. createAdapter() detects Node, Bun, and Deno, so the defaults work on all three.

Deprecation Notices

.deprecated() produces one notice per flag that actually sourced a value, in resolution order. Each reaches onDeprecation as a DeprecationWarning:

ts
await readFlags(
  { minify: flag.boolean().deprecated('use --optimize') },
  {
    argv: ['--minify'],
    env: {},
    onDeprecation: (warning) => {
      console.warn(`--${warning.name}: ${warning.message}`);
    },
  },
);

A command prints these on its warning stream. There is no output channel here, so a call without onDeprecation drops them.

Errors

Parse failures throw ParseError and resolution and constraint failures throw ValidationError. Two kinds of definitions record throw CLIError before argv is read. A record that collides on a name, an alias, or a negated spelling throws FLAG_NAME_COLLISION. A record whose keys cannot all be read throws INVALID_SCHEMA: the definition key __proto__, or a replaced prototype, which covers an object-literal __proto__ key and Object.create(base). Object.create(null) is fine. Nothing is written anywhere and adapter.exit is never called, so the script owns what happens next:

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

try {
  const options = await readFlags({
    target: flag.enum(['node', 'browser']),
  });
  console.log(options.target);
} catch (error) {
  if (isCLIError(error)) {
    console.error(error.message);
    process.exit(error.exitCode);
  }
  throw error;
}

Non-Goals

No positional arguments. A positional token throws UNEXPECTED_POSITIONAL. This API covers flags.

No config discovery. Standalone flag reading has no application name and no discovery policy to guess from, so config is whatever the caller passes.

No root built-in flags. --help, --json, and --quiet are neither reserved nor answered, so a record may declare any of them as an ordinary flag. Against a record that does not declare it, --help is an unknown flag.

No synchronous variant. Prompts, Standard Schema validators, and filesystem checks are asynchronous. A synchronous variant would either carry different semantics or reject part of the flag DSL, and either one means a second implementation of what this reuses.

No dispatch, actions, middleware, output, help, or completions. Those belong to a CLI. Reach for cli() when the script grows into one.

Released under the MIT License.