Modern CLI development combining oclif's command framework with Ink's React-based terminal rendering
How this skill is triggered — by the user, by Claude, or both
Slash command
/cli-framework-oclif-ink:cli-framework-oclif-inkThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
> **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 `await waitUntilExit()` when rendering Ink from oclif commands. Use `this.log()` instead of `console.log` to preserve JSON output mode.
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
await waitUntilExit()when rendering Ink from oclif commands. Usethis.log()instead ofconsole.logto preserve JSON output mode.
<critical_requirements>
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST await waitUntilExit() after render() in oclif commands -- without it the process exits before the UI completes)
(You MUST use this.log() / this.warn() / this.error() in commands -- console.log breaks --json mode and test capture)
(You MUST wrap all text in <Text> components in Ink -- bare strings cause rendering errors)
(You MUST use useEffect cleanup to cancel async operations -- Ink components unmount when the user presses Ctrl+C)
</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:
When NOT to use:
Key patterns covered:
@oclif/test and components with ink-testing-libraryoclif 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:
.ts files (not .tsx) -- they import Ink components from separate .tsx filesuseInput, not in oclif commandsCommands 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.
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.
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.
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.
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.
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.
<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
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:
<red_flags>
High Priority:
await waitUntilExit() -- Command exits before Ink UI completes, user sees nothingconsole.log in commands -- Breaks --json output mode and is not captured by @oclif/test<Text> or rendering failsMedium Priority:
.tsx files as commands -- oclif does not auto-discover .tsx files; use .ts command files that import .tsx componentsuseInput or useApp().exit()useInput hooks -- Multiple active useInput hooks fire simultaneously; use the isActive option to scope themGotchas & Edge Cases:
useInput fires once for pasted text, not per-character -- handle multi-character input strings explicitlyenableJsonFlag makes run() return value the JSON output -- ensure the return type matches what consumers expectthis.error() throws (exits the process) -- it does not return</red_flags>
<critical_reminders>
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST await waitUntilExit() after render() in oclif commands -- without it the process exits before the UI completes)
(You MUST use this.log() / this.warn() / this.error() in commands -- console.log breaks --json mode and test capture)
(You MUST wrap all text in <Text> components in Ink -- bare strings cause rendering errors)
(You MUST use useEffect cleanup to cancel async operations -- Ink components unmount when the user presses Ctrl+C)
Failure to follow these rules will cause silent process exits, broken JSON output, and terminal rendering crashes.
</critical_reminders>
Provides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub agents-inc/skills --plugin cli-framework-oclif-ink