Learn-skills.dev cli-framework-oclif-ink
Modern CLI development combining oclif's command framework with Ink's React-based terminal rendering
git clone https://github.com/NeverSight/learn-skills.dev
T=$(mktemp -d) && git clone --depth=1 https://github.com/NeverSight/learn-skills.dev "$T" && mkdir -p ~/.claude/skills && cp -r "$T/data/skills-md/agents-inc/skills/cli-framework-oclif-ink" ~/.claude/skills/neversight-learn-skills-dev-cli-framework-oclif-ink && rm -rf "$T"
data/skills-md/agents-inc/skills/cli-framework-oclif-ink/SKILL.mdoclif + Ink CLI Patterns
Quick Guide: Use oclif for command routing, flag/arg parsing, and plugin architecture. Use Ink for React-based interactive terminal UIs with Flexbox layout. Combine both when commands need rich stateful interfaces. Always
when rendering Ink from oclif commands. Useawait waitUntilExit()instead ofthis.log()to preserve JSON output mode.console.log
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
, named constants)import type
(You MUST
after await waitUntilExit()
in oclif commands -- without it the process exits before the UI completes)render()
(You MUST use
/ this.log()
/ this.warn()
in commands -- this.error()
breaks console.log
mode and test capture)--json
(You MUST wrap all text in
components in Ink -- bare strings cause rendering errors)<Text>
(You MUST use
cleanup to cancel async operations -- Ink components unmount when the user presses Ctrl+C)useEffect
</critical_requirements>
Auto-detection: oclif, @oclif/core, @oclif/test, Ink, ink, @inkjs/ui, Command class, Flags, Args, useInput, useApp, useFocus, render(), waitUntilExit, terminal UI, CLI command, ink-testing-library
When to use:
- Building multi-command CLIs with flag/arg parsing
- Creating interactive terminal UIs (wizards, dashboards, progress displays)
- Combining command routing with rich React-based interfaces
- Building plugin-extensible CLI architectures
When NOT to use:
- Simple one-off scripts (plain Node.js suffices)
- Basic prompts only (a lightweight prompt library suffices)
- Performance-critical startup under 100ms (oclif adds ~200ms overhead)
Key patterns covered:
- oclif command structure with typed flags, args, and output methods
- Ink components, Flexbox layout, keyboard input, and focus management
- Integration: rendering Ink from oclif commands with lifecycle management
- @inkjs/ui pre-built components (Select, TextInput, Spinner, etc.)
- Plugin architecture and lifecycle hooks
- Multi-step wizards, progress indicators, and cancelable operations
- Testing commands with
and components with@oclif/testink-testing-library
<philosophy>
Philosophy
oclif and Ink solve orthogonal problems. oclif handles the boring-but-critical parts: command routing, flag parsing, help generation, plugin discovery, auto-updates. Ink handles the interactive parts: stateful terminal UIs using React's component model with Flexbox layout.
Use oclif alone when commands do their work and print output. Add Ink when a command needs real-time user interaction (wizards, dashboards, progress). The integration point is simple: the oclif command's
run() calls render() and awaits waitUntilExit().
Key architectural decisions:
- Commands are
files (not.ts
) -- they import Ink components from separate.tsx
files.tsx - oclif handles process lifecycle; Ink handles UI lifecycle within it
- Keyboard handling lives in Ink components via
, not in oclif commandsuseInput - State management for complex Ink UIs should use an external store (not prop drilling)
<patterns>
Core Patterns
Pattern 1: oclif Command with Typed Flags and Args
Commands use static properties for metadata and flag/arg definitions. The
run() method is async and returns typed data for JSON output support.
import { Command, Flags, Args } from "@oclif/core"; const DEFAULT_RETRIES = 3; export class Deploy extends Command { static summary = "Deploy to target environment"; static enableJsonFlag = true; // Adds --json flag static flags = { env: Flags.string({ char: "e", required: true, options: ["staging", "production"] as const, }), retries: Flags.integer({ char: "r", default: DEFAULT_RETRIES, min: 0, max: 10, }), verbose: Flags.boolean({ char: "v", default: false, allowNo: true }), apiKey: Flags.string({ env: "MY_CLI_API_KEY" }), // From env var }; static args = { target: Args.string({ description: "Deploy target", required: true }), }; async run(): Promise<{ status: string }> { const { args, flags } = await this.parse(Deploy); // Use this.log, this.warn, this.error -- never console.* this.log(`Deploying ${args.target} to ${flags.env}`); return { status: "deployed" }; } }
See examples/core.md Pattern 1-5 for complete flag types, args, output methods, and error handling.
Pattern 2: Ink Component with Keyboard Handling
Ink components are React functional components using hooks for input, app lifecycle, and focus.
import React, { useState } from "react"; import { Box, Text, useInput, useApp } from "ink"; interface SelectorProps { items: string[]; onSelect: (item: string) => void; } export const Selector: React.FC<SelectorProps> = ({ items, onSelect }) => { const [index, setIndex] = useState(0); const { exit } = useApp(); useInput((input, key) => { if (key.upArrow) setIndex((i) => Math.max(0, i - 1)); if (key.downArrow) setIndex((i) => Math.min(items.length - 1, i + 1)); if (key.return) onSelect(items[index]); if (input === "q") exit(); }); return ( <Box flexDirection="column"> {items.map((item, i) => ( <Text key={item} bold={i === index}> {i === index ? "> " : " "} {item} </Text> ))} </Box> ); };
See examples/core.md Pattern 6-8 for styling, layout, and @inkjs/ui components.
Pattern 3: Rendering Ink from oclif Command
The integration pattern: oclif command renders an Ink component and awaits its completion.
import { Command, Flags } from "@oclif/core"; import { render } from "ink"; import React from "react"; import { SetupWizard } from "../components/setup-wizard.js"; export class Init extends Command { static summary = "Initialize a new project"; static flags = { yes: Flags.boolean({ char: "y", description: "Use defaults", default: false }), }; async run(): Promise<void> { const { flags } = await this.parse(Init); if (flags.yes) { this.log("Initialized with defaults."); return; } // CRITICAL: Destructure waitUntilExit and await it const { waitUntilExit } = render(<SetupWizard />); await waitUntilExit(); } }
See examples/core.md Pattern 9 for the full integration pattern with non-interactive fallback.
Pattern 4: Multi-Step Wizard
Wizards use step-based state with back/forward navigation and data accumulation.
const MultiStepWizard: React.FC<WizardProps> = ({ steps, onComplete }) => { const [currentIndex, setCurrentIndex] = useState(0); const [data, setData] = useState<Record<string, unknown>>({}); const handleNext = (stepData: Record<string, unknown>) => { const merged = { ...data, ...stepData }; setData(merged); if (currentIndex === steps.length - 1) onComplete(merged); else setCurrentIndex((i) => i + 1); }; const handleBack = () => setCurrentIndex((i) => Math.max(0, i - 1)); // Render steps[currentIndex].component with {onNext, onBack, data} props };
See examples/advanced.md Pattern 1-2 for complete wizard implementation with navigation.
Pattern 5: Plugin Architecture
oclif plugins are npm packages with their own commands and hooks. The host CLI registers plugins in package.json.
{ "oclif": { "plugins": [ "@oclif/plugin-help", "@oclif/plugin-autocomplete", "@myorg/cli-plugin-analytics" ] } }
See examples/advanced.md Pattern 4 for creating plugins and user-installable plugin support.
Pattern 6: Testing Commands and Components
Use
@oclif/test for command tests (flags, args, output, errors) and ink-testing-library for Ink component tests (rendering, keyboard simulation).
// Command test import { runCommand } from "@oclif/test"; const { stdout, error } = await runCommand(["deploy", "--env", "staging", "app"]); expect(stdout).toContain("Deploying"); // Ink component test import { render } from "ink-testing-library"; const { lastFrame, stdin } = render(<Selector items={["a", "b"]} onSelect={fn} />); stdin.write("\u001B[B"); // Down arrow stdin.write("\r"); // Enter expect(fn).toHaveBeenCalledWith("b");
See examples/testing.md for full testing patterns including async operations, mocking, and snapshot tests.
</patterns><decision_framework>
Decision Framework
Building a CLI? | +-> Need multiple commands / subcommands? | +-> YES -> oclif (multi-command mode) | +-> NO -> oclif (single-command mode) or plain Node.js | +-> Need interactive terminal UI? | +-> Simple prompts (name, confirm)? -> Lightweight prompt library | +-> Complex stateful UI (wizard, dashboard)? -> Ink | +-> Need both routing AND complex UI? +-> YES -> oclif commands + Ink components +-> NO -> Use whichever fits the primary need
Command File Organization
src/ commands/ # oclif command classes (.ts files) init.ts config/ get.ts # mycli config get <key> set.ts # mycli config set <key> <value> components/ # Ink React components (.tsx files) wizard.tsx progress.tsx hooks/ # oclif lifecycle hooks init.ts # Runs before every command postrun.ts # Runs after every command lib/ # Shared utilities
</decision_framework>
Detailed Resources:
- examples/core.md -- Commands, flags, args, Ink components, integration
- examples/advanced.md -- Wizards, progress, plugins, hooks, error boundaries
- examples/testing.md -- Command tests, component tests, async testing
<red_flags>
RED FLAGS
High Priority:
- Missing
-- Command exits before Ink UI completes, user sees nothingawait waitUntilExit() - Using
in commands -- Breaksconsole.log
output mode and is not captured by--json@oclif/test - Bare strings in Ink -- All text must be wrapped in
or rendering fails<Text> - Blocking the render loop -- Synchronous work in components freezes the terminal UI
Medium Priority:
files as commands -- oclif does not auto-discover.tsx
files; use.tsx
command files that import.ts
components.tsx- Missing Ctrl+C handling -- Always provide an exit mechanism via
oruseInputuseApp().exit() - No cleanup in useEffect -- Async operations must be canceled on unmount to avoid state updates after exit
- Conflicting
hooks -- Multiple activeuseInput
hooks fire simultaneously; use theuseInput
option to scope themisActive
Gotchas & Edge Cases:
- oclif hooks run in parallel, not sequence -- don't depend on execution order between hooks
fires once for pasted text, not per-character -- handle multi-character input strings explicitlyuseInput- Ink v5 requires React 18+, Ink v6 requires React 19+ -- check your Ink version's peer dependencies
makesenableJsonFlag
return value the JSON output -- ensure the return type matches what consumers expectrun()- oclif's
throws (exits the process) -- it does not returnthis.error()
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
, named constants)import type
(You MUST
after await waitUntilExit()
in oclif commands -- without it the process exits before the UI completes)render()
(You MUST use
/ this.log()
/ this.warn()
in commands -- this.error()
breaks console.log
mode and test capture)--json
(You MUST wrap all text in
components in Ink -- bare strings cause rendering errors)<Text>
(You MUST use
cleanup to cancel async operations -- Ink components unmount when the user presses Ctrl+C)useEffect
Failure to follow these rules will cause silent process exits, broken JSON output, and terminal rendering crashes.
</critical_reminders>