cli-framework-oclif-ink
Modern CLI development combining oclif's command framework with Ink's React-based terminal rendering
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/cli-framework-oclif-ink/skills/cli-framework-oclif-ink
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
oclif + 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
await waitUntilExit()when rendering Ink from oclif commands. Usethis.log()instead ofconsole.logto preserve JSON output mode.
<critical_requirements>
CRITICAL: Before Using This Skill
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:
- 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
@oclif/testand components withink-testing-library
<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
await waitUntilExit()-- Command exits before Ink UI completes, user sees nothing - Using
console.login commands -- Breaks--jsonoutput mode and is not captured by@oclif/test - Bare strings in Ink -- All text must be wrapped in
<Text>or rendering fails - Blocking the render loop -- Synchronous work in components freezes the terminal UI
Medium Priority:
.tsxfiles as commands -- oclif does not auto-discover.tsxfiles; use.tscommand files that import.tsxcomponents- Missing Ctrl+C handling -- Always provide an exit mechanism via
useInputoruseApp().exit() - No cleanup in useEffect -- Async operations must be canceled on unmount to avoid state updates after exit
- Conflicting
useInputhooks -- Multiple activeuseInputhooks fire simultaneously; use theisActiveoption to scope them
Gotchas & Edge Cases:
- oclif hooks run in parallel, not sequence -- don't depend on execution order between hooks
useInputfires once for pasted text, not per-character -- handle multi-character input strings explicitly- Ink v5 requires React 18+, Ink v6 requires React 19+ -- check your Ink version's peer dependencies
enableJsonFlagmakesrun()return value the JSON output -- ensure the return type matches what consumers expect- oclif's
this.error()throws (exits the process) -- it does not return
</red_flags>
<critical_reminders>
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>
Files (skills)
-
examples
-
advanced.md 17.6 KB
# oclif + Ink - Advanced Examples > Advanced patterns for complex CLI applications. See [core.md](core.md) for essential patterns first. **Prerequisites**: Understand command structure, Ink components, and hooks from core examples. --- ## Pattern 1: Multi-Step Wizard with State Management For complex wizards, separate state from UI. Use an external store or `useReducer` to manage wizard data outside the component tree. ### Store Pattern (External State) ```typescript // src/stores/wizard-store.ts // Use your preferred state management solution. // This example shows the store interface -- adapt to your tool. type WizardStep = "approach" | "stack" | "skills" | "confirm" | "complete"; interface WizardState { step: WizardStep; approach: "stack" | "category" | null; selectedStack: string | null; selectedSkills: string[]; isLoading: boolean; error: string | null; setStep: (step: WizardStep) => void; setApproach: (approach: "stack" | "category") => void; selectStack: (stackId: string) => void; toggleSkill: (skillId: string) => void; reset: () => void; canProceed: () => boolean; } ``` ### Using Store in Ink Component ```tsx // src/components/wizard.tsx import React, { useEffect } from "react"; import { Box, Text, useApp } from "ink"; import { Select, Spinner } from "@inkjs/ui"; // Assuming a store hook: useWizardStore(selector) => state slice // Adapt to your state management solution's API export const Wizard: React.FC = () => { const { exit } = useApp(); const step = useWizardStore((s) => s.step); const isLoading = useWizardStore((s) => s.isLoading); const error = useWizardStore((s) => s.error); if (error) { return ( <Box> <Text color="red">Error: {error}</Text> </Box> ); } if (isLoading) { return <Spinner label="Loading..." />; } return ( <Box flexDirection="column"> {step === "approach" && <ApproachStep />} {step === "stack" && <StackStep />} {step === "skills" && <SkillsStep />} {step === "confirm" && <ConfirmStep onComplete={() => exit()} />} </Box> ); }; ``` **Why good:** State lives outside React tree (testable independently), selective subscriptions prevent unnecessary re-renders, store actions callable from effects. --- ## Pattern 2: Reusable Wizard with Back/Forward Navigation ```tsx // src/components/multi-step-wizard.tsx import React, { useState, useCallback } from "react"; import { Box, Text, useInput, useApp } from "ink"; import { TextInput, Select, MultiSelect, ConfirmInput } from "@inkjs/ui"; interface WizardStep { id: string; title: string; component: React.FC<StepProps>; } interface StepProps { onNext: (data: Record<string, unknown>) => void; onBack: () => void; data: Record<string, unknown>; } interface MultiStepWizardProps { steps: WizardStep[]; onComplete: (data: Record<string, unknown>) => void; onCancel: () => void; } export const MultiStepWizard: React.FC<MultiStepWizardProps> = ({ steps, onComplete, onCancel, }) => { const [currentIndex, setCurrentIndex] = useState(0); const [wizardData, setWizardData] = useState<Record<string, unknown>>({}); const { exit } = useApp(); useInput((_input, key) => { if (key.escape) { onCancel(); exit(); } }); const handleNext = useCallback( (stepData: Record<string, unknown>) => { const newData = { ...wizardData, ...stepData }; setWizardData(newData); if (currentIndex === steps.length - 1) { onComplete(newData); exit(); } else { setCurrentIndex((i) => i + 1); } }, [currentIndex, steps.length, wizardData, onComplete, exit], ); const handleBack = useCallback(() => { if (currentIndex > 0) setCurrentIndex((i) => i - 1); }, [currentIndex]); const CurrentStepComponent = steps[currentIndex].component; return ( <Box flexDirection="column" gap={1}> <Box> <Text dimColor> Step {currentIndex + 1} of {steps.length}:{" "} </Text> <Text bold>{steps[currentIndex].title}</Text> </Box> <CurrentStepComponent onNext={handleNext} onBack={handleBack} data={wizardData} /> <Box marginTop={1}> <Text dimColor> {currentIndex > 0 && "Backspace: Go back | "}ESC: Cancel </Text> </Box> </Box> ); }; // Step implementations export const NameStep: React.FC<StepProps> = ({ onNext, data }) => ( <Box flexDirection="column"> <Text>Enter project name:</Text> <TextInput placeholder="my-project" defaultValue={(data.name as string) ?? ""} onSubmit={(name) => onNext({ name })} /> </Box> ); export const FrameworkStep: React.FC<StepProps> = ({ onNext, onBack }) => { useInput((_input, key) => { if (key.backspace) onBack(); }); return ( <Box flexDirection="column"> <Text>Select framework:</Text> <Select options={[ { label: "React", value: "react" }, { label: "Vue", value: "vue" }, { label: "Svelte", value: "svelte" }, ]} onChange={(value) => onNext({ framework: value })} /> </Box> ); }; export const FeaturesStep: React.FC<StepProps> = ({ onNext, onBack }) => { useInput((_input, key) => { if (key.backspace) onBack(); }); return ( <Box flexDirection="column"> <Text>Select features (Space to toggle, Enter to confirm):</Text> <MultiSelect options={[ { label: "TypeScript", value: "typescript" }, { label: "ESLint", value: "eslint" }, { label: "Prettier", value: "prettier" }, { label: "Testing", value: "testing" }, ]} onSubmit={(values) => onNext({ features: values })} /> </Box> ); }; // Usage const wizardSteps: WizardStep[] = [ { id: "name", title: "Project Name", component: NameStep }, { id: "framework", title: "Framework", component: FrameworkStep }, { id: "features", title: "Features", component: FeaturesStep }, ]; ``` **Why good:** Reusable wizard pattern, back/forward navigation, state preserved across steps, escape-to-cancel. --- ## Pattern 3: Progress Indicators ```tsx import React, { useState, useEffect } from "react"; import { Box, Text } from "ink"; import { Spinner, ProgressBar, StatusMessage } from "@inkjs/ui"; interface Task { id: string; title: string; run: () => Promise<void>; } type TaskStatus = "pending" | "running" | "complete" | "error"; const TASK_COLORS = { pending: "gray", running: "blue", complete: "green", error: "red", } as const; export const TaskProgress: React.FC<{ tasks: Task[]; onComplete: () => void; onError: (error: Error) => void; }> = ({ tasks, onComplete, onError }) => { const [statuses, setStatuses] = useState<Record<string, TaskStatus>>( Object.fromEntries(tasks.map((t) => [t.id, "pending"])), ); const [error, setError] = useState<Error | null>(null); useEffect(() => { const runTasks = async () => { for (const task of tasks) { setStatuses((s) => ({ ...s, [task.id]: "running" })); try { await task.run(); setStatuses((s) => ({ ...s, [task.id]: "complete" })); } catch (err) { setStatuses((s) => ({ ...s, [task.id]: "error" })); const taskError = err instanceof Error ? err : new Error(String(err)); setError(taskError); onError(taskError); return; } } onComplete(); }; runTasks(); }, [tasks, onComplete, onError]); const completedCount = Object.values(statuses).filter( (s) => s === "complete", ).length; const progress = Math.round((completedCount / tasks.length) * 100); return ( <Box flexDirection="column" gap={1}> <Box> <Text>Progress: </Text> <ProgressBar value={progress} /> <Text> {progress}%</Text> </Box> <Box flexDirection="column"> {tasks.map((task) => { const status = statuses[task.id]; return ( <Box key={task.id}> {status === "running" ? ( <Spinner /> ) : ( <Text color={TASK_COLORS[status]}> </Text> )} <Text color={status === "pending" ? "gray" : undefined}> {" "} {task.title} </Text> </Box> ); })} </Box> {error && <StatusMessage variant="error">{error.message}</StatusMessage>} </Box> ); }; ``` --- ## Pattern 4: Plugin Architecture ### Creating a Plugin ```typescript // my-plugin/src/commands/hello.ts import { Command, Flags } from "@oclif/core"; export class Hello extends Command { static summary = "Say hello from plugin"; static flags = { name: Flags.string({ char: "n", default: "World" }) }; async run(): Promise<void> { const { flags } = await this.parse(Hello); this.log(`Hello, ${flags.name}! (from plugin)`); } } ``` ```json // my-plugin/package.json { "name": "@myorg/cli-plugin-hello", "oclif": { "commands": { "strategy": "pattern", "target": "./dist/commands" }, "hooks": { "init": "./dist/hooks/init" } }, "dependencies": { "@oclif/core": "^4.x" } } ``` ### Registering and Installing Plugins ```json // Host CLI package.json { "oclif": { "plugins": [ "@oclif/plugin-help", "@oclif/plugin-autocomplete", "@oclif/plugin-plugins", "@myorg/cli-plugin-hello" ] } } ``` ```bash # With @oclif/plugin-plugins, users can install plugins at runtime mycli plugins install @myorg/cli-plugin-extra mycli plugins # List installed mycli plugins uninstall @myorg/cli-plugin-extra ``` --- ## Pattern 5: Custom Ink Hooks ### Step Focus Management ```tsx import { useState, useCallback } from "react"; import { useInput } from "ink"; interface UseStepFocusOptions { steps: string[]; initialStep?: string; onStepChange?: (step: string) => void; loop?: boolean; } export const useStepFocus = ({ steps, initialStep, onStepChange, loop = false, }: UseStepFocusOptions) => { const [currentIndex, setCurrentIndex] = useState(() => { if (initialStep) { const index = steps.indexOf(initialStep); return index >= 0 ? index : 0; } return 0; }); const goToNext = useCallback(() => { setCurrentIndex((i) => { const next = i + 1; if (next >= steps.length) return loop ? 0 : i; onStepChange?.(steps[next]); return next; }); }, [steps, loop, onStepChange]); const goToPrevious = useCallback(() => { setCurrentIndex((i) => { const prev = i - 1; if (prev < 0) return loop ? steps.length - 1 : i; onStepChange?.(steps[prev]); return prev; }); }, [steps, loop, onStepChange]); return { currentStep: steps[currentIndex], currentIndex, goToNext, goToPrevious, isFirst: currentIndex === 0, isLast: currentIndex === steps.length - 1, }; }; ``` ### Async Task Hook ```tsx import { useState, useCallback, useEffect, useRef } from "react"; interface UseAsyncTaskOptions<T> { task: () => Promise<T>; immediate?: boolean; onSuccess?: (result: T) => void; onError?: (error: Error) => void; } export const useAsyncTask = <T>({ task, immediate = false, onSuccess, onError, }: UseAsyncTaskOptions<T>) => { const [isLoading, setIsLoading] = useState(false); const [result, setResult] = useState<T | null>(null); const [error, setError] = useState<Error | null>(null); const mountedRef = useRef(true); const execute = useCallback(async () => { setIsLoading(true); setError(null); try { const data = await task(); if (mountedRef.current) { setResult(data); onSuccess?.(data); } } catch (err) { if (mountedRef.current) { const taskError = err instanceof Error ? err : new Error(String(err)); setError(taskError); onError?.(taskError); } } finally { if (mountedRef.current) setIsLoading(false); } }, [task, onSuccess, onError]); useEffect(() => { if (immediate) execute(); return () => { mountedRef.current = false; }; }, [immediate, execute]); return { execute, isLoading, result, error }; }; ``` **Why good:** Mounted ref prevents state updates after unmount, cleanup on effect teardown, reusable across components. --- ## Pattern 6: Error Boundary for Ink Error boundaries must be class components (React limitation). Useful for catching render errors in complex Ink UIs. ```tsx import React, { Component, ErrorInfo, ReactNode } from "react"; import { Box, Text } from "ink"; interface ErrorBoundaryProps { children: ReactNode; fallback?: ReactNode; onError?: (error: Error, errorInfo: ErrorInfo) => void; } interface ErrorBoundaryState { hasError: boolean; error: Error | null; } export class ErrorBoundary extends Component< ErrorBoundaryProps, ErrorBoundaryState > { constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: ErrorInfo): void { this.props.onError?.(error, errorInfo); } render(): ReactNode { if (this.state.hasError) { return ( this.props.fallback ?? ( <Box flexDirection="column" borderStyle="single" borderColor="red" padding={1} > <Text color="red" bold> An error occurred </Text> <Text color="red">{this.state.error?.message}</Text> </Box> ) ); } return this.props.children; } } ``` --- ## Pattern 7: Cancelable Long-Running Operations ```tsx import React, { useState, useEffect, useRef } from "react"; import { Box, Text, useApp, useInput } from "ink"; import { ProgressBar, Spinner } from "@inkjs/ui"; interface DownloadProgressProps { url: string; onComplete: () => void; onCancel: () => void; } export const DownloadProgress: React.FC<DownloadProgressProps> = ({ url, onComplete, onCancel, }) => { const [progress, setProgress] = useState(0); const [status, setStatus] = useState< "downloading" | "complete" | "cancelled" >("downloading"); const abortRef = useRef<AbortController | null>(null); useInput((input, key) => { if (input === "c" || key.escape) { abortRef.current?.abort(); setStatus("cancelled"); onCancel(); } }); useEffect(() => { const controller = new AbortController(); abortRef.current = controller; const download = async () => { try { const response = await fetch(url, { signal: controller.signal }); const contentLength = Number(response.headers.get("Content-Length")) || 0; const reader = response.body?.getReader(); if (!reader) throw new Error("No response body"); let received = 0; while (true) { const { done, value } = await reader.read(); if (done) break; received += value.length; if (contentLength > 0) setProgress(Math.round((received / contentLength) * 100)); } setStatus("complete"); onComplete(); } catch (err) { if ((err as Error).name !== "AbortError") throw err; } }; download(); return () => { controller.abort(); }; // Cleanup on unmount }, [url, onComplete]); if (status === "cancelled") return <Text color="yellow">Download cancelled</Text>; if (status === "complete") return <Text color="green">Download complete</Text>; return ( <Box flexDirection="column" gap={1}> <Box> <Spinner /> <Text> Downloading...</Text> </Box> <ProgressBar value={progress} /> <Text dimColor>Press 'c' or ESC to cancel</Text> </Box> ); }; ``` **Why good:** AbortController for cancellation, cleanup on unmount, keyboard cancel support, progress streaming. --- ## Pattern 8: JSON Output Mode with Ink Fallback ```typescript // src/commands/list.ts import { Command, Flags } from "@oclif/core"; import { render } from "ink"; import React from "react"; import { SkillsList } from "../components/skills-list.js"; interface Skill { id: string; name: string; installed: boolean; } export class List extends Command { static summary = "List available skills"; static enableJsonFlag = true; static flags = { installed: Flags.boolean({ char: "i", description: "Only installed" }), }; async run(): Promise<Skill[]> { const { flags } = await this.parse(List); const skills = await this.fetchSkills(); const filtered = flags.installed ? skills.filter((s) => s.installed) : skills; // JSON mode: return data, skip UI if (this.jsonEnabled()) return filtered; // Interactive mode: render UI const { waitUntilExit } = render(<SkillsList skills={filtered} />); await waitUntilExit(); return filtered; } private async fetchSkills(): Promise<Skill[]> { return []; } } ``` **Why good:** Dual mode (JSON for scripts, interactive for humans), same return type for both paths. --- ## Pattern 9: @inkjs/ui Theming ```tsx import { extendTheme, defaultTheme, ThemeProvider } from "@inkjs/ui"; export const customTheme = extendTheme(defaultTheme, { components: { Spinner: { styles: { frame: () => ({ color: "cyan" }), label: () => ({ color: "gray" }), }, }, Select: { styles: { focusIndicator: () => ({ color: "cyan" }), label: ({ isFocused }) => ({ color: isFocused ? "cyan" : undefined }), }, }, }, }); // Wrap your app export const App: React.FC<{ children: React.ReactNode }> = ({ children }) => ( <ThemeProvider theme={customTheme}>{children}</ThemeProvider> ); ``` -
core.md 14.3 KB
# oclif + Ink - Core Examples > Essential patterns for building CLIs with oclif and Ink. See [SKILL.md](../SKILL.md) for overview and decision guidance. **Prerequisites**: TypeScript, React hooks, async/await. --- ## Pattern 1: Command with Typed Flags and Args ```typescript // src/commands/greet.ts import { Command, Flags, Args } from "@oclif/core"; const DEFAULT_GREETING = "Hello"; export class Greet extends Command { static summary = "Greet a user"; static description = "Displays a greeting message to the specified user."; static examples = [ "<%= config.bin %> greet World", "<%= config.bin %> greet World --greeting Hi", "<%= config.bin %> greet World -g Hey --loud", ]; static flags = { greeting: Flags.string({ char: "g", description: "Custom greeting", default: DEFAULT_GREETING, }), loud: Flags.boolean({ char: "l", description: "Print in uppercase", default: false, }), }; static args = { name: Args.string({ description: "Name to greet", required: true, }), }; async run(): Promise<void> { const { args, flags } = await this.parse(Greet); let message = `${flags.greeting}, ${args.name}!`; if (flags.loud) { message = message.toUpperCase(); } this.log(message); } } ``` **Why good:** Named constants for defaults, typed flags/args via static properties, `this.log()` for output, `examples` for help text generation. --- ## Pattern 2: Comprehensive Flag Types ```typescript import { Command, Flags } from "@oclif/core"; const MAX_RETRIES = 3; const DEFAULT_TIMEOUT_MS = 30000; export class Process extends Command { static summary = "Process files with various options"; static flags = { // String with short alias output: Flags.string({ char: "o", description: "Output directory", required: true, }), // Boolean with --no-verbose support verbose: Flags.boolean({ char: "v", default: false, allowNo: true }), // Integer with range validation retries: Flags.integer({ char: "r", default: MAX_RETRIES, min: 0, max: 10, }), // Constrained string options (type-safe) format: Flags.string({ char: "f", options: ["json", "yaml", "toml"] as const, default: "json", }), // Multiple values include: Flags.string({ char: "i", multiple: true, default: [] }), // From environment variable apiKey: Flags.string({ env: "MY_CLI_API_KEY" }), // URL with built-in validation endpoint: Flags.url({ description: "API endpoint URL" }), // Custom parse function timeout: Flags.integer({ default: DEFAULT_TIMEOUT_MS / 1000, parse: async (input) => { const seconds = parseInt(input, 10); if (isNaN(seconds) || seconds < 0) { throw new Error("Timeout must be a positive number"); } return seconds * 1000; // Convert to ms }, }), }; async run(): Promise<void> { const { flags } = await this.parse(Process); if (flags.verbose) { this.log(`Format: ${flags.format}, Retries: ${flags.retries}`); } } } ``` --- ## Pattern 3: Variable Arguments with Strict Mode ```typescript import { Command, Args } from "@oclif/core"; export class Concat extends Command { static summary = "Concatenate multiple files"; static strict = false; // Allow variable number of args static args = { files: Args.string({ description: "Files to concatenate", required: true }), }; async run(): Promise<void> { const { argv } = await this.parse(Concat); // argv contains all positional arguments as string[] this.log(`Concatenating ${argv.length} files: ${argv.join(", ")}`); } } ``` --- ## Pattern 4: Output Methods and JSON Support ```typescript import { Command, Flags } from "@oclif/core"; export class Status extends Command { static summary = "Check system status"; static enableJsonFlag = true; // Adds --json flag automatically async run(): Promise<{ status: string; healthy: boolean }> { const { flags } = await this.parse(Status); const result = { status: "operational", healthy: true }; // Standard output this.log("Checking system status..."); // Warnings (yellow by default) this.warn("Cache is stale, consider refreshing"); // When --json flag is used, return value becomes the JSON output if (this.jsonEnabled()) { return result; } this.log(`Status: ${result.status}`); return result; } } ``` **Why good:** `enableJsonFlag` auto-adds `--json`, return type becomes the output shape. `this.log`/`this.warn` are captured by test harness. --- ## Pattern 5: Error Handling with Codes and Suggestions ```typescript import { Command, Flags } from "@oclif/core"; const EXIT_CODE_AUTH_FAILED = 2; const EXIT_CODE_NOT_FOUND = 3; export class Deploy extends Command { static summary = "Deploy application"; static flags = { env: Flags.string({ char: "e", required: true, options: ["staging", "production"] as const, }), }; async run(): Promise<void> { const { flags } = await this.parse(Deploy); const isAuthenticated = await this.checkAuth(); if (!isAuthenticated) { // this.error() throws -- it does NOT return this.error("Not authenticated. Run 'mycli login' first.", { code: "AUTH_REQUIRED", exit: EXIT_CODE_AUTH_FAILED, suggestions: ["Run 'mycli login' to authenticate"], }); } try { await this.deploy(flags.env); this.log(`Deployed to ${flags.env}`); } catch (error) { if (error instanceof NotFoundError) { this.error(`Deploy target not found: ${error.message}`, { code: "NOT_FOUND", exit: EXIT_CODE_NOT_FOUND, }); } throw error; // Re-throw unexpected errors } } private async checkAuth(): Promise<boolean> { return true; } private async deploy(_env: string): Promise<void> {} } class NotFoundError extends Error { constructor(message: string) { super(message); this.name = "NotFoundError"; } } ``` **Why good:** Named exit codes, `this.error()` with structured codes/suggestions, specific error type matching, re-throws unknowns. --- ## Pattern 6: Ink Component with Keyboard Input ```tsx // src/components/counter.tsx import React, { useState } from "react"; import { Box, Text, useInput, useApp } from "ink"; interface CounterProps { initialValue?: number; onComplete?: (finalValue: number) => void; } const MIN_VALUE = 0; const MAX_VALUE = 100; export const Counter: React.FC<CounterProps> = ({ initialValue = 0, onComplete, }) => { const [count, setCount] = useState(initialValue); const { exit } = useApp(); useInput((input, key) => { if (input === "q" || key.escape) { onComplete?.(count); exit(); return; } if (key.upArrow && count < MAX_VALUE) setCount((c) => c + 1); if (key.downArrow && count > MIN_VALUE) setCount((c) => c - 1); if (key.return) { onComplete?.(count); exit(); } }); return ( <Box flexDirection="column" padding={1}> <Text bold>Counter: {count}</Text> <Box marginTop={1}> <Text dimColor>Arrows to change, Enter to confirm, q to quit</Text> </Box> </Box> ); }; ``` **Why good:** Functional component, typed props, `useInput` for keyboard, `useApp().exit()` for cleanup, named constants for bounds. --- ## Pattern 7: Box Layout and Text Styling ```tsx import React from "react"; import { Box, Text, Newline, Spacer } from "ink"; interface StatusDisplayProps { title: string; status: "success" | "warning" | "error"; message: string; details?: string[]; } const STATUS_COLORS = { success: "green", warning: "yellow", error: "red", } as const; export const StatusDisplay: React.FC<StatusDisplayProps> = ({ title, status, message, details, }) => { const color = STATUS_COLORS[status]; return ( <Box flexDirection="column" borderStyle="round" borderColor={color} padding={1} > <Box> <Text bold>{title}</Text> <Spacer /> <Text color={color}>[{status.toUpperCase()}]</Text> </Box> <Newline /> <Text>{message}</Text> {details && details.length > 0 && ( <Box flexDirection="column" marginTop={1}> {details.map((detail, index) => ( <Text key={index} dimColor> - {detail} </Text> ))} </Box> )} </Box> ); }; ``` **Why good:** `Box` for flexbox layout, `Spacer` for alignment, `borderStyle` for visual structure, color constants. --- ## Pattern 8: @inkjs/ui Components ```tsx import React, { useState } from "react"; import { Box, Text } from "ink"; import { TextInput, Select, ConfirmInput, Spinner, StatusMessage, } from "@inkjs/ui"; type WizardStep = "name" | "framework" | "confirm" | "saving"; const FRAMEWORK_OPTIONS = [ { label: "React", value: "react" }, { label: "Vue", value: "vue" }, { label: "Svelte", value: "svelte" }, ]; export const SetupWizard: React.FC<{ onComplete: (config: Record<string, unknown>) => void; }> = ({ onComplete }) => { const [step, setStep] = useState<WizardStep>("name"); const [config, setConfig] = useState<Record<string, unknown>>({}); return ( <Box flexDirection="column" gap={1}> {step === "name" && ( <> <Text bold>What is your project name?</Text> <TextInput placeholder="my-awesome-project" onSubmit={(name) => { setConfig((c) => ({ ...c, projectName: name })); setStep("framework"); }} /> </> )} {step === "framework" && ( <> <Text bold>Select a framework:</Text> <Select options={FRAMEWORK_OPTIONS} onChange={(framework) => { setConfig((c) => ({ ...c, framework })); setStep("confirm"); }} /> </> )} {step === "confirm" && ( <> <Text> Create project "{config.projectName as string}" with{" "} {config.framework as string}? </Text> <ConfirmInput onSubmit={(confirmed) => { if (confirmed) { setStep("saving"); onComplete(config); } else { setStep("name"); } }} /> </> )} {step === "saving" && <Spinner label="Creating project..." />} {step === "saving" && ( <StatusMessage variant="info">This may take a moment...</StatusMessage> )} </Box> ); }; ``` **Why good:** Uses @inkjs/ui for consistent UX (TextInput, Select, ConfirmInput, Spinner, StatusMessage), step-based navigation, typed step union. --- ## Pattern 9: oclif + Ink Integration ```typescript // src/commands/init.ts import { Command, Flags } from "@oclif/core"; import { render } from "ink"; import React from "react"; import { SetupWizard } from "../components/setup-wizard.js"; import { writeConfig } from "../lib/config.js"; export class Init extends Command { static summary = "Initialize a new project"; static flags = { yes: Flags.boolean({ char: "y", description: "Skip prompts", default: false }), }; async run(): Promise<void> { const { flags } = await this.parse(Init); if (flags.yes) { await writeConfig({ projectName: "my-project", framework: "react" }); this.log("Project initialized with defaults."); return; } const { waitUntilExit } = render( <SetupWizard onComplete={async (config) => { await writeConfig(config); this.log("Project initialized!"); }} /> ); // CRITICAL: Without this, the command exits before the wizard finishes await waitUntilExit(); } } ``` **Why good:** Non-interactive fallback with `--yes`, `waitUntilExit()` properly awaited, clean separation between command (`.ts`) and component (`.tsx`). --- ## Pattern 10: Lifecycle Hooks ```typescript // src/hooks/init.ts -- oclif hooks use default exports (framework requirement) import { Hook } from "@oclif/core"; import { loadConfig } from "../lib/config.js"; const hook: Hook.Init = async function (options) { const { config, id } = options; // Skip for help commands if (id === "help" || id?.startsWith("help:")) return; try { const userConfig = await loadConfig(config.configDir); this.config.pjson.userConfig = userConfig; } catch { if (id !== "init") { this.warn("No configuration found. Run 'mycli init' first."); } } }; export default hook; // Default export required by oclif hook system ``` ```typescript // src/hooks/postrun.ts import { Hook } from "@oclif/core"; const TELEMETRY_TIMEOUT_MS = 5000; const hook: Hook.Postrun = async function (options) { const { Command } = options; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS); try { await fetch("https://telemetry.example.com/event", { method: "POST", body: JSON.stringify({ command: Command.id, timestamp: new Date().toISOString(), }), signal: controller.signal, }); } catch { // Telemetry failure is non-critical -- never block CLI exit } finally { clearTimeout(timeout); } }; export default hook; ``` **Why good:** Default exports (oclif hook requirement), handles errors gracefully, fire-and-forget telemetry with abort timeout. --- ## Pattern 11: Subcommands via Directory Structure ``` src/commands/ config/ get.ts # mycli config get <key> set.ts # mycli config set <key> <value> list.ts # mycli config list ``` ```typescript // src/commands/config/get.ts import { Command, Args } from "@oclif/core"; import { config } from "../../lib/config.js"; export class ConfigGet extends Command { static summary = "Get a configuration value"; static args = { key: Args.string({ description: "Configuration key", required: true }), }; async run(): Promise<void> { const { args } = await this.parse(ConfigGet); const value = config.get(args.key); if (value === undefined) { this.error(`Configuration key '${args.key}' not found`); } this.log(JSON.stringify(value, null, 2)); } } ``` **Why good:** Directory structure auto-generates topics (`mycli config`), consistent arg patterns across subcommands. -
testing.md 14.7 KB
# oclif + Ink - Testing Examples > Testing patterns for oclif commands and Ink components. See [core.md](core.md) for the patterns being tested. **Prerequisites**: Understand command structure and Ink components from core examples. --- ## Pattern 1: Testing oclif Commands Use `runCommand` from `@oclif/test` v4. It returns `{ stdout, stderr, error }` for assertion. ```typescript // src/commands/greet.test.ts import { runCommand } from "@oclif/test"; describe("greet command", () => { it("greets with default message", async () => { const { stdout } = await runCommand(["greet", "World"]); expect(stdout).toContain("Hello, World!"); }); it("uses custom greeting flag", async () => { const { stdout } = await runCommand(["greet", "World", "--greeting", "Hi"]); expect(stdout).toContain("Hi, World!"); }); it("supports short flag alias", async () => { const { stdout } = await runCommand(["greet", "World", "-g", "Hey"]); expect(stdout).toContain("Hey, World!"); }); it("converts to uppercase with --loud flag", async () => { const { stdout } = await runCommand(["greet", "World", "--loud"]); expect(stdout).toContain("HELLO, WORLD!"); }); it("requires name argument", async () => { const { error } = await runCommand(["greet"]); expect(error?.message).toContain("Missing required arg"); }); }); ``` **Why good:** Tests flags, args, short aliases, and error cases; `runCommand` handles setup/teardown. --- ## Pattern 2: Testing JSON Output and Mocked Dependencies ```typescript // src/commands/list.test.ts import { runCommand } from "@oclif/test"; // Mock external dependencies vi.mock("../lib/api.js", () => ({ fetchSkills: vi.fn(), })); import { fetchSkills } from "../lib/api.js"; const mockSkills = [ { id: "react", name: "React", category: "frontend", installed: true }, { id: "node", name: "Node.js", category: "backend", installed: false }, ]; describe("list command", () => { beforeEach(() => { vi.mocked(fetchSkills).mockResolvedValue(mockSkills); }); it("returns JSON when --json flag is used", async () => { const { stdout } = await runCommand(["list", "--json"]); const result = JSON.parse(stdout); expect(result).toHaveLength(2); expect(result[0].id).toBe("react"); }); it("filters installed skills", async () => { const { stdout } = await runCommand(["list", "--installed", "--json"]); const result = JSON.parse(stdout); expect(result).toHaveLength(1); expect(result[0].installed).toBe(true); }); }); ``` --- ## Pattern 3: Testing Error Handling ```typescript import { runCommand } from "@oclif/test"; vi.mock("../lib/deploy.js", () => ({ deploy: vi.fn(), checkAuth: vi.fn(), })); import { deploy, checkAuth } from "../lib/deploy.js"; describe("deploy command", () => { it("exits with error when not authenticated", async () => { vi.mocked(checkAuth).mockResolvedValue(false); const { error } = await runCommand(["deploy", "--env", "staging"]); expect(error?.oclif?.exit).toBe(2); expect(error?.message).toContain("Not authenticated"); }); it("provides suggestions on auth error", async () => { vi.mocked(checkAuth).mockResolvedValue(false); const { error } = await runCommand(["deploy", "--env", "staging"]); expect(error?.message).toContain("mycli login"); }); it("deploys successfully when authenticated", async () => { vi.mocked(checkAuth).mockResolvedValue(true); vi.mocked(deploy).mockResolvedValue(undefined); const { stdout, error } = await runCommand(["deploy", "--env", "staging"]); expect(error).toBeUndefined(); expect(stdout).toContain("Deployed to staging"); }); }); ``` --- ## Pattern 4: Testing oclif Hooks ```typescript import { runHook } from "@oclif/test"; import * as fs from "node:fs/promises"; vi.mock("node:fs/promises"); describe("init hook", () => { beforeEach(() => { vi.resetAllMocks(); }); it("loads config when file exists", async () => { vi.mocked(fs.readFile).mockResolvedValue( JSON.stringify({ source: "https://example.com" }), ); // runHook returns { stdout, stderr } -- assert on captured output const { stdout, stderr } = await runHook("init", { argv: ["list"] }); expect(stderr).toBe(""); }); it("warns when config missing for non-init commands", async () => { vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT")); const { stdout } = await runHook("init", { argv: ["list"] }); expect(stdout).toContain("No configuration found"); }); it("skips warning for init command", async () => { vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT")); const { stdout } = await runHook("init", { argv: ["init"] }); expect(stdout).not.toContain("No configuration found"); }); it("skips for help commands", async () => { await runHook("init", { argv: ["help"] }); expect(fs.readFile).not.toHaveBeenCalled(); }); }); ``` --- ## Pattern 5: Testing Ink Components with ink-testing-library Use `render` from `ink-testing-library`. It returns `{ lastFrame, stdin }` for rendering assertions and keyboard simulation. ```tsx // src/components/counter.test.tsx import React from "react"; import { render } from "ink-testing-library"; import { Counter } from "./counter.js"; describe("Counter component", () => { it("renders initial value", () => { const { lastFrame } = render(<Counter initialValue={5} />); expect(lastFrame()).toContain("Counter: 5"); }); it("increments on up arrow", () => { const { lastFrame, stdin } = render(<Counter initialValue={0} />); stdin.write("\u001B[A"); // Up arrow expect(lastFrame()).toContain("Counter: 1"); }); it("decrements on down arrow", () => { const { lastFrame, stdin } = render(<Counter initialValue={5} />); stdin.write("\u001B[B"); // Down arrow expect(lastFrame()).toContain("Counter: 4"); }); it("does not go below minimum", () => { const { lastFrame, stdin } = render(<Counter initialValue={0} />); stdin.write("\u001B[B"); expect(lastFrame()).toContain("Counter: 0"); }); it("calls onComplete on Enter", () => { const onComplete = vi.fn(); const { stdin } = render( <Counter initialValue={5} onComplete={onComplete} />, ); stdin.write("\r"); expect(onComplete).toHaveBeenCalledWith(5); }); it("exits on q key", () => { const onComplete = vi.fn(); const { stdin } = render( <Counter initialValue={3} onComplete={onComplete} />, ); stdin.write("q"); expect(onComplete).toHaveBeenCalledWith(3); }); }); ``` ### Key Code Reference ```typescript // Common escape sequences for stdin.write() const KEY_CODES = { UP: "\u001B[A", DOWN: "\u001B[B", RIGHT: "\u001B[C", LEFT: "\u001B[D", ENTER: "\r", ESCAPE: "\u001B", TAB: "\t", BACKSPACE: "\u007F", SPACE: " ", CTRL_C: "\u0003", } as const; ``` --- ## Pattern 6: Testing Stateful Wizards ```tsx import React from "react"; import { render } from "ink-testing-library"; import { SetupWizard } from "./setup-wizard.js"; describe("SetupWizard", () => { const onComplete = vi.fn(); beforeEach(() => { vi.resetAllMocks(); }); it("starts on name step", () => { const { lastFrame } = render(<SetupWizard onComplete={onComplete} />); expect(lastFrame()).toContain("project name"); }); it("advances to framework step after name input", async () => { const { lastFrame, stdin } = render( <SetupWizard onComplete={onComplete} />, ); stdin.write("my-project"); stdin.write("\r"); await new Promise((resolve) => setTimeout(resolve, 0)); // Flush state expect(lastFrame()).toContain("Select framework"); }); it("calls onComplete with config on confirmation", async () => { const { stdin } = render(<SetupWizard onComplete={onComplete} />); stdin.write("test-project\r"); await new Promise((resolve) => setTimeout(resolve, 0)); stdin.write("\r"); // Select framework await new Promise((resolve) => setTimeout(resolve, 0)); stdin.write("y"); // Confirm await new Promise((resolve) => setTimeout(resolve, 0)); expect(onComplete).toHaveBeenCalledWith( expect.objectContaining({ projectName: "test-project", confirmed: true }), ); }); }); ``` **Why good:** Tests step transitions, uses `setTimeout(0)` to flush React state updates between interactions. --- ## Pattern 7: Testing Async Operations in Components ```tsx import React from "react"; import { render } from "ink-testing-library"; import { DataLoader } from "./data-loader.js"; vi.mock("../lib/api.js", () => ({ fetchData: vi.fn() })); import { fetchData } from "../lib/api.js"; describe("DataLoader", () => { beforeEach(() => { vi.resetAllMocks(); }); it("shows loading state initially", () => { vi.mocked(fetchData).mockReturnValue(new Promise(() => {})); // Never resolves const { lastFrame } = render(<DataLoader />); expect(lastFrame()).toContain("Loading"); }); it("shows data when loaded", async () => { vi.mocked(fetchData).mockResolvedValue([ { id: "1", name: "Item 1" }, { id: "2", name: "Item 2" }, ]); const { lastFrame } = render(<DataLoader />); await vi.waitFor(() => { expect(lastFrame()).toContain("Item 1"); }); expect(lastFrame()).toContain("Item 2"); }); it("shows error on failure", async () => { vi.mocked(fetchData).mockRejectedValue(new Error("Network error")); const { lastFrame } = render(<DataLoader />); await vi.waitFor(() => { expect(lastFrame()).toContain("Error"); }); expect(lastFrame()).toContain("Network error"); }); it("retries on retry action", async () => { vi.mocked(fetchData) .mockRejectedValueOnce(new Error("First failure")) .mockResolvedValueOnce([{ id: "1", name: "Item 1" }]); const { lastFrame, stdin } = render(<DataLoader />); await vi.waitFor(() => { expect(lastFrame()).toContain("Error"); }); stdin.write("r"); // Press 'r' to retry await vi.waitFor(() => { expect(lastFrame()).toContain("Item 1"); }); }); }); ``` **Why good:** Tests loading/success/error states, uses `vi.waitFor` for async assertions, tests retry flow. --- ## Pattern 8: Integration Testing (Command + Ink) ```typescript import { runCommand } from "@oclif/test"; vi.mock("../lib/config.js", () => ({ writeConfig: vi.fn().mockResolvedValue(undefined), })); import { writeConfig } from "../lib/config.js"; describe("init command integration", () => { beforeEach(() => { vi.resetAllMocks(); }); it("uses defaults with --yes flag", async () => { const { stdout, error } = await runCommand(["init", "--yes"]); expect(error).toBeUndefined(); expect(stdout).toContain("initialized with defaults"); expect(writeConfig).toHaveBeenCalledWith( expect.objectContaining({ projectName: "my-project", confirmed: true }), ); }); // Note: Full interactive Ink testing through oclif is complex due to the render loop. // Test Ink components directly with ink-testing-library for interaction coverage. }); ``` --- ## Pattern 9: Mocking File System and External Processes ```typescript import { runCommand } from "@oclif/test"; import * as fs from "node:fs/promises"; import { execa } from "execa"; vi.mock("node:fs/promises"); vi.mock("execa"); describe("sync command", () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(fs.readFile).mockResolvedValue( JSON.stringify({ source: "https://example.com" }), ); vi.mocked(fs.writeFile).mockResolvedValue(undefined); vi.mocked(execa).mockResolvedValue({ stdout: "", stderr: "", exitCode: 0, } as any); }); it("syncs from configured source", async () => { const { error } = await runCommand(["sync"]); expect(error).toBeUndefined(); expect(execa).toHaveBeenCalledWith( "git", expect.arrayContaining(["clone"]), expect.any(Object), ); }); it("handles missing config file", async () => { vi.mocked(fs.readFile).mockRejectedValue( Object.assign(new Error("ENOENT"), { code: "ENOENT" }), ); const { error } = await runCommand(["sync"]); expect(error?.message).toContain("No configuration found"); }); it("respects --dry-run flag", async () => { const { stdout } = await runCommand(["sync", "--dry-run"]); expect(stdout).toContain("Dry run"); expect(fs.writeFile).not.toHaveBeenCalled(); expect(execa).not.toHaveBeenCalled(); }); }); ``` --- ## Pattern 10: Snapshot Testing for Ink ```tsx import React from "react"; import { render } from "ink-testing-library"; import { StatusDisplay } from "./status-display.js"; describe("StatusDisplay snapshots", () => { it("renders success state", () => { const { lastFrame } = render( <StatusDisplay title="Build" status="success" message="All tasks completed" details={["Task 1: OK", "Task 2: OK"]} />, ); // Inline snapshots capture exact terminal output expect(lastFrame()).toMatchSnapshot(); }); it("contains expected content in error state", () => { const { lastFrame } = render( <StatusDisplay title="Deploy" status="error" message="Deployment failed" />, ); expect(lastFrame()).toContain("ERROR"); expect(lastFrame()).toContain("Deployment failed"); }); }); ``` --- ## Pattern 11: E2E Command Testing with Real File System ```typescript import { runCommand } from "@oclif/test"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import * as os from "node:os"; describe("init command e2e", () => { let testDir: string; beforeEach(async () => { testDir = await fs.mkdtemp(path.join(os.tmpdir(), "cli-test-")); process.chdir(testDir); }); afterEach(async () => { process.chdir("/"); await fs.rm(testDir, { recursive: true, force: true }); }); it("creates config file with --yes flag", async () => { const { error } = await runCommand(["init", "--yes"]); expect(error).toBeUndefined(); const configPath = path.join(testDir, ".config", "config.yaml"); const exists = await fs .access(configPath) .then(() => true) .catch(() => false); expect(exists).toBe(true); }); it("does not overwrite existing config without --force", async () => { const configDir = path.join(testDir, ".config"); await fs.mkdir(configDir, { recursive: true }); await fs.writeFile(path.join(configDir, "config.yaml"), "existing: true"); const { error } = await runCommand(["init", "--yes"]); expect(error?.message).toContain("already exists"); const content = await fs.readFile( path.join(configDir, "config.yaml"), "utf-8", ); expect(content).toBe("existing: true"); }); }); ``` **Why good:** Real file system operations, temp directory per test, cleanup in afterEach.
-
-
SKILL.md 12.2 KB
--- 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, 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. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **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:** - 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 `@oclif/test` and components with `ink-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 `.ts` files (not `.tsx`) -- they import Ink components from separate `.tsx` files - oclif handles process lifecycle; Ink handles UI lifecycle within it - Keyboard handling lives in Ink components via `useInput`, not in oclif commands - State management for complex Ink UIs should use an external store (not prop drilling) </philosophy> --- <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. ```typescript 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](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. ```tsx 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](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. ```typescript 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](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. ```tsx 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](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. ```json { "oclif": { "plugins": [ "@oclif/plugin-help", "@oclif/plugin-autocomplete", "@myorg/cli-plugin-analytics" ] } } ``` See [examples/advanced.md](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). ```typescript // 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](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](examples/core.md) -- Commands, flags, args, Ink components, integration - [examples/advanced.md](examples/advanced.md) -- Wizards, progress, plugins, hooks, error boundaries - [examples/testing.md](examples/testing.md) -- Command tests, component tests, async testing --- <red_flags> ## RED FLAGS **High Priority:** - **Missing `await waitUntilExit()`** -- Command exits before Ink UI completes, user sees nothing - **Using `console.log` in commands** -- Breaks `--json` output mode and is not captured by `@oclif/test` - **Bare strings in Ink** -- All text must be wrapped in `<Text>` or rendering fails - **Blocking the render loop** -- Synchronous work in components freezes the terminal UI **Medium Priority:** - **`.tsx` files as commands** -- oclif does not auto-discover `.tsx` files; use `.ts` command files that import `.tsx` components - **Missing Ctrl+C handling** -- Always provide an exit mechanism via `useInput` or `useApp().exit()` - **No cleanup in useEffect** -- Async operations must be canceled on unmount to avoid state updates after exit - **Conflicting `useInput` hooks** -- Multiple active `useInput` hooks fire simultaneously; use the `isActive` option to scope them **Gotchas & Edge Cases:** - oclif hooks run in **parallel**, not sequence -- don't depend on execution order between hooks - `useInput` fires **once** for pasted text, not per-character -- handle multi-character input strings explicitly - Ink v5 requires **React 18+**, Ink v6 requires **React 19+** -- check your Ink version's peer dependencies - `enableJsonFlag` makes `run()` return value the JSON output -- ensure the return type matches what consumers expect - oclif's `this.error()` throws (exits the process) -- it does not return </red_flags> --- <critical_reminders> ## 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>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.