@samyx/repl-orpc
v0.0.3
Published
Turn an oRPC contract + link into @samyx/repl-builder commands — a CLI, REPL, TUI or web UI over your RPC procedures
Maintainers
Readme
@samyx/repl-orpc
Turn an oRPC contract into @samyx/repl-builder
commands — one per procedure — so your RPC API becomes a CLI, a REPL, a TUI, or a web UI
without writing an argument parser.
npm i @samyx/repl-orpc @orpc/client @orpc/contractUsage
#!/usr/bin/env node
import { RPCLink } from '@orpc/client/fetch';
import { createCli } from '@samyx/repl-builder';
import { createOrpcCommands } from '@samyx/repl-orpc';
import { z } from 'zod';
import { contract } from './contract';
const commands = createOrpcCommands({
contract,
link: new RPCLink({ url: 'http://localhost:3000/rpc' }),
toJsonSchema: (schema) => z.toJSONSchema(schema, { io: 'input' }),
});
process.exit(await createCli(commands, { name: 'planets' }).run());$ planets # lists every procedure in the contract
$ planets help planet.find # usage, built from the input schema
$ planets planet.find --id 4 # → calls client.planet.find({ id: 4 })
$ planets planet.find --input '{"id":4}'Commands are named by their router path (planet.find), so nesting is preserved.
route({ summary }) becomes the command description; without one, the dotted path is
used — most contracts set no route metadata, so that is the common case, not a rare
fallback.
Subcommand style
Nested procedures are dotted by default (planet.find). Set style: 'subcommand' for
git-style grouping instead:
createOrpcCommands({ contract, link, style: 'subcommand' });$ planets planet find --id 1
$ planets planet # lists the group
$ planets planet help find # ┐ all three show the same usage
$ planets help planet find # ├
$ planets planet find --help # ┘Note that the top-level listing stays flat: planets with no arguments prints every
procedure (planet find, planet list, …), not one row per group. On a large contract
that is a long list — reach for filter, or ship a hand-written top-level help via
epilogue, if that matters to you.
Both styles are just a separator — createCli resolves the longest registered name
matching a prefix of argv, so a space-separated name is a subcommand path. For anything
else, name takes a router path and returns whatever you want:
name: (path) => path.join(':'), // planet:find
name: (path) => path.at(-1)!, // flat — find, list (beware collisions)Any link works
The transport is entirely the link's business — this package only dispatches. RPCLink
(fetch), OpenAPILink, WebSocket, and message-port links all work unchanged, as does
anything implementing ClientLink:
import { RPCLink } from '@orpc/client/websocket';
const link = new RPCLink({ websocket: new WebSocket('ws://localhost:3000') });Flags come from the input schema
Standard Schema exposes validation, not structure — so flattening an input into flags
needs a converter from whatever schema library the contract uses. Pass one via
toJsonSchema:
| Library | Hook |
|---------|------|
| Zod 4 | (s) => z.toJSONSchema(s, { io: 'input' }) |
| Valibot / ArkType | oRPC's *ToJsonSchemaConverter, e.g. (s) => new ValibotToJsonSchemaConverter().convert(s, { strategy: 'input' })[1] |
Without a converter there are no per-field flags at all — --input '<json>' is the
only way in, and help <cmd> will show just that. That is the honest ceiling; the whole
package works, it is only the ergonomics that degrade.
Only flat, scalar top-level properties become flags. Anything nested still goes through
--input. Flattened flags are coerced (--id 4 → 4, not "4") and enums are validated
before anything leaves the process. --input and flattened flags compose — --input is
the base, flags overlay it.
Options
| Option | Default | |
|--------|---------|--|
| contract | — | The contract router. |
| link | — | Any oRPC ClientLink. |
| toJsonSchema | — | Structure-aware flags (see above). |
| style | 'dot' | 'dot' → planet.find; 'subcommand' → planet find. |
| name | from style | Command name from a router path. Overrides style. |
| filter | — | (entry) => boolean, to expose a subset of procedures. |
| command | — | (command, entry) => CommandDef — last word on each command: aliases, descriptions, wrapping execute. |
| inputFlag | 'input' | Flag carrying the whole input as JSON. Reserved: an input property of the same name is exposed as --input.<name>. |
Errors
An ORPCError prints as <command>: <code> — <message>, plus its data when present,
and exits 1. Argument errors (unknown flag, bad enum, non-integer) are caught locally
and exit 2 without dispatching.
