---
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](https://github.com/ilbertt/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

1. **One file per command.** Each command file calls `defineCommand('path', { … })` (or `defineRootCommand` for the root). The path string is the source of truth.
2. **Directories mirror paths.** `defineCommand('s3 buckets list', …)` lives at `commands/s3/buckets/list.ts`. `[name]` segments declare params and become `[name]` directories or files.
3. **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/`.**
4. **`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](https://standardschema.dev) — 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

```sh
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

```ts
// src/commands/_root.ts
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

```ts
// src/commands/hello.ts
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`:

```sh
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, watch mode in dev) 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

```ts
// src/main.ts
#!/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

```sh
bun src/main.ts hello --name parsh
# → hello, parsh

bun src/main.ts --help
# → autogenerated help
```

## Adding a command to an existing CLI

This is the everyday workflow.

1. **Pick the path.** Use space-separated segments, `[name]` for params: e.g. `'s3 buckets [name] create'`.
2. **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.
3. **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.
4. **Call `defineCommand` with that exact path.** TypeScript will error if the path's `[name]` segments and the `params` object disagree on keys.
5. **Run the codegen** (`parsh-codegen generate`, or however the project wires it). Use `--watch` while iterating.
6. **`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`:

```ts
// src/commands/s3/buckets/[name].ts  ← declares the param
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: {},
});
```

```ts
// src/commands/s3/buckets/[name]/create.ts  ← inherits the param, doesn't redeclare it
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;  // string
    const acl = options.public ? 'public-read' : 'private'; // boolean | undefined
    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`](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.

```ts
// src/commands/s3/ls.ts — paramless alias
export const command = defineCommand('s3 ls', {
  aliasOf: 's3 buckets list',
});

// src/commands/s3/c/[name].ts — alias with a param
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:

1. The target must be a **registered path** (a key of `CommandRegistry`).
2. The target must be **different** from the alias's own path.
3. 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.

```sh
parsh-codegen generate          # one-shot
parsh-codegen generate --watch  # while iterating
```

**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:

```ts
// ❌ slow --help: every help invocation pays for the SDK import
import { S3 } from '@aws-sdk/client-s3';

export const command = defineCommand('s3 ls', {
  options: {},
  handler: async () => {
    const client = new S3();
    /* … */
  },
});

// ✅ lazy: SDK only imports when this command actually runs
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.

```ts
// src/main.ts
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:

```ts
defineCommand('migrate', {
  options: {},
  handler: async ({ context, print }) => {
    context.now();              // Date
    await context.db.query(/* … */);
    context.foo;                // ❌ compile-time TypeScript error
    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`](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`](../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`](../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`:

```ts
print.info('starting…');            // plain → stdout
print.success('done');              // green → stdout
print.warn('config is stale');      // yellow → stderr
print.error('failed to push');      // red → stderr
print.dim('took 12.4s');            // dim → stdout
```

`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`](references/options-and-params.md) — schemas, `optional` / `required` / `default`, `aliases`, boolean flags, `forwardToChildren`, accessing `parents['<path>'].options` / `.params` / `rootOptions`.
- [`references/context-and-state.md`](references/context-and-state.md) — registering shared context, factory contexts, lifecycle hooks (`beforeHandler` / `afterHandler`).
- [`references/error-handling.md`](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`](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`](references/testing.md) — using `cli.run(argv)` (not `main()`) for integration tests, handler unit tests, injecting test doubles through `context`, capturing stdout/stderr, and what not to test.

Sibling skills for parsh add-on packages:

- [`../parsh-env/SKILL.md`](../parsh-env/SKILL.md) — `@parshjs/env` for typed, lazy environment-variable access.
- [`../parsh-files/SKILL.md`](../parsh-files/SKILL.md) — `@parshjs/files` for typed JSON file storage.

## Common mistakes

- **Forgetting to regenerate** after touching `commands/`. The single biggest cause of broken types — see [`references/troubleshooting.md`](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.
