Opinionated Node CLI Toolkit
Define flags, args and commands separately, then compose them. Everything is immutable and fully typed.
import { arg, flag, jinju, scope } from 'jinju';
// Flags shared by every command created from the scope.
const app = scope({
flags: { verbose: flag.count({ alias: 'v', description: 'Increase verbosity' }) },
});
const files = arg.directory({ name: 'files', description: 'Path to the files' }).required();
const repeat = flag.boolean({ alias: 'r', description: 'Repeat the operation' });
const sync = app.command({
description: 'Sync files',
args: [files],
flags: {
repeat,
mode: flag.string({ description: 'Mode', choices: ['fast', 'safe'] }).default('safe'),
},
run: ({ args: [path], flags }) => {
// path: string, flags: { verbose: number; repeat: boolean; mode: 'fast' | 'safe' }
},
});
await jinju({
version: '1.0.0',
commands: { sync: { $: sync, repeat: app.command({ description: '…', run: () => {} }) } },
}).run(); // defaults to process.argv.slice(2)flag.string({choices?}), flag.number({min?, max?, integer?}), flag.url(), flag.email(), flag.directory({exists?}), flag.file({exists?}) and flag.custom(parse) take a value (--name value, --name=value, -n value, -nvalue). Paths resolve to absolute paths.
flag.boolean() (--x / --no-x, defaults to false) and flag.count() (-vvv → 3) are switches: they don't consume the next argument. A switch can be set explicitly with --x=<value>, using true/false, 1/0, yes/no or on/off (--no-x=<value> inverts it). The built-in --help and --version follow the same rules, so --help=false doesn't show help.
All flags share {description, alias?, hidden?, env?}. The object key is the flag name, converted from camelCase to kebab-case (dryRun → --dry-run).
env names an environment variable to read when the flag isn't given: flag.string({ description: 'API token', env: 'API_TOKEN' }).
- Priority: the command line wins, then the variable, then the default. A value from the variable satisfies
.required(). - Parsing: values are parsed like command-line values, so
flag.number({ env: 'PORT' })validates the number. Errors name the source:Invalid value 'abc' for --port (from $PORT): must be a number. - Switches:
flag.booleanacceptstrue/false,1/0,yes/noandon/off;flag.countaccepts a whole number (VERBOSE=2). - Empty values (
API_TOKEN=) count as unset. A.multiple()flag gets a single value from its variable. - Help shows the variable, e.g.
(env: API_TOKEN).
| Modifier | Effect |
|---|---|
.required(on = true) |
Must be provided. Clears any default. |
.multiple(on = true) |
Repeatable, collected into an array. Converts any existing default (v ↔ [v]). |
.default(value) |
Used when absent. Clears required. Must be a non-empty array when multiple. |
| Definition | InferFlagType |
|---|---|
flag.url(o) |
URL | undefined |
flag.url(o).multiple() |
URL[] | undefined |
flag.url(o).default(url) |
URL |
flag.url(o).required().multiple() |
URL[] |
flag.string({choices: ['a', 'b'], …}) |
'a' | 'b' | undefined |
Custom types: flag.custom((input, options) => value, 'name') returns a creator. Throw to reject input; parsers may be async. arg.custom accepts the same parser.
Any Standard Schema (zod, valibot, arktype, …) can be used as a parser with flag.schema(schema, name?) or arg.schema(schema, name?). Both return a creator, like custom:
import { command, flag } from 'jinju';
import { z } from 'zod';
const port = flag.schema(z.coerce.number().int().min(1), 'port');
const tags = flag.schema(z.enum(['alpha', 'beta']));
command({
flags: {
port: port({ description: 'Port' }).default(80),
tag: tags({ description: 'Tag' }).multiple(),
},
run: ({ flags }) => {}, // { port: number; tag: ('alpha' | 'beta')[] | undefined }
});- Types come from the schema's output.
- Values arrive as strings, so schemas for other types must coerce (
z.coerce.number(),z.stringbool()). A schema whose input can't be a string, such asz.number(),z.boolean()orz.date(), is a type error. Coercing schemas (inputunknown), enums and string schemas are accepted, whether written inline or declared beforehand. - Errors use the schema's issue messages, e.g.
Invalid value '0' for --port: Too small: expected number to be >=1. - No
undefinedoutputs. A schema that can outputundefined(such as.optional()) is a type error, becauseundefinedalready means "not provided". Use.default()on the flag instead. - No dependency: jinju has no dependency on any schema library or on
@standard-schema/spec. It ships a compatibleStandardSchemaV1type.
Args are a tuple (args: [src, dest]) and run receives a typed tuple. They use the same creators and modifiers as flags (except switches and alias) and are optional by default. At most one arg can be .multiple(). Args before it fill from the left, args after it fill from the right, and it takes the middle. For example, [src.multiple().required(), dest.required()] behaves like cp. When values are scarce, required args are filled first.
command({name?, description, aliases?, hidden?, flags?, args?, run}). A command's name comes from its key in the tree (camelCase → kebab-case) unless name is set. run may be async and may return an exit code. Throw a JinjuError from run to report an error cleanly.
scope({flags?, args?}) groups what several commands share. scope.command(config) creates a command that inherits the scope's flags and args, and scope.scope(config) nests a scope that adds more. command() is the same as a command from an empty scope.
const app = scope({ flags: { verbose } });
const project = app.scope({
args: [arg.string({ name: 'project', description: 'Project' }).required()],
});
const deploy = project.command({
description: 'Deploy',
args: [arg.string({ name: 'env', description: 'Environment' })],
run: ({ args: [project, env], flags: { verbose } }) => {},
});
// app -v deploy my-site staging- Flags merge with the command's own. If a command defines a flag with the same key, its own definition replaces the inherited one.
- Args go first:
runreceives[...scopeArgs, ...commandArgs], read after the command name. The one-.multiple()rule applies across the whole tuple. - Before the command name, any flag inherited by a command further down the tree is accepted (
app -v deploy …). If the command that matches doesn't declare it, that's an "Unknown flag" error. A flag name that takes a value in one scope but not in another must come after the command name.
A :name key in the command tree reads a value before the command name. The value goes into the commands' arg called name, which comes from a scope:
const project = scope({
args: [arg.string({ name: 'project', description: 'Project' }).required()],
});
const deploy = project.command({
description: 'Deploy',
args: [arg.string({ name: 'env', description: 'Environment' })],
run: ({ args: [project, env] }) => {}, // [string, string | undefined]
});
jinju({ commands: { list, ':project': { deploy } } });
// app my-site deploy staging-
Segment values are ordinary args: they're parsed and validated by the arg, and appear first in
args. Nested segments ({ ':project': { ':env': { logs } } }) fill the leading args in order. -
Static names win over a segment:
app listrunslist, not a project namedlist. -
Mistakes in the tree are compile-time errors on
commands, worded like theConfigErrorthatjinju()throws for them at runtime:$isn't a command (subcommands go under named keys next to it):'$' must be a command (at 'sync');- a level has more than one segment;
- a level's
$command takes its own positional args alongside a segment; - a command under a segment doesn't have the matching named arg at the right position, e.g.
Command '<project> x' must have an arg named 'project' at position 1 for segment ':project'; - the segment's arg is
.multiple().
The arg checks need literal names (as above); args named with a plain
stringare only checked at runtime. -
Help shows segments in the path (
app <project> deploy [<env>]). Error hints use the values that were actually typed (Run 'app my-site deploy --help').
jinju({name?, version?, description?, commands, strict?, help?, error?, env?, stdout?, stderr?, exit?})
- Nest objects for subcommands.
$is the command for that level. A:namekey is a dynamic segment (see below). - Routing takes the longest matching command path. A flag not inherited by any command below that point, or
--, ends the path. - Help lists every flag a command accepts (its own, inherited and built-in) in one table, sorted by name.
--help/-hand--versionare built in. UsepackageVersion(import.meta.url)to readversionfrom your CLI's nearestpackage.json.- Errors are collected and printed with a usage hint. Exit code
1. run()resolves to the exit code and setsprocess.exitCode. Override this withexit.- Flags'
envvariables are read fromprocess.env. Passenvto supply them yourself, e.g. in tests.
--help pages are rendered by a HelpTemplate. Pass an instance as help to customise them, either with options or by extending the class and overriding any method:
import { type HelpData, type HelpFlag, HelpTemplate } from 'jinju';
class MyHelp extends HelpTemplate {
override formatFlag(flag: HelpFlag) {
const [label, description] = super.formatFlag(flag);
return [label, flag.source === 'builtin' ? `${description} (built-in)` : description] as const;
}
override sections(data: HelpData) {
return [`${data.program.name} v${data.program.version}`, ...super.sections(data)];
}
}
jinju({ version: '1.0.0', help: new MyHelp({ sortFlags: false }), commands });| Method | Default |
|---|---|
render(data) |
Joins sections(data) with blank lines and adds a trailing newline. Override it to replace the layout entirely |
sections(data) |
[description, usage, commands, args, flags], with empty sections left out |
formatUsage, formatCommands, formatArgs, formatFlags |
Each renders one section from data |
commands(data), flags(data) |
Choose what to list: hidden entries are left out unless showHidden, and flags are sorted unless sortFlags: false |
formatCommand, formatArg, formatFlag |
One [left, right] table row each |
formatTags, formatValue, formatPath |
(required; default: 3), default values, <project> deploy |
formatTable(rows), formatSection(title, lines) |
Layout primitives |
HelpData holds the building blocks of a page; the template decides what to use and how. It makes no layout decisions: nothing is filtered or sorted.
| Field | Contents |
|---|---|
program |
name, version, description |
path |
The command path as { name, dynamic } parts. formatPath(path) gives <project> deploy |
description, aliases, runnable |
This level's description, its command's aliases, and whether it has a command to run |
usage |
Usage lines, e.g. app <project> deploy [flags] [<env>] |
commands |
Each subcommand: name, label, description, aliases, hidden, dynamic, full path, runnable, group (has subcommands) |
args |
Each arg in order: name, label (<env>), usage ([<env>]), source (command or scope), segment |
flags |
Each flag in definition order, hidden ones included: key, name, alias, kind (flag or switch), source (command, scope or builtin), hidden, label (-n, --dry-run <string>) |
Args and flags also carry description, type, required, multiple, hasDefault, the raw default, choices, and options. options is everything passed to the creator, including min, max, integer, exists and the options of custom parsers. The text is printed as-is, so include a trailing newline.
Error output (printed to stderr, with exit code 1) is rendered by an ErrorTemplate, customised the same way as help: pass an instance as error, with options or a subclass.
import { ErrorTemplate, type JinjuError, MissingRequiredError } from 'jinju';
class MyErrors extends ErrorTemplate {
override formatError(error: JinjuError) {
return error instanceof MissingRequiredError
? `✗ please provide ${error.label}`
: super.formatError(error);
}
}
jinju({ error: new MyErrors({ hint: false }), commands });ErrorData has the program, the errors (each a JinjuError subclass such as MissingRequiredError, UnknownFlagError or InvalidValueError, with its own details), the matched path, the words as typed, and the matched level's full help data, for showing usage or suggesting names.
| Method | Default |
|---|---|
render(data) |
Joins sections(data) with blank lines and adds a trailing newline |
sections(data) |
[errors, hint]; the hint is left out with hint: false |
formatErrors(data), formatError(error) |
One error: <message> line per error |
formatHint(data), formatCommand(data) |
Run 'app my-site deploy --help' for usage. |
© 2026 Mike Simmonds