Examples
Calculator Example
Here's a more involved example, along with what it outputs:
import * as trpcServer from '@trpc/server'
import {z} from 'zod/v4'
import {createCli, type TrpcCliMeta} from '../../src/index.js'
const trpc = trpcServer.initTRPC.meta<TrpcCliMeta>().create()
const router = trpc.router({
add: trpc.procedure
.meta({
description:
'Add two numbers. Use this if you and your friend both have apples, and you want to know how many apples there are in total.',
})
.input(z.tuple([z.number(), z.number()]))
.query(({input}) => input[0] + input[1]),
subtract: trpc.procedure
.meta({
description:
'Subtract two numbers. Useful if you have a number and you want to make it smaller.',
})
.input(z.tuple([z.number(), z.number()]))
.query(({input}) => input[0] - input[1]),
multiply: trpc.procedure
.meta({
description:
'Multiply two numbers together. Useful if you want to count the number of tiles on your bathroom wall and are short on time.',
})
.input(z.tuple([z.number(), z.number()]))
.query(({input}) => input[0] * input[1]),
divide: trpc.procedure
.meta({
version: '1.0.0',
description:
"Divide two numbers. Useful if you have a number and you want to make it smaller and `subtract` isn't quite powerful enough for you.",
examples: 'divide --left 8 --right 4',
})
.input(
z.tuple([
z.number().describe('numerator'),
z
.number()
.refine(n => n !== 0)
.describe('denominator'),
]),
)
.mutation(({input}) => input[0] / input[1]),
squareRoot: trpc.procedure
.meta({
description:
'Square root of a number. Useful if you have a square, know the area, and want to find the length of the side.',
})
.input(z.number())
.query(({input}) => {
if (input < 0) throw new Error(`Get real`)
return Math.sqrt(input)
}),
})
void createCli({router, name: 'calculator', version: '1.0.0'}).run()
Run node path/to/cli --help for formatted help text for the sum and divide commands.
node path/to/calculator --help output:
Usage: calculator [options] [command]
Available subcommands: add, subtract, multiply, divide, square-root
Options:
-V, --version output the version number
-h, --help display help for command
Commands:
add <parameter_1> <parameter_2> Add two numbers. Use this if you and
your friend both have apples, and you
want to know how many apples there are
in total.
subtract <parameter_1> <parameter_2> Subtract two numbers. Useful if you have
a number and you want to make it
smaller.
multiply <parameter_1> <parameter_2> Multiply two numbers together. Useful if
you want to count the number of tiles on
your bathroom wall and are short on
time.
divide <numerator> <denominator> Divide two numbers. Useful if you have a
number and you want to make it smaller
and `subtract` isn't quite powerful
enough for you.
square-root <number> Square root of a number. Useful if you
have a square, know the area, and want
to find the length of the side.
help [command] display help for command
You can also show help text for the corresponding procedures (which become "commands" in the CLI):
node path/to/calculator add --help output:
Usage: calculator add [options] <parameter_1> <parameter_2>
Add two numbers. Use this if you and your friend both have apples, and you want
to know how many apples there are in total.
Arguments:
parameter_1 number (required)
parameter_2 number (required)
Options:
-h, --help display help for command
When passing a command along with its options, the return value will be logged to stdout:
node path/to/calculator add 2 3 output:
5
Invalid inputs are helpfully displayed, along with help text for the associated command:
node path/to/calculator add 2 notanumber output:
Note that procedures can define meta value with description, usage and help props. Zod's describe method allows adding descriptions to individual options.
import {type TrpcCliMeta} from 'trpc-cli'
const trpc = initTRPC.meta<TrpcCliMeta>().create()
const appRouter = trpc.router({
divide: trpc.procedure
.meta({
description:
'Divide two numbers. Useful when you have a pizza and you want to share it equally between friends.',
})
.input(
z.object({
left: z.number().describe('The numerator of the division operator'),
right: z.number().describe('The denominator of the division operator'),
}),
)
.mutation(({input}) => input.left / input.right),
})
Migrator Example
Given a migrations router looking like this:
import * as trpcServer from '@trpc/server'
import {z} from 'zod/v4'
import {createCli, type TrpcCliMeta} from '../../src/index.js'
import * as parseRouter from '../../src/parse-router.js'
const trpc = trpcServer.initTRPC.meta<TrpcCliMeta>().create()
const migrations = getMigrations()
const searchProcedure = trpc.procedure
.meta({
aliases: {
options: {status: 's'},
},
})
.input(
z.object({
status: z
.enum(['executed', 'pending'])
.optional()
.describe('Filter to only show migrations with this status'),
}),
)
.use(async ({next, input}) => {
return next({
ctx: {
filter: (list: typeof migrations) =>
list.filter(m => !input.status || m.status === input.status),
},
})
})
export const router = trpc.router({
up: trpc.procedure
.meta({
description:
'Apply migrations. By default all pending migrations will be applied.',
})
.input(
z.union([
z.object({}).strict(),
z.object({
to: z.string().describe('Mark migrations up to this one as exectued'),
}),
z.object({
step: z
.number()
.int()
.positive()
.describe('Mark this many migrations as executed'),
}),
]),
)
.query(async ({input}) => {
let toBeApplied = migrations
if ('to' in input) {
const index = migrations.findIndex(m => m.name === input.to)
toBeApplied = migrations.slice(0, index + 1)
}
if ('step' in input) {
const start = migrations.findIndex(m => m.status === 'pending')
toBeApplied = migrations.slice(0, start + input.step)
}
toBeApplied.forEach(m => (m.status = 'executed'))
return migrations.map(m => `${m.name}: ${m.status}`)
}),
create: trpc.procedure
.meta({description: 'Create a new migration'})
.input(
z.object({name: z.string(), content: z.string()}),
)
.mutation(async ({input}) => {
migrations.push({...input, status: 'pending'})
return migrations
}),
list: searchProcedure
.meta({description: 'List all migrations'})
.query(({ctx}) => ctx.filter(migrations)),
search: trpc.router({
byName: searchProcedure
.meta({description: 'Look for migrations by name'})
.input(z.object({name: z.string()}))
.query(({ctx, input}) => {
return ctx.filter(migrations.filter(m => m.name === input.name))
}),
byContent: searchProcedure
.meta({
description: 'Look for migrations by their script content',
aliases: {
options: {searchTerm: 'q'},
},
})
.input(
z.object({
searchTerm: z
.string()
.describe(
'Only show migrations whose `content` value contains this string',
),
}),
)
.query(({ctx, input}) => {
return ctx.filter(
migrations.filter(m => m.content.includes(input.searchTerm)),
)
}),
}),
}) satisfies parseRouter.Trpc11RouterLike
const cli = createCli({
router,
name: 'migrations',
version: '1.0.0',
description: 'Manage migrations',
})
void cli.run()
function getMigrations() {
return [
{
name: 'one',
content: 'create table one(id int, name text)',
status: 'executed',
},
{
name: 'two',
content: 'create view two as select name from one',
status: 'executed',
},
{
name: 'three',
content: 'create table three(id int, foo int)',
status: 'pending',
},
{
name: 'four',
content: 'create view four as select foo from three',
status: 'pending',
},
{name: 'five', content: 'create table five(id int)', status: 'pending'},
]
}
Here's how the CLI will work:
node path/to/migrations --help output:
Usage: migrations [options] [command]
Manage migrations
Available subcommands: up, create, list, search
Options:
-V, --version output the version number
-h, --help display help for command
Commands:
up [options] Apply migrations. By default all pending migrations will be
applied.
create [options] Create a new migration
list [options] List all migrations
search Available subcommands: by-name, by-content
help [command] display help for command
Other Features
tRPC v10 vs v11
Both versions 10 and 11 of @trpc/server are both supported. If using tRPC v10 you must pass in your @trpc/server module to createCli:
const cli = createCli({router, trpcServer: import('@trpc/server')})
Or you can use top level await or require if you prefer:
const cli = createCli({router, trpcServer: await import('@trpc/server')})
const cli = createCli({router, trpcServer: require('@trpc/server')})
Note: previously, @trpc/server was included in the dependencies of this package, but now you have to install it separately.
oRPC
You can now also pass an oRPC router! Note that it needs to be an @orpc/server router, not an @orpc/contract. It works the same way as with tRPC, just pass a router:
import {os} from '@orpc/server'
import {createCli} from 'trpc-cli'
import {z} from 'zod'
export const router = os.router({
add: os
.input(z.object({left: z.number(), right: z.number()}))
.handler(({input}) => input.left + input.right),
})
const cli = createCli({router})
cli.run()
Note: lazy procedures aren't supported right now. If you are using some, call orpc's unlazyRouter helper before passing the router to trpc-cli:
import {os, unlazyRouter} from '@orpc/server'
import {createCli} from 'trpc-cli'
import {z} from 'zod'
export const router = os.router({
real: {
add: os
.input(z.object({left: z.number(), right: z.number()}))
.handler(({input}) => input.left + input.right),
},
imaginary: os.lazy(() => import('./imaginary-numbers-calculator')),
})
const cli = createCli({router: await unlazyRouter(router)})
cli.run()
Output and Lifecycle
The output of the command will be logged if it is truthy. The log algorithm aims to be friendly for bash-piping, usage with jq etc.:
- Arrays will be logged line be line
- For each line logged:
- string, numbers and booleans are logged directly
- objects are logged with
JSON.stringify(___, null, 2)
So if the procedure returns ['one', 'two', 'three] this will be written to stdout:
one
two
three
If the procedure returns [{name: 'one'}, {name: 'two'}, {name: 'three'}] this will be written to stdout:
{
"name": "one"
}
{
"name": "two"
}
{
"name": "three"
}
This is to make it as easy as possible to use with other command line tools like xargs, jq etc. via bash-piping. If you don't want to rely on this logging, you can always log inside your procedures however you like and avoid returning a value.
The process will exit with code 0 if the command was successful, or 1 otherwise.
You can also override the logger and process properties of the run method to change the default return-value logging and/or process.exit behaviour:
import {createCli} from 'trpc-cli'
const cli = createCli({router: yourRouter})
cli.run({
logger: yourLogger,
process: {
exit: code => {
if (code === 0) process.exit(0)
else process.exit(123)
},
},
})
You could also override process.exit to avoid killing the process at all - see programmatic usage for an example.
Testing your CLI
Rather than testing your CLI via a subprocess, which is slow and doesn't provide great DX, it's better to use the router that is passed to it directly with createCallerFactory:
import {initTRPC} from '@trpc/server'
import {test, expect} from 'your-test-library'
import {router} from '../src'
const caller = initTRPC.create().createCallerFactory(router)({})
test('add', async () => {
expect(await caller.add([2, 3])).toBe(5)
})
If you really want to test it as like a CLI and want to avoid a subprocess, you can also call the run method programmatically, and override the process.exit call and extract the resolve/reject values from FailedToExitError:
import {createCli, FailedToExitError} from 'trpc-cli'
const run = async (argv: string[]) => {
const cli = createCli({router: calculatorRouter})
return cli
.run({
argv,
process: {exit: () => void 0 as never},
logger: {info: () => {}, error: () => {}},
})
.catch(err => {
while (err instanceof FailedToExitError) {
if (err.exitCode === 0) {
return err.cause
}
err = err.cause
}
throw err
})
}
test('make sure parsing works correctly', async () => {
await expect(run(['add', '2', '3'])).resolves.toBe(5)
await expect(run(['squareRoot', '--value=4'])).resolves.toBe(2)
await expect(run(['squareRoot', `--value=-1`])).rejects.toMatchInlineSnapshot(
`[Error: Get real]`,
)
await expect(run(['add', '2', 'notanumber'])).rejects.toMatchInlineSnapshot(`
[Error: Validation error
- Expected number, received string at index 1
Usage: program add [options] <parameter_1> <parameter_2>
Arguments:
parameter_1 (required)
parameter_2 (required)
Options:
-h, --help display help for command
]
`)
})
This will give you strong types for inputs and outputs, and is essentially what trpc-cli does under the hood after parsing and validating command-line input.
In general, you should rely on trpc-cli to correctly handle the lifecycle and output etc. when it's invoked as a CLI by end-users. If there are any problems there, they should be fixed on this repo - please raise an issue.
Programmatic Usage
This library should probably not be used programmatically - the functionality all comes from a trpc router, which has many other ways to be invoked (including the built-in createCaller helper bundled with @trpc/server).
The .run() function does return a value, but it's typed as unknown since the input is just argv: string[] . But if you really need to for some reason, you could override the console.error and process.exit calls:
import {createCli} from 'trpc-cli'
const cli = createCli({router: yourAppRouter})
const runCli = async (argv: string[]) => {
return new Promise<void>((resolve, reject) => {
cli.run({
argv,
logger: yourLogger,
process: {
exit: code => {
if (code === 0) {
resolve()
} else {
reject(`CLI failed with exit code ${code}`)
}
},
},
})
})
}
You can enable prompts for positional arguments and options simply by installing enquirer, prompts or @inquirer/prompts:

npm install @inquirer/prompts
The pass it in when running your CLI:
import * as prompts from '@inquirer/prompts'
import {createCli} from 'trpc-cli'
const cli = createCli({router: myRouter})
cli.run({prompts})
The user will then be asked to input any missing arguments or options. Booleans, numbers, enums etc. will get appropriate user-friendly prompts, along with input validation.
You can also pass in a custom "Prompter". This in theory enables you to prompt in any way you'd like. You will be passed a Command instance, and then must define input, select, confirm and checkbox prompts. You can also define setup and teardown functions which run before and after the individual prompts for arguments and options. This could be used to render an all-in-one form filling in inputs. See the tests for an example.
Experimental By default, if you define prompts, the user will be prompted for input if and only if there are some arguments missing. You can forcible enable or disable prompts via .meta({prompt: true/false}). If you need to more dynamically figure out if prompting should be enabled, create an issue. It might be possible to allow a callback function too.
Completions
Note: This feature is new! Please try it out and file an issues if you have problems. 
Completions are supported via omelette, which is an optional peer dependency. How to get them working:
npm install omelette @types/omelette
Then, pass in an omelette instance to the completion option:
import omelette from 'omelette'
import {createCli} from 'trpc-cli'
const cli = createCli({router: myRouter})
cli.run({
completion: async () => {
const completion = omelette('myprogram')
if (process.argv.includes('--setupCompletions')) {
completion.setupShellInitFile()
}
if (process.argv.includes('--removeCompletions')) {
completion.cleanupShellInitFile()
}
return completion
},
})
Write the completions to your shell init file by running:
node path/to/myprogram --setupCompletions
Then add an alias for the program corresponding to your omelette instance (in the example above, omelette('myprogram')):
echo 'myprogram() { node path/to/myprogram.js "$@" }' >> ~/.zshrc
Then reload your shell:
source ~/.zshrc
You can then use tab-completion to autocomplete commands and flags.
.toJSON()
If you want to generate a website/help docs for your CLI, you can use .toJSON():
const myRouter = t.router({
hello: t.procedure
.input(z.object({firstName: z.string()}))
.mutation(({input}) => `hello, ${input.firstName}`),
})
const cli = createCli({router: myRouter, name: 'mycli', version: '1.2.3'})
cli.toJSON()
This is a rough JSON representation of the CLI - useful for generating documentation etc. It returns basic information about the CLI and each command - to get any extra details you will need to use the cli.buildProgram() method and walk the tree of commands yourself.
Using Existing Routers
This feature is usable but likely to change. Right now, the trpc-cli bin script will import tsx before running your CLI in order to import routers written in typescript. This might change in future to allow for more ways of running typescript files (possibly checking if importx instead of tsx) 
If you already have a trpc router (say, for a regular server rather), you can invoke it as a CLI without writing any additional code - just use the built in bin script:
npx trpc-cli src/your-router.ts
npx trpc-cli src/your-router.ts --help
npx trpc-cli src/your-router.ts yourprocedure --foo bar
Note - in the above example src/your-router.ts will be imported, and then its exports will be checked to see if they match the shape of a tRPC router. If no routers or more than one router is found, an error will be thrown.
Reference
API docs
Run a trpc router as a CLI.
Params
| name |
description |
| router |
A trpc router |
| context |
The context to use when calling the procedures - needed if your router requires a context |
| trpcServer |
The trpc server module to use. Only needed if using trpc v10. |
Returns
A CLI object with a run method that can be called to run the CLI. The run method will parse the command line arguments, call the appropriate trpc procedure, log the result and exit the process. On error, it will log the error and exit with a non-zero exit code.
Features and Limitations
- Nested subrouters (example) - procedures in nested routers will become subcommands will be dot separated e.g.
mycli search byId --id 123
- Middleware,
ctx, multi-inputs work as normal
- Return values are logged using
console.info (can be configured to pass in a custom logger)
process.exit(...) called with either 0 or 1 depending on successful resolve
- Help text shown on invalid inputs
- Support option aliases via
aliases meta property (see migrations example below)
- Union types work, but they should ideally be non-overlapping for best results
- e.g.
z.object({ foo: z.object({ bar: z.number() }) })) can be supplied via using --foo '{"bar": 123}'
- Limitation: No
subscription support.
- In theory, this might be supportable via
@inquirer/prompts. Proposals welcome!
Out of scope
- No stdout prettiness other than help text - use
tasuku or listr2
Disclaimer
Note that this library is still v0, so parts of the API may change slightly. The basic usage of createCli({router}).run() will remain though, and any breaking changes will be published via release notes.
Contributing
Implementation and dependencies
The only dependency is commander, for parsing arguments before passing to trpc, which has no dependencies of its own.

@trpc/server and @orpc/server are peerDependencies, but one of the two must be installed. Similarly one of zod, valibot, arktype or effect will likely be needed for input validation. The code from zod-to-json-schema has been copied more or less as-is to this repo in order to support json schema conversion for old versions of zod.
Testing
vitest is used for testing. The tests consists of the example fixtures from this readme, executed as CLIs via a subprocess. Avoiding mocks this way ensures fully realistic outputs (the tradeoff being test-speed, but they're acceptably fast for now).