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 upNaming 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.tsThen 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:
- Package manager scripts (if
pmScriptsis configured) - Package manager commands (if
pmCommandsis configured) - Static extra commands (if
extraCommandsis configured) - Root plugins (if
pluginsis configured) - 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.
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'),
]);
},Tabbed Console
run: async (r) => {
await r.tabs([
r.exec('npm run dev'),
r.exec('stripe listen'),
r.run(watchTask),
], { name: 'Development' });
},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 menumycli check all→ Run all sequentiallymycli 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 aliasAlias Rules
- Names take precedence - If a command name matches, it's used even if it's also an alias of another command
- No conflicts - Aliases cannot conflict with other command names or aliases at the same level
- 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 helpAliases 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);
},3. Group Related Activities
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',
},
},