# Build a Command-Line Tool with @sdxc/cli

Used: remix@3.0.0 - @sdxc/cli@2026.10.16

Most CLIs start as a script reading `process.argv` by hand. That works for one command, but once you have a few you end up writing the parsing, the `--help` output, the error messages and the exit codes yourself, and keeping all of them in sync with what the commands actually accept.

With `@sdxc/cli` we declare the commands once, like we declare routes in Remix v3, and the parser, the help, the types of each handler and the prompts all come from that declaration. Let's see how to use it by building a small todo list CLI.

## Install the Package

We need `@sdxc/cli` and `remix`, since we will use `remix/data-schema` to validate the arguments.

```bash
bun add @sdxc/cli remix
```

Any schema library that follows the Standard Schema spec works too, but `remix/data-schema` is what I use here.

## Store the Todos

First we need somewhere to keep the todos, so let's save them in a `todos.json` file in the directory where the command runs.

```ts {% path="src/todos.ts" %}
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";

export interface Todo {
	id: number;
	text: string;
	done: boolean;
}

const FILE_NAME = "todos.json";

export async function readTodos(cwd: string): Promise<Todo[]> {
	let path = join(cwd, FILE_NAME);
	if (!existsSync(path)) return [];
	return JSON.parse(await readFile(path, "utf8"));
}

export async function writeTodos(cwd: string, todos: Todo[]) {
	await writeFile(join(cwd, FILE_NAME), JSON.stringify(todos, null, 2));
}
```

Both functions receive the directory instead of using `process.cwd()`. This will be useful later when we test the commands.

## Declare the Commands

Now we declare what our CLI accepts. We will have three commands: `add` to create a todo, `list` to show them, and `done` to mark one as finished.

```ts {% path="src/commands.ts" %}
import { argument, command, commands, flag } from "@sdxc/cli";
import * as s from "remix/data-schema";
import * as coerce from "remix/data-schema/coerce";

export let contract = commands({
	add: command({
		description: "Add a todo",
		arguments: { text: argument(s.string()) },
	}),
	list: command({
		description: "List your todos",
		flags: {
			all: flag.boolean("all", { short: "a", description: "Include finished todos" }),
		},
	}),
	done: command({
		description: "Mark a todo as finished",
		arguments: { id: argument(coerce.number()) },
	}),
});
```

Each argument receives a schema. Everything in `argv` is a string, so for the `id` we use `coerce.number()`, which turns `"1"` into `1` and rejects anything that isn't a number.

The `--all` flag is a boolean, so `todo list --all` or `todo list -a` turns it on.

## Map the Handlers

With the contract declared, we create the CLI and map each command to a handler, the same way we map a route to a controller.

```ts {% path="src/cli.ts" %}
import { createCLI, failure, success } from "@sdxc/cli";

import { contract } from "./commands";
import { readTodos, writeTodos } from "./todos";

export let cli = createCLI({ name: "todo", version: "1.0.0", commands: contract });

cli.map(contract.add, async (ctx) => {
	let todos = await readTodos(ctx.cwd);
	let id = todos.length + 1;
	todos.push({ id, text: ctx.arguments.text, done: false });
	await writeTodos(ctx.cwd, todos);
	ctx.stdout(`Added #${id}\n`);
	return success();
});

cli.map(contract.list, async (ctx) => {
	let todos = await readTodos(ctx.cwd);
	for (let todo of todos) {
		if (todo.done && !ctx.flags.all) continue;
		ctx.stdout(`${todo.done ? "✓" : "○"} #${todo.id} ${todo.text}\n`);
	}
	return success();
});

cli.map(contract.done, async (ctx) => {
	let todos = await readTodos(ctx.cwd);
	let todo = todos.find((todo) => todo.id === ctx.arguments.id);
	if (!todo) return failure(new Error(`There's no todo #${ctx.arguments.id}`));
	todo.done = true;
	await writeTodos(ctx.cwd, todos);
	ctx.stdout(`Finished #${todo.id} ${todo.text}\n`);
	return success();
});
```

The types of `ctx.arguments` and `ctx.flags` come from the contract, so `ctx.arguments.id` is a `number` and `ctx.flags.all` is a `boolean`.

Handlers don't throw, they return a result, and the result decides the exit code. A `success()` exits with 0, and a `failure(error)` prints the error message and exits with 1.

## Run the CLI

Now we need an entry point that passes the arguments to the CLI and sets the exit code.

```ts {% path="bin/todo.ts" %}
#!/usr/bin/env bun
import { cli } from "../src/cli";

process.exitCode = await cli.run(process.argv.slice(2));
```

Then we add it to the `bin` field of our `package.json` and run `bun link`, so the `todo` command is available everywhere.

```json {% path="package.json" %}
{
	"name": "todo",
	"type": "module",
	"bin": { "todo": "./bin/todo.ts" }
}
```

And we can use it.

```bash
todo add "Buy milk"           # Added #1
todo add "Walk the dog"       # Added #2
todo done 1                   # Finished #1 Buy milk
todo list                     # ○ #2 Walk the dog
todo list --all               # ✓ #1 Buy milk
                              # ○ #2 Walk the dog
```

We also get the help, generated from the descriptions in the contract.

```bash
todo --help
```

```txt
Usage: todo <command> [options]

Commands:
  add   Add a todo
  list  List your todos
  done  Mark a todo as finished

Options:
  --version   Print the version
  -h, --help  Show this help
```

And if something is wrong, the CLI explains it without us writing any error message. A typo like `todo lsit` suggests `list`, and `todo done abc` prints `<id>: Expected number` followed by the help of `done`, and exits with 2.

## Ask for Missing Arguments

Right now `todo add` without a text is an error. It would be nicer to ask for it, so let's add a prompt to the argument.

```ts {% path="src/commands.ts" %}
import { argument, command, commands, flag, prompt } from "@sdxc/cli";

// ... previous code

	add: command({
		description: "Add a todo",
		arguments: {
			text: argument(s.string(), {
				prompt: prompt.text({ message: "What do you need to do?" }),
			}),
		},
	}),

// ... previous code
```

Now `todo add "Buy milk"` works as before, and `todo add` asks the question in the terminal.

The prompt only runs when there's a terminal to answer it. If the command runs in CI or with stdin piped, there's nobody to ask, so the missing text is still an error, instead of a process waiting forever for an answer.

## Test the Commands

Finally, let's test the CLI. The package comes with an `invoke` function that runs a command without a terminal and gives us back the exit code and the output.

```ts {% path="src/cli.test.ts" %}
import { invoke } from "@sdxc/cli/test";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";

import { cli } from "./cli";

test("adds a todo and lists it", async () => {
	let cwd = await mkdtemp(join(tmpdir(), "todo-"));

	await invoke(cli, ["add", "Buy milk"], { cwd });
	let run = await invoke(cli, ["list"], { cwd });

	expect(run.code).toBe(0);
	expect(run.stdout).toBe("○ #1 Buy milk\n");
});

test("asks for the text when it's missing", async () => {
	let cwd = await mkdtemp(join(tmpdir(), "todo-"));

	let run = await invoke(cli, ["add"], { cwd, answers: { text: "Walk the dog" } });

	expect(run.stdout).toBe("Added #1\n");
});

test("fails on a todo that doesn't exist", async () => {
	let cwd = await mkdtemp(join(tmpdir(), "todo-"));

	let run = await invoke(cli, ["done", "42"], { cwd });

	expect(run.code).toBe(1);
	expect(run.stderr).toContain("There's no todo #42");
});
```

Each test runs in a temporary directory, which is why our handlers read `ctx.cwd` instead of `process.cwd()`. And `answers` replies to the prompts by the name of the argument they fill, so we can test the interactive path too.

And that's it, we have a working CLI with typed handlers, generated help, prompts and tests. From here we could group commands with a nested `commands()`, add a middleware to share something like a database connection with every command, or show a spinner with `ctx.task` while a slow command runs.

