npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 zod

Node.js ≥ 24 required. ESM only ("type": "module" in your package.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 JSON

Yield + 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 --formatdefaultFormatyaml. 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 lookup

Used when --format text or --format html is selected:

$ mycli greet World --format text
Hello World

For --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 / --render becomes a valid flag
  • Your action can check flags.render === true to decide whether to produce rendered output
  • Without the plugin, passing --render produces 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
}

format holds the user's raw --format value (or undefined). The resolved output format (which honors --json, defaultFormat, and the yaml fallback) is applied internally when rendering the final envelope and is not written back into flags.format.

License

MIT