defineCommand
Define type-safe commands with automatic inference
Defines a command with full type inference for options and handler arguments.
Syntax
function defineCommand<
TOptions extends Options = Options,
TStore = {},
TName extends string = string,
>(
command: RunnableCommand<TOptions, TStore> & { name: TName },
): RunnableCommand<TOptions, TStore> & { name: TName };Defines a non-runnable command group:
function defineGroup<TStore = {}, TName extends string = string>(
group: Group<TStore, TName>,
): Group<TStore, TName>;Parameters
command
The command definition object.
// RunnableCommand requires at least handler OR render (or both)
type RunnableCommand<TOptions = Options, TStore = {}> =
| (BaseRunnableCommand<TOptions, TStore> & { handler: Handler<TOptions, TStore> })
| (BaseRunnableCommand<TOptions, TStore> & { render: RenderFunction<TOptions, TStore> })
| (BaseRunnableCommand<TOptions, TStore> & {
handler: Handler<TOptions, TStore>;
render: RenderFunction<TOptions, TStore>;
});
interface BaseRunnableCommand<TOptions = Options, TStore = {}> {
name: string;
description: string;
alias?: string | string[];
options?: TOptions;
commands?: Command[];
tui?: CommandTuiOptions;
outputPolicy?: OutputPolicy;
defaultFormat?: OutputFormat;
}Type Inference
defineCommand automatically infers types from your Zod schemas:
import { defineCommand, option } from "@bunli/core";
import { z } from "zod";
const command = defineCommand({
name: "serve" as const,
description: "Start server",
options: {
port: option(z.coerce.number().default(3000)),
host: option(z.string().default("localhost")),
},
handler: async ({ flags }) => {
// TypeScript knows:
// flags.port is number
// flags.host is string
},
});Examples
Basic Command
export default defineCommand({
name: "hello" as const,
description: "Say hello",
handler: async ({ colors }) => {
console.log(colors.green("Hello, World!"));
},
});Command with Options
export default defineCommand({
name: "deploy" as const,
description: "Deploy application",
options: {
env: option(z.enum(["dev", "staging", "prod"]), {
description: "Target environment",
short: "e",
}),
force: option(z.coerce.boolean().default(false), {
description: "Force deployment",
short: "f",
}),
tag: option(z.string().optional(), { description: "Version tag" }),
},
handler: async ({ flags, shell, spinner }) => {
const spin = spinner(`Deploying to ${flags.env}...`);
spin.start();
if (flags.tag) {
await shell`git tag ${flags.tag}`;
}
await shell`deploy --env ${flags.env} ${flags.force ? "--force" : ""}`;
spin.succeed("Deployed successfully!");
},
});TUI Rendering
Commands can use render: instead of (or alongside) handler: for interactive TUI views:
export default defineCommand({
name: 'greet' as const,
description: 'Interactive greeting',
options: {
name: option(z.string().default('World'), { short: 'n' }),
loud: option(z.coerce.boolean().default(false), { short: 'l' })
},
render: ({ flags }) => (
<GreetProgress
name={String(flags.name)}
loud={Boolean(flags.loud)}
/>
),
handler: async ({ flags, colors }) => {
// Non-interactive fallback
console.log(colors.green(`Hello, ${flags.name}!`))
}
})Command with Aliases
export default defineCommand({
name: "development" as const,
alias: ["dev", "d"],
description: "Start development server",
handler: async ({ shell }) => {
await shell`bun run dev`;
},
});Nested Commands
export default defineGroup({
name: "db" as const,
description: "Database operations",
commands: [
defineCommand({
name: "migrate" as const,
alias: "m",
description: "Run migrations",
options: {
direction: option(z.enum(["up", "down"]).default("up"), {
short: "d",
description: "Migration direction",
}),
},
handler: async ({ flags, shell }) => {
await shell`bun run db:migrate ${flags.direction}`;
},
}),
defineCommand({
name: "seed" as const,
description: "Seed database",
handler: async ({ shell }) => {
await shell`bun run db:seed`;
},
}),
],
});Handler Context
The handler receives a context object with these properties:
interface HandlerArgs<
TFlags = Record<string, unknown>,
TStore = {},
TCommandName extends string = string,
> {
// Parsed and validated option values
flags: TFlags;
// Non-flag arguments
positional: string[];
// Bun Shell template tag
shell: typeof Bun.$;
// Environment variables
env: typeof process.env;
// Current working directory
cwd: string;
// Interactive prompts
prompt: {
text(message: string, options?: PromptOptions): Promise<string>;
confirm(message: string, options?: ConfirmOptions): Promise<boolean>;
select<T>(message: string, options: SelectOptions<T>): Promise<T>;
password(message: string, options?: PromptOptions): Promise<string>;
multiselect<T>(message: string, options: MultiSelectOptions<T>): Promise<T[]>;
pager(content: string, options?: PagerOptions): Promise<void>;
};
// Progress spinner factory
spinner: (text?: string) => {
start(text?: string): void;
stop(text?: string): void;
succeed(text?: string): void;
fail(text?: string): void;
warn(text?: string): void;
info(text?: string): void;
update(text: string): void;
};
// Terminal colors
colors: typeof import("@bunli/utils").colors;
// Terminal information
terminal: {
width: number;
height: number;
isInteractive: boolean;
isCI: boolean;
supportsColor: boolean;
supportsMouse: boolean;
};
// Runtime information
runtime: {
startTime: number;
args: string[];
command: string;
outputFormat: OutputFormat;
};
// Abort signal for cancellation
signal: AbortSignal;
// Image/TUI rendering options
image: {
mode: "off" | "auto" | "on";
protocol: "auto" | "kitty";
width?: number;
height?: number;
};
// Output format (json, yaml, toon, md)
format: OutputFormat;
// Whether format was explicitly set via flag
formatExplicit: boolean;
// Whether running in an AI agent context
agent: boolean;
// Write formatted output
output: (data: unknown) => void;
// Plugin context (when using plugins)
context?: CommandContext<Record<string, unknown>>;
}Using Handler Context
handler: async ({
flags,
positional,
shell,
env,
cwd,
prompt,
spinner,
colors,
terminal,
runtime,
signal,
image,
format,
formatExplicit,
agent,
output,
context,
}) => {
// Access parsed flags
console.log(`Port: ${flags.port}`);
// Use positional arguments
const [file] = positional;
// Check terminal capabilities
if (terminal.supportsMouse) {
// Enable mouse mode
}
// Check if in AI agent context
if (agent) {
// Provide structured output for AI
output({ status: "building", progress: 50 });
}
// Use format for structured output
if (formatExplicit) {
output({ status: "done", result: "value" });
}
// Run shell commands with template syntax
const result = await shell`ls -la ${file}`.text();
// Check environment
if (env.NODE_ENV === "production") {
const confirm = await prompt.confirm("Deploy to production?");
if (!confirm) return;
}
// Show progress
const spin = spinner("Building...");
spin.start();
await shell`bun run build`;
spin.succeed("Build complete!");
// Colored output
console.log(colors.green("✓ Success"));
// Access timing from runtime
const duration = Date.now() - runtime.startTime;
};TUI Command Options
Commands with render: can specify TUI options:
export default defineCommand({
name: 'interactive' as const,
description: 'Interactive TUI command',
tui: {
renderer: {
bufferMode: 'alternate' // Use alternate screen buffer
}
},
outputPolicy: 'all', // Always use TUI even in CI
defaultFormat: 'toon', // Default to toon format
render: ({ flags }) => <InteractiveView flags={flags} />
})CommandTuiOptions
interface CommandTuiOptions {
renderer?: {
bufferMode?: "alternate" | "standard";
exitOnCtrlC?: boolean;
targetFps?: number;
enableMouseMovement?: boolean;
useMouse?: boolean;
};
image?: {
mode?: "off" | "auto" | "on";
protocol?: "auto" | "kitty";
width?: number;
height?: number;
};
}Validation
Options are validated automatically before the handler runs:
// This command requires a valid port number
export default defineCommand({
name: "serve" as const,
options: {
port: option(z.coerce.number().int().min(1).max(65535), { description: "Port number" }),
},
handler: async ({ flags }) => {
// flags.port is guaranteed to be 1-65535
},
});
// Invalid input shows error:
// $ my-cli serve --port 70000
// Validation Error:
// --port:
// • Number must be less than or equal to 65535Command Without Handler
Commands can be defined without handlers when they only contain subcommands:
export default defineGroup({
name: "tools" as const,
description: "Development tools",
commands: [
// Subcommands here
],
// No handler - shows help when called directly
});Using Plugin Context
When using plugins, commands can access the type-safe plugin store:
// With plugins configured in createCLI
const cli = await createCLI({
plugins: [aiAgentPlugin(), timingPlugin()] as const,
});
// In your command:
export default defineCommand({
name: "build" as const,
description: "Build the project",
handler: async ({ flags, context, colors }) => {
// Access plugin store with full type safety
if (context?.store.isAIAgent) {
// Provide structured output for AI
console.log(
JSON.stringify({
status: "building",
agents: context.store.aiAgents,
}),
);
} else {
// Human-friendly output
console.log(colors.blue("Building project..."));
}
// Access timing data from plugin
if (context?.store.startTime) {
console.log(`Started at: ${new Date(context.store.startTime)}`);
}
},
});Use defineCommand for the best TypeScript experience. It provides complete type inference from
your option schemas to your handler implementation.
Best Practices
- Always add descriptions - Help users understand what commands do
- Use semantic names - Command names should be verbs (build, deploy, test)
- Add short flags - For frequently used options
- Group related commands - Use nested commands for organization
- Validate early - Use Zod schemas to validate input before processing
- Provide both render and handler - For commands that work in both TUI and non-interactive modes
See Also
- option - Create command options
- defineGroup - Group commands together
- Commands - Command concepts
- Type Inference - How type inference works
- Plugins - Learn about plugins and context
- Plugin API - Plugin API reference