Skip to main content
Back to Blog
Tutorials
3 min read
January 11, 2025

How to Build a CLI Tool With Node.js and Commander

Build a production-ready CLI tool with interactive prompts, progress indicators, configuration files, and proper error handling.

Ryel Banfield

Founder & Lead Developer

CLI tools are powerful for automation and developer workflows. Here is how to build one properly.

Project Setup

mkdir my-cli && cd my-cli
npm init -y
npm install commander chalk ora prompts conf
npm install -D typescript @types/node @types/prompts tsx
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Main CLI Entry

// src/index.ts
#!/usr/bin/env node
import { Command } from "commander";
import { initCommand } from "./commands/init.js";
import { generateCommand } from "./commands/generate.js";
import { configCommand } from "./commands/config.js";

const program = new Command();

program
  .name("my-cli")
  .description("A helpful CLI tool")
  .version("1.0.0");

program.addCommand(initCommand);
program.addCommand(generateCommand);
program.addCommand(configCommand);

program.parse();

Init Command With Interactive Prompts

// src/commands/init.ts
import { Command } from "commander";
import prompts from "prompts";
import chalk from "chalk";
import ora from "ora";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";

export const initCommand = new Command("init")
  .description("Initialize a new project")
  .option("-y, --yes", "Skip prompts and use defaults")
  .action(async (options) => {
    console.log(chalk.cyan.bold("\nProject Setup\n"));

    let answers: Record<string, unknown>;

    if (options.yes) {
      answers = {
        name: "my-project",
        framework: "nextjs",
        typescript: true,
        styling: "tailwind",
      };
    } else {
      answers = await prompts(
        [
          {
            type: "text",
            name: "name",
            message: "Project name:",
            initial: "my-project",
            validate: (value: string) =>
              /^[a-z0-9-]+$/.test(value) || "Lowercase letters, numbers, and hyphens only",
          },
          {
            type: "select",
            name: "framework",
            message: "Framework:",
            choices: [
              { title: "Next.js", value: "nextjs" },
              { title: "Remix", value: "remix" },
              { title: "Astro", value: "astro" },
            ],
          },
          {
            type: "toggle",
            name: "typescript",
            message: "Use TypeScript?",
            initial: true,
            active: "Yes",
            inactive: "No",
          },
          {
            type: "select",
            name: "styling",
            message: "Styling:",
            choices: [
              { title: "Tailwind CSS", value: "tailwind" },
              { title: "CSS Modules", value: "modules" },
              { title: "Styled Components", value: "styled" },
            ],
          },
        ],
        { onCancel: () => process.exit(0) },
      );
    }

    const spinner = ora("Creating project files...").start();

    try {
      const dir = join(process.cwd(), answers.name as string);
      await mkdir(dir, { recursive: true });

      // Generate config file
      const config = {
        name: answers.name,
        framework: answers.framework,
        typescript: answers.typescript,
        styling: answers.styling,
        createdAt: new Date().toISOString(),
      };

      await writeFile(
        join(dir, "project.config.json"),
        JSON.stringify(config, null, 2),
      );

      // Generate package.json
      const packageJson = {
        name: answers.name,
        version: "0.1.0",
        private: true,
        scripts: {
          dev: "next dev",
          build: "next build",
          start: "next start",
        },
      };

      await writeFile(
        join(dir, "package.json"),
        JSON.stringify(packageJson, null, 2),
      );

      spinner.succeed(chalk.green("Project created successfully!"));
      console.log(`\n  ${chalk.dim("cd")} ${answers.name as string}`);
      console.log(`  ${chalk.dim("npm install")}`);
      console.log(`  ${chalk.dim("npm run dev")}\n`);
    } catch (error) {
      spinner.fail(chalk.red("Failed to create project"));
      console.error(error);
      process.exit(1);
    }
  });

Generate Command With Templates

// src/commands/generate.ts
import { Command } from "commander";
import chalk from "chalk";
import { writeFile, mkdir } from "fs/promises";
import { join, dirname } from "path";

const templates: Record<string, (name: string) => string> = {
  component: (name) => `interface ${name}Props {
  children: React.ReactNode;
}

export function ${name}({ children }: ${name}Props) {
  return <div>{children}</div>;
}
`,
  hook: (name) => `import { useState, useEffect } from "react";

export function ${name}() {
  const [data, setData] = useState(null);

  useEffect(() => {
    // fetch data
  }, []);

  return { data };
}
`,
  page: (name) => `export default function ${name}Page() {
  return (
    <main>
      <h1>${name}</h1>
    </main>
  );
}
`,
};

export const generateCommand = new Command("generate")
  .alias("g")
  .description("Generate a file from a template")
  .argument("<type>", `Type: ${Object.keys(templates).join(", ")}`)
  .argument("<name>", "Name for the generated file")
  .option("-d, --dir <directory>", "Output directory", "src")
  .action(async (type: string, name: string, options) => {
    const template = templates[type];
    if (!template) {
      console.error(
        chalk.red(`Unknown type "${type}". Available: ${Object.keys(templates).join(", ")}`),
      );
      process.exit(1);
    }

    const pascalName = name
      .split("-")
      .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
      .join("");

    const fileName =
      type === "hook" ? `use-${name}.ts` : `${name}.tsx`;
    const filePath = join(process.cwd(), options.dir, fileName);

    try {
      await mkdir(dirname(filePath), { recursive: true });
      await writeFile(filePath, template(pascalName));
      console.log(chalk.green(`Created ${filePath}`));
    } catch (error) {
      console.error(chalk.red("Failed to generate file"), error);
      process.exit(1);
    }
  });

Config Command

// src/commands/config.ts
import { Command } from "commander";
import Conf from "conf";
import chalk from "chalk";

const config = new Conf({ projectName: "my-cli" });

export const configCommand = new Command("config")
  .description("Manage configuration");

configCommand
  .command("set <key> <value>")
  .description("Set a config value")
  .action((key: string, value: string) => {
    config.set(key, value);
    console.log(chalk.green(`Set ${key} = ${value}`));
  });

configCommand
  .command("get <key>")
  .description("Get a config value")
  .action((key: string) => {
    const value = config.get(key);
    if (value === undefined) {
      console.log(chalk.yellow(`"${key}" is not set`));
    } else {
      console.log(`${key} = ${String(value)}`);
    }
  });

configCommand
  .command("list")
  .description("List all config values")
  .action(() => {
    const all = config.store;
    if (Object.keys(all).length === 0) {
      console.log(chalk.dim("No configuration set"));
      return;
    }
    for (const [key, value] of Object.entries(all)) {
      console.log(`  ${chalk.cyan(key)}: ${String(value)}`);
    }
  });

configCommand
  .command("reset")
  .description("Reset all config values")
  .action(() => {
    config.clear();
    console.log(chalk.green("Configuration cleared"));
  });

Make It Executable

// package.json additions
{
  "bin": {
    "my-cli": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "link": "npm run build && npm link"
  }
}
npm run build
npm link
my-cli init
my-cli generate component button
my-cli config set theme dark

Need Custom Developer Tools?

We build CLI tools and developer workflows that save time. Contact us to discuss your needs.

CLINode.jsCommanderterminaldeveloper toolstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles