Skip to content
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ dist
*ignoreme*
*.log
session-ses_*.md
tasks/*interview*
tasks/**/*interview*
84 changes: 80 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -656,15 +656,77 @@ void createCli({filename: '/path/to/commands.ts'}).run()

trpc-cli reads the module's *source text* and dynamically imports it: each exported function becomes a command (kebab-cased, e.g. `listVersions` → `list-versions`), the jsdoc above the function becomes the command description, and parameter type annotations are parsed by the vendored `Type.Script` into real JSON schemas - property jsdoc comments become flag descriptions, and inputs are validated before your function runs (`mycli add --package-name left-pad --dev`). A default-exported function becomes the default command, so `export default function hola(...)` can be run as either `mycli ...` or `mycli hola ...`.

Exported functions whose signatures cannot be converted into CLI inputs are ignored as ordinary non-command exports. This gives helper exports an escape hatch without adding trpc-cli-specific metadata:

```ts
export const loadSomeInternalThing = (params: NoInfer<{foo: string}>) => {
return loadIt(params.foo) // not a command
}
```

Command and option aliases can be added with `@alias` tags in the same jsdoc comments:

```ts
/** install dependencies
* @alias i
*/
export function install(options: {
/** fail if the lockfile is out of date
* @alias f
*/
frozenLockfile?: boolean
}) {
// mycli i -f
}
```

File-backed module mode can compose command modules too:

```ts
// commands.ts
export * from './workspace.ts' // merges named commands into this level
export {Users} from './users.ts' // re-exports one selected command/group
export * as users from './users.ts' // creates a nested `users` sub-router
```

Relative re-export specifiers are resolved from the containing file. Exact paths win first (`'./users.ts'`, `'./users.mts'`, etc.); extensionless specifiers then probe common TypeScript/JavaScript extensions such as `.ts`, `.mts`, `.js`, and `.mjs`. `export * from './workspace.ts'` follows ESM semantics and does not merge that module's default export. `export * as users from './users.ts'` keeps the child router intact, including a default export as the default command for `users`.
Relative re-export specifiers are resolved from the containing file. Exact paths win first (`'./users.ts'`, `'./users.mts'`, etc.); extensionless specifiers then probe common TypeScript/JavaScript extensions such as `.ts`, `.mts`, `.js`, and `.mjs`. `.js`/`.mjs`/`.cjs` specifiers may also resolve to sibling TypeScript source files such as `.ts`/`.mts`/`.cts`, matching the common TypeScript ESM pattern. `export * from './workspace.ts'` follows ESM semantics and does not merge that module's default export. `export {Users} from './users.ts'` exposes that child command/group at the current level. `export * as users from './users.ts'` keeps the child router intact, including a default export as the default command for `users`.

Same-file subcommand groups can be written as exported classes. The class is not instantiated when help/schema information is built; trpc-cli creates a fresh instance only when one of the class's method commands runs.

```ts
// commands.ts
export class Users {
/** invite a user */
invite(options: {
/** address to invite */
email: string
}) {
this.#audit('invite')
return `invite ${options.email}` // mycli users invite --email ada@example.com
}

#audit(action: string) {
// private implementation detail, not a command
}
}
```

Run it like:

```console
$ tsx commands.ts users invite --email bob@example.com
invite bob@example.com
```

Class command groups must be direct `export class Name { ... }` declarations. Classes without a base class may omit the constructor; classes with `extends` must declare an explicit zero-argument constructor. Classes with constructor parameters, unsupported inheritance, or no public command methods are ignored as ordinary exports. Public instance methods declared directly in the class body become commands. Static methods, inherited methods, private/protected methods, getters and setters are not commands. A default-exported class exposes its public methods at the current level instead of under a class-name subcommand:

```ts
export default class Commands {
invite(options: {email: string}) {
return `invite ${options.email}` // mycli invite --email ada@example.com
}
}
```

Multi-parameter functions work too: leading string/number/boolean parameters become positional arguments, and a trailing object parameter becomes flags - the same convention as [tuple inputs](#combining-positional-arguments-and-options):

Expand Down Expand Up @@ -692,9 +754,9 @@ Details and limitations:

- `filename` accepts an absolute path (robust - works from any directory), `import.meta` (when the call lives in the commands file itself), or a `URL` like `new URL('./commands.ts', import.meta.url)` (resolves relative to the importing file, so a distributed CLI works wherever it's invoked). A *relative* path string is resolved against `process.cwd()`, so it's only reliable when the CLI is run from a known directory - fine for quick scripts, but it breaks the moment a globally-installed CLI runs somewhere else, so prefer `import.meta`/`URL` for anything you distribute. For `.ts` modules, run under tsx, bun, deno, or node >=22.18 (which strip types natively).
- `createCli(import.meta)` re-imports the commands file to read its exports, so when the call lives in that same file it's a *self-import*. That's fine as long as the call is at the **bottom** of the file (so all `export const` arrow functions above it are initialized) and is **not** top-level-`await`ed - `void createCli(import.meta).run()` is the safe form. A top-level `await createCli(import.meta).run()` would deadlock (the await suspends the module before the self-import can resolve). When another module imports this file, the `.run()` call is a no-op, so the exported command functions remain importable as plain functions.
- Parameter types can be inline literals (`{foo: string}`, `'fast' | 'slow'`) or references to a `type X = {...}`/`interface X {...}` declared in the same file (intersections of object literals like `type X = {a: string} & {b: number}` are flattened into one set of flags). Functions with no parameters become commands with no arguments.
- Parameter types can be inline literals (`{foo: string}`, `'fast' | 'slow'`), references to a `type X = {...}`/`interface X {...}` declared in the same file, or relative file-backed type imports such as `import type {Options} from './types.ts'`. Interface `extends` clauses and intersections of object literals like `type X = {a: string} & {b: number}` are flattened into one set of flags when possible. Functions with no parameters become commands with no arguments.
- Only the *last* parameter can be an object type (it maps to flags); the others must be strings, numbers, booleans, or arrays of those (a `files: string[]` parameter becomes a variadic positional). Rest parameters and destructured positional parameters aren't supported.
- Supported declaration syntaxes: `export function f(...)`, `export async function f(...)`, `export const f = (...) => ...` (including `export const f = async (...) => ...`; type-annotated consts like `export const f: Cmd = ...` aren't parsed), `export default function f(...)` (anonymous default functions become a command named `default`), `export * as group from './group'`, and `export * from './group'`. The module must export *only* commands: any other exported function - an `export {f}` statement, a re-export form other than `export *`/`export * as`, or a declaration the extractor can't parse - makes the CLI fail at startup with an error naming the offending export (failing loudly beats silently dropping a command). Keep helpers in a separate, un-re-exported module.
- Supported declaration syntaxes: `export function f(...)`, `export async function f(...)`, `export const f = (...) => ...` (including `export const f = async (...) => ...`; type-annotated consts like `export const f: Cmd = ...` aren't parsed), `export default function f(...)` (anonymous default functions become a command named `default`), `export class Group { method(...) {} }`, `export default class Commands { method(...) {} }`, `export * as group from './group'`, `export * from './group'`, and `export {commandOrGroup} from './group'`. The module must export *only* commands: any other exported function - a local `export {f}` statement or a declaration the extractor can't parse - makes the CLI fail at startup with an error naming the offending export (failing loudly beats silently dropping a command). Keep helpers in a separate, un-re-exported module.
- Overloaded functions work, with a simple rule: only the *first* overload signature is used; later overloads and the implementation signature are ignored. TypeScript resolves calls against overload signatures in order, so the first one is the primary documented shape - and a CLI can only present one calling convention.
- For bundlers/browsers (no filesystem, no dynamic import), pass the source and exports explicitly: `createCli({source: rawSourceText, exports: await import('./commands.js')})`. Re-exported command modules are not supported in this form because trpc-cli has no filesystem location to resolve child modules from.
- In this mode `run`/`buildProgram`/`toJSON` are all **async** (the module is loaded asynchronously): `const program = await createCli(import.meta).buildProgram()`. With a router, `buildProgram`/`toJSON` are synchronous.
Expand Down Expand Up @@ -1399,7 +1461,21 @@ const cli = createCli({router: myRouter})
cli.run({prompts: true})
```

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. The built-in prompts intentionally use plain line input: selects are numbered and checkboxes accept comma-separated numbers. If you want a richer terminal UI, install and pass `enquirer`, `prompts`, `@clack/prompts`, or `@inquirer/prompts` instead:
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. The built-in prompts intentionally use plain line input: selects are numbered and checkboxes accept comma-separated numbers.

You can pass a boolean expression too. `false` disables prompting, so this uses the built-in prompts unless trpc-cli detects a coding-agent environment:

```ts
import {createCli, isAgent} from 'trpc-cli'

const cli = createCli({router: myRouter})

await cli.run({
prompts: !isAgent(),
})
```

If you want a richer terminal UI, install and pass `enquirer`, `prompts`, `@clack/prompts`, or `@inquirer/prompts` instead:

```ts
import * as prompts from '@inquirer/prompts' // or import * as prompts from 'enquirer', or import * as prompts from 'prompts'
Expand Down
Loading
Loading