Guide

The CLI

Turn the same tool list into a command line. Flags come from the schema and MCP rides along

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.

tssrc/cli.ts
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:

textterminal
$ 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. Numbers have to look like numbers: --max-words 2 works, --max-words 1.5 gets refused instead of rounded down. And 0x10? Just a typo here. Objects and arrays come in as JSON:

shellterminal
$ 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. Same checks a model's typo gets, but the answer speaks command line. You typed --separator, not /separator, so that's the name you get back:

textterminal
$ 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 -, _, .
$ slugs slug
Invalid arguments: missing --text

Every other host keeps the JSON pointer. A model reads /separator just fine.

Positionals and stdin

Typing --text every time gets old fast. Two more hints fix that:

tstools.ts
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 -:

textterminal
$ 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:

text
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

Errors borrow these names. Forget the title and you get missing <TEXT>, a blank one says at <TEXT>: must match pattern "\S". Where's /text? Back with the other hosts.

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:

tstools.ts
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:

textterminal
$ 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.

Joining is right for a title and wrong for a list. Glue "jacque fresco" venus together with spaces and nobody can tell anymore which words came as a pair. Make the property an array of strings and rest keeps each word as typed, one item apiece:

tstools.ts
input: Type.Object({ digest: Type.String(), words: Type.Array(Type.String()) }),
cli: { positional: ["digest"], rest: "words" },

hashes search 5d41 "jacque fresco" venus hands execute the words ["jacque fresco", "venus"]. Those quotes you typed finally count for something. Each item reads like any other positional word. Then the core checks the array as on every surface, minItems and enum items included.

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:

textterminal
$ 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:

textterminal
$ 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 --.

Great for Morse, less great for an id. No collection is called --withPubkey, but list --withPubkey hands it to collection anyway and the command answers Unknown collection. Wrong word blamed. The flag you wanted was --with-pubkey.

So tell the CLI which positionals take plain words only:

tstools.ts
cli: { positional: ["collection"], plain: ["collection"] },
textterminal
$ puzzles list --withPubkey
Invalid arguments: unknown option "--withPubkey"; takes --with-pubkey, --json

That's the typo named, with the flags the command does take, and not one check in execute. A rest can be plain too, then each word it takes has to be. Got an id that really starts with a dash? -- still hands it over.

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, plain on a flag. 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:

textterminal
$ 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:

tssrc/cli.ts
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.

What about bytes going out?

The CLI ends every text with a newline. Lovely for a slug. Quietly fatal for a gzip file, which comes out one byte too long. Nobody notices until a checksum does. So a command that writes bytes writes them itself and answers with no content blocks at all:

ts
cli: { json: false },
execute({ file }) {
  process.stdout.write(gunzipSync(readFileSync(file)));
  return { content: [], details: null };
},

No blocks, nothing printed, not even the newline. An empty text block still prints its empty line, so you only get silence when you ask for it. json: false takes --json away, since bytes followed by JSON is neither. A returned isError works like on any other command.

Rows now, or JSON later?

Say a command checks 114 addresses, a quarter second each. Sitting silent for half a minute and then dumping everything is a bad look. Better to print each row as it lands and answer with no content. Except now --json would glue the details under the rows, and that's neither text nor JSON.

So the CLI tells the command what was asked. host is a CliHost, and json says whether --json came along:

ts
import type { CliHost } from "@agntn/tools/cli";

async execute({ status }, { host }) {
  const cli = host as CliHost | undefined;
  const live = cli?.cli === true && !cli.json;
  const rows = await check(status, (row) => {
    if (live) process.stdout.write(`${row}\n`);
  });
  return { content: [], details: { rows } };
},

Text mode streams. --json waits and prints the whole list, valid to the last bracket. Got a pair that can't work together, like bytes on stdout and --json? Return an isError before the first byte and it lands on stderr with exit code 1. Why check cli and not just json? Off the command line host is Pi's own, OMP's or nothing at all. And on mcp, stdout is the protocol. Skip progress in a command like this. The rows already are the progress, and a progress line would sit right in front of the next one.

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:

ts
fallback: "slug",
textterminal
$ slugs "Hello, World"
hello-world

Just remember it's greedy. Leave mcp out and slugs mcp slugs the word "mcp". Correct, and completely useless.

A flag up front goes there too, so slugs --separator _ "Hello, World" works without the command name. Flags first is how half of us type anyway. Only --help, -h, --version and -v stay with the root, which answers them itself.

An MCP server on the side

mcp: true adds an mcp command. It's createMcpServer over stdio, and the server introduces itself with the CLI's name, version and help line:

json.mcp.json
{
  "mcpServers": {
    "slugs": { "command": "slugs", "args": ["mcp"] }
  }
}

A help line makes a sad connector card, though. Got a real pitch and an icon? Hand mcp the whole server info instead of true:

tssrc/cli.ts
import { serverInfo } from "./server-info.ts";

await runCli({
  name: "slugs",
  version: "1.0.0",
  description: "Slugs from titles",
  tools: [slugTool],
  mcp: serverInfo,
});

It's the same McpServerInfo that createMcpServer takes. So the /mcp on your site and slugs mcp can share one object and say hello the same way. The CLI's own description stays where it belongs, in --help.

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 shows up on both sides. On a terminal, each progress?.() line takes over one line of stderr, with (3/10) when you pass a total. It's wiped before the answer prints, so the scrollback keeps the answer and nothing else. Is stderr a pipe or a file? Then there's no line at all, since nobody wants "second 7 of 10" in a log. Through slugs mcp it's a real MCP server, so a client that sends a progressToken gets every line.