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 zodzod 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:, andGlobal 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
helpSectioncome first, underCommands:. Each declared section that has rows follows, in declared order. Empty sections, including an emptyCommands:, are omitted. Each section sizes its own label column. - A group's
helpSectionplaces only the group's own row in its parent's listing. The group's children use their ownhelpSectionvalues. - Section names must be nonempty after trimming, single-line, and unique, and
Commandsis reserved.createApplicationthrows otherwise.application.helpSectionsexposes the declared list, an empty array by default. - A
helpSectionthat names an undeclared section is an error. A mount'shelpSectionfailscreateApplication. In built-in metadata it fails built-in discovery, socreateApplicationtoo. In mounted metadata it fails discovery when the mount is first entered, and that failure is cached like any other mount discovery failure. validateCommandTreechecks only thathelpSectionis well formed, because it does not know the application's sections.- A hand-built
ClinkrApplicationwhose row names a section missing from itshelpSectionsfails help rendering withFATAL_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;
createApplicationthrows otherwise. It is exposed asapplication.version, absent when not supplied. A hand-builtClinkrApplicationsets the field directly. --versionprints the version and exits0without selecting a command, so no mount is opened and nocommand.tsmodule loads. Built-in metadata discovery still runs increateApplication, beforerunCli, so a broken built-inmetadata.tsstill fails there.- It is recognized only as the first command word, after Clinkr removes the global
--jsonand--input-json. After a command path it is ordinary argv: on a leaf it is the leaf's own--versionoption 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
-vstays free for leaf flags, and nosysroute. -hor--helpwith 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-jsonwith--versionis an invalid invocation too.- Root help lists
--versionunderGlobal 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 --versionisINVALID_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
--versiononly 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
runCliinstead. - 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:
cwdandenv: the working directory and environment variables.signal: aborts on the first SIGINT, SIGTERM, or SIGHUP. Its reason is aNodeSignalCancellation, which carriessignalandexitStatus(128 + n).runNodeClialso passes it torunCli, so it is every handler'scontext.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:CIis set to anything but"","0", or"false"(any case).agent: the detected coding-agent harness,{ name, variable }, orundefined. A non-emptyAI_AGENTnames the agent (lowercased). Otherwise the first non-empty variable of a fixed table decides:CLAUDECODEorCLAUDE_CODE(claude),REPL_ID(replit),GEMINI_CLI(gemini),CODEX_SANDBOXorCODEX_THREAD_ID(codex),OPENCODE(opencode),AUGMENT_AGENT(auggie),GOOSE_PROVIDER(goose),JUNIE_DATAorJUNIE_SHIM_PATH(junie),COPILOT_AGENTorCOPILOT_CLI(copilot),CURSOR_AGENT(cursor), andPI_CODING_AGENT(pi).stdin.isTTY, and forstdoutandstderr:isTTY,columns(the reported width, orundefined), andrich(the stream is a terminal andTERMis notdumb). Color decisions such asNO_COLORandFORCE_COLORbelong to the renderer, which reads them fromenv.
Behavior
- Signals and statuses. The first SIGINT, SIGTERM, or SIGHUP aborts
host.signal. When the canceled run ends at Clinkr status2,runNodeClireturns128 + nfor that signal:130,143, or129. A result returned after the abort keeps its0or1. A second signal of any kind abortshost.forceSignaland exits the process at once with128 + nfor 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 exits0; a mutation already sent reports its real outcome. Any other write error rejects, andrunNodeClirejects 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
interactiveis true,createReadlineElicitoris installed on stdin and stderr. Otherwise no elicitor is passed, soui.elicitis 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.signalis not cut short.
Limits and deferred breadth
runClihas nooutputClosedoption yet, so a handler sees a closed stdout only throughhost.outputClosedin 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.
--jsonand--non-interactivedo not changeinteractiveyet; a handler can still prompt on stderr under--jsonin a terminal.runNodeClihandles one invocation per process. Embedded hosts, tests, and hosts that capture output keep usingrunCli.
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 --jsonThe 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.tsA 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 nestedsysalias 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:17CommandMetadata accepts optional summary, helpSection, hidden, and readonly aliases, fields, examples, and usages:
summaryis a one-line replacement fordescriptionin the parent group's listing. Group-help headers and leaf help still showdescription. Introspection and completion do not use it. Keep it to 60 characters or fewer;assertCommandHelpValidenforces the limit, but discovery does not.helpSectionplaces the node's own row under a declared help section.summaryandhelpSectionmust be nonempty after trimming and single-line: control characters and Unicode line or paragraph separators are rejected. Both apply to leaf and group metadata.hiddenis 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.aliaseslists extra names that select a leaf command. Only leaf metadata may set it; see Command aliases.fieldsmaps request-schema keys, such astaskId, not flag spellings, to descriptors containing only optionaldescriptionandvalueLabelstrings. Descriptors reject unknown properties. Descriptions may span lines; continuation lines align under the description column.valueLabelis a bare display atom such astask-idorsubject-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.examplesis a readonly array whose entries are either a bareargvstring 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 carryResolvedCommandMetadata, in which every example is an object.argvis 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 optionaldescriptionmust 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.
usagesis 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 afterUsage:, 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:
--jsonand--input-jsonare global: they are recognized anywhere before a bare--, which ends global option recognition.--json-schemamust be the only command argument, apart from an optional global--json.--input-jsoncannot be combined with command arguments or--json-schema. It may pair with--json.-hor--helptakes 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 listtask-1 Inspect the requested change
task-2 Build the requested artifactUse 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 |
exitCodeis optional, defaults to0, and admits only0or1. Errors throw; a handler cannot return exit2.- Both exit codes share the command's one
responseSchemaand onerender; only the status differs. The handler choosesexitCodeexplicitly, so the same data can mean different things: acheckcommand can return{ present: false }at0by default and at1under--require. - When the exit-
1data 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 examplepresent: falseat exit1, is the command's responsibility. - Stdout carries the bare validated response JSON under
--json, or therendertext 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-1output is machine-usable data because callers branch on it. render(value, { app })receives the validated value and the sameappcontext 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 inapp, 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 thanApplicationError. 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 akindtag), anexitCodeother than0or1, or a value that failsresponseSchema. A renderer that returns something other than a string is alsoFATAL_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"] },
});codemust be upper snake case and must not be one of Clinkr's codes (the keys ofDEFAULT_CLINKR_ERROR_CODES).descriptionmust be a nonempty single-line string. Otherwise the constructor throwsTypeError.contextis what went wrong this time. An empty context counts as absent.- An optional
detailsobject carries structured, machine-readable facts about the occurrence. JSON mode emits it aserror.details; human mode never prints it, so keepcontextreadable on its own. Clinkr rejectsnulland arrays but does not otherwise validate its shape or check that it is JSON-serializable. - An optional
causeis kept as the error'scause. - Subclasses classify the same way. Clinkr checks only the thrown value itself, not its
causechain. - Application error codes are not declared and not listed in
--json-schema. Callers branch on exit-1data, not on exit-2codes; error codes serve diagnosis. If a script would want to branch on an error code, the condition belongs at exit1.
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 status0. - A command whose specified target is absent, such as reading, showing, or deleting a named item, exits
1with data that names the target. A command whose intended use assumes the target exists, such asrun <task>, throws instead. - A query may offer a flag such as
--requirethat turns its miss into exit1. No flag turns exit1into exit0. - A problem that stops the command from answering, such as a missing permission or an unreachable backend, throws an
ApplicationErrorand exits2.
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-schemaThe 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
0and status1.
{
"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 --jsonFor 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." }
]
}pathis the selected group's path, empty at the root. Paths never include the application name.commandsis 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'shelpSection), and a command's nonemptyaliasesappear only when the metadata sets them, andapplication.descriptiononly when the application has one. - Entries carry no schemas. Use
--json-schemaon a leaf for its request and response contract. - The reserved
sysnamespace 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.signalis always present. It is the host'ssignaloption, or a signal that never aborts when the host supplies none.context.uiis always present. It holds host-injected UI capabilities, such asui.progressandui.elicit.context.appis the host'sappoption, passed by reference without copying or freezing. It isundefinedwhen 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