Bunli
API Reference

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 65535

Command 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

  1. Always add descriptions - Help users understand what commands do
  2. Use semantic names - Command names should be verbs (build, deploy, test)
  3. Add short flags - For frequently used options
  4. Group related commands - Use nested commands for organization
  5. Validate early - Use Zod schemas to validate input before processing
  6. Provide both render and handler - For commands that work in both TUI and non-interactive modes

See Also

On this page