Commands

Commands are the primary unit in a pok CLI. A command file defines its label, context schema, checks, and run() function, and the router turns the file path into a command path.

File-Based Discovery

Commands are discovered from .ts files in your commands directory:

commands/
├── build.ts              → mycli build
├── deploy.ts             → mycli deploy
├── db.ts                 → mycli db (parent)
├── db.migrate.ts         → mycli db migrate
└── db.migrate.up.ts      → mycli db migrate up

Naming Rules

  • Use lowercase with dots for hierarchy
  • First segment is the top-level command
  • Each dot creates a nesting level
  • Files starting with _ are ignored

Implicit Parents

You don't need to create parent files. If you have:

commands/
└── db.migrate.ts

Then mycli db will show a submenu with just "migrate".

Interactive Menus

When a command has subcommands but no run function, or when the CLI is invoked without arguments, pok displays an interactive menu.

Search-ahead

Interactive menus support search-ahead filtering. Start typing any part of a command's label or name to filter the list. This makes navigating large command trees very fast.

$ mycli
  mycli
 What would you like to do?
 build - Build the project
 deploy - Deploy to environment
 db - Database operations
# Typing 'dep' will filter to 'deploy'

Plugin System & Mounting

You can build complex command trees by composing commands from multiple sources (plugins, directories, or scripts).

Mounting from a Directory

You can mount a directory of commands under a specific command:

// commands/admin.ts
import { defineCommand, fromDirectory } from '@pokit/core';

export const command = defineCommand({
  label: 'Admin',
  // Mounts all commands from ./admin/*.ts under 'mycli admin.*'
  mount: fromDirectory(import.meta.url, './admin'),
});

Mounting from Another Config

You can mount an entire sub-app from another pok.config.ts file or directory:

// commands/tools.ts
import { defineCommand, fromConfig } from '@pokit/core';

export const command = defineCommand({
  label: 'Tools',
  // Mounts the other app as a sub-tree under 'mycli tools.*'
  mount: fromConfig(import.meta.url, '../tools-app'),
});

Composition

The root of your CLI is composed of multiple mountables. The default composition order is:

  1. Package manager scripts (if pmScripts is configured)
  2. Package manager commands (if pmCommands is configured)
  3. Static extra commands (if extraCommands is configured)
  4. Root plugins (if plugins is configured)
  5. File-based commands (from commandsDir)

This order ensures file-based commands can override package manager scripts if needed.

Root Plugins

You can inject plugins at the root level via pok.config.ts:

// pok.config.ts
import { defineConfig, fromDirectory } from '@pokit/core';

export default defineConfig({
  // ...
  plugins: [
    // Mount a directory from a package or local folder at the root
    fromDirectory(import.meta.url, './internal-tools'),
  ],
});

Context Patterns

Required vs Optional

context: {
  // Required - prompts if missing
  env: {
    from: 'flag',
    schema: z.enum(['dev', 'prod']),
  },

  // Optional - never prompts
  verbose: {
    from: 'flag',
    schema: z.boolean().optional(),
  },

  // Default - uses default if missing
  timeout: {
    from: 'flag',
    schema: z.number().default(30000),
  },
}

Complex Types

context: {
  // Array of strings
  tags: {
    from: 'flag',
    schema: z.array(z.string()).default([]),
  },

  // Union types
  format: {
    from: 'flag',
    schema: z.enum(['json', 'yaml', 'toml']),
  },

  // Transformed values
  port: {
    from: 'flag',
    schema: z.string().transform(Number),
  },
}

Dependent Context

Use hooks for context that depends on other values:

context: {
  env: {
    from: 'flag',
    schema: z.enum(['dev', 'prod']),
  },
},
pre: (ctx) => {
  // Only require confirmation for prod
  if (ctx.env === 'prod') {
    return [prodConfirmation];
  }
  return [];
},

Dynamic Flag Values

Use resolve to load interactive options for a missing flag.

context: {
  env: {
    from: 'flag',
    schema: z.enum(['dev', 'prod']),
    resolve: async () => ['dev', 'prod'],
  },
  id: {
    from: 'flag',
    schema: z.string(),
    dependsOn: ['env'],
    resolve: async ({ cursor, filter, signal }, ctx) =>
      listTaskOptionPage({
        env: String(ctx.env),
        cursor,
        filter,
        signal,
      }), // { options, nextCursor }
  },
},

resolve supports primitive arrays (string[], number[], boolean[]), paginated pages, and async iterators. If the schema is an array (z.array(...)), the prompt becomes multi-select. Otherwise, it is single-select. Resolver output provides values only. Single-select resolve prompts use typeahead with incremental filter and cursor requests.

Positional Arguments

A context field's from decides where its value comes from:

  • from: 'flag'--name value (the default)
  • from: 'arg' — a single positional, consumed in declaration order
  • from: 'args' — a variadic positional that soaks up all remaining positionals (pair it with a z.array(...) schema; it must be the last positional field)
context: {
  // `emails show 3`  ->  query = '3'
  query: {
    from: 'arg',
    schema: z.string(),
    description: 'Search query',
  },
  // `tag release v1 v2`  ->  name = 'release', extra = ['v1', 'v2']
  extra: {
    from: 'args',
    schema: z.array(z.string()).default([]),
    description: 'Additional values',
  },
}

Positionals and flags can be interleaved freely (tag release --upper v1). Any positionals beyond the declared fields fall through to extraArgs.

Passthrough (--)

A lone -- stops flag parsing; every token after it is forwarded verbatim to extraArgs and never interpreted as a flag. This is the escape hatch for wrapping subprocesses whose own flags you don't want to model in Zod:

mycli medical build -- --verbose --foo   # --verbose --foo land in extraArgs

Wrapping a Subprocess

Wrapping a script is just defineCommand + r.exec with an argument array. Declare the inputs as positional/flag context, then spread extraArgs (extra positionals and everything after --) into the argv so the wrapped script's own flags are reachable without modeling them:

import { z } from 'zod';
import { defineCommand } from '@pokit/core';

export const command = defineCommand({
  label: 'Show emails',
  context: {
    query: { from: 'arg', schema: z.string(), description: 'Search query' },
  },
  run: (r, { context, extraArgs }) =>
    r.exec(['python3', script('read_emails.py'), 'show', context.query, ...extraArgs]),
});

// emails show 3            ->  python3 read_emails.py show 3
// emails show 3 -- --json  ->  python3 read_emails.py show 3 --json

Prefer the array form of r.exec for wrapped scripts: it spawns the process directly (no shell), so dynamic values are never re-split or word-expanded. Reach for the string form (r.exec("...")) only when you genuinely need shell features like pipes or globs.

Note: r.exec() streams stdout/stderr live to the terminal as the child runs (output is also captured so a failure can show the tail).

Pre-flight Check Patterns

Static Checks

pre: [dockerRunning, nodeVersion, envFileExists],

Dynamic Checks

pre: (ctx) => {
  const checks = [nodeVersion];

  if (ctx.useDocker) {
    checks.push(dockerRunning);
  }

  if (ctx.env === 'prod') {
    checks.push(prodConfirmation);
  }

  return checks;
},

Async Checks

pre: async (ctx) => {
  const isCI = await checkIsCI();
  if (isCI) {
    return []; // Skip interactive checks in CI
  }
  return [confirmDeployment];
},

Runner Patterns

Sequential Execution

run: async (r) => {
  await r.exec('npm run lint');
  await r.exec('npm run test');
  await r.exec('npm run build');
},

Grouped Activities

run: async (r) => {
  await r.group('Quality Checks', { layout: 'sequence' }, async (g) => {
    await g.activity('Lint', () => r.exec('npm run lint'));
    await g.activity('Types', () => r.exec('npm run typecheck'));
    await g.activity('Tests', () => r.exec('npm run test'));
  });
},

Parallel Execution

run: async (r) => {
  // Race semantics - first exit kills others
  await r.parallel([
    r.exec('npm run dev'),
    r.exec('npm run watch:css'),
  ]);
},

Parent Command Patterns

Simple Menu

// commands/db.ts
export const command = defineCommand({
  label: 'Database operations',
  // No run function - shows submenu
});

Run All Children

// commands/check.ts
export const command = defineCommand({
  label: 'Run all checks',
  enableRunAllChildren: 'sequential',
});

Users can run:

  • mycli check → Interactive menu
  • mycli check all → Run all sequentially
  • mycli check lint → Run specific check

Parallel All Children

export const command = defineCommand({
  label: 'Run all checks',
  enableRunAllChildren: 'parallel',
  quietRunAll: true, // Capture output, show on failure
});

Error Handling

In Run Function

run: async (r) => {
  try {
    await r.exec('npm test');
  } catch (error) {
    if (error instanceof CommandError) {
      r.reporter.error('Tests failed');
      r.reporter.info(error.output);
    }
    throw error; // Re-throw to fail the command
  }
},

Graceful Degradation

run: async (r) => {
  try {
    await r.exec('optional-tool');
  } catch {
    r.reporter.warn('optional-tool not found, skipping');
  }

  await r.exec('npm run build'); // Always run
},

Context Flow to Tasks

Context flows from commands to tasks:

// Command provides context
defineCommand({
  context: {
    env: {
      from: 'flag',
      schema: z.enum(['dev', 'prod']),
    },
  },
  run: async (r) => {
    // Task receives context for env resolution
    await r.run(deployTask);
  },
});

// Task uses context via env resolver
const deployTask = defineTask({
  env: envThatNeedsEnvContext,
  exec: 'deploy',
});

Command Aliases

You can define aliases for commands to provide shorter or alternative names:

export const command = defineCommand({
  label: 'Deploy to environment',
  aliases: ['d', 'dep'],
  run: async (r) => {
    await r.exec('deploy');
  },
});

Users can invoke the command using any of its names:

mycli deploy    # Primary name
mycli d         # Short alias
mycli dep       # Alternative alias

Alias Rules

  1. Names take precedence - If a command name matches, it's used even if it's also an alias of another command
  2. No conflicts - Aliases cannot conflict with other command names or aliases at the same level
  3. Scoped to level - Aliases only apply within their command's hierarchy level

Aliases in Help

Aliases are shown in command help output:

$ mycli deploy --help
Deploy to environment

Usage: mycli deploy [options]

Aliases: d, dep

Options:
  --env <value>   Target environment
  --help          Show help

Aliases in Shell Completion

Aliases are included in tab completion suggestions:

mycli <Tab>
# Shows: deploy, d, dep, build, ...

Best Practices

1. Keep Commands Focused

Each command should do one thing well:

// Good - focused
export const command = defineCommand({
  label: 'Run database migrations',
  run: async (r) => {
    await r.exec('prisma migrate deploy');
  },
});

// Bad - doing too much
export const command = defineCommand({
  label: 'Setup everything',
  run: async (r) => {
    await r.exec('npm install');
    await r.exec('prisma migrate');
    await r.exec('npm run seed');
    await r.exec('npm run build');
    // ...
  },
});

2. Use Tasks for Reusable Logic

Extract common operations into tasks:

// tasks/build.ts
export const buildTask = defineTask({
  label: 'Build',
  exec: 'npm run build',
});

// commands/build.ts
run: async (r) => {
  await r.run(buildTask);
},

// commands/deploy.ts
run: async (r) => {
  await r.run(buildTask);
  await r.run(deployTask);
},

Use visual grouping for clarity:

run: async (r) => {
  await r.group('Build', { layout: 'sequence' }, async (g) => {
    await g.activity('Compile', () => r.exec('tsc'));
    await g.activity('Bundle', () => r.exec('esbuild'));
  });

  await r.group('Deploy', { layout: 'sequence' }, async (g) => {
    await g.activity('Upload', () => r.exec('upload'));
    await g.activity('Verify', () => r.exec('verify'));
  });
},

4. Provide Helpful Descriptions

context: {
  env: {
    from: 'flag',
    schema: z.enum(['dev', 'staging', 'prod']),
    description: 'Target environment for deployment',
  },
  dryRun: {
    from: 'flag',
    schema: z.boolean().default(false),
    description: 'Preview changes without applying',
  },
},