Build a Command-Line Tool with @sdxc/cli
By Sergio Xalambrí ·
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.
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.
// src/todos.tsimport { 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.
// src/commands.tsimport { 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.
// src/cli.tsimport { 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.
// 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.
// package.json{ "name": "todo", "type": "module", "bin": { "todo": "./bin/todo.ts" } }
And we can use it.
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.
todo --help
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.
// src/commands.tsimport { 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.
// src/cli.test.tsimport { 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.