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