CLI
The same contract that drives the HTTP API, MCP tools and agent tools also
drives a command-line program. createCli turns contract methods into commands
— myapp generate "a fox" --wait, myapp models list --json | jq … — run
through the very same validation, auth gate and error model as every other
surface (HTTP ≡ MCP ≡ agent ≡ CLI, ADR 0014).
It exists for what the other three surfaces cannot do: a generation kicked off
with Bash(run_in_background) that notifies on exit, a SKILL.md that shells
out in one line, a pipeable terminal command.
Exposure is opt-in
Unlike MCP and agent — where an endpoint with no expose is a tool by default —
a method becomes a CLI command only when its expose lists 'CLI'. Adding
the CLI never silently turns your existing API tools into shell commands.
{ method: 'POST', path: '/', desc: 'Generate media', toolName: 'generate',
expose: ['CLI', 'MCP', 'AGENT'], input: GenerateInput, output: Generation } // CLI + MCP + agent
{ method: 'GET', path: '/models', desc: 'List models', toolName: 'list_models',
expose: ['CLI'] } // CLI only
{ method: 'GET', path: '/search', desc: 'Search' } // HTTP + MCP + AGENT — NOT CLI
A fresh contract shows zero CLI commands until methods opt in — that is the
design, not a bug. The command name is the tool name — toolName if set, else a
verb-aware name from the method + prefix (list → list_widgets, get →
get_widget), not a literal prefix_key.
A minimal CLI
stitchkit ships no binary — you write the executable and point your app’s bin
at it:
#!/usr/bin/env node
// src/cli.ts
import { createCli } from 'stitchkit/cli'
import { catalogService, generateService } from './services'
await createCli({
name: 'myapp',
version: '1.0.0',
services: [catalogService, generateService],
auth: process.env.MYAPP_TOKEN, // resolved ONCE, like a stdio MCP server
})
// package.json
{ "bin": { "myapp": "./dist/cli.js" } }
stitchkit/cli pulls in neither the MCP SDK nor ai, so a CLI binary needs no
MCP/agent peer dependencies.
Calling commands
<app> <command> [positional] [--flags]
Arguments are coerced to the contract schema’s types — every argv token is a string, the schema says what it should be:
| Zod field | CLI |
|---|---|
z.string() |
--name "box" or a positional |
z.number() |
--count 3 → 3 |
z.boolean() |
--active (presence) / --no-active |
z.enum([...]) |
--size large |
z.array(z.string()) |
--tag a --tag b → ["a","b"] |
z.object({...}) |
--opts '{"k":"v"}' (JSON) or --opts.k v |
.optional() / .default() |
not required |
Positional arguments fill non-boolean fields in declaration order, so
myapp generate "a fox" is --prompt "a fox". A piped value fills the first
unset field: echo "a fox" | myapp generate.
The advertised schema is never mutated — a CLI call validates against the exact same Zod schema an HTTP or MCP call does.
Global flags
| Flag | Effect |
|---|---|
--json |
Raw JSON on stdout for piping / scripts |
--wait |
Block-poll an async result to a terminal state |
--wait-timeout <s> |
Override the --wait timeout |
--output-dir <dir> |
Download result media into a directory |
--quiet |
Suppress non-essential stderr output |
--dry-run |
Print the resolved call without executing |
--help, -h |
Usage — top-level or per-command flag table |
stdout carries the result; errors and progress go to stderr, so --json stays
pipeable and 2>/dev/null stays clean. The process exit code carries the error
class (0 ok, VALIDATION_ERROR → 1, UNAUTHORIZED → 2, FORBIDDEN → 3,
NOT_FOUND → 4, …) — override per app with exitCodes.
--wait — background-friendly generation
--wait polls an async result until it is done. It is generic — the core knows
nothing about “generations”: you say how to read the poll target, which command
to re-call and when it is done.
await createCli({
name: 'myapp',
version: '1.0.0',
services,
wait: {
generate: {
tool: 'get_generation',
poll: (r) => (isRecord(r) && typeof r.id === 'string' ? { id: r.id } : null),
done: (r) => isRecord(r) && r.status === 'COMPLETED',
},
},
})
# foreground
myapp generate "a fox" --wait --output-dir ./out
# background — frees the agent; a notification fires on exit
myapp generate "a fox" --wait --json > result.json &
Auth parity
A scoped command is guarded by the same createAuthHook your HTTP server uses —
pass it as lifecycle, and inject the identity through context so
resolveFromContext can read it:
const authHook = createAuthHook({ /* resolve, resolveFromContext, rules */ })
await createCli({
name: 'myapp',
version: '1.0.0',
auth: await resolveIdentityFromToken(process.env.MYAPP_TOKEN),
context: (identity) => ({ user: identity }), // resolveFromContext reads this
lifecycle: { beforeHandle: authHook }, // same gate as HTTP
services,
})
Without lifecycle, a scoped command runs unguarded — the scope check lives
entirely inside the createAuthHook result, so with no hook wired in there is
nothing to enforce a method’s scope. This matches the MCP / agent surfaces
exactly (ADR 0014): on every tool
transport the auth gate is opt-in, so a contract with scoped methods must be
given a lifecycle (and context identity) to be protected. The gate only
fails closed once the hook is present but resolveFromContext is missing —
then a scoped call has no identity and is rejected.
Typed context
Use createToolkit<AppContext>() to type the injected context against your
app’s context shape — the tool-side mirror of createImplement
(ADR 0017):
const tools = createToolkit<{ user: User }>()
await tools.createCli({
name: 'myapp',
version: '1.0.0',
services,
context: (identity) => ({ user: identity }), // checked against { user: User }
})
Not in v1
File-upload (multipart) endpoints are CLI-invisible, the same as on MCP /
agent — a dedicated upload command (auto-uploading local paths) is future work.
Streaming (SSE) output is not yet piped to stdout.