The CLI
Your tools already know their arguments. The schema says which ones are required, what type each one is and which values an enum takes. So why write a CLI by hand? runCli reads all of that and gives every tool its own command. No CLI library underneath, just util.parseArgs from Node.
import { runCli } from "@agntn/tools/cli";
import { slugTool } from "./tools.ts";
await runCli({
name: "slugs",
version: "1.0.0",
description: "Slugs from titles",
tools: [slugTool],
mcp: true,
});
Point your bin at the built file and that's it:
$ slugs slug --text "Crème brûlée"
creme-brulee
It's the one Node part of the package. The core and the host adapters run in a browser tab, this one reads process.argv.
How a tool turns into a command
Where does the command name come from?
From the tool name, minus the package prefix. text_slug becomes slug, hashes_digest_identify becomes digest-identify. Want another word? The cli hint on defineTool takes a command and a few aliases.
Flags straight from the schema
Every property is a flag, in kebab case. maxWords and max_words both turn into --max-words. Strings go in as they are. Integers and booleans get converted against the schema. Objects and arrays come in as JSON:
$ slugs batch --titles '["Hello World", "Crème brûlée"]' --max-words 2 --no-lower
A boolean answers to --lower and to --no-lower. Say both and the command refuses, it doesn't pick a winner. Same for an unknown option, a missing value or a word too many. Nothing on the line is quietly skipped.
Then the core validates, like on every other host. A typo here gets the same treatment a model's typo gets:
$ slugs slug --text Hello --sep _
Invalid arguments: unknown option "--sep"; takes --text, --separator, --json
$ slugs slug --text Hello --separator "~"
Invalid arguments at /separator: must be one of -, _, .
Positionals and stdin
Typing --text every time gets old fast. Two more hints fix that:
export const slugTool = defineTool({
name: "text_slug",
// …
cli: { positional: ["text"], stdin: ["text"] },
});
positional takes properties in order, as plain words. stdin lets a string property read standard input when you pass -:
$ slugs slug "Hello, World" --separator _
hello_world
$ echo "Pipe me in" | slugs slug -
pipe-me-in
Stdin has to be valid UTF-8. A lenient decode would swap bad bytes for U+FFFD, and a hash tool would happily hash something you never sent. So invalid input is an error. Got real binary data? That's a job for a command of your own, see below.
--help is built from the same fields. Descriptions come from the schema, the enum values too:
Turn a title into a lowercase URL slug. (slugs slug v1.0.0)
USAGE slugs slug [OPTIONS] <TEXT>
ARGUMENTS
TEXT Title to slug (- reads stdin) (required)
OPTIONS
--separator=<-|_|.> Joins the words. '-' when omitted
--json Print the details as JSON instead of the text
-h, --help Show this help
Short flags and the rest of the line
Quotes around every title get old too. So does --separator. Two hints bring back what every CLI written by hand had:
cli: { rest: "text", stdin: ["text"], short: { separator: "s" } },
rest takes every word the positionals leave over and joins them with single spaces. short gives an option an alias of one letter:
$ slugs slug Hello big world -s _
hello_big_world
Only a whole word counts as a short flag, though. -s _ works, -s_ is an unknown option. Pedantic? A bit. But util.parseArgs reads -separator _ as -s eparator, and next to a greedy rest one dash too few shouldn't turn into some other call. Here it fails and tells you why.
Text that starts with a dash
Is -.-. an option? util.parseArgs thinks so. In Morse it's a C. Every PGP armor line opens with five dashes, and a temperature can be -12.
So a word counts as an option only when it spells one as a whole: --separator, --no-lower, -s. Any other dashed word takes the next free positional:
$ slugs slug "-12 below zero"
12-below-zero
Free means the plain words left room for it. Once they fill every positional, a dashed word is an unknown option again. A typo after the title fails, it doesn't end up in your slug:
$ slugs slug Hello --seperator _
Invalid arguments: unknown option "--seperator"; takes --separator (-s), --json
rest counts as one positional here. slugs slug -12 below zero fails, because below and zero already took the spot. Quote the title and it's one word again. Text that does spell a flag, like -s itself, goes after --.
When does a broken hint show up?
At startup, before any command runs. A hint naming a property the tool doesn't have, two properties fighting over one flag, a boolean called noCache, a required positional after an optional one, a short flag that tries to take -h. Each one fails in createCli with a ToolDefinitionError. Not on the first user who types the wrong thing.
What comes out
The command prints the tool's text, the same text a model reads. Want data instead? --json prints details:
$ slugs slug "Hello" --json
{
"slug": "hello",
"words": 1
}
A returned isError goes to stderr with exit code 1. So slugs slug "!!!" fails like a proper command, scripts included.
A thrown error is a different story. Tell runCli which errors you throw on purpose through expected, and those print as one line with exit code 1. Anything else keeps its stack trace. A bug should look like a bug.
Errors get the same treatment as on the other hosts: one line each, so a newline in an echoed argument can't forge a second one. Escape codes and bidi tricks don't reach your terminal, not even through the tool's own text. And there are no colors at all, so nothing breaks in a pipe.
Commands only the CLI gets
Some things belong on the command line and nowhere else. A version with build details, a doctor, a reader for binary stdin. Write them as tool definitions and pass them as commands:
await runCli({
name: "slugs",
version: "1.0.0",
description: "Slugs from titles",
tools: [slugTool],
commands: [separatorsCommand],
});
MCP, Pi, OMP and the AI SDK never see them. They parse, validate and print exactly like the tools. One with the name of a generated command takes its place, mcp included.
Can it guess what I meant?
If you let it. default names the command for an empty line. fallback takes any first word that isn't a command:
fallback: "slug",
$ slugs "Hello, World"
hello-world
Just remember it's greedy. Without mcp: true, slugs mcp slugs the word "mcp". Correct, and completely useless.
An MCP server on the side
mcp: true adds an mcp command. It's createMcpServer over stdio, with the CLI's name and version as the server info:
{
"mcpServers": {
"slugs": { "command": "slugs", "args": ["mcp"] }
}
}
One binary, two jobs. The server serves tools only, your own commands stay on the command line. It needs @modelcontextprotocol/server installed, and loads it only when someone actually runs mcp.
Progress is the one thing that differs. A plain command has nowhere to show it, so progress is undefined there and your progress?.() calls stay quiet. Through slugs mcp it's a real MCP server, so a client that sends a progressToken gets every line.