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

clinkr

v0.4.3

Published

A TypeScript library for building CLIs in the age of agents.

Readme

Clinkr

clinkr is a TypeScript library for building CLIs in the age of agents.

Define schema-backed operations in a filesystem-based layout, express each operation as typed request/response objects, and the library projects these operations as CLI commands. Each command has an out-of-box JSON interface and a projection to a human-friendly CLI interface. The developer can fully customize these projections.

Install

npm install clinkr zod

zod is a peer dependency.

TODO: Document supported bundler and deployment layouts.

Requirements

  • Node.js 24.12 or newer
  • An ESM TypeScript project
  • Zod 4.4 or newer

Define a command

A command definition owns request and response schemas, an asynchronous handler, and a human-output renderer. The handler receives (context, request): the execution context described in Context and testability, then the validated request. The context carries the host's cancellation signal, UI capabilities, and the application-defined app context, which can hold a working directory, API clients, or a clock. The handler returns a result, { value } for exit 0 or { value, exitCode: 1 } for exit 1, and throws an ApplicationError when a known cause stops it. The renderer receives the validated response and a render context holding the same app. The schemas infer the handler's request and result types and the renderer's validated response type.

This example is the run operation in examples/reference-cli. The example compiles:

import { ApplicationError, type CommandBuilder } from "clinkr";
import { z } from "zod";

import type { ReferenceAppContext } from "../../app-context.js";

export function command(cmd: CommandBuilder<ReferenceAppContext>) {
  const subjectSchema = z
    .string()
    .regex(/^(?:issue|pull-request):[1-9]\d*$/, "Expected issue:<number> or pull-request:<number>");

  return cmd.define({
    requestSchema: z.object({
      taskId: cmd.cliPositional(z.string().min(1)),
      subject: subjectSchema,
    }),
    responseSchema: z.object({
      taskId: z.string(),
      subject: subjectSchema,
      state: z.literal("queued"),
    }),
    handler: async ({ app }, { taskId, subject }) => {
      if ((await app.tasks.get(taskId)) === undefined)
        throw new ApplicationError({
          code: "TASK_NOT_FOUND",
          description: "Task not found",
          context: `No task ${taskId}; list shows the available tasks`,
        });
      return { value: { taskId, subject, state: "queued" as const } };
    },
    render: async ({ taskId, subject }) => `Queued task ${taskId} for ${subject}.`,
  });
}

The handler reads tasks from the host's application context. run assumes the task exists, so a calling script would not branch on a miss: it reports an unknown task as an application error at exit 2. Compare the reference show command, which reports a miss at exit 1 (see Choose an exit status).

The CLI exposes this definition with a schema-derived synopsis (the help annotations below give subject a semantic value label):

run <task-id> --subject <subject-ref>

Set up the CLI application

Create a bin entry point that loads the application and runs it with runNodeCli from clinkr/node, which wires it to the Node process (see Run the CLI in Node):

import { createApplication } from "clinkr";
import { runNodeCli } from "clinkr/node";

const application = await createApplication("my-cli", import.meta.dirname);

process.exitCode = await runNodeCli(application);

createApplication discovers built-in commands under commands relative to the supplied directory. Pass { commandsDirectory: "other-commands" } as the third argument to change that directory. Pass description in the same options to print prose under Usage: in root help; it must be a string that is nonempty after trimming, may span lines, and is exposed as application.description (absent when not supplied). Pass version to give the application a root --version. Selection is always asynchronous: await application.select(argv) returns a selection with full command paths. application.commands and group selections' available are immutable shallow listings (kind, name, metadata), not recursive trees. They include hidden nodes, which carry metadata.hidden: true.

runCli selects and loads the requested command, projects argv, executes the handler, validates and renders the response, writes one framed result or diagnostic, and returns a CliStatus. It sends command output, at status 0 or 1, to the supplied stdout writer and diagnostics to the supplied stderr writer. Both writers use the structural write(text) contract, so Node process streams work directly.

The application owns its name and command behavior. The host owns process state: it supplies argv and writers, and the bin assigns the returned status to process.exitCode. runCli does not read or mutate process globals; runNodeCli is the host that does, and an embedded host calls runCli with its own writers.

Group help and wrapping

Clinkr owns the plain-text help layout; command metadata owns the wording. Group help lists one row per child: the child's name, followed by any aliases (list, ls), then its summary when it has one, otherwise its description. Nested group help prints the group's full description above the listing, and root help prints the application's description in the same place:

Usage: my-cli image <command>

Image operations, including local builds and registry pushes.

Commands:
  build  Build an image.
  push   Push an image to the configured registry.

Root help, and the usage that follows a root-level invocation error, ends with a Global options: section for -h, --help, --json, and --input-json, plus --version when the application has a version. Nested group help omits it.

With global --json, group help prints a JSON listing of the group's whole subtree instead of text; see List commands.

Discovered siblings are listed in code-unit order (uppercase before lowercase), independent of the runtime locale. A hand-built ClinkrApplication keeps the order it supplies. Root help does not list the reserved sys namespace; see Shell completion. No group lists its hidden children.

An unknown command is an invalid invocation that prints the enclosing group's help after the headline. When a listed sibling is within edit distance 2 of the unknown name, and that distance is smaller than the name's length, the context suggests it: INVALID_INVOCATION: Unknown command: image biuld; did you mean build?. Distance is Levenshtein distance in UTF-16 code units. Equally near siblings are all suggested in listing order (did you mean pin or pun?). Suggestions appear only in human mode; with --json, the context is just Unknown command: <path>. sys and hidden siblings are never suggested, and Clinkr never suggests through a command alias: only canonical names are candidates.

Help prose wraps at 100 columns by default. Pass helpWidth, a positive safe integer, in the runCli options to choose another width; any other value makes runCli throw a TypeError before it does anything else. runCli never reads the terminal. runNodeCli passes the terminal's width when stdout is a terminal that reports a positive width, and keeps the default otherwise.

Wrapping follows these rules:

  • Group rows, root, group and leaf descriptions, and the Arguments:, Options:, and Global options: tables wrap. This applies to help on stdout and to the usage that follows an invocation error on stderr.
  • Usage: synopses and example command lines stay verbatim so they can be copied. Example descriptions wrap.
  • Lines break only at whitespace. A word longer than the available space goes on its own line and overflows.
  • Continuation lines indent to the text column. Authored newlines stay line breaks under the same indent.
  • The text column is never narrower than 20 characters, so a very wide label can push lines past the width.
  • Widths are JavaScript string lengths, not terminal cells, so wide CJK characters and emoji can misalign or overflow.

Help sections

To split a long listing, declare ordered helpSections in createApplication, then opt rows in with helpSection in a command's or group's metadata, or on a mount:

const application = await createApplication("my-cli", import.meta.dirname, {
  helpSections: ["Stacks", "Extensions"],
  mounts: [
    {
      source: linear,
      path: ["linear"],
      description: "Linear operations for issues, projects, and cycles.",
      summary: "Linear operations.",
      helpSection: "Extensions",
    },
  ],
});

With helpSection: "Stacks" in the submit and sync metadata, root help prints:

Usage: my-cli <command>

Commands:
  init     Initialize the repository.
  version  Print the version.

Stacks:
  submit  Submit the current stack.
  sync    Sync branches with their remotes.

Extensions:
  linear  Linear operations.

Global options:
  -h, --help    Show help.
  --json        Print JSON output; with help, list commands as JSON.
  --input-json  Read a command's request as one JSON object from stdin.
  • Rows without a helpSection come first, under Commands:. Each declared section that has rows follows, in declared order. Empty sections, including an empty Commands:, are omitted. Each section sizes its own label column.
  • A group's helpSection places only the group's own row in its parent's listing. The group's children use their own helpSection values.
  • Section names must be nonempty after trimming, single-line, and unique, and Commands is reserved. createApplication throws otherwise. application.helpSections exposes the declared list, an empty array by default.
  • A helpSection that names an undeclared section is an error. A mount's helpSection fails createApplication. In built-in metadata it fails built-in discovery, so createApplication too. In mounted metadata it fails discovery when the mount is first entered, and that failure is cached like any other mount discovery failure.
  • validateCommandTree checks only that helpSection is well formed, because it does not know the application's sections.
  • A hand-built ClinkrApplication whose row names a section missing from its helpSections fails help rendering with FATAL_INTERNAL_ERROR.

Print the version

Pass version to createApplication to give the application a root --version. Clinkr never reads package.json; the host passes its own version string, and Clinkr prints it verbatim. The reference CLI imports its package manifest, which a bundler inlines:

import packageJson from "../package.json" with { type: "json" };

const application = await createApplication("my-cli", import.meta.dirname, { version: packageJson.version });
my-cli --version          # 1.2.3
my-cli --json --version   # {"version":"1.2.3"}
  • The version must be a nonempty, single-line string without control characters; createApplication throws otherwise. It is exposed as application.version, absent when not supplied. A hand-built ClinkrApplication sets the field directly.
  • --version prints the version and exits 0 without selecting a command, so no mount is opened and no command.ts module loads. Built-in metadata discovery still runs in createApplication, before runCli, so a broken built-in metadata.ts still fails there.
  • It is recognized only as the first command word, after Clinkr removes the global --json and --input-json. After a command path it is ordinary argv: on a leaf it is the leaf's own --version option if the leaf declares one, and otherwise an unknown-option invalid invocation; on a group it is an unknown command. A bare -- before it also leaves it to command selection.
  • There is no short form, so -v stays free for leaf flags, and no sys route.
  • -h or --help with root --version, in either order, prints root help (or, with --json, the command listing). Any other word after it is an invalid invocation: --version takes no arguments. --input-json with --version is an invalid invocation too.
  • Root help lists --version under Global options:, and root completion offers it, only when a version is configured. Leaf help and nested group help never list it. The JSON command listing does not include the version.
  • Without a version, nothing changes: my-cli --version is INVALID_INVOCATION: Unknown command: --version, and help and completion do not offer it.
  • Mounted sources cannot set a version. A host that mounts sources has a root --version only if it configures its own.

Run the CLI in Node

runNodeCli from clinkr/node is the standard Node host for a Clinkr CLI. It owns every piece of process wiring, so each bin behaves the same for signals, cancellation statuses, closed pipes, stdout backpressure, --input-json stdin, help width, and prompts. clinkr/node is the only Clinkr module that reads process globals.

Requirements

  • Node.js 24.12 or newer, run as the process's CLI. A host that embeds Clinkr in a larger program, or captures output, calls runCli instead.
  • A bin that assigns the returned status to process.exitCode.

Write the bin

Pass the application and, when it declares an application context, an app factory. The factory receives the resolved NodeHost and returns the context, so a command reads the working directory, the host signals, and the terminal facts from app instead of from process:

import { createApplication } from "clinkr";
import { runNodeCli, type NodeHost } from "clinkr/node";

interface AppContext {
  readonly cwd: string;
  readonly signal: AbortSignal;
  readonly color: boolean;
}

const application = await createApplication<AppContext>("my-cli", import.meta.dirname);

process.exitCode = await runNodeCli(application, {
  app: (host: NodeHost) => ({ cwd: host.cwd, signal: host.signal, color: host.stdout.rich }),
});

app is required when the application declares a context and disallowed when it declares none, as for runCli. argv defaults to process.argv.slice(2); pass argv to run other arguments.

The host

NodeHost carries:

  • cwd and env: the working directory and environment variables.
  • signal: aborts on the first SIGINT, SIGTERM, or SIGHUP. Its reason is a NodeSignalCancellation, which carries signal and exitStatus (128 + n). runNodeCli also passes it to runCli, so it is every handler's context.signal.
  • forceSignal: aborts on a second signal, just before the process exits. Listeners that must act before the exit (killing child process groups, for example) must act synchronously.
  • outputClosed: aborts when stdout closes, for example when the reader of a pipe exits.
  • interactive: stdin and stderr are terminals, and neither CI nor a coding agent is detected.
  • ci: CI is set to anything but "", "0", or "false" (any case).
  • agent: the detected coding-agent harness, { name, variable }, or undefined. A non-empty AI_AGENT names the agent (lowercased). Otherwise the first non-empty variable of a fixed table decides: CLAUDECODE or CLAUDE_CODE (claude), REPL_ID (replit), GEMINI_CLI (gemini), CODEX_SANDBOX or CODEX_THREAD_ID (codex), OPENCODE (opencode), AUGMENT_AGENT (auggie), GOOSE_PROVIDER (goose), JUNIE_DATA or JUNIE_SHIM_PATH (junie), COPILOT_AGENT or COPILOT_CLI (copilot), CURSOR_AGENT (cursor), and PI_CODING_AGENT (pi).
  • stdin.isTTY, and for stdout and stderr: isTTY, columns (the reported width, or undefined), and rich (the stream is a terminal and TERM is not dumb). Color decisions such as NO_COLOR and FORCE_COLOR belong to the renderer, which reads them from env.

Behavior

  • Signals and statuses. The first SIGINT, SIGTERM, or SIGHUP aborts host.signal. When the canceled run ends at Clinkr status 2, runNodeCli returns 128 + n for that signal: 130, 143, or 129. A result returned after the abort keeps its 0 or 1. A second signal of any kind aborts host.forceSignal and exits the process at once with 128 + n for the second signal; pending writes and asynchronous cleanup do not run. A signal that arrives after the run settles, while something else keeps the process alive, exits the same way.
  • Closed pipe. When the reader of stdout goes away (EPIPE, as with | head), output ends quietly: the write resolves, nothing more is written, nothing goes to stderr, and the status Clinkr decided stands. A read exits 0; a mutation already sent reports its real outcome. Any other write error rejects, and runNodeCli rejects with it.
  • Backpressure. The stdout and stderr writers resolve only after the stream accepts the text, so a fast writer waits for a slow reader, and they resolve when the stream closes, so they never hang.
  • Stdin. Under --input-json, the request is read from stdin until EOF, at most 1 MiB. The first signal destroys stdin, so a pending read ends and the run reports its cancellation.
  • Help width. Help wraps to stdout's width when stdout is a terminal that reports a positive width, and at 100 columns otherwise.
  • Prompts. When interactive is true, createReadlineElicitor is installed on stdin and stderr. Otherwise no elicitor is passed, so ui.elicit is absent: under CI, under a coding agent, and without a terminal on stdin or stderr. Ctrl-C at an open prompt cancels the prompt, not the run.
  • Lifetime. A handle keeps the process alive until the run settles, so a handler that waits only on context.signal is not cut short.

Limits and deferred breadth

  • runCli has no outputClosed option yet, so a handler sees a closed stdout only through host.outputClosed in its application context. Commands that stream output are not supported yet.
  • The agent table and the elicitor are fixed: an application cannot add agent variables or substitute an elicitor.
  • --json and --non-interactive do not change interactive yet; a handler can still prompt on stderr under --json in a terminal.
  • runNodeCli handles one invocation per process. Embedded hosts, tests, and hosts that capture output keep using runCli.

Provide input: argv, JSON, and stdin

Clinkr projects a command's argv tail into request-schema input; the request schema remains the authoritative validation boundary.

Wrap a request field with cmd.cliPositional(field, { position }) to make the field positional. The default position is 0. Unannotated camel-case fields become kebab-case long options, such as taskId to --task-id.

The projector supports:

  • strings and string enums;
  • numbers and integers;
  • booleans, including --no-<name> for a defaulted boolean;
  • repeated string-array options;
  • terminal variadic string-array positionals; and
  • JSON object and record options.

A positional string array must have the final positional index. It collects every bare token not consumed as a named option value while preserving argv order. For example, a taskIds positional around --reason projects task-1 --reason no task-2 as { taskIds: ["task-1", "task-2"], reason: "no" }. Help renders an array with a positive .min(...) or .length(...) constraint as <task-ids...> and an empty-allowed, optional, or defaulted array as [task-ids...]. Clinkr always projects an absent positional array as []; Zod then enforces minimum cardinality, so .min(1) produces an invalid-request diagnostic rather than a missing-argument diagnostic.

You can omit other optional and defaulted Zod fields. Apply cmd.cliPositional(...) to the complete field expression, after .optional() or .default(). Projection rejects annotations hidden inside those wrappers. Schema defaults and domain validation run during execution. Unsupported schema shapes fail explicitly. They do not silently become strings.

A request field can be a Zod pipe, such as z.string().transform(parse), when the pipe's input side is one of the supported shapes. Clinkr projects, documents, and completes the field by its input side, and the handler receives the transformed output. Report bad input from a transform with ctx.addIssue(...) and return z.NEVER: that yields INVALID_REQUEST with the issue. A refinement or transform that throws an ApplicationError reports that error; any other throw is FATAL_UNHANDLED_ERROR with a stack. Elicitation forms do not accept pipes.

Filesystem command factories receive the host's CommandBuilder, so they must use the supplied cmd argument. Direct or embedded callers can import commandBuilder and call commandBuilder().define(...), commandBuilder().cliPositional(...), and commandBuilder().cliOption(...), or commandBuilder<MyAppContext>() to type context.app; author and project the definition through the same Clinkr runtime.

Boolean options follow Commander.js-style switch semantics. Optional and default(false) fields use --flag, while default(true) fields use --no-flag. Boolean switches do not accept explicit values. Default factories run once when Clinkr derives each projection plan and must be pure.

For example, run task-1 --subject issue:42 projects to input with taskId set to "task-1" and subject set to "issue:42".

Use global --input-json before a bare -- to read one complete JSON request object from finite stdin instead of supplying command request arguments:

printf '{"taskId":"task-1","subject":"issue:42"}' | node examples/reference-cli/bin/clinkr-reference.js --input-json run
printf '{"taskId":"task-1","subject":"issue:42"}' | node examples/reference-cli/bin/clinkr-reference.js run --input-json --json

The first command renders human output; the second returns the unwrapped validated response as JSON. --json selects output independently of --input-json. The input switch is available on every executable leaf, including zero-field leaves ({}); it does not read stdin for help, schema introspection, groups or unknown routes. Supply exactly one JSON object (whitespace and a leading UTF-8 BOM are accepted). Empty input, malformed JSON, arrays, null, primitives, or trailing content are invalid invocations. Undeclared top-level request keys and schema violations are invalid requests; nested keys follow their schema's own policy. Input JSON cannot be combined with argv request arguments or --json-schema; --help retains precedence at a leaf. A bare -- ends global option recognition.

Embedded hosts can pass a fifth runCli argument { inputReader: async () => text }; the reader is invoked once only on an executing JSON-input leaf. Without a reader, JSON-input execution fails with an invocation diagnostic. runNodeCli reads process.stdin until EOF, with a 1 MiB limit; close the pipe to finish the request. Read failures and oversized input produce invocation diagnostics without exposing the payload. No stdin is consumed without the switch.

Short flags and aliases

Wrap an option field with cmd.cliOption(field, { short, aliases }) to accept more spellings for it. short is one ASCII letter, accepted as -<letter>; h is reserved for help. Each alias is a kebab-case name without leading dashes, accepted as --<alias>; help, json, input-json, and json-schema are reserved. Every spelling sets the same value as the field's derived flag, so a short flag or alias on a default(true) boolean sets false like its --no-<name> flag. Short flags are case-sensitive.

requestSchema: z.object({
  title: cmd.cliOption(z.string(), { short: "t", aliases: ["name"] }),
  verbose: cmd.cliOption(z.boolean().optional(), { short: "v" }),
}),

This command accepts -t Hello, -tHello, -t=Hello, --name Hello, and --title=Hello. Short switches can share a token: -v -t Hello can be written -vt Hello or -vtHello. In a cluster, the first value-taking flag consumes the rest of the token after one optional =, or the next token when nothing remains. A boolean switch cannot take a value, so -v=yes is an invalid invocation.

Apply cmd.cliOption(...) to the complete field expression, after .optional() or .default(), as with cliPositional. A field cannot be both positional and an option. Projection rejects invalid spellings and any spelling claimed by two fields, including an alias that matches another field's derived flag.

Before a bare --, a token that starts with --, or with - followed by a letter, is an option, even when the command declares no short flags. An unknown one is an invalid invocation, such as Unknown option: -x. Other dash-led tokens, such as -1, -.5, or a bare -, are data. An option's separate value cannot be an option token either: --title -x reports Missing value for --title. Pass such values inline (--title=-x or -t-x) or, for positionals, after --.

Leaf help lists every spelling in the option's row, short flag first and aliases last (-t, --title, --name <value>). Synopses keep the derived long flag. Diagnostics name the spelling that was typed.

Organize commands on the filesystem

The directory hierarchy is the command hierarchy. Each built-in node has a cheap metadata.ts module, which is loaded eagerly. Mounted metadata is loaded only when that mount is entered. command.ts modules contain the implementations and are loaded only for selected commands.

The reference CLI uses this layout:

src/
  cli.ts
  commands/
    list/
      metadata.ts
      command.ts
    run/
      metadata.ts
      command.ts
    image/
      metadata.ts
      build/
        metadata.ts
        command.ts

A metadata module exports a description and optional structured help. It does not import the command definition:

import type { CommandMetadata } from "clinkr";

export const metadata = {
  description: "Run a representative task (reference stub).",
  fields: {
    taskId: { valueLabel: "task-id", description: "Task to queue." },
    subject: {
      valueLabel: "subject-ref",
      description: "Issue or pull request, such as issue:42 or pull-request:17.",
    },
  },
  examples: [
    { argv: "task-1 --subject issue:42", description: "Queue a task against an issue." },
    {
      argv: "task-2 --subject pull-request:17",
      description: "Queue a task against a pull request.",
    },
  ],
} satisfies CommandMetadata;

Discovery imports built-in metadata for navigation, but it does not import command definitions. When the application loads and executes one command, it does not import or invoke sibling commands.

Each command directory may use metadata.ts or metadata.js, and command.ts or command.js, independently of its siblings. When a directory contains both extensions of the same module, the .ts module wins. TypeScript modules suit a CLI that runs from source through Node type stripping; a published CLI must ship built .js modules, because Node does not strip types from files under node_modules. The reference CLI builds src into dist and runs the built tree.

Mount external command trees

A host can attach an external package's relative command tree at a host-owned path without changing the package layout. The host resolves the package's absolute commands directory; Clinkr does not resolve package names:

import { createApplication, validateCommandTree } from "clinkr";

const linear = { id: "linear-package", directory: "/absolute/path/to/linear/commands" };
const application = await createApplication("fdk", import.meta.dirname, {
  commandsDirectory: "fdk-commands",
  mounts: [{ source: linear, path: ["ext", "linear"], description: "Linear operations" }],
});
// Optional explicit preflight, e.g. during installation, not CLI startup:
await validateCommandTree(linear);

Command authoring and shared Zod

Filesystem command factories receive the host's CommandBuilder. External factories should accept that argument and use cmd.define(...), cmd.cliPositional(...), and cmd.cliOption(...) instead of importing a runtime Clinkr authoring helper. TypeScript command packages can use a type-only CommandBuilder import, with Clinkr available as a development or peer dependency for compilation; factory authoring does not require a runtime Clinkr peer.

Mounted commands receive the same context.app object as the host's built-in commands. A mounted factory that uses it annotates its parameter as CommandBuilder<HostAppContext> against the host's application context type; as with built-in factories, discovery cannot verify that the types agree.

The host and separately installed command packages must still use one compatible runtime instance of Zod. Declare Zod as a peer dependency and leave it external when bundling either side. A host bundle may include Clinkr while it injects its builder into external command factories, but independently bundled Zod instances or versions are not supported.

A self-contained multi-entry bundle can retain its common-chunk layout so its entries share one coherent runtime. Direct callers that import commandBuilder must author and project through that same Clinkr runtime; arbitrary command-definition interchange between independent Clinkr runtimes is not supported.

Discovery and ownership

The built-in host tree must already contain ext as a group; mount parents are never synthesized. A mount at ["linear"] goes at the root. Distinct aliases (including aliases of the same source) can point to one directory; the alias does not change package files. Paths in help, usage, diagnostics, and JSON Schema introspection include the entire mount path (schema paths omit the application name).

Built-in discovery is eager. Root and parent help show declared mount descriptions without opening mounted sources. Selecting a mount root or any descendant discovers its entire metadata tree, once per application, including cached failures; other mounts stay unopened. Only the selected command's implementation is imported and its factory called. Reconstruct an application to re-evaluate a filesystem (subject to Node module caching). A missing or malformed unopened source does not prevent built-in commands from running, but invalid mount declarations fail construction.

Mounts are exclusive: duplicate or ancestor/descendant mounts, collisions with built-in groups or leaves, absent parent groups, and mounts beneath built-in leaves are rejected. Paths are case-sensitive; segments must be nonempty and cannot be . or .., contain whitespace, control characters or separators, or begin with -. Each mount requires a nonempty diagnostic source id, absolute source directory, and nonempty description. A mount's description is its group-help header and, without a summary, its row in the parent listing. Optional summary and helpSection follow the metadata rules: summary replaces description only in the parent listing, and helpSection must name a declared help section. Optional hidden must be a boolean; hidden: true leaves the mount's row out of its parent's listings without opening the mount (see Hidden commands). An empty source root is allowed, matching the existing filesystem rule.

validateCommandTree({ id, directory }) eagerly checks one tree's structure and metadata without importing command implementations or invoking factories. This does not validate factories, handlers, projection, package compatibility, or side-effect freedom. Metadata modules are trusted executable code: importing them can have side effects, hang, or exit the process. Clinkr is not a sandbox. The host owns configuration, installation, descriptor loading, invalid-composition recovery, and trust policy; it must avoid importing broken extension code itself before Clinkr can defer discovery.

Hidden commands

Set hidden: true in a leaf's or group's metadata.ts, or on a mount, to keep a command out of sight while it stays invocable. The reference CLI hides its machine-facing exec group this way:

import type { CommandMetadata } from "clinkr";

export const metadata = {
  description: "Machine-facing task commands for scripts and agents (hidden from help).",
  hidden: true,
} satisfies CommandMetadata;

A hidden node's own row is left out of its parent's help, the group usage printed after an invocation error, shell completion candidates, "did you mean" suggestions, and the JSON command listing. Absent and false mean the same thing.

Hiding is per node and nothing inherits it, the same way helpSection places only the node's own row. clinkr-reference exec --help lists task-ids as usual. A child of a hidden group is left out only when it sets hidden itself. A group whose children are all hidden prints Usage: and its description with no Commands: section.

Addressed by its full path, a hidden node behaves like any other. clinkr-reference exec task-ids --json runs, and leaf --help, group --help, and --json-schema print with no marker. Completion never offers exec, whatever the prefix, but once you type it, child names, flags, and values complete normally. A JSON listing rooted at or inside a hidden group (clinkr-reference exec --json) lists that subtree. A listing rooted above it skips the hidden node and its subtree and never selects it, so it never opens a hidden mount.

hidden is shape-checked only. Discovery rejects a non-boolean value as a metadata error, and createApplication rejects one on a mount. A hidden node may still set summary or helpSection, and an undeclared helpSection still fails.

application.commands and selection available still include hidden nodes. Clinkr filters them in its own help, completion, suggestions, and listCommands. An embedder that renders its own listing should skip nodes whose metadata.hidden is true, and a hand-built ClinkrApplication hides a node by setting that field.

The reserved root sys namespace is the built-in instance of the same rule: absent from root help, completion candidates, suggestions, and listings, yet invocable by name. It stays a structural special case rather than a metadata-hidden node.

Command aliases

List extra names for a leaf command in aliases in its metadata.ts:

import type { CommandMetadata } from "clinkr";

export const metadata = {
  description: "List slots.",
  aliases: ["ls"],
} satisfies CommandMetadata;

An alias selects its command only on an exact, case-sensitive match, so my-cli slot ls --json runs list. After selection every path uses the canonical name: the selection path, leaf help and invocation-error usage (Usage: my-cli slot list), --json-schema command.path, and the path a completion provider receives. my-cli slot ls --json and my-cli slot list --json print the same bytes.

Clinkr teaches aliases in two places. The parent's group help puts them in the command's one row (list, ls List slots.), and the JSON command listing adds "aliases": ["ls"] to the entry. Leaf help has no alias section. Completion and "did you mean" suggestions use canonical names only. my-cli slot co<TAB> offers nothing, even when checkout declares co, and my-cli slot cp does not suggest checkout. Once you type an alias in full, flags, values, and providers complete as they do after the name.

A hidden command's aliases leave help, the listing, and completion with it. They stay invocable and still count when Clinkr checks for collisions.

Each alias must be nonempty and cannot be . or .., contain whitespace, control characters, / or \, or begin with -, the same rule as mount path segments. Clinkr rejects:

  • an alias equal to the command's own name, or listed twice on one command;
  • an alias equal to a sibling's name or alias, hidden siblings included, for example Command alias conflict at slot ls: list and ls;
  • the root alias sys, which is reserved for completion; a nested sys alias is allowed;
  • a mount path segment equal to a built-in alias, for example Mount conflict at slot ls: my-cli (alias of list) and slots.

Built-in tree errors fail createApplication and validateCommandTree. Errors inside a mounted source surface when the mount is entered and stay cached, like other mounted discovery failures. Aliases live in metadata, so navigation stays metadata-only and only the selected command's module is imported.

Only leaf commands have aliases. Discovery rejects aliases in group metadata, even an empty array, and CommandMount has no alias field because a mount is a group. An empty array on a leaf behaves as absent. In a hand-built ClinkrApplication, help, listCommands, and completion read leaf metadata.aliases, and the application's own select must resolve aliases and return canonical paths.

Author leaf-command help

Leaf -h or --help loads the selected command's schema and renders its synopsis, description, argument and option tables, and any authored examples. Root and group help remain metadata-only command listings. For the reference metadata above, clinkr-reference run --help prints:

Usage: clinkr-reference run <task-id> --subject <subject-ref>

Run a representative task (reference stub).

Arguments:
  <task-id>  Task to queue.

Options:
  --subject <subject-ref>  Issue or pull request, such as issue:42 or pull-request:17.

Global options:
  -h, --help     Show command help.
  --json         Print the response as JSON.
  --input-json   Read the request as one JSON object from stdin.
  --json-schema  Print the request and response JSON Schemas.

Examples:
  # Queue a task against an issue.
  clinkr-reference run task-1 --subject issue:42

  # Queue a task against a pull request.
  clinkr-reference run task-2 --subject pull-request:17

CommandMetadata accepts optional summary, helpSection, hidden, and readonly aliases, fields, examples, and usages:

  • summary is a one-line replacement for description in the parent group's listing. Group-help headers and leaf help still show description. Introspection and completion do not use it. Keep it to 60 characters or fewer; assertCommandHelpValid enforces the limit, but discovery does not.

  • helpSection places the node's own row under a declared help section.

  • summary and helpSection must be nonempty after trimming and single-line: control characters and Unicode line or paragraph separators are rejected. Both apply to leaf and group metadata.

  • hidden is a boolean that leaves the node's own row out of its parent's listings while it stays invocable by path. It applies to leaf and group metadata; see Hidden commands.

  • aliases lists extra names that select a leaf command. Only leaf metadata may set it; see Command aliases.

  • fields maps request-schema keys, such as taskId, not flag spellings, to descriptors containing only optional description and valueLabel strings. Descriptors reject unknown properties. Descriptions may span lines; continuation lines align under the description column.

  • valueLabel is a bare display atom such as task-id or subject-ref. It cannot be empty, contain whitespace, control characters, <, >, [ or ], or end in .... Clinkr adds brackets and variadic suffixes. Boolean switches cannot have value labels.

  • examples is a readonly array whose entries are either a bare argv string or a { argv, description? } object; object entries reject unknown keys, and the forms can be mixed. Parsing normalizes bare strings to { argv }, so discovered command nodes carry ResolvedCommandMetadata, in which every example is an object. argv is a concrete, single-line command-argument tail, not a full invocation or usage template. Clinkr prefixes each tail with the actual application name and selected command path, and an empty tail represents an invocation without command arguments. The optional description must be nonempty and single-line; help prints it as a # comment line above the command, wrapped to the help width with # on every line.

  • When any example has a description, help separates every example with a blank line. Otherwise examples stay one per line.

  • usages is a readonly array of argv-tail templates that replace the schema-derived synopsis when the array is nonempty. Each entry must be single-line without control characters; an empty entry shows the bare command path. Clinkr prefixes each entry with the application name and command path, prints the first after Usage: , and aligns later entries under the first prefix. Usage lines never wrap. An empty array behaves as absent.

A command with two invocation shapes can show both instead of one synopsis that hides the difference:

export const metadata = {
  description: "Pin a package version for the workspace.",
  usages: ["<github-url> [--workspace <path>...]", "<package> --default [--workspace <path>...]"],
} satisfies CommandMetadata;
Usage: shamu pin <github-url> [--workspace <path>...]
       shamu pin <package> --default [--workspace <path>...]

Description-only metadata remains valid, but all leaf help now uses this sectioned layout. Options: lists only the command's own flags and is omitted when the command has none; Global options: always lists the framework flags. An absent or empty examples array omits Examples:. Output is deterministic plain text that wraps at the help width (100 columns by default) under the wrapping rules; the synopsis and example commands stay verbatim.

Help keeps the global-option rows short; their placement rules are:

  • --json and --input-json are global: they are recognized anywhere before a bare --, which ends global option recognition.
  • --json-schema must be the only command argument, apart from an optional global --json.
  • --input-json cannot be combined with command arguments or --json-schema. It may pair with --json.
  • -h or --help takes precedence at a leaf, even over malformed arguments.

The schema still determines flag names, short flags and aliases, positional order, requiredness, boolean polarity, and variadic syntax. Labels and descriptions do not change parsing or validation. Declared usages override only the displayed synopsis, in leaf help and in invocation-error usage; the schema still owns parsing, and the argument and option tables stay schema-derived. assertCommandHelpValid checks only the flag names that usages mention, so positional placeholders and requiredness can drift from the schema; that check is a test-time guarantee. Unannotated fields still appear with derived labels and no description. Without usages, invocation-error synopses use the field annotations' value labels like leaf help, and fall back to unlabeled schema-derived units when the annotations are stale.

Validate authored help in tests

Import the opt-in testing helper and validate metadata against the actual command request schema:

import { commandBuilder } from "clinkr";
import { assertCommandHelpValid } from "clinkr/testing";
import { command } from "./commands/run/command.js";
import { metadata } from "./commands/run/metadata.js";

assertCommandHelpValid(metadata, command(commandBuilder()).requestSchema);

assertCommandHelpValid(metadata, requestSchema): void throws on invalid metadata shape, a summary longer than 60 characters (JavaScript string length, so listing rows fit), unknown field keys, labels on boolean switches, unsupported projection shapes, invalid examples, or usages that name an unknown option or a framework control flag. For each usage it strips brackets, angle brackets, parentheses, ... suffixes, and =value suffixes from every whitespace-separated token and splits | alternatives; each piece that starts with --, or is - followed by one letter, must be one of the schema-derived flags, including the negated --no-x form only when the schema derives it, or a declared short flag or alias. -h, --help, --json, --input-json, --json-schema, and bare -- are rejected. Positional placeholders are not checked. For each plain example it splits the argv tail on ASCII spaces, projects the resulting argv, and calls synchronous requestSchema.parse. It never calls a handler or renderer, but schema defaults, refinements, and transforms can run: use deterministic schemas and fixtures. Async validation is not supported by this synchronous helper.

Validation happens at different times:

  • Discovery validates metadata shape without loading command definitions.
  • Explicit leaf help reconciles field annotations with the selected schema and derives the projection plan, which can evaluate schema default factories. It displays examples without tokenizing or parsing them, so it does not run example refinements or transforms.
  • The testing helper additionally validates every concrete example and the flag names in every usage. Ordinary execution and schema introspection do not perform this help validation.

Example validation limits

Help renders authored example argv tails verbatim; example descriptions are not validated beyond their shape. The opt-in testing helper validates only plain, ASCII-space-separated argv tails (for example, task-1 --subject issue:42). Repeated spaces are ignored; an empty tail means no arguments. It rejects other whitespace, quotes, backslashes, shell operators, expansions, and glob characters rather than interpreting them. It cannot validate arguments containing spaces, empty-string arguments, or JSON object arguments containing shell-special characters. Test those with explicit argv arrays and the normal CLI execution path instead. Quoted examples can still be displayed in help, but the helper will not certify them.

Validated tails cannot contain framework control tokens -h, --help, --json, --input-json, --json-schema, or bare --. Examples describe command-request execution only, not help, introspection, or output-mode invocations.

Human and JSON output

For a completed command, at exit 0 or 1, runCli validates the response and passes it to the command's render function. The resulting deterministic, command-specific text goes to the supplied stdout writer. The examples below use the reference CLI:

node examples/reference-cli/bin/clinkr-reference.js list
task-1	Inspect the requested change
task-2	Build the requested artifact

Use global --json anywhere before a bare -- to get an unwrapped validated response:

node examples/reference-cli/bin/clinkr-reference.js --json run task-1 --subject issue:42
{ "taskId": "task-1", "subject": "issue:42", "state": "queued" }

--json also switches group help to a JSON command listing. Leaf help stays text.

TODO: Document Markdown rendering.

Errors, outcomes, and exit statuses

The exit status tells a calling script what to do next:

| Status | Class | The caller | | ------ | --------------- | -------------------------------------------- | | 0 | Success | Continues | | 1 | Non-ideal state | Branches: a valid, expected alternative path | | 2 | Error | Almost certainly terminates and reports |

To choose between exit 1 and an error, ask: would a reasonable calling script branch on this? If yes, return a value with exitCode: 1. If no, throw. Intended use decides the gray cases: task exists 42 exits 1 when the task is missing, because answering that question is its job, while run <task> assumes the task exists and throws ApplicationError with code TASK_NOT_FOUND. Agents follow the same rule. Scripts under set -e treat exit 1 the way they treat grep finding no match: test for it explicitly, such as if my-cli task exists 42; then ....

Help, introspection, and command listings exit 0. runCli returns the status after the selected writer accepts the output. A writer failure rejects the call instead of producing a fallback diagnostic.

Completed results

A handler that completes returns a plain object, with no tag and no helper:

| Returned | Exit status | Output | | ------------------------ | ----------- | ------------------------------------------ | | { value } | 0 | value, validated and rendered, on stdout | | { value, exitCode: 1 } | 1 | value, validated and rendered, on stdout |

  • exitCode is optional, defaults to 0, and admits only 0 or 1. Errors throw; a handler cannot return exit 2.
  • Both exit codes share the command's one responseSchema and one render; only the status differs. The handler chooses exitCode explicitly, so the same data can mean different things: a check command can return { present: false } at 0 by default and at 1 under --require.
  • When the exit-1 data has a different shape, such as locator-only facts or partial data, model both shapes in the response schema, usually as a union. Whether the payload agrees with the status, for example present: false at exit 1, is the command's responsibility.
  • Stdout carries the bare validated response JSON under --json, or the render text otherwise. Stderr stays empty. Clinkr does not wrap the value or add a reserved field: readers tell success from a non-ideal state by status alone, and exit-1 output is machine-usable data because callers branch on it.
  • render(value, { app }) receives the validated value and the same app context the handler received. Keep it a pure function of those two: a host that styles output for a terminal resolves the stdout's capabilities itself and puts them in app, so tests can inject them.

The reference CLI's show command reports a missing task at exit 1 through a union response schema: the task with found: true at status 0, or { "found": false, "taskId": "task-9" } at status 1. The show definition:

const responseSchema = z.union([
  z.object({ found: z.literal(true), task: z.object({ id: z.string(), description: z.string() }) }),
  z.object({ found: z.literal(false), taskId: z.string() }),
]);

return cmd.define({
  requestSchema: z.object({ taskId: cmd.cliPositional(z.string().min(1)) }),
  responseSchema,
  handler: async ({ app }, { taskId }): Promise<CommandResult<z.input<typeof responseSchema>>> => {
    const task = await app.tasks.get(taskId);
    return task === undefined
      ? { value: { found: false, taskId }, exitCode: 1 }
      : { value: { found: true, task: { id: task.id, description: task.description } } };
  },
  render: (response) =>
    response.found ? `${response.task.id}\t${response.task.description}` : `No task ${response.taskId}.`,
});

Errors

Every error exits 2, writes nothing to stdout, and writes one diagnostic to stderr. Each error carries an open string code, a stable description of what that code means, and optional call-site context that says what went wrong in this occurrence. Human mode renders one headline: CODE: context when the error has context, for example TASK_NOT_FOUND: No task task-9; list shows the available tasks, and otherwise CODE: description, for example INVALID_REQUEST: Invalid request. Context may span lines, and human mode prints it verbatim after the headline. JSON mode writes { "error": { "code", "description", "context"?, "details"?, "issues"?, "stack"? } }. Request validation errors carry no context; they contain language-neutral issues with path and message fields instead.

Errors fall into four subsets:

| Subset | Codes | Raised by | Stack | | ----------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------- | ----- | | Usage error | INVALID_INVOCATION, INVALID_REQUEST, INVALID_ELICITATION_RESPONSE | Clinkr: the caller is wrong | no | | Interrupted | COMMAND_ABORTED, ELICITATION_CANCELLED | Clinkr: the caller or host stopped it | no | | Known application error | Application-owned, such as TASK_NOT_FOUND or GIT_UNAVAILABLE | throw new ApplicationError(...) | no | | Fatal | FATAL_UNHANDLED_ERROR, FATAL_INVALID_RESPONSE, FATAL_INTERNAL_ERROR | Any other throw, a malformed return, or a Clinkr defect | yes |

  • FATAL_UNHANDLED_ERROR: application code threw something other than ApplicationError. Application code is the handler, render, and any request-schema refinement or transform. Its context comes from the thrown error.
  • FATAL_INVALID_RESPONSE: the handler returned something other than { value, exitCode? } (including unknown fields such as a kind tag), an exitCode other than 0 or 1, or a value that fails responseSchema. A renderer that returns something other than a string is also FATAL_INVALID_RESPONSE.
  • FATAL_INTERNAL_ERROR: a Clinkr defect, or any other failure that Clinkr does not attribute to a handler, renderer, or request schema, such as a command module that fails to import or returns an invalid definition.
  • Only these three fatal codes print a stack. Every other error has a known cause, so its diagnostic omits the stack.

DEFAULT_CLINKR_ERROR_CODES maps each of Clinkr's codes to its description.

Application errors

Throw ApplicationError for a known cause that puts the request outside the application's domain, such as a missing or changed dependency, an unreachable backend, or a missing permission:

import { ApplicationError } from "clinkr";

throw new ApplicationError({
  code: "GIT_UNAVAILABLE",
  description: "Git is unavailable",
  context: "git was not found on PATH",
  details: { searched: ["/usr/bin", "/usr/local/bin"] },
});
  • code must be upper snake case and must not be one of Clinkr's codes (the keys of DEFAULT_CLINKR_ERROR_CODES). description must be a nonempty single-line string. Otherwise the constructor throws TypeError.
  • context is what went wrong this time. An empty context counts as absent.
  • An optional details object carries structured, machine-readable facts about the occurrence. JSON mode emits it as error.details; human mode never prints it, so keep context readable on its own. Clinkr rejects null and arrays but does not otherwise validate its shape or check that it is JSON-serializable.
  • An optional cause is kept as the error's cause.
  • Subclasses classify the same way. Clinkr checks only the thrown value itself, not its cause chain.
  • Application error codes are not declared and not listed in --json-schema. Callers branch on exit-1 data, not on exit-2 codes; error codes serve diagnosis. If a script would want to branch on an error code, the condition belongs at exit 1.

Cancellation

After the host's signal aborts, any throw from the handler, including an ApplicationError, becomes COMMAND_ABORTED, with the thrown value as its cause and its message as the context. A result returned after the abort still goes through, at exit 0 or 1, because the work finished. See Context and testability for how cancellation reaches handlers.

runCli reports a canceled run at status 2. runNodeCli turns that 2 into the signal's conventional status, 128 + n: 130 for SIGINT (Ctrl+C), 143 for SIGTERM, and 129 for SIGHUP. A result returned after the abort keeps its 0 or 1.

Choose an exit status

These rules apply the caller test to common command shapes:

  • A query that finds nothing succeeds and says so in its data, such as { "present": false } at status 0.
  • A command whose specified target is absent, such as reading, showing, or deleting a named item, exits 1 with data that names the target. A command whose intended use assumes the target exists, such as run <task>, throws instead.
  • A query may offer a flag such as --require that turns its miss into exit 1. No flag turns exit 1 into exit 0.
  • A problem that stops the command from answering, such as a missing permission or an unreachable backend, throws an ApplicationError and exits 2.

Clinkr has no mechanism for a declined elicitation: { action: "decline" } is data the handler receives. A destructive command whose confirmation is declined returns domain data, such as { value: { deleted: false }, exitCode: 1 }, so callers see status 1 and nothing changed.

Inspect a command

Pass --json-schema as the only command argument of the selected leaf:

node examples/reference-cli/bin/clinkr-reference.js run --json-schema

The command emits one self-describing document that contains:

  • the selected command path;
  • the canonical request-input JSON Schema; and
  • the canonical response-output JSON Schema, which describes the value at both status 0 and status 1.
{
  "command": { "path": ["run"] },
  "requestSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "...": "..." },
  "responseSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "...": "..." }
}

The document lists no error codes: callers branch on exit-1 data, which the response schema describes, not on exit-2 codes. commandIntrospectionDocumentSchema() returns the Zod schema that validates the document, and introspectCommand(selection) builds it without runCli.

Introspection can load the selected definition. It does not project execution argv, invoke the handler, or import sibling definitions. The schemas are strict Draft 2020-12 documents. Clinkr generates them from the same Zod schemas that execution uses.

List commands

Add global --json to any group help to print a JSON document on stdout, with status 0, that lists every command under the selected group. It works at the root and for a nested group, with or without --help: my-cli --json, my-cli --help --json, my-cli image --json, and my-cli image --help --json all list.

node examples/reference-cli/bin/clinkr-reference.js --json

For an application with a description, a Stacks help section, and an image group, the document has this shape:

{
  "application": { "name": "my-cli", "description": "Build and ship images." },
  "path": [],
  "commands": [
    {
      "kind": "group",
      "name": "image",
      "path": ["image"],
      "description": "Image operations, including local builds and registry pushes.",
      "summary": "Work with images.",
      "section": "Stacks"
    },
    {
      "kind": "command",
      "name": "build",
      "path": ["image", "build"],
      "description": "Build an image."
    },
    { "kind": "command", "name": "status", "path": ["status"], "description": "Show status." }
  ]
}
  • path is the selected group's path, empty at the root. Paths never include the application name.
  • commands is flat and depth-first: each group entry comes immediately before its descendants. Each level keeps the order of group help (code-unit order for discovered commands; supplied order for a hand-built application).
  • Each entry carries the node's description. summary, section (the node's helpSection), and a command's nonempty aliases appear only when the metadata sets them, and application.description only when the application has one.
  • Entries carry no schemas. Use --json-schema on a leaf for its request and response contract.
  • The reserved sys namespace is not listed. Hidden nodes and their subtrees are not listed either, unless the listing is rooted at or inside them. Leaf help with --json (my-cli image build --help --json) stays text.

The listing reads metadata only: it never imports command modules or calls factories. It does open every mount under the selected group, which discovers each mount's metadata tree. The exception is a hidden mount, or a mount under a hidden group: a listing rooted above it never opens it. As described in Discovery and ownership, discovery results, including failures, are cached per application, so one broken mount fails the whole listing with the usual discovery diagnostic: FATAL_INTERNAL_ERROR at status 2, or { "error": { ... } } on stderr in JSON mode. Plain-text group help still shows the broken mount's row without opening it.

Embedded callers can build the same document without runCli: await listCommands(application, path?) returns a CommandListingDocument, and commandListingDocumentSchema() returns the Zod schema that validates it. listCommands uses only application.commands and application.select, so it works for hand-built applications too. It rejects with an Error when path does not select a group.

Context and testability

TODO: Document embedded, command-path-addressed application execution.

Hosts and adapters run a definition with execute(command, input, options?) when they already have structured input. execute validates the request, invokes the handler exactly once, and resolves to an ExecutionResult, { value, exitCode }, with value validated against responseSchema and exitCode filled in as 0 when the handler omitted it. It does not create an application, read argv, write output, or touch process globals. Command authors do not call execute directly.

execute rejects with a ClinkrExecutionError for every error, so hosts handle one rejection type. Its code, description, and context match the CLI diagnostic. An ApplicationError becomes a ClinkrExecutionError that keeps the application's code, description, context, and details, with the ApplicationError as its cause. Clinkr's own codes, such as INVALID_REQUEST, FATAL_UNHANDLED_ERROR, FATAL_INVALID_RESPONSE, and COMMAND_ABORTED, take their context from the cause.

try {
  const { value, exitCode } = await execute(command(commandBuilder<ReferenceAppContext>()), input, {
    app,
  });
  console.log(exitCode, value); // 0 or 1, with the validated value
} catch (error) {
  if (!(error instanceof ClinkrExecutionError)) throw error;
  console.error(`${error.code}: ${error.context ?? error.description}`);
}

execute builds the handler's ExecutionContext from its ExecutionOptions; hosts and tests never construct a context themselves. ExecutionOptions holds signal, elicitor, app, and progress, a TextWriter that receives ui.progress messages. runCli accepts the same options except progress, because it wires progress to its own stderr writer, plus inputReader and helpWidth, as its fifth argument.

  • context.signal is always present. It is the host's signal option, or a signal that never aborts when the host supplies none.
  • context.ui is always present. It holds host-injected UI capabilities, such as ui.progress and ui.elicit.
  • context.app is the host's app option, passed by reference without copying or freezing. It is undefined when the application declares no application context.

runCli passes the same app to the command's render as { app } (RenderContext), so human output can depend on what the host knows, such as whether stdout is a terminal. execute never renders.

Use the application context to inject what handlers would otherwise read from process globals or module state, such as the working directory, API clients, clocks, or stores. The application declares its type once, and command factories assert it through their builder parameter:

import { createApplication, runCli, type CommandBuilder } from "clinkr";
import { z } from "zod";

in