@ontrails/cli
v0.2.3
Published
Framework-agnostic CLI command model for Trails. Import command derivation from `@ontrails/cli`; import the Commander runtime adapter from `@ontrails/commander`.
Readme
@ontrails/cli
Framework-agnostic CLI command model for Trails. Import command derivation from @ontrails/cli; import the Commander runtime adapter from @ontrails/commander.
Usage
import { trail, topo, Result } from '@ontrails/core';
import { surface } from '@ontrails/commander';
import { z } from 'zod';
const greet = trail('greet', {
input: z.object({ name: z.string().describe('Who to greet') }),
implementation: (input) => Result.ok(`Hello, ${input.name}!`),
});
const graph = topo('myapp', { greet });
await surface(graph);$ myapp greet --name World
Hello, World!
$ myapp greet --help
Usage: myapp greet [options]
Options:
--name <value> Who to greet
-h, --help display help for commandFor more control, build the commands yourself:
import { deriveCliCommands } from '@ontrails/cli';
import { toCommander } from '@ontrails/commander';
const commands = deriveCliCommands(graph);
if (commands.isErr()) throw commands.error;
const program = toCommander(commands.value, { name: 'myapp' });
program.parse();deriveCliCommands returns Result<CliCommand[], Error>. Use toCommander with commands.value on success, or write your own adapter. Invalid command models are rejected before adapter wiring, including duplicate CLI paths and executable parents that also declare positional args beneath child commands.
API
| Export | What it does |
| --- | --- |
| deriveCliCommands(graph) | Framework-agnostic command builder, returns Result<CliCommand[], Error> |
| validateCliCommands(commands) | Validate CliCommand[] shapes before wiring a CLI adapter |
| deriveFlags(schema) | Extract honest CLI flags from a Zod schema |
| normalizeCliArgv(commands, argv) | Normalize framework-owned CLI syntax before adapter parsing |
| output(data, mode) | Format output as JSON, JSONL, or text |
| deriveOutputMode(flags, topoName) | Derive output mode from flags and topo-derived env vars (<TOPO>_JSON, <TOPO>_JSONL) |
@ontrails/commander
| Export | What it does |
| --- | --- |
| surface(graph, options?) | One-liner: build commands, wire Commander, parse argv |
| createProgram(graph, options?) | Build a Commander program without parsing argv |
| toCommander(commands, options?) | Connect CliCommand[] to a Commander program |
See the API Reference for the full list.
Flag derivation
Flags come from the Zod schema automatically when the field shape can be represented truthfully on the command line. No manual flag definitions.
| Zod type | CLI flag | Notes |
| --- | --- | --- |
| z.string() | --name <value> | Required |
| z.boolean() | --verbose | Switch |
| z.enum(["a","b"]) | --format <value> | With choices |
| z.array(z.enum(["a","b"])) | --mode a b or --mode a --mode b | Bounded multiselect |
| z.array(z.string()) | --tag <values...> | Repeatable |
| z.optional(...) | --name [value] | Optional |
camelCase fields become --kebab-case flags. .describe() becomes help text.
Nested objects and arrays of objects are intentionally omitted from automatic flag derivation. The CLI prefers fewer flags over dishonest flags.
CLI adapters should pass user argv through normalizeCliArgv(commands, argv) before parsing. This gives bounded multiselects one framework-owned grammar: contiguous and repeated values are both accepted. The first matching token after a flag is its explicit value; after that first value, additional collection stops before known child routes or values outside the declared choices.
Enum flags can expose standalone boolean aliases when a surface wants pipe-friendly shortcuts without inventing parallel flags. The alias still normalizes to the canonical enum field before the trail input is validated:
deriveFlags(z.object({ format: z.enum(['summary', 'json']) }), {
format: { aliases: { json: 'json' } },
});An adapter such as @ontrails/commander will parse --json as if the caller had passed --format json.
Positional arguments
When a trail's input schema has exactly one required string field with no default, the CLI auto-promotes it to a positional argument instead of a flag:
const greet = trail('greet', {
input: z.object({ name: z.string().describe('Who to greet') }),
implementation: (input) => Result.ok(`Hello, ${input.name}!`),
});myapp greet World # positional
myapp greet --name World # flag alias is kept for backward compatibilityThe heuristic is intentionally conservative: multiple required strings stay as flags. To override, declare args on the trail:
const copy = trail('file.copy', {
input: z.object({ src: z.string(), dest: z.string() }),
args: ['src'],
implementation: (input) => Result.ok({ src: input.src, dest: input.dest }),
});args accepts string[] for explicit positional order, false to suppress auto-promotion entirely, or undefined (omit) for the heuristic.
myapp file copy ./readme.md --dest /tmp/readme.mdApp auto-discovery
When running from the workspace root without an explicit --module flag, the CLI automatically discovers your app entry point:
src/app.ts(single-app layout)apps/*/src/app.ts(monorepo convention)
If exactly one candidate is found, it is used automatically. If multiple candidates are found, the CLI lists them and asks you to choose with --module.
# Auto-discovers src/app.ts — no --module needed
myapp topo
# Explicit when multiple apps exist
myapp topo --module ./apps/api/src/app.tsUse findAppModuleCandidates(cwd) and findAppModule(cwd, explicit?) directly for programmatic access.
Structured input
For every non-empty object input schema, the CLI also exposes:
--input-json <json>--input <path|->
These channels supply the full input object before positional args and explicit flags are merged on top. Explicit CLI inputs always win on conflict, and the final merged object is still validated once by the trail schema.
myapp gist create \
--input-json '{"files":[{"filename":"README.md","content":"Hello"}]}'Subcommands
Dotted trail IDs derive to full ordered command paths:
entity.show->myapp entity showtopo.pin->myapp topo pintopo.pin.remove->myapp topo pin remove
Command-path nodes may be both executable and parents, so myapp topo and myapp topo pin can coexist naturally.
CliCommand[] validation rejects ambiguous parent/child shapes, so an executable parent cannot also declare positional args if child commands exist beneath that path.
Resource Resolution
Declared resources on each trail are resolved into the context before execution enters the implementation.
Filtering
await surface(graph, { include: ['entity.**'] });
await surface(graph, { exclude: ['dev.**'] });* matches one dotted segment and ** matches any depth. Trails declared with visibility: 'internal' stay hidden unless you include their exact trail ID intentionally.
Derived behavior
The CLI surface derives previously layer-shaped behavior directly from trail schemas:
- Trails whose output matches the pagination pattern (
items,hasMore,nextCursor) automatically get an--allflag that walks every page. - Trails with
since/untilinput fields automatically expand date shortcuts ("today","yesterday","7d","30d","this-week","this-month") into ISO 8601 strings before validation.
The legacy autoIterateLayer and dateShortcutsLayer exports were removed in TRL-475; these behaviors are now intrinsic to the CLI surface and require no wiring.
Installation
These installation examples target Trails 0.2.1 on the normal npm release line.
bun add --exact @ontrails/[email protected] @ontrails/[email protected]@ontrails/cli owns command derivation. @ontrails/commander owns Commander program materialization and parsing.
