@rune-cli/rune
v0.3.3
Published
Rune is an agent-native CLI framework designed for the agentic era.
Downloads
1,108
Readme
Rune
Rune is an agent-native CLI framework designed for the agentic era. It is built around two principles:
- Rune must be a CLI framework that is easy to understand not only for humans, but also for agents
- CLIs built with Rune must likewise be easy to work with for both humans and agents
Key features:
- File-based command routing — directory structure maps directly to the CLI command tree
- Type-safe command definitions with full inference from
defineCommand() - Global options via
defineConfig({ options }) - Global lifecycle hooks via
defineConfig({ hooks }) - Project locals via
defineConfig({ locals }) - Standard Schema support for options and args (Zod, Valibot, ArkType, ...)
- Built-in
--jsonmode that turns the same command into a machine-readable API, auto-enabled under AI agents - In-process test utility with no child-process overhead
- Automatic
--helpgeneration, with per-command and project-wide customization hooks - Structured errors via
CommandError, rendered for humans or emitted as JSON - Official Agent Skill for AI agents working on Rune projects
[!IMPORTANT] This package is experimental and unstable. Proceed with caution when using it.
Getting Started
Rune requires Node.js v22.12.0 or higher.
Scaffold a new project:
npm create rune-app@latest my-cli
cd my-cli
npm installThis generates the following structure:
my-cli/
src/
commands/
hello.ts
hello.test.ts
text/
_group.ts
count.ts
count.test.ts
package.json
tsconfig.jsonRun your CLI directly from source:
npm run start -- helloBuild for production:
npm run buildDefining Commands
Commands are TypeScript files under src/commands/. The directory structure maps directly to the command structure:
src/commands/
index.ts -> my-cli
hello.ts -> my-cli hello
project/
index.ts -> my-cli project
create.ts -> my-cli project create
list.ts -> my-cli project listSimple leaf commands can be bare files (hello.ts), while commands that need subcommands use a directory with index.ts. Only the matched leaf command module is loaded at runtime.
Files and directories whose command name starts with _ are ignored by routing, so command-specific helpers can live next to the command that uses them. Colocated test files ending in .test.ts or .spec.ts are also ignored:
src/commands/
deploy.ts -> my-cli deploy
deploy.test.ts -> ignored
_deploy-logic.ts -> ignored
project/
_group.ts -> group metadata
_schema.ts -> ignored
create.ts -> my-cli project create_group.ts is a reserved metadata file, not a private helper. The _ prefix keeps Rune-owned metadata and private implementation files out of the public command namespace.
Each command file exports a default defineCommand() call:
import { defineCommand } from "@rune-cli/rune";
export default defineCommand({
description: "Greet someone",
options: [{ name: "loud", type: "boolean", short: "l" }],
args: [{ name: "name", type: "string", required: true }],
run({ options, args, output }) {
const greeting = `Hello, ${args.name}!`;
output.log(options.loud ? greeting.toUpperCase() : greeting);
},
});Command Groups
Place a _group.ts file in a command directory to attach metadata (description, aliases, examples) to the group itself:
import { defineGroup } from "@rune-cli/rune";
export default defineGroup({
description: "Manage projects",
});Options and Arguments
options are --name flags; args are positional. Required args must come before optional ones.
Global options can be defined once in rune.config.ts and are available to every executable command:
import { defineConfig } from "@rune-cli/rune";
export default defineConfig({
options: [{ name: "profile", type: "string", env: "APP_PROFILE", default: "prod" }],
});Use them after the resolved command path:
my-cli deploy --profile devGlobal Hooks
Project-wide hooks can be registered in rune.config.ts and run around each matched command's run() lifecycle:
import { defineConfig } from "@rune-cli/rune";
export default defineConfig({
hooks: {
beforeRun(ctx) {
ctx.output.error(`running ${ctx.command.path.join(" ")}`);
},
afterRun(ctx) {
ctx.output.error(`completed ${ctx.result.kind}`);
},
onRunError(ctx) {
ctx.output.error(`${ctx.stage} failed: ${ctx.error.message}`);
},
},
});Hooks run only after routing and argument parsing succeed for an executable command. They do not run for help, version, unknown-command, group-help, JSON-help, or parse-failure paths. Hook context exposes parsed args, parsed options, outputMode, command metadata, output, and stdin. Global options declared in the same defineConfig() call are inferred on hook ctx.options.
Project Locals
Project-defined runtime values can be created once per command invocation in rune.config.ts and read from ctx.locals:
import { defineConfig } from "@rune-cli/rune";
export default defineConfig({
options: [{ name: "profile", type: "string", default: "prod" }],
async locals(ctx) {
const workspace = await resolveWorkspace(ctx.cwd);
return {
workspace,
api: createApiClient({ profile: ctx.options.profile }),
};
},
});locals runs after routing and argument parsing succeed, before beforeRun. It does not run for help, version, unknown-command, group-help, JSON-help, or parse-failure paths. Global options declared in the same defineConfig() call are inferred on ctx.options. The factory context intentionally does not include stdin; hooks and commands can still use ctx.stdin.
Primitive Fields
| Property | Description |
| ------------- | -------------------------------------------------------- |
| name | Identifier used as the key in ctx.args / ctx.options |
| type | "string", "number", "boolean", or "enum" |
| required | Whether the field must be provided |
| default | Default value; shown in --help output |
| description | Help text |
| short | Single-letter alias (options only, e.g. "f" → -f) |
| env | Environment variable fallback for scalar options |
| multiple | Allow an option to be repeated and parsed as an array |
defineCommand({
options: [
{ name: "retries", type: "number", default: 3, description: "Retry count" },
{ name: "verbose", type: "boolean", short: "v" },
],
args: [{ name: "name", type: "string", required: true }],
run({ options, args }) {
// options.retries: number, options.verbose: boolean, args.name: string
},
});Environment Variable Fallback
Scalar options can read an environment variable when the CLI flag is omitted. Priority is CLI > env > default, and env values use the same parser and validation path as CLI values.
defineCommand({
options: [{ name: "port", type: "number", env: "PORT", default: 3000 }],
run({ options }) {
// --port 4000 wins over PORT=5000; PORT wins over default
// options.port: number
},
});env does not affect type inference. Repeatable options cannot use it.
Enum Fields
Use type: "enum" with a values list to accept only a fixed set of string or number choices. The allowed-values union is inferred automatically and rendered in --help.
defineCommand({
options: [{ name: "mode", type: "enum", values: ["dev", "prod"], default: "dev" }],
args: [{ name: "target", type: "enum", values: ["web", "node"], required: true }],
run({ options, args }) {
// options.mode: "dev" | "prod", args.target: "web" | "node"
},
});String values must match /^[A-Za-z0-9_.-]+$/. For free-form strings or runtime validation (regex, uniqueness, transformation), use a type: "string" field or a schema field.
Repeatable Options
Set multiple: true on a string, number, enum, or schema value option to allow repeated flags. Values are parsed in declaration order and exposed as an array. If omitted, the option is optional unless you provide an array default such as [] or set required: true.
defineCommand({
options: [
{ name: "tag", type: "string", multiple: true, default: [] },
{ name: "level", type: "number", multiple: true },
],
run({ options }) {
// options.tag: string[], options.level?: number[]
},
});Primitive boolean options and schema flag: true options cannot be repeatable.
Standard Schema Fields
Use schema instead of type to plug in any Standard Schema-compatible library (Zod, Valibot, ArkType, ...). Rune calls validators through the Standard Schema contract, so there is no lock-in to a specific library.
import { defineCommand } from "@rune-cli/rune";
import { z } from "zod";
export default defineCommand({
description: "Fetch a resource by id",
options: [{ name: "retries", schema: z.coerce.number().int().min(0).max(10), defaultLabel: "3" }],
args: [{ name: "id", schema: z.uuid(), typeLabel: "uuid", description: "Resource id" }],
run({ options, args }) {
// options.retries: number, args.id: string
},
});typeLabel and defaultLabel are display-only hints rendered in --help. Required/optional semantics are derived from the schema itself.
Negatable Boolean Options
A primitive boolean option with default: true automatically gets a --no-<name> counterpart:
options: [{ name: "color", type: "boolean", default: true }];
// --color -> true (default)
// --no-color -> falseKebab-case Field Names
Hyphenated field names (e.g. dry-run) are accessible as both ctx.options["dry-run"] and ctx.options.dryRun, with full type safety.
JSON Output
Set json: true to opt into a built-in --json flag. The run() return value becomes the structured output, while output.log() is suppressed so the stdout stream remains machine-parseable. output.error() still writes to stderr, and JSON error payloads are emitted to stderr on failure. Inside run(), options.json is true whenever JSON mode is active.
export default defineCommand({
json: true,
run({ options }) {
if (!options.json) {
// Render human-readable output here.
}
return { items: [1, 2, 3] };
},
});my-cli # human-readable text CLI
my-cli --json # {"items":[1,2,3]}Under AI agents (Claude Code, Cursor, Codex, etc.), json: true commands auto-enable JSON mode even without --json, so a single command serves both humans and agents seamlessly. Detection only triggers on known agent environment variables — CI jobs and shell pipes continue to produce human-readable output unless --json is passed explicitly. Auto-enabled JSON mode also sets options.json to true.
Set RUNE_DISABLE_AUTO_JSON=1 to opt out of auto-activation while keeping --json working as usual. This is mainly intended for AI agents that are themselves developing a Rune-based CLI and need to inspect the human-facing output.
JSON Lines Output
Set jsonl: true when a command's stdout contract is a stream of newline-delimited JSON records. The run() function returns an Iterable or AsyncIterable; Rune serializes each record as one compact JSON line, suppresses output.log(), and keeps output.error() on stderr.
export default defineCommand({
jsonl: true,
async *run() {
yield { id: "a" };
yield { id: "b" };
},
});my-cli # {"id":"a"}
# {"id":"b"}JSON Lines mode is fixed for the command; Rune does not add a --jsonl flag and json: true cannot be combined with jsonl: true. If the downstream pipe closes early, Rune treats that broken pipe as a normal early stop instead of printing an error. If you need both a human view and a JSON Lines stream, prefer separate commands. In tests, runCommand() exposes yielded records as result.output.records.
Structured Errors
CommandError carries kind, message, hint, and details. Rune formats it for humans in normal mode and emits it as structured JSON to stderr under --json.
import { CommandError, defineCommand } from "@rune-cli/rune";
export default defineCommand({
json: true,
run() {
throw new CommandError({
kind: "not-found",
message: "Resource not found",
hint: "Check the id and try again",
});
},
});Help Output
--help output is generated from description, options, args, examples, and the surrounding command tree. Override per command with defineCommand({ help }), or configure project-wide help and CLI metadata via rune.config.ts:
import { defineConfig, renderDefaultHelp } from "@rune-cli/rune";
export default defineConfig({
name: "my-cli",
version: "1.0.0",
help(data) {
return `${data.cliName}\n\n${renderDefaultHelp(data)}\n\nDocs: https://example.com`;
},
});name and version affect help output, --version, and JSON help metadata. When omitted, Rune derives them from package.json.
Pass --json with --help to inspect the same help data as structured JSON. This works even for commands that do not enable runtime JSON output with json: true:
my-cli deploy --help --jsonTesting
Import runCommand() from @rune-cli/rune/test to exercise commands in-process — argv parsing, type coercion, schema validation, env fallbacks, and defaults all run exactly as they do at real invocation.
import { runCommand } from "@rune-cli/rune/test";
import { expect, test } from "vitest";
import greeting from "../src/commands/index.ts";
test("greets by name", async () => {
const result = await runCommand(greeting, ["world"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("Hello, world!\n");
});Pass { env } as the third argument to test option env fallbacks. The env map replaces process.env for that command test, and omitted env defaults to an empty map so tests stay isolated from the host environment.
If you intentionally want to inherit the current process environment, merge it explicitly:
const result = await runCommand(command, [], {
env: { ...process.env, TASKS_FILE: file },
});Pass { stdin } to test commands that read ctx.stdin without mocking process.stdin:
const result = await runCommand(command, [], {
stdin: "hello\n",
});The returned CommandExecutionResult exposes exitCode, stdout, stderr, error, and an output union. Use output.document for json: true commands and output.records for jsonl: true commands.
CLI
The rune binary provides two commands:
| Command | Description |
| ------------ | ---------------------------------------------------------------------------------- |
| rune run | Run the project directly from source. Trailing args are forwarded to the user CLI. |
| rune build | Build the project into a distributable CLI. |
Both accept --project <path> to target a project root other than the current directory.
rune run hello --loud
rune build --project ./my-appAgent Skills
Rune ships an official Agent Skill that gives AI agents on-demand access to Rune-specific conventions — file-based routing, defineCommand() usage, testing patterns, and more — so agents can work on Rune projects more accurately and efficiently.
API
| Export | Description |
| --------------------- | ----------------------------------------------------------------------- |
| defineCommand(def) | Define a command. The returned value must be the file's default export. |
| defineGroup(def) | Define metadata for a command group in _group.ts. |
| defineConfig(def) | Define project-wide CLI metadata, help, options, hooks, and locals. |
| CommandError | Structured error class for command failures. |
| renderDefaultHelp() | Render the default help output as a string; useful from custom help. |
| runCommand() | (from @rune-cli/rune/test) Execute a command in-process for testing. |
Documentation
Full documentation is available at the Rune docs site.
License
Published under the MIT License.
