Building a CLI Tool with Node.js from Scratch
Step-by-step guide to a professional Node.js CLI: argument parsing, interactive prompts, colored output, progress, config files and npm publishing.

CLI tools are the backbone of developer workflows—from git to npm to project scaffolders. Building one teaches you argument parsing, terminal I/O, process signals, and software distribution in a way that's immediately useful. The result is a tool you and your team actually use, not a tutorial artifact that sits in a repo.
This walkthrough builds a project scaffolding CLI from scratch, covering the full journey from argument parsing to npm publishing.
Project Setup and Entry Point
A CLI tool needs a package.json bin field and a shebang line. TypeScript compilation targets CommonJS for maximum Node.js compatibility.
{
"name": "create-project-scaffold",
"version": "1.0.0",
"bin": {
"scaffold": "./dist/cli.js"
},
"type": "commonjs",
"scripts": {
"build": "tsc",
"dev": "tsx src/cli.ts"
},
"dependencies": {
"commander": "^12.0.0",
"chalk": "^4.1.2",
"ora": "^5.4.1",
"inquirer": "^8.2.6"
},
"devDependencies": {
"typescript": "^5.3.0",
"tsx": "^4.0.0",
"@types/inquirer": "^8.2.10",
"@types/node": "^20.0.0"
}
}#!/usr/bin/env node
// src/cli.ts — the entry point
import { Command } from "commander";
import chalk from "chalk";
import { createCommand } from "./commands/create";
import { configCommand } from "./commands/config";
const program = new Command();
program
.name("scaffold")
.description("Project scaffolding CLI")
.version("1.0.0");
// Register subcommands
program.addCommand(createCommand);
program.addCommand(configCommand);
// Global error handling
program.exitOverride();
try {
program.parse();
} catch (err: unknown) {
if (
err instanceof Error &&
"exitCode" in err &&
(err as any).exitCode !== 0
) {
console.error(
chalk.red(`Error: ${err.message}`)
);
process.exit(1);
}
}The shebang #!/usr/bin/env node tells the OS to use Node.js to execute the file. Commander handles argument parsing, subcommands, and auto-generated help text.
Interactive Prompts and Validation
When required arguments are missing, fall back to interactive prompts. This gives power users fast CLI flags while keeping the tool approachable for everyone.
// ❌ Fail if arguments are missing
function createProject(name: string, template: string) {
if (!name) {
console.error("Error: project name is required");
process.exit(1);
}
// Users must read --help to discover all flags
}// ✅ Fall back to interactive prompts for missing args
import inquirer from "inquirer";
import { Command } from "commander";
import chalk from "chalk";
interface CreateOptions {
name: string;
template: string;
typescript: boolean;
git: boolean;
install: boolean;
}
export const createCommand = new Command("create")
.description("Create a new project from a template")
.argument("[name]", "Project name")
.option("-t, --template <template>", "Template to use")
.option("--typescript", "Use TypeScript", true)
.option("--no-git", "Skip git initialization")
.option("--no-install", "Skip dependency installation")
.action(async (name, opts) => {
const options = await resolveOptions(name, opts);
await executeCreate(options);
});
async function resolveOptions(
name: string | undefined,
opts: Record<string, unknown>
): Promise<CreateOptions> {
const questions: any[] = [];
if (!name) {
questions.push({
type: "input",
name: "name",
message: "Project name:",
validate: (input: string) => {
if (!input.trim()) return "Name is required";
if (!/^[a-z0-9-]+$/.test(input)) {
return "Use lowercase letters, numbers, hyphens only";
}
return true;
},
});
}
if (!opts.template) {
questions.push({
type: "list",
name: "template",
message: "Select a template:",
choices: [
{ name: "React + Vite", value: "react-vite" },
{ name: "Next.js App Router", value: "nextjs" },
{ name: "Express API", value: "express" },
{ name: "CLI Tool", value: "cli" },
],
});
}
const answers =
questions.length > 0
? await inquirer.prompt(questions)
: {};
return {
name: name ?? answers.name,
template: (opts.template as string) ?? answers.template,
typescript: opts.typescript !== false,
git: opts.git !== false,
install: opts.install !== false,
};
}Progress Indicators and Colored Output
Long-running operations need visual feedback. A spinner during file copying and dependency installation keeps users informed.
import ora from "ora";
import chalk from "chalk";
import { execSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
async function executeCreate(
options: CreateOptions
): Promise<void> {
const projectPath = path.resolve(
process.cwd(),
options.name
);
// Check if directory exists
if (fs.existsSync(projectPath)) {
console.error(
chalk.red(
`Directory "${options.name}" already exists`
)
);
process.exit(1);
}
console.log();
console.log(
chalk.bold(`Creating ${chalk.cyan(options.name)}...`)
);
console.log();
// Step 1: Copy template
const spinner = ora("Copying template files").start();
try {
await copyTemplate(options.template, projectPath);
spinner.succeed("Template files copied");
} catch (err) {
spinner.fail("Failed to copy template");
throw err;
}
// Step 2: Customize project
spinner.start("Customizing project configuration");
await customizePackageJson(projectPath, options);
spinner.succeed("Project configured");
// Step 3: Initialize git
if (options.git) {
spinner.start("Initializing git repository");
execSync("git init", {
cwd: projectPath,
stdio: "ignore",
});
spinner.succeed("Git repository initialized");
}
// Step 4: Install dependencies
if (options.install) {
spinner.start("Installing dependencies");
execSync("npm install", {
cwd: projectPath,
stdio: "ignore",
});
spinner.succeed("Dependencies installed");
}
// Summary
console.log();
console.log(chalk.green("✓ Project created successfully!"));
console.log();
console.log(" Next steps:");
console.log(
chalk.cyan(` cd ${options.name}`)
);
console.log(chalk.cyan(" npm run dev"));
console.log();
}
async function copyTemplate(
template: string,
dest: string
): Promise<void> {
const templateDir = path.join(
__dirname,
"..",
"templates",
template
);
if (!fs.existsSync(templateDir)) {
throw new Error(`Template "${template}" not found`);
}
fs.cpSync(templateDir, dest, { recursive: true });
}
async function customizePackageJson(
projectPath: string,
options: CreateOptions
): Promise<void> {
const pkgPath = path.join(projectPath, "package.json");
const pkg = JSON.parse(
fs.readFileSync(pkgPath, "utf-8")
);
pkg.name = options.name;
pkg.version = "0.1.0";
fs.writeFileSync(
pkgPath,
JSON.stringify(pkg, null, 2) + "\n"
);
}Configuration File Support
CLI tools that remember user preferences through a config file reduce repetitive flag passing.
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
interface ScaffoldConfig {
defaultTemplate: string;
typescript: boolean;
git: boolean;
author: string;
license: string;
}
const CONFIG_DIR = path.join(os.homedir(), ".scaffold");
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
const DEFAULT_CONFIG: ScaffoldConfig = {
defaultTemplate: "react-vite",
typescript: true,
git: true,
author: "",
license: "MIT",
};
function loadConfig(): ScaffoldConfig {
try {
if (fs.existsSync(CONFIG_FILE)) {
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
}
} catch {
// Corrupted config — use defaults
}
return { ...DEFAULT_CONFIG };
}
function saveConfig(
updates: Partial<ScaffoldConfig>
): void {
const current = loadConfig();
const merged = { ...current, ...updates };
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
fs.writeFileSync(
CONFIG_FILE,
JSON.stringify(merged, null, 2) + "\n"
);
}
// Config subcommand
export const configCommand = new Command("config")
.description("Manage CLI configuration")
.addCommand(
new Command("set")
.argument("<key>", "Configuration key")
.argument("<value>", "Configuration value")
.action((key: string, value: string) => {
const config = loadConfig();
if (!(key in config)) {
console.error(
chalk.red(`Unknown config key: ${key}`)
);
process.exit(1);
}
let parsed: unknown = value;
if (value === "true") parsed = true;
if (value === "false") parsed = false;
saveConfig({ [key]: parsed });
console.log(
chalk.green(`Set ${key} = ${value}`)
);
})
)
.addCommand(
new Command("list").action(() => {
const config = loadConfig();
console.log(chalk.bold("\nCurrent configuration:\n"));
for (const [key, value] of Object.entries(config)) {
console.log(` ${chalk.cyan(key)}: ${value}`);
}
console.log();
})
);Graceful Signal Handling and Cleanup
CLI tools must handle SIGINT (Ctrl+C) gracefully, cleaning up temporary files and partially created directories.
// Register cleanup handlers
let cleanupPath: string | null = null;
function registerCleanup(projectPath: string): void {
cleanupPath = projectPath;
}
function cleanup(): void {
if (cleanupPath && fs.existsSync(cleanupPath)) {
console.log(
chalk.yellow("\nCleaning up partial project...")
);
fs.rmSync(cleanupPath, {
recursive: true,
force: true,
});
console.log(chalk.yellow("Cleanup complete."));
}
}
process.on("SIGINT", () => {
cleanup();
process.exit(130);
});
process.on("SIGTERM", () => {
cleanup();
process.exit(143);
});
// In executeCreate, register before starting work:
async function executeCreate(
options: CreateOptions
): Promise<void> {
const projectPath = path.resolve(
process.cwd(),
options.name
);
registerCleanup(projectPath);
// ... creation steps ...
// Clear cleanup after successful completion
cleanupPath = null;
}Key Takeaways
A professional CLI tool combines Commander for argument parsing with Inquirer for interactive prompts, falling back to prompts when CLI flags are missing so power users get speed while newcomers get guidance. Ora spinners and Chalk colored output provide essential visual feedback—users need to know that a 30-second npm install is still running, not hung. Configuration files in ~/.toolname/config.json reduce flag repetition by storing user defaults, with a config set subcommand for easy management. Signal handling via process.on('SIGINT') ensures graceful cleanup of temporary files and partial outputs when users press Ctrl+C mid-operation. Input validation in interactive prompts catches errors early with helpful messages, preventing downstream failures from malformed project names or missing templates. The bin field in package.json and the #!/usr/bin/env node shebang are the two pieces that make npm install -g create a system-wide command.


