Building a CLI Tool with Commander.js and Ink

· 6 min read
nodejs cli react typescript

Prerequisites

Before we get into it, you’ll need:

  • Node.js 18+ (or higher, we dont judge)
  • Familiarity with React (at least enough to know what useState does without Googling it)
  • Basic TypeScript (you can spell interface, that’s enough)
  • A terminal emulator you actually like (controversial, I know)
  • The ability to resist the urge to rewrite everything in Rust halfway through this tutorial

What We’re Building

Right. So you know that feeling when you run npm init and it asks you a bunch of questions in the terminal, and you think “this is quite nice actually, I wonder how they did that”? That’s what we’re building today.

We’re combining Commander.js for argument parsing with Ink for interactive React-based terminal UI. Yes, React. In your terminal. I know it sounds like someone had too much coffee and started putting React in places it doesnt belong, but honestly? It works brilliantly.

Hackerman

The Approach

  1. Set up the project with TypeScript and ESM
  2. Configure Commander for commands and options
  3. Build interactive UI components with Ink
  4. Package as an executable

Nothing revolutionary. Just solid fundamentals stacked on top of each other like a well-organised Jenga tower that hopefully won’t collapse.

Step 1: Project Setup

Let’s get the boring bits out of the way. Scaffolding a new project is the programming equivalent of chopping onions before you can actually cook.

mkdir my-cli && cd my-cli
npm init -y
npm install commander ink react
npm install -D typescript @types/node @types/react tsx

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "jsx": "react-jsx",
    "strict": true,
    "outDir": "dist",
    "skipLibCheck": true
  },
  "include": ["src"]
}

The usual TypeScript ceremony. skipLibCheck: true because life is too short to wait for the compiler to validate every type in node_modules.

Update package.json:

{
  "type": "module",
  "bin": {
    "my-cli": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.tsx"
  }
}

That bin field is the magic bit. It tells npm “when someone installs this globally, give them a my-cli command that points here.” It’s how every CLI tool you’ve ever installed actually works. Not magic, just a field in a JSON file. Anticlimactic, really.

When you find out CLI tools are just a JSON field

Step 2: Set Up Commander

Commander.js is the sensible, reliable friend who always remembers where you parked the car. It handles all the argument parsing so you dont have to write your own process.argv spaghetti.

Create src/index.tsx:

#!/usr/bin/env node
import { Command } from 'commander';
import { render } from 'ink';
import { App } from './App.js';

const program = new Command();

program
  .name('my-cli')
  .description('An example CLI tool')
  .version('1.0.0');

program
  .command('init')
  .description('Initialise a new project')
  .option('-t, --template <name>', 'Template to use', 'default')
  .action((options) => {
    render(<App template={options.template} />);
  });

program.parse();

Notice that render(<App template={options.template} />) call. That’s where Commander hands off to Ink and things get interesting. Commander parses the arguments, figures out what the user wants, then Ink takes over and renders an actual interactive UI in the terminal.

The .js extension on the import is an ESM thing. Yes, even though the file is .tsx. TypeScript compiles it to .js, so that’s what you import. Its one of those things that feels wrong but is completely correct. Welcome to modern JavaScript.

Step 3: Build the Ink UI

This is where the fun begins. If you’ve ever written React, this will feel immediately familiar. If you haven’t, well, you probably should have paid more attention to the prerequisites.

Create src/App.tsx:

import React, { useState } from 'react';
import { Box, Text, useInput, useApp } from 'ink';
import TextInput from 'ink-text-input';

interface Props {
  template: string;
}

export function App({ template }: Props) {
  const { exit } = useApp();
  const [step, setStep] = useState(0);
  const [projectName, setProjectName] = useState('');

  useInput((input, key) => {
    if (key.escape) {
      exit();
    }
  });

  if (step === 0) {
    return (
      <Box flexDirection="column">
        <Text>Using template: <Text color="cyan">{template}</Text></Text>
        <Box>
          <Text>Project name: </Text>
          <TextInput
            value={projectName}
            onChange={setProjectName}
            onSubmit={() => setStep(1)}
          />
        </Box>
      </Box>
    );
  }

  return (
    <Box flexDirection="column">
      <Text color="green"> Created project: {projectName}</Text>
      <Text>Run: cd {projectName} && npm install</Text>
    </Box>
  );
}

Box is your div. Text is your span. useInput is like addEventListener('keydown') but, you know, not terrible. The whole thing is just React with a different renderer. Instead of pushing pixels to a browser, Ink pushes characters to your terminal. Same mental model, completely different output.

The useApp hook gives you that exit function, which is important because without it your CLI would just hang there forever like a polite guest who doesn’t know when to leave.

Your CLI without an exit function

Step 4: Add Interactive Components

Now let’s make it properly interactive. Ink has a lovely ecosystem of pre-built components that save you from reinventing the wheel.

Install additional Ink components:

npm install ink-text-input ink-select-input ink-spinner

Add a selection step:

import SelectInput from 'ink-select-input';

const items = [
  { label: 'TypeScript', value: 'ts' },
  { label: 'JavaScript', value: 'js' },
];

<SelectInput items={items} onSelect={(item) => setLanguage(item.value)} />

SelectInput gives you those lovely arrow-key navigable lists. The kind of thing that makes your CLI feel professional instead of like a university coursework submission. Add ink-spinner for loading states and suddenly you’ve got yourself a tool that people might actually enjoy using.

npm run build
npm link
my-cli init --template react

npm link creates a symlink so you can test your CLI globally without publishing it. Run the last command and you should see your interactive UI pop up in the terminal. If you see a wall of red errors instead, check that your import paths all use .js extensions. It’s always the import paths.

It’s alive!

The Result

What you end up with:

  • Full React component model for terminal UIs
  • Proper CLI argument parsing
  • Interactive prompts with keyboard navigation
  • Colourful output and spinners

All of this in about 50 lines of actual application code. The React mental model translates beautifully to terminal interfaces, and Ink handles all the terminal rendering complexity so you can focus on what your tool actually does.

What I’d Do Differently

For anything bigger than a small tool, use pastel (from the Ink team). It handles routing, flags, and errors more elegantly than wiring Commander up manually. Think of it as Next.js for your terminal. Commander is great for quick tools, but pastel gives you proper structure when things inevitably grow beyond what you originally planned.

Ink is one of those libraries that makes you rethink what a terminal can do. I’ve built entire dashboard apps that run in nothing but a terminal window, and every time it still feels a little bit like showing off.

Related Posts

Comments