cli-framework-oclif-ink · git:20260202.b236384 · 2026-02-02 · sha256 8e2f25814af1f5c8

cli-framework-oclif-ink git:20260202.b236384A

Immutable. This exact content is served forever at /api/v1/blob/8e2f25814af1f5c8.

---
name: cli-framework-oclif-ink
description: Modern CLI development combining oclif's command framework with Ink's React-based terminal rendering
---

# oclif + Ink CLI Patterns

> **Quick Guide:** Use oclif for command routing, parsing, and plugin architecture. Use Ink for React-based interactive terminal UIs. Combine both when building CLIs that need complex stateful interfaces beyond simple prompts.

---

## Quick Reference

### oclif Command Structure

```typescript
import { Command, Flags, Args } from "@oclif/core";

export class MyCommand extends Command {
  static summary = "Brief description";
  static description = "Detailed description";
  static examples = ["<%= config.bin %> <%= command.id %> --flag value"];

  static flags = {
    name: Flags.string({ char: "n", description: "Name flag", required: true }),
    force: Flags.boolean({ char: "f", default: false }),
  };

  static args = {
    file: Args.string({ description: "File path", required: true }),
  };

  async run(): Promise<void> {
    const { args, flags } = await this.parse(MyCommand);
    this.log(`Processing ${args.file} with name ${flags.name}`);
  }
}
```

### Ink Component Structure

```tsx
import React, { useState } from "react";
import { render, Box, Text, useInput, useApp } from "ink";

const App = () => {
  const [count, setCount] = useState(0);
  const { exit } = useApp();

  useInput((input, key) => {
    if (input === "q") exit();
    if (key.upArrow) setCount((c) => c + 1);
    if (key.downArrow) setCount((c) => c - 1);
  });

  return (
    <Box flexDirection="column">
      <Text>Count: {count}</Text>
      <Text dimColor>Arrows to change, q to quit</Text>
    </Box>
  );
};

render(<App />);
```

### oclif + Ink Integration

```typescript
import { Command, Flags } from "@oclif/core";
import { render } from "ink";
import React from "react";
import { Wizard } from "../components/wizard.js";

export class Init extends Command {
  static flags = {
    source: Flags.string({ char: "s" }),
  };

  async run(): Promise<void> {
    const { flags } = await this.parse(Init);
    const { waitUntilExit } = render(<Wizard source={flags.source} />);
    await waitUntilExit();
  }
}
```

---

## When to Use

**Use oclif when:**

- Building multi-command CLIs (like `git`, `npm`)
- Need plugin architecture for extensibility
- Want auto-generated help and shell completion
- Building enterprise CLIs requiring auto-updates

**Use Ink when:**

- Building complex interactive terminal UIs
- Need React's component model and state management
- Want declarative UI with Flexbox layouts
- Building real-time displays (progress, dashboards)

**Use both together when:**

- CLI commands need rich interactive experiences
- Multi-step wizards with complex state
- Real-time progress displays during operations
- Terminal dashboards or monitoring tools

**Don't use when:**

- Simple one-off scripts (use plain Node.js)
- Basic prompts only (use @clack/prompts or inquirer)
- Performance-critical startup (oclif has ~200ms overhead)

---

## Key Patterns Summary

| Pattern                | Location                                                      |
| ---------------------- | ------------------------------------------------------------- |
| Command definition     | [examples.md](examples.md#command-basics)                     |
| Flags and args         | [examples.md](examples.md#flags-and-args)                     |
| Ink components         | [examples.md](examples.md#ink-basics)                         |
| State with Zustand     | [examples-advanced.md](examples-advanced.md#state-management) |
| Multi-step wizards     | [examples-advanced.md](examples-advanced.md#wizards)          |
| Plugin architecture    | [examples-advanced.md](examples-advanced.md#plugins)          |
| Testing commands       | [examples-testing.md](examples-testing.md)                    |
| Testing Ink components | [examples-testing.md](examples-testing.md#ink-testing)        |

---

## Anti-Patterns

### Command Anti-Patterns

- **Blocking the event loop** - Always use async/await, never sync I/O
- **Not awaiting promises in run()** - Commands timeout after 10s if promises aren't awaited
- **Using `console.log`** - Use `this.log()`, `this.warn()`, `this.error()` instead
- **Mixing Commander.js patterns** - Don't use chained methods, use static properties

### Ink Anti-Patterns

- **Using class components** - Always use functional components with hooks
- **Not wrapping text in `<Text>`** - All text must be inside `<Text>` components
- **Nesting `<Box>` inside `<Text>`** - Only `<Text>` can be nested in `<Text>`
- **Blocking the render loop** - Use `useEffect` for async operations
- **Forgetting cleanup** - Always return cleanup functions from `useEffect`

### Integration Anti-Patterns

- **Not calling `waitUntilExit()`** - Command will exit before Ink component unmounts
- **Using `.tsx` files directly** - oclif doesn't auto-discover `.tsx`, use `.ts` that imports JSX
- **Mixing imperative and declarative** - Don't mix clack prompts with Ink components

---

## Decision Framework

```
Building a CLI?
|
+-> Need multiple commands?
|   +-> YES -> Use oclif
|   +-> NO -> Single command CLI? Use oclif with single command mode
|
+-> Need interactive UI?
|   +-> Simple prompts only? -> Use @clack/prompts (lighter)
|   +-> Complex stateful UI? -> Use Ink
|   +-> Multi-step wizard? -> Use Ink + Zustand
|
+-> Need both routing AND complex UI?
    +-> YES -> oclif + Ink integration
```

---

## Ecosystem Libraries

| Library        | Purpose                  | When to Use                              |
| -------------- | ------------------------ | ---------------------------------------- |
| `@inkjs/ui`    | Pre-built Ink components | Spinners, Select, TextInput, ProgressBar |
| `conf`         | Persistent CLI config    | Store user preferences, last-used values |
| `cosmiconfig`  | Config file loading      | Load .myapprc, myapp.config.js, etc.     |
| `listr2`       | Task list with spinners  | Multiple concurrent/sequential tasks     |
| `execa`        | Child process execution  | Running git, npm, other CLIs             |
| `zod`          | Schema validation        | Validating flags, config, user input     |
| `@oclif/table` | Table rendering          | Displaying data in columns               |

---

## File Structure

```
src/
  commands/           # oclif command classes
    init.ts          # Uses: import { Init } from './init'
    config/
      get.ts         # Subcommand: mycli config get
      set.ts         # Subcommand: mycli config set
  components/        # Ink React components
    wizard.tsx       # Interactive wizards
    spinner.tsx      # Custom spinners
  hooks/             # oclif lifecycle hooks
    init.ts          # Runs before command
    postrun.ts       # Runs after command
  lib/               # Shared utilities
    config.ts        # Configuration helpers
  stores/            # Zustand stores for complex state
    wizard-store.ts
bin/
  dev.js             # Development entry: #!/usr/bin/env -S npx tsx
  run.js             # Production entry
package.json         # oclif configuration
```

---

## Package.json Configuration

```json
{
  "name": "mycli",
  "type": "module",
  "bin": {
    "mycli": "./bin/run.js"
  },
  "oclif": {
    "bin": "mycli",
    "dirname": "mycli",
    "commands": {
      "strategy": "pattern",
      "target": "./dist/commands"
    },
    "plugins": [
      "@oclif/plugin-help",
      "@oclif/plugin-autocomplete",
      "@oclif/plugin-not-found",
      "@oclif/plugin-warn-if-update-available"
    ],
    "hooks": {
      "init": "./dist/hooks/init"
    },
    "topicSeparator": " "
  },
  "dependencies": {
    "@oclif/core": "^4.x",
    "ink": "^5.x",
    "react": "^18.x",
    "@inkjs/ui": "^2.x"
  }
}
```

---

## RED FLAGS

### High Priority

- **Not awaiting promises in run()** - Commands timeout after 10s
- **Missing `waitUntilExit()` call** - Ink component won't complete
- **Using `console.log` in commands** - Breaks JSON output mode
- **Blocking render loop** - Freezes terminal UI

### Medium Priority

- **Using class state in Ink** - Use hooks instead
- **Not handling Ctrl+C** - Always provide exit mechanism
- **Magic numbers** - Use named constants for timeouts, limits

### Common Gotchas

- `.tsx` files not auto-discovered by oclif - import from `.ts` wrapper
- Ink requires React 18+ for concurrent features
- `useInput` callback called once for pasted text (not per-character)
- Multiple `useInput` hooks can conflict - use `isActive` option
- oclif hooks run in parallel, not sequence