Build a command line
CLIBuild a product CLI over your MCP server, with custom commands, authentication and terminal conversations.
Expose your MCP tools as commands for people, scripts and coding agents. Tool commands need no
model key: they call the server directly. The optional chat command talks to a messaging agent
through a separate terminal endpoint.
Try the starter
Section titled “Try the starter”Start npm run mcp in one terminal. In another terminal in the same project:
npm run cli -- --helpnpm run cli -- menu --jsonnpm run cli -- order --helpCheck it worked: menu --json prints the café’s menu as JSON. order --help lists the
commands for placing and tracking orders.
Define your commands
Section titled “Define your commands”Install the optional peers when adding a CLI to an existing project:
npm i @modelcontextprotocol/client@2.3.1 @stricli/core@1.3.0src/cli.ts defines your product’s command names, groups, arguments and output:
import { fileURLToPath } from "node:url";import { defineCli, runCli } from "@flow-engineer/flow/cli";
export const cli = defineCli({ name: "cafe", server: process.env.CAFE_SERVER ?? "http://127.0.0.1:8790/mcp", description: "Menu and pickup orders", groups: { order: "Place and track orders" }, sections: { Orders: ["order"], Catalog: ["menu"] }, commands: { menu: { tool: "get_menu", columns: ["item", "milks", "allergens"] }, "order status": { tool: "order_status", args: ["order_id"] }, },});
if (process.argv[1] === fileURLToPath(import.meta.url)) { process.exit(await runCli(cli, process.argv.slice(2)));}Tools without an explicit command use their tool name with underscores changed to dashes.
Commands support --json and --fields; writes support --dry-run, and actions that can delete
or overwrite require --yes without an interactive terminal. Add your own Stricli commands
alongside the generated tool commands. Handwritten commands execute locally without discovery,
including their help and argument parsing; an explicit this.cli.call(...) calls MCP. A handwritten
command takes precedence over a derived command with the same name. flow check checks that your
CLI configuration still matches the tools.
sections groups top-level commands and groups under headings in the order declared; remaining
commands appear under Other. chat, when configured, comes first; built-in commands appear under
This CLI. flow check rejects unknown names and a name listed in more than one section.
Run cafe help order to find commands whose names or descriptions contain every supplied word.
Format results for people
Section titled “Format results for people”Add output and format to a tool to share readable
Markdown across MCP and the CLI. A command without columns or view prints that text by
default; --json and --fields keep using the structured result.
For terminal rendering, install these optional peers:
npm i marked@12.0.2 marked-terminal@7.3.0For a tool returning { order_id: "BL-1040", ready_by: "10:30" }, the example formatter prints
Order BL-1040 is ready by 10:30. Piped output, NO_COLOR, and installations without these
peers print the Markdown as written. A formatter error falls back to JSON and records
formatError on tool.end; it does not turn a successful operation into a failure.
For an account-protected server, run the CLI’s auth login command. It signs the person in with
OAuth; automation can use the configured token environment variable instead. The account and
permissions are verified by the same MCP server.
Run npx @flow-engineer/flow cli --server <url> --help for a generic CLI over an existing MCP
server. Always use the full package name with npx; npx flow fetches a different package.
Common commands
Section titled “Common commands”npm run cli -- --helpnpm run cli -- order --helpnpm run cli -- menu --jsonnpm run cli -- order status BL-1040 --jsonnpm run cli -- order place --dry-run \ --name Sam --items '[{"item":"Latte","size":"large","milk":"oat"}]'The order status result depends on whether that order exists in the running café server.
--dry-run previews the operation without invoking the write tool.
List and read skills
Section titled “List and read skills”Use skill as a catalog and reader, without loading every skill body:
npm run cli -- skill listnpm run cli -- skill get allergensnpm run cli -- skill get cafe-cli allergens --jsonExpected result: list shows names and descriptions, including the generated cafe-cli guide
and the café’s allergens workflow. get allergens prints that skill’s full instructions. The
last command returns two named records, each with name, description and content; no other
skill bodies are read. Replace cafe-cli with <your-cli-name>-cli in your own project.
Bare skill is the same as skill list. skill list --json returns only names and descriptions;
skill get <name> [name...] --json returns an array even for one selected skill. The generated
CLI guide includes handwritten commands and maps MCP tool names to configured CLI command paths.
Workflow instructions come from the same agent/skills/ source used by MCP and messaging.
To install selected skills:
npm run cli -- skill get cafe-cli allergens --write ~/.claude/skillsThis writes <dir>/<skill-name>/SKILL.md for each selected skill. Additional source assets are
not copied; use native plugin packaging to distribute those. The existing
skill --write <dir> command continues to export only the generated CLI guide. A handwritten
workflow with the same name takes precedence in the catalog; the legacy export still produces
the generated guide.
Use the standalone MCP endpoint (/mcp) for workflow skills. Its existing read_skill descriptor
advertises names and descriptions in _meta["flow.engineer/skills"]; bodies are fetched only by
get. Older MCP servers without this metadata still expose names through the reader’s schema,
with empty descriptions. Unknown names trigger one discovery refresh before the CLI reports an
error. All requested names are checked before a batch is read or exported.
Cached discovery and server updates
Section titled “Cached discovery and server updates”MCP-backed commands execute on your deployed MCP server; ship that server alongside the CLI.
The CLI caches tool definitions and the server’s name/version in ~/.config/<name>/discovery/
(XDG_CONFIG_HOME or CliConfig.directory overrides the location). Entries are scoped to the
endpoint, client and credentials. Discovery files have mode 600 and contain no credentials or tool results.
Cached definitions load immediately, including older snapshots. Help, tools, skill and dry
runs do not wait for a server check. Once a snapshot is five minutes old, a background refresh
checks the server and fetches current definitions. Installed CLIs use a separate process that
survives the command exiting, with a 15-second limit. Concurrent commands share one refresh;
background failures keep the previous definitions and do not add output.
Tool calls still connect and authenticate. If the server’s name or version differs, the CLI fetches definitions and rebuilds the generated command’s parser and approval flags before sending the call. Bump the MCP server’s version when changing tool contracts or safety annotations. Periodic discovery also picks up changes when a server does not bump its version.
If an unknown command or invalid argument could be caused by an old cache, the CLI fetches current definitions and reparses once before execution. If the server rejects arguments after a request has been sent, the CLI refreshes definitions and reports the error with a help hint. It never silently resubmits that request or reruns a handwritten command.
To force current discovery:
npm run cli -- tools --refresh-tools --jsonExpected result: the CLI fetches and prints the server’s current tool definitions and updates the cache. A new tool can also be invoked directly; an unknown command triggers foreground discovery. Missing or malformed cache files require discovery. An unwritable cache directory does not stop commands working, but each invocation must discover again.
Set discoveryCache: false in defineCli to disable disk caching, or
discoveryCache: { maxAgeMs: 60_000 } for a one-minute background interval. Handwritten local
commands need no MCP server unless they explicitly call it; --refresh-tools opts into discovery.
If a handwritten command declares its own --refresh-tools flag, that flag keeps its local meaning;
use tools --refresh-tools for framework discovery.
Local playground CLI
Section titled “Local playground CLI”Run the actual CLI inside a local playground
A local playground can run the team’s actual CLI, including custom commands, OAuth, interactive chat, confirmations, stdout/stderr and exit codes. Opt in on the server that runs on your computer:
import { cli } from "./cli.ts"; // the team's defineCli configurationawait play.listen(agent, 8790, { localCli: { channel: "terminal", config: cli } });// For a local agent mounting its playground under /playground:const handler = play.handler(agent, { token, base: "/playground", localCli: { channel: "terminal", config: cli } });The named channel must be declared on the agent. Its tab opens the runner instead of simulated
chat. Each browser gets an independent temporary sign-in and conversation directory; the test
person picker does not grant it an account. Run auth login, open the sign-in link, then return
to the runner. chat keeps reading input until /exit; reload reconnects to that session.
Clear output clears the viewport; New CLI session ends the session and removes its local
credentials, without clearing the account’s agent history. Sessions expire after 30 minutes
without browser activity, and a server restart starts fresh. Ending a session does not undo
work already accepted by the agent.
This runner accepts only direct loopback connections with a loopback Host and same-origin API
requests, even if explicitly configured on a mounted handler. It is absent by default and is
not enabled in the hosted test cloud. It invokes runCli in-process: no shell, arbitrary program
execution, expansion or pipelines. Quoted arguments work. Its configured endpoints stay fixed;
use the installed CLI for --server, --token or skill --write.
The browser CLI runner is available locally only. Hosted browser execution is not supported.
Embedded clients can set CliConfig.directory to isolate credential/conversation/discovery files and
CliIO.env to avoid inheriting process tokens; CliIO.signal cancels sign-in and terminal HTTP
requests. CliIO.background(work) lets an embedded host schedule discovery refreshes without
delaying the response; invoke work while the session is alive. The local playground supplies this
scheduler and cancels refreshes when the session ends. Defaults retain the installed CLI’s existing
environment and config directory.
Terminal conversations
Section titled “Terminal conversations”chat talks directly to the agent, using the same bearer as MCP; it is not an MCP tool. defineCli({ name: "acme", server, agent: "https://agent.example.com/agent/terminal" })
overrides the terminal URL; the default is the MCP server’s origin plus /agent/terminal.
acme chat "Which orders are ready?" --json prints one response; acme chat reads a TTY until
Ctrl-D or /exit. Without a TTY pass a message to chat (an interactive chat exits 2); sign-in failures exit 3.
chat.json is mode 600 and scoped by terminal endpoint. --new forgets the terminal’s saved
reference; the agent keeps its one-person history across channels. It does not erase a person’s
WhatsApp history. Teams can replace chat with their own Stricli command in commands.
Mount the optional terminal handler beside MCP (no model or extra dependency in the CLI):
import { terminalServer } from "@flow-engineer/flow/mcp";const terminal = terminalServer({ agent: () => agent, auth });const agent = createAgent({ ...options, channels: [...channels, terminal.channel] });// Route terminal.path to terminal.fetch(request), and MCP's paths to mcp.fetch(request).terminalServer accepts path and channel overrides. It verifies auth on every POST,
serializes concurrent requests per person, and returns { conversationId, replies } after the
turn settles. The authenticated key becomes the same person that resolve returns on other
channels. A body cannot choose its own person; a conversation reference from another person is
refused. Its custom UI uses Markdown and exports turns as channel cli.
For your own HTTP adapters, agent.request({ channel, key, text, conversationId? }) exposes this
without the handler. Supply a verified key and serialize calls for that channel and person.
Thanks, we read every comment.