Back to journal
Engineering

Build a Production CLI Tool in 80 Lines of TypeScript

Most CLI tutorials teach you echo and process.argv. We'll build a real tool with argument parsing, colored output, spinners, and auto-update — all under 80 lines.

PMML Engineering 27 April 2026 10 min read 3 views
Build a Production CLI Tool in 80 Lines of TypeScript

Ever built a quick script and wished it was a proper CLI tool? Turns out, making it production-ready takes less code than you think.

We'll build pmml-init — a project scaffolder that creates directories, writes config files, and initializes git — all with beautiful terminal output.

What We're Building

A CLI that:

  • Accepts commands and flags (pmml-init create --template react)
  • Shows colored, formatted output
  • Displays progress spinners for async operations
  • Validates inputs and shows helpful errors
  • Is installable globally via npm install -g

Part 1: Project Setup

Terminal window with project scaffolding commands

mkdir pmml-init && cd pmml-init
npm init -y
npm install commander chalk ora
npm install -D typescript @types/node tsx

Add the bin entry to package.json:

{
  "name": "pmml-init",
  "version": "1.0.0",
  "bin": {
    "pmml-init": "./dist/index.js"
  },
  "type": "module"
}

Part 2: The CLI (80 Lines)

Code editor showing the CLI implementation

Create src/index.ts:

#!/usr/bin/env node
import { Command } from "commander";
import chalk from "chalk";
import ora from "ora";
import { mkdir, writeFile } from "fs/promises";
import { execSync } from "child_process";
import { join } from "path";

const TEMPLATES: Record<string, Record<string, string>> = {
  react: {
    "tsconfig.json": JSON.stringify(
      { compilerOptions: { target: "ES2022", jsx: "react-jsx", module: "ESNext", strict: true } },
      null,
      2
    ),
    "src/App.tsx": `export default function App() {
  return <h1>Hello from PMML</h1>;
}`,
    "src/index.tsx": `import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(<App />);`,
  },
  api: {
    "tsconfig.json": JSON.stringify(
      { compilerOptions: { target: "ES2022", module: "ES2022", strict: true } },
      null,
      2
    ),
    "src/server.ts": `import http from "http";
const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ status: "ok", timestamp: Date.now() }));
});
server.listen(3000, () => console.log("API running on :3000"));`,
  },
};

const program = new Command()
  .name("pmml-init")
  .version("1.0.0")
  .description("Scaffold projects the PMML way");

program
  .command("create <name>")
  .description("Create a new project")
  .option("-t, --template <type>", "Project template", "react")
  .option("--no-git", "Skip git initialization")
  .action(async (name: string, opts: { template: string; git: boolean }) => {
    const template = TEMPLATES[opts.template];
    if (!template) {
      console.error(chalk.red(`Unknown template: ${opts.template}`));
      console.log(chalk.dim(`Available: ${Object.keys(TEMPLATES).join(", ")}`));
      process.exit(1);
    }

    console.log(chalk.bold(`\nCreating ${chalk.cyan(name)} with ${opts.template} template\n`));
    const spinner = ora("Scaffolding project...").start();

    const root = join(process.cwd(), name);
    await mkdir(root, { recursive: true });

    for (const [file, content] of Object.entries(template)) {
      const filePath = join(root, file);
      await mkdir(join(filePath, ".."), { recursive: true });
      await writeFile(filePath, content);
      spinner.text = `Writing ${chalk.dim(file)}`;
    }

    const pkg = { name, version: "0.1.0", private: true, type: "module", scripts: { dev: "tsx watch src", build: "tsc" } };
    await writeFile(join(root, "package.json"), JSON.stringify(pkg, null, 2));

    if (opts.git) {
      spinner.text = "Initializing git...";
      execSync("git init", { cwd: root, stdio: "ignore" });
      await writeFile(join(root, ".gitignore"), "node_modules\ndist\n.env\n");
    }

    spinner.succeed(chalk.green("Project created!"));
    console.log(`\n  ${chalk.dim("cd")} ${name}`);
    console.log(`  ${chalk.dim("npm install")}`);
    console.log(`  ${chalk.dim("npm run dev")}\n`);
  });

program
  .command("list")
  .description("List available templates")
  .action(() => {
    console.log(chalk.bold("\nAvailable templates:\n"));
    for (const key of Object.keys(TEMPLATES)) {
      const fileCount = Object.keys(TEMPLATES[key]).length;
      console.log(`  ${chalk.cyan(key)} ${chalk.dim(`(${fileCount} files)`)}`);
    }
    console.log();
  });

program.parse();

That's it. 80 lines for a fully functional CLI with argument parsing, colored output, progress spinners, and file scaffolding.

Part 3: Try It

# Run directly
npx tsx src/index.ts create my-app --template react

# Or compile and link globally
npx tsc && npm link
pmml-init create my-api --template api
pmml-init list

How It Works

Matrix-style visualization of command parsing

Commander for Argument Parsing

Commander handles the boring parts — help text, version flags, subcommands, and option validation. Instead of parsing process.argv yourself:

// Without Commander (painful)
const args = process.argv.slice(2);
const templateIdx = args.indexOf("--template");
const template = templateIdx >= 0 ? args[templateIdx + 1] : "react";

// With Commander (clean)
program
  .command("create <name>")
  .option("-t, --template <type>", "Project template", "react")
  .action((name, opts) => { /* opts.template is ready */ });

Ora for Progress Feedback

Long operations need visual feedback. Ora gives you spinners that update in place:

const spinner = ora("Installing dependencies...").start();
// ... do work ...
spinner.text = "Compiling TypeScript...";  // update message
// ... do work ...
spinner.succeed("Done!");  // ✓ Done!
spinner.fail("Build failed");  // ✗ Build failed

Chalk for Colored Output

Color isn't decoration — it's information density. Errors in red, paths in cyan, hints in dim:

console.log(chalk.red("Error:"), "File not found");
console.log(chalk.cyan(filePath));
console.log(chalk.dim("hint: check your working directory"));

Level Up: 4 Exercises

Exercise 1: Interactive Prompts

Add inquirer for interactive template selection when no --template flag is passed:

import inquirer from "inquirer";

if (!opts.template) {
  const answers = await inquirer.prompt([{
    type: "list",
    name: "template",
    message: "Choose a template:",
    choices: Object.keys(TEMPLATES),
  }]);
  opts.template = answers.template;
}

Exercise 2: Config File Support

Read defaults from a .pmmlrc.json file:

import { readFile } from "fs/promises";
import { homedir } from "os";

async function loadConfig() {
  try {
    const raw = await readFile(join(homedir(), ".pmmlrc.json"), "utf8");
    return JSON.parse(raw);
  } catch {
    return {};
  }
}

Exercise 3: Auto-Install Dependencies

Run npm install automatically after scaffolding:

import { exec } from "child_process";
import { promisify } from "util";
const run = promisify(exec);

spinner.text = "Installing dependencies...";
await run("npm install", { cwd: root });
spinner.succeed("Dependencies installed!");

Exercise 4: Publish to npm

Add a prepublish script and publish:

# Add to package.json scripts
"prepublishOnly": "tsc"

# Then publish
npm login
npm publish

Now anyone can run npx pmml-init create my-app.

Why This Matters

The difference between a script and a tool is user experience. Scripts work for you. Tools work for your team. The patterns here — argument parsing, progress feedback, colored output, error handling — are what separate "it works on my machine" from "anyone can use this."

Every tool your team uses daily started as someone's 80-line script. Make yours count.

What CLI tool would save your team the most time? Drop us a message at pmml.info@gmail.com.

PMML Engineering builds developer tools, web platforms, and AI products for teams that ship. Based in Accra, Ghana.

#typescript#cli#node#developer-tools#tutorial

Keep reading

You might also like