| name | parsh |
| description | How to build TypeScript CLIs with parsh. Use when working in a project that imports @parshjs/core, @parshjs/codegen, @parshjs/env, or @parshjs/files — or when the user asks to scaffold a new TypeScript CLI and you choose parsh. Covers starting from scratch, adding commands, the codegen workflow, and shared-context wiring. Deeper topics live under references/. |
parsh
parsh is a type-safe router for TypeScript CLIs. The directory layout under commands/ is the command tree; a codegen step makes every handler's ctx (options, params, parents, root options, print helper, registered shared context) fully typed without any user-written generics.
This skill teaches you the workflow. For depth on a specific topic, jump to the relevant reference under references/.
Mental model
- One file per command. Each command file calls
defineCommand('path', { … }) (or defineRootCommand for the root). The path string is the source of truth.
- Directories mirror paths.
defineCommand('s3 buckets list', …) lives at commands/s3/buckets/list.ts. [name] segments declare params and become [name] directories or files.
- Codegen wires types.
parsh-codegen generate walks commands/ and emits commandTree.gen.ts. Without it, ctx won't be typed. Regenerate after every change under commands/.
ctx has a fixed shape with framework + user fields. Framework provides ctx.options, ctx.params, ctx.parents['<ancestor>'], ctx.rootOptions, and ctx.print (a colored output helper). Registered shared context lives under ctx.context — kept separate so framework fields can never collide with user keys. Never add per-call generics to defineCommand — types are inferred end-to-end.
Schemas use Standard Schema v1 — Zod, Valibot, ArkType, etc. all work. Use whatever the project already uses; don't introduce a new one.
Starting a new parsh CLI
When the user asks for a fresh CLI, follow this exact sequence.
1. Install
bun add @parshjs/core zod
bun add -d @parshjs/codegen
(Replace zod with the project's existing schema lib if there is one.)
2. Create the root command
import { defineRootCommand } from '@parshjs/core';
import { z } from 'zod';
export const command = defineRootCommand({
options: {
verbose: { schema: z.boolean().optional(), forwardToChildren: true },
},
});
_root.ts is the only filename treated specially — it's the root (path ''). Every other command file is named after its last path segment.
3. Add at least one command
import { defineCommand } from '@parshjs/core';
import { z } from 'zod';
export const command = defineCommand('hello', {
description: 'Say hello.',
options: {
name: { schema: z.string().default('world') },
},
handler: ({ options, rootOptions, print }) => {
if (rootOptions.verbose) {
print.dim(`greeting ${options.name}…`);
}
print.success(`hello, ${options.name}`);
},
});
ctx.print is a framework-provided helper for colored, leveled output (info, success, warn, error, dim). Use it instead of console.log so output is consistent with the auto-generated help and respects NO_COLOR.
4. Generate the command tree
Run the codegen CLI to produce src/commandTree.gen.ts:
parsh-codegen generate --commands src/commands --out src/commandTree.gen.ts
How you invoke it (a package.json script, a Makefile, a pre-commit hook) is up to the project. Commit the generated file — without it, the next step won't type-check. See parsh-codegen generate --help for all flags.
5. Wire the runner
#!/usr/bin/env bun
import { createCli } from '@parshjs/core';
import { commandTree } from './commandTree.gen.ts';
const cli = createCli({
programName: 'mycli',
programDescription: 'My CLI.',
tree: commandTree,
});
await cli.main();
main() parses process.argv, dispatches, prints help, and exits with the right code. That's the whole runner.
6. Run it
bun src/main.ts hello --name parsh
bun src/main.ts --help
Adding a command to an existing CLI
This is the everyday workflow.
- Pick the path. Use space-separated segments,
[name] for params: e.g. 's3 buckets [name] create'.
- Create the file at the matching location.
commands/s3/buckets/[name]/create.ts. Intermediate segments become directories. A [name] segment is the literal directory or filename.
- Make sure the
[name] ancestor exists. A param is declared on the command whose last segment is [name] — typically commands/s3/buckets/[name].ts. Descendants inherit the param and do not redeclare it.
- Call
defineCommand with that exact path. TypeScript will error if the path's [name] segments and the params object disagree on keys.
- Run the codegen (
parsh-codegen generate, or however the project wires it).
ctx is now typed. Open the new handler — autocomplete works on ctx.options, ctx.params, ctx.parents['<ancestor path>'], ctx.rootOptions.
Example for the path above. The param name is declared once on the ancestor; the child reads it through parents['s3 buckets [name]'].params:
import { defineCommand } from '@parshjs/core';
import { z } from 'zod';
export const command = defineCommand('s3 buckets [name]', {
description: 'Operate on a single bucket.',
params: { name: { schema: z.string() } },
options: {},
});
import { defineCommand } from '@parshjs/core';
import { z } from 'zod';
export const command = defineCommand('s3 buckets [name] create', {
description: 'Create a new S3 bucket.',
options: {
public: { schema: z.boolean().optional() },
},
handler: ({ parents, options, rootOptions, print }) => {
const name = parents['s3 buckets [name]'].params.name;
const acl = options.public ? 'public-read' : 'private';
print.success(`Creating ${name} (${acl}) in ${rootOptions.region}`);
},
});
For deeper option/param patterns (aliases, required, defaults, forwardToChildren, accessing parents and root), see references/options-and-params.md.
The path string is the contract
- The path string declares which params exist; the
params object declares their schemas. Wrong key, missing key, or extra key are compile errors.
- Children inherit ancestor params — they don't redeclare them. Reach them through
parents['<ancestor path>'].params.
- A parent with no
handler is a routing group — running it prints help and exits.
hidden: true on defineCommand removes the command from parent help listings. It can still be invoked and --help'd directly. Useful for [name].ts files that exist purely to declare a param schema for descendants and aren't meant to be run on their own.
Aliases
To point one command path at another, pass aliasOf and nothing else. The alias inherits the target's options, params, description, and handler — there's nothing else to configure.
export const command = defineCommand('s3 ls', {
aliasOf: 's3 buckets list',
});
export const command = defineCommand('s3 c [name]', {
aliasOf: 's3 buckets [name] create',
});
The compiler enforces three invariants — break any of them and it's a TypeScript error, not a runtime surprise:
- The target must be a registered path (a key of
CommandRegistry).
- The target must be different from the alias's own path.
- The alias's param-name tuple must match the target's exactly (same names, same order).
's3 ls' aliasing 's3 buckets [name] create' is a type error; so is 'i [sku]' aliasing 'items [name]'.
In help output, when both the alias and its target are reachable in the current view, the alias is folded into the target's row ((alias: …)); otherwise the alias is listed separately as (alias of …).
Always regenerate after editing commands/
The commandTree.gen.ts file is what makes ctx typed. Regenerate after structural changes under commands/ — adding, renaming, or deleting a command file, or changing a path string. Editing the body of an existing command (options, params, description, handler, hidden) never requires regeneration: the generated file depends only on the filesystem layout and each path-string literal.
parsh-codegen generate
Diagnostic rule: if ctx.options, ctx.params, ctx.parents, or ctx.rootOptions looks any / unknown / wrong, regenerate first before debugging anything else. The most common cause of broken inference is a stale commandTree.gen.ts.
The codegen ignores *.gen.ts, *.test.ts, and any _* file other than _root.ts.
Keep top-level imports light
--help loads the commands it lists so it can read each one's description and hidden. For a top-level --help that means evaluating every command file in the tree. Module top-level runs each file's imports; the handler doesn't.
To keep --help fast, put heavy imports inside the handler, not at module top:
import { S3 } from '@aws-sdk/client-s3';
export const command = defineCommand('s3 ls', {
options: {},
handler: async () => {
const client = new S3();
},
});
export const command = defineCommand('s3 ls', {
options: {},
handler: async () => {
const { S3 } = await import('@aws-sdk/client-s3');
const client = new S3();
},
});
This is also what makes parsh's lazy dispatch work end-to-end on real-world CLIs — without it, every dispatch pays for every command's transitive imports.
Shared context
For dependencies that should be available on every handler (DB handles, HTTP clients, env vars, file storage), pass them via context and register the Cli instance once so the types propagate everywhere. Registered context lives under ctx.context — kept separate from framework-provided fields so nothing collides.
const cli = createCli({
programName: 'mycli',
tree: commandTree,
context: {
db: connect(),
now: () => new Date(),
},
});
declare module '@parshjs/core' {
interface Register {
cli: typeof cli;
}
}
await cli.main();
In any handler:
defineCommand('migrate', {
options: {},
handler: async ({ context, print }) => {
context.now();
await context.db.query();
context.foo;
print.success('migrated');
},
});
If the CLI was created without a context, ctx.context is never — reading it from a handler is a loud type error rather than silent undefined access.
For factory contexts and lifecycle hooks (beforeHandler / afterHandler), see references/context-and-state.md.
Two add-on packages plug into this same context slot and have their own dedicated skills — read those when you need them:
- Typed env vars →
../parsh-env/SKILL.md. Use whenever a handler would otherwise touch process.env. Reach via ctx.context.env.<KEY>.
- Typed JSON file storage →
../parsh-files/SKILL.md. Use for persistent CLI config and small JSON state. Reach via ctx.context.files.<key>.
Output
Use ctx.print for output — it's a framework-provided helper for colored, leveled output that respects NO_COLOR:
print.info('starting…');
print.success('done');
print.warn('config is stale');
print.error('failed to push');
print.dim('took 12.4s');
console.log still works if you need it, but print is the idiomatic choice — output stays consistent with the auto-generated help, and warn/error go to stderr for free.
For richer UX, render inside the handler:
- TUI:
import { render } from 'ink' and render React components inside the handler.
- Wizards:
@clack/prompts works as-is.
- Spinners / extra colors: ora, chalk, picocolors — all callable from handlers.
Where to go next
references/options-and-params.md — schemas, optional / required / default, aliases, boolean flags, forwardToChildren, accessing parents['<path>'].options / .params / rootOptions.
references/context-and-state.md — registering shared context, factory contexts, lifecycle hooks (beforeHandler / afterHandler).
references/error-handling.md — registering custom error classes, the onError hook, exit(n), built-in error codes, and how the instanceof walk picks a match.
references/troubleshooting.md — "ctx is any", TS errors after a rename, alias collisions, codegen ignore rules, what to check when something doesn't type.
references/testing.md — using cli.run(argv) (not main()) for integration tests, the @parshjs/core/testing submodule, injecting test doubles through context, capturing stdout/stderr, and what not to test.
Sibling skills for parsh add-on packages:
Common mistakes
- Forgetting to regenerate after touching
commands/. The single biggest cause of broken types — see references/troubleshooting.md.
- Hand-editing
commandTree.gen.ts. Always regenerate; don't patch.
- Adding generics at the call site.
defineCommand<…>(…) with explicit type args is wrong — generics are inferred from the path string, options, and params.
- Re-declaring an ancestor's params on a child. Children inherit through
parents['<ancestor>']. Re-declaring is a type error.
- Reading
process.env directly inside a handler. Use @parshjs/env so the variable is typed, validated, and lazy.
- Forgetting the
Register augmentation when using context. Without the declare module block, ctx.context is invisible to TypeScript.
- Looking for user-provided fields directly on
ctx (e.g. ctx.db). They live under ctx.context.db. The flat layout is reserved for framework fields (options, params, parents, root, print).
- Reaching for
console.log when ctx.print is right there. Use print.info / success / warn / error / dim for consistent, colored, level-aware output.
- Treating
forwardToChildren as "global". Only descendants of the declaring command see the option. Put truly global flags on the root.