Skip to content

Quick Start

This guide walks you through the basics of developing with Rune — adding commands, running tests, and building your CLI — using the starter project created during installation.

The project generated by create rune-app has the following structure:

  • Directorysrc
    • Directorycommands/
      • hello.ts
      • hello.test.ts
      • Directorytext/
        • _group.ts
        • count.ts
        • count.test.ts
  • package.json
  • tsconfig.json

The starter includes a simple top-level hello command plus a text command group with a count subcommand. In Rune, the file structure under src/commands maps directly to your CLI’s command structure. See the Routing guide for details.

Let’s start by looking at the hello command included in the starter. Here is src/commands/hello.ts:

src/commands/hello.ts
import { defineCommand } from "@rune-cli/rune";
export default defineCommand({
description: "Say hello from your new Rune CLI",
run({ output }) {
output.log("hello from my-cli");
},
});

You define a command by passing an object to defineCommand() and default-exporting the result. The run function is where your command logic lives. Writing to stdout via output.log allows runCommand to capture the output during testing.

You already verified this works during installation, but let’s run it again:

Terminal window
$ pnpm start hello
hello from my-cli

The starter also includes colocated test files. Let’s take a look at src/commands/hello.test.ts:

src/commands/hello.test.ts
import { runCommand } from "@rune-cli/rune/test";
import { expect, test } from "vitest";
import helloCommand from "./hello";
test("hello command prints a greeting", async () => {
const result = await runCommand(helloCommand);
expect(result.stdout).toEqual("hello from my-cli\n");
});

runCommand from @rune-cli/rune/test executes a command in-process without spawning a child process, making it easy to assert on its output. Here it verifies that the stdout is hello from my-cli. The starter ships with Vitest out of the box, so you can run the tests right away:

Terminal window
pnpm test

You should see output like this:

RUN v4.1.4 /path/to/project
✓ src/commands/hello.test.ts (1 test) 1ms
✓ src/commands/text/count.test.ts (2 tests) 2ms
Test Files 2 passed (2)
Tests 3 passed (3)
Start at 22:42:44
Duration 96ms (transform 22ms, setup 0ms, import 59ms, tests 3ms, environment 0ms)

As mentioned above, the starter includes not only the top-level hello command, but also a text command group. When you create a directory under src/commands, such as src/commands/text/, Rune treats that directory name as a command group and exposes its routable files as subcommands.

This starter already includes src/commands/text/count.ts, so the text count command is available automatically. src/commands/text/_group.ts defines metadata for the group, such as its description.

Let’s run text count. This command counts the words in the given input:

Terminal window
$ pnpm start text count "hello rune world"
3

Now let’s extend the existing text command group by adding a transform subcommand.

Create src/commands/text/transform.ts with the following content:

src/commands/text/transform.ts
import { defineCommand } from "@rune-cli/rune";
export default defineCommand({
description: "Transform the input text",
args: [
{
name: "input",
type: "string",
required: true,
},
],
run({ args, output }) {
output.log(args.input.toUpperCase());
},
});

When you add this file under src/commands/text/, Rune recognizes it as the text transform command. It also defines a positional argument via args, available as args.input inside the run function with full type safety.

Let’s try it out:

Terminal window
$ pnpm start text transform hello
HELLO

Next, let’s add a --mode option to the transform command. This is an enum option that accepts either upper or lower, letting you choose whether the input should be transformed to uppercase or lowercase. Add it to options:

src/commands/text/transform.ts
import { defineCommand } from "@rune-cli/rune";
export default defineCommand({
description: "Transform the input text",
options: [
{
name: "mode",
type: "enum",
values: ["upper", "lower"],
default: "upper",
description: "Transformation to apply",
},
],
args: [
{
name: "input",
type: "string",
required: true,
},
],
run({ args, output }) {
output.log(args.input.toUpperCase());
run({ options, args, output }) {
const result = options.mode === "upper" ? args.input.toUpperCase() : args.input.toLowerCase();
output.log(result);
},
});

With --mode lower, the command lowercases the input instead:

Terminal window
$ pnpm start text transform HELLO --mode lower
hello

You can also verify that options and arguments are automatically reflected in the help output with --help:

Transform the input text
Usage: my-cli text transform [options] <input>
Options:
--mode <upper|lower> Transformation to apply (default: "upper")
-h, --help Show help
Arguments:
input <string>

Just as we tested the hello command, let’s write tests for transform. Create src/commands/text/transform.test.ts:

src/commands/text/transform.test.ts
import { runCommand } from "@rune-cli/rune/test";
import { expect, test } from "vitest";
import transformCommand from "./transform";
test("uppercases by default", async () => {
const result = await runCommand(transformCommand, ["hello"]);
expect(result.stdout).toEqual("HELLO\n");
});
test("lowercases with --mode lower", async () => {
const result = await runCommand(transformCommand, ["HELLO", "--mode", "lower"]);
expect(result.stdout).toEqual("hello\n");
});

The second argument to runCommand accepts an array of command-line arguments, so you can simulate real CLI invocations in your tests. Let’s run the tests:

Terminal window
pnpm test

You should see output like this:

RUN v4.1.4 /path/to/project
✓ src/commands/hello.test.ts (1 test) 1ms
✓ src/commands/text/count.test.ts (2 tests) 2ms
✓ src/commands/text/transform.test.ts (2 tests) 2ms
Test Files 3 passed (3)
Tests 5 passed (5)
Start at 22:42:44
Duration 164ms (transform 56ms, setup 0ms, import 86ms, tests 4ms, environment 0ms)

Build your Rune project to produce a distributable CLI binary.

Terminal window
pnpm build

The build output is written to the dist/ directory. The entry point specified in the bin field of package.json (defaults to dist/cli.mjs) can be executed via npx or after a global install.