Claude Skill

cli-framework-oclif-ink

Modern CLI development combining oclif's command framework with Ink's React-based terminal rendering

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_cli-framework-oclif-ink_skills_cli-framework-oclif-ink-3a51ef5.zip · 18 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/cli-framework-oclif-ink/skills/cli-framework-oclif-ink
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git 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. 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



<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:


<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>

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.

No comments yet.

Reviews (0)

No reviews yet.

Related