@ocas/cli-kit
v0.4.0
Published
Schema-driven CLI framework with structured output
Readme
@ocas/cli-kit
Schema-driven CLI framework with structured output, dual-channel I/O, and Zod-validated data flow.
Extracted from the OCAS CLI tooling, @ocas/cli-kit lets you build CLIs where every command declares what it yields (progress/streaming) and what it returns (final result), with runtime schema validation and multi-format output out of the box.
Install
pnpm add @ocas/cli-kit zodNode.js ≥ 24 required. ESM only (
"type": "module"in yourpackage.json).
Quick Start
#!/usr/bin/env node
import { createCLI } from "@ocas/cli-kit";
import { z } from "zod";
const cli = createCLI({ name: "mycli", version: "1.0.0" });
cli
.command("greet")
.arg("name")
.flag("loud", { type: "boolean", default: false })
.returns(
z.object({ greeting: z.string() }),
"{{greeting}}",
)
.action(async (args, flags) => {
const msg = flags.loud
? `HELLO ${args.name.toUpperCase()}!`
: `Hello ${args.name}`;
return { greeting: msg };
});
cli.run().then((code) => process.exit(code));$ mycli greet World
type: "@mycli/greet"
value:
greeting: "Hello World"
$ mycli greet World --loud --format json
{"type":"@mycli/greet","value":{"greeting":"HELLO WORLD!"}}Core Concepts
Dual-Channel Output
Every command has two output channels:
| Channel | Stream | Purpose |
|---------|--------|---------|
| Yields | stderr | Streaming progress items (NDJSON envelopes) |
| Return | stdout | Final result (envelope or rendered text) |
This separation means you can pipe stdout to another tool while still seeing progress on stderr:
$ mycli search "query" | jq . # stderr progress visible, stdout is JSONYield + Return Pattern
Use an async generator to yield intermediate values and return a final result:
cli
.command("search")
.arg("query")
.yields(
z.object({ card: z.string(), score: z.number() }),
"{{card}}: {{score}}",
)
.returns(
z.object({ query: z.string(), count: z.number() }),
"Found {{count}} results for {{query}}",
)
.action(async function* (args) {
const results = await fetchResults(args.query);
for (const r of results) {
yield { card: r.card, score: r.score }; // → stderr
}
return { query: args.query, count: results.length }; // → stdout
});Each yield value is validated against the yields schema and emitted to stderr as an NDJSON envelope:
{"type":"@mycli/search/yield","value":{"card":"alpha","score":0.9}}The return value is validated against the returns schema and written to stdout.
Tip: If your action returns
undefined, cli-kit skips the final envelope — use this when your action handles its own output (e.g. writing rendered content directly to stdout).
Schema Validation
Both .yields() and .returns() accept a Zod schema. Every value flows through schema.parse() before output, ensuring your CLI only emits well-formed data:
.returns(
z.object({
hash: z.string().length(13),
size: z.number().int().nonnegative(),
}),
"{{hash}} ({{size}} bytes)",
)If validation fails, the error is caught and emitted as a structured error envelope (see Error Handling).
Output Envelopes
All JSON/YAML output uses a { type, value } envelope:
{
"type": "@mycli/search",
"value": { "query": "needle", "count": 3 }
}type— a schema identifier string (see Schema Naming)value— the Zod-validated payload
Schema Naming
Schema type names are auto-generated from your CLI name and command path:
| CLI name | Command path | Auto-generated type |
|----------|-------------|-------------------|
| mycli | search | @mycli/search |
| mycli | var set | @mycli/var/set |
| mycli | search (yield) | @mycli/search/yield |
Override with the name option:
.returns(schema, template, { name: "@custom/result" })
.yields(schema, template, { name: "@custom/progress" })Output Formats
Four formats are available via --format:
| Flag | Behavior |
|------|----------|
| --format yaml | YAML envelope (default) |
| --format json | Pretty-printed JSON envelope |
| --format text | Rendered via template string |
| --format html | Rendered via template string |
The --compact flag produces minified JSON (no whitespace).
The --json flag is shorthand for --format json --compact.
The --quiet flag suppresses all yield output on stderr.
Command Builder API
createCLI(options)
Create a new CLI instance.
import { createCLI } from "@ocas/cli-kit";
const cli = createCLI({
name: "mycli", // CLI name, used in schema naming and log paths
version: "1.0.0", // CLI version
homeDir: "~/.mycli", // Optional: base dir for log files (default: os.homedir())
plugins: [], // Optional: CliPlugin[] (see Plugins)
});Returns a CommandBuilder augmented with run() and help().
CommandBuilder
Fluent interface for defining commands. All methods return this for chaining.
.command(name) — Register a subcommand
cli.command("var").command("set") // defines `mycli var set`Commands can be nested arbitrarily deep. A command with children is a group and cannot be executed directly.
.describe(text) — Add a command description
cli.command("search").describe("Search the index").arg("query")Optional. The description appears in --help output for the command.
.arg(name) — Declare a positional argument
cli.command("get").arg("hash") // mycli get <hash>
cli.command("mv").arg("src").arg("dst") // mycli mv <src> <dst>Arguments are positional and required. Extra positional args are accessible via flags._positionals.
.flag(name, definition) — Declare a flag
cli.command("search")
.flag("limit", { type: "number", default: 10 })
.flag("verbose", { type: "boolean", default: false })
.flag("output", { type: "string" })Flag types: "string" | "number" | "boolean"
Short aliases — declare a single-char alias next to the long flag; help renders it as -s, --scene:
cli.command("start").flag("scene", { type: "string", alias: "s" });
// `mycli start -s lobby` === `mycli start --scene lobby`Boolean negation — any boolean flag can be set to false with a --no- prefix:
cli.command("start").flag("network", { type: "boolean", default: true });
// `mycli start --no-network` → flags.network === false--no-<unknown> or --no- on a non-boolean flag still raises Unknown option.
Built-in flags (always available):
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| --help / -h | boolean | false | Print command usage and exit 0 (intercepted before parsing) |
| --format | string | "yaml" | Output format: yaml, json, text, html |
| --compact | boolean | false | Minified JSON output |
| --json | boolean | false | Shorthand for --format json --compact |
| --quiet | boolean | false | Suppress yield output on stderr |
| --no-<flag> | — | — | Negate any boolean flag |
.yields(schema, template, options?) — Declare yield schema
.yields(
z.object({ step: z.string() }),
"step: {{step}}",
{ name: "@mycli/custom/yield" }, // optional override
)Required when your action uses yield. If the action yields but no .yields() was declared, the command fails at runtime.
.returns(schema, template, options?) — Declare return schema
.returns(
z.object({ ok: z.boolean() }),
"ok: {{ok}}",
{ name: "@mycli/custom/result" }, // optional override
)Required for every executable (leaf) command. Without .returns(), the command fails with "Executable command requires .returns(...)".
Per-command default format — pass defaultFormat to opt a command out of the global YAML default:
.returns(StatusSchema, "{{state}}", { defaultFormat: "text" })
// `mycli status` → plain text (template render)
// `mycli status --format yaml` → YAML envelope (explicit flag wins)
// `mycli status --json` → JSON envelope (--json always wins)Output-format precedence (highest first): --json → explicit --format → defaultFormat → yaml. Inside an action, flags.format is always the user's raw --format value (or undefined when omitted) — never the resolved wire format — so a command can treat --format html|text|tree as a domain argument independently of the output encoding.
.action(fn) — Define command logic
.action(async (args, flags, ctx) => {
// args: Record<string, string> — positional arguments
// flags: ParsedFlags — parsed flags + _positionals
// ctx: CliContext — error(), log, command name
return { ok: true };
})The action can be:
async function— returns a value (written to stdout)async function*— yields values (stderr) and returns a value (stdout)
cli.run(options?)
Execute the CLI. Returns a Promise<number> (exit code, 0 = success, 1 = error).
// Use process.argv (default)
const code = await cli.run();
// Custom argv and I/O (useful for testing)
const code = await cli.run({
argv: ["search", "needle", "--format", "json"],
stdout: { write: (s) => captured += s },
stderr: { write: (s) => captured += s },
});cli.help()
Returns a usage string listing standard flags and render flag availability.
CliContext
Every action receives a CliContext as its third parameter:
interface CliContext {
command: string; // e.g. "var set"
error: (message: string, code?: string) => never; // throw a structured error
log: {
debug: (tag: string, msg: string) => void;
info: (tag: string, msg: string) => void;
warn: (tag: string, msg: string) => void;
};
stdout: (text: string) => void; // direct write to stdout
stderr: (text: string) => void; // direct write to stderr
}ctx.error(message, code?)
Throws a structured error that becomes an @<cli>/error envelope on stderr:
.action(async (_args, _flags, ctx) => {
const node = await fetchNode();
if (!node) return ctx.error("Node not found", "E_NOT_FOUND");
return node;
})Produces on stderr:
{"type":"@mycli/error","value":{"message":"Node not found","code":"E_NOT_FOUND","command":"get"}}The process exits with code 1.
ctx.log
Structured JSONL logging to ~/.<cliName>/logs/<YYYY-MM-DD>.jsonl:
.action(async (_args, _flags, ctx) => {
ctx.log.info("ABCDEFGH", "starting operation");
// ...
return result;
})Log tags must be exactly 8 uppercase Crockford Base32 characters ([0-9A-HJKMNP-TV-Z]). Use assertValidLogTag(tag) to validate at import time.
Each log record:
{"ts":"2026-06-25T10:30:00.000Z","pid":12345,"level":"info","tag":"ABCDEFGH","msg":"starting operation"}ctx.stdout / ctx.stderr
Direct write channels to the process streams, independent of the file-based ctx.log. Use them for immediate human-facing diagnostics from long-running commands (servers, watchers) that need to surface output on the terminal:
.action(async (_args, _flags, ctx) => {
ctx.stderr("listening on :8080\n"); // straight to stderr
ctx.stdout("ready\n"); // straight to stdout
// ...
});ctx.log (structured JSONL → file) and ctx.stdout/ctx.stderr (raw text → console) are separate: audit logs and human diagnostics no longer compete for the same channel. In tests, these route to the stdout/stderr buffers passed to cli.run().
Templates
The template string in .yields() and .returns() uses simple mustache-style interpolation:
"{{property}}" → top-level property
"{{nested.deep.path}}" → dot-path lookupUsed when --format text or --format html is selected:
$ mycli greet World --format text
Hello WorldFor --format yaml and --format json, the template is ignored and the full { type, value } envelope is emitted.
Plugins
Plugins extend cli-kit with optional features.
ocasRenderPlugin(openStore)
Enables the -r / --render flag for inline content rendering:
import { createCLI, ocasRenderPlugin } from "@ocas/cli-kit";
const cli = createCLI({
name: "ocas",
version: "1.0.0",
plugins: [ocasRenderPlugin(() => myStore)],
});When the plugin is registered:
-r/--renderbecomes a valid flag- Your action can check
flags.render === trueto decide whether to produce rendered output - Without the plugin, passing
--renderproduces an"Unknown option"error
Custom Plugins
interface CliPlugin {
name: string;
enableRenderFlag?: boolean; // adds -r/--render to the parser
openStore?: () => unknown; // your store accessor
}Error Handling
cli-kit wraps all errors into structured envelopes:
| Scenario | Error envelope value |
|----------|----------------------|
| ctx.error(msg, code) | { message, code, command } |
| throw new Error(msg) | { message, command } |
| Unknown command | { message: "Unknown command: ...", code: "E_USAGE", command } |
| Missing .returns() | { message: "Executable command requires .returns(...)", ... } |
All errors:
- Write to stderr as NDJSON:
{"type":"@<cli>/error","value":{...}} - Return exit code 1
- Never crash the process with an unhandled exception
Testing
cli-kit is designed for easy testing with mock I/O:
import { describe, expect, test } from "vitest";
import { z } from "zod";
import { createCLI } from "@ocas/cli-kit";
function createBuffers() {
let stdout = "";
let stderr = "";
return {
out: {
stdout: { write: (text: string) => (stdout += text) },
stderr: { write: (text: string) => (stderr += text) },
},
read: () => ({ stdout, stderr }),
};
}
test("greet command returns correct output", async () => {
const cli = createCLI({ name: "mycli", version: "1.0.0" });
cli
.command("greet")
.arg("name")
.returns(z.object({ greeting: z.string() }), "{{greeting}}")
.action(async (args) => ({ greeting: `Hello ${args.name}` }));
const io = createBuffers();
const code = await cli.run({ argv: ["greet", "World", "--format", "json"], ...io.out });
expect(code).toBe(0);
expect(JSON.parse(io.read().stdout)).toEqual({
type: "@mycli/greet",
value: { greeting: "Hello World" },
});
});Subcommands
Build command hierarchies by chaining .command():
cli
.command("var")
.command("set")
.arg("name")
.arg("value")
.returns(z.object({ ok: z.boolean() }), "ok")
.action(async (args) => {
// args.name, args.value
return { ok: true };
});
cli
.command("var")
.command("get")
.arg("name")
.returns(z.object({ value: z.string() }), "{{value}}")
.action(async (args) => {
return { value: await lookup(args.name) };
});Running mycli var (without a subcommand) returns an error: "Command is not executable".
Advanced Patterns
Self-Handled Output
Return undefined from your action to skip cli-kit's envelope wrapping:
.action(async (_args, flags, ctx) => {
const rendered = await myRenderer();
process.stdout.write(`${rendered}\n`);
return undefined; // cli-kit won't wrap anything
})Multi-Value Flags (Tags)
The built-in tag flag supports repeat values — passing --tag a --tag b produces flags.tag = ["a", "b"]:
cli.command("list")
.flag("tag", { type: "string" })
.returns(z.unknown(), "{{value}}")
.action(async (_args, flags) => {
const tags = Array.isArray(flags.tag) ? flags.tag : flags.tag ? [flags.tag] : [];
// ...
});Extra Positional Arguments
Access all positional args (including those beyond declared .arg() count) via flags._positionals:
cli.command("tag")
.arg("target")
.returns(z.unknown(), "{{value}}")
.action(async (args, flags) => {
const target = args.target;
const extraTags = (flags._positionals as string[]).slice(1);
// ...
});API Reference
Exports
| Export | Type | Description |
|--------|------|-------------|
| createCLI | function | Create a new CLI instance |
| assertValidLogTag | function | Validate a log tag (8-char Crockford Base32) |
| ocasRenderPlugin | function | Create a render plugin |
| CliContext | type | Context passed to action functions |
| CliPlugin | type | Plugin interface |
| CommandAction | type | Action function signature |
| CommandBuilder | type | Fluent builder interface |
| CreateCliOptions | type | Options for createCLI |
| ParsedFlags | type | Parsed flags object |
| RunOptions | type | Options for cli.run() |
FlagDefinition
interface FlagDefinition {
type: "string" | "number" | "boolean";
default?: string | number | boolean;
alias?: string; // single-char short alias, e.g. "s" for --scene
}ParsedFlags
interface ParsedFlags extends Record<string, unknown> {
format?: "yaml" | "json" | "text" | "html"; // user's raw --format, undefined if omitted
compact: boolean;
quiet: boolean;
json: boolean;
render?: boolean; // only when render plugin is registered
_positionals: string[]; // all positional arguments
}
formatholds the user's raw--formatvalue (orundefined). The resolved output format (which honors--json,defaultFormat, and theyamlfallback) is applied internally when rendering the final envelope and is not written back intoflags.format.
License
MIT
