Skip to content

Build a CLI Tool with TypeScript — Complete Project Tutorial

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about Build a CLI Tool with TypeScript. We cover key concepts, practical examples, and best practices to help you master this topic.

Build a production-ready CLI tool with TypeScript using Commander.js, file system APIs, and interactive prompts — this project demonstrates how TypeScript shines for command-line applications with typed configuration, safe file operations, and structured error handling.

What You'll Learn

  • CLI project structure with TypeScript
  • Command parsing with Commander.js
  • Typed configuration management
  • File system operations
  • Interactive prompts
  • Publishing to npm

Why It Matters

CLI tools are the backbone of developer workflows. TypeScript makes CLI tools more reliable by typing command arguments, configuration files, and return values — catching bugs in tooling before they affect your work.

Real-World Use

The DodaZIP CLI tool — used to package and sign browser extensions — is built with TypeScript. It handles file globbing, compression, and cryptographic signing, all with full type safety ensuring no malformed packages reach the store.

Learning Path

flowchart LR
  A[Project: Dashboard] --> B[Project: CLI Tool]
  B --> C[Migration from JS]
  B --> D[You Are Here]
  C --> E[Ecosystem Overview]
  D --> F[What's Next]

Project Overview

We'll build a Project Scaffolder CLI that generates TypeScript project templates:

  • init — scaffold a new TypeScript project
  • add — add features (testing, linting, CI/CD)
  • build — run the TypeScript build
  • Config file support (.scaffolderrc.json)
  • Interactive prompts for project options

Project Structure

scaffolder/
  src/
    commands/
      init.ts
      add.ts
      build.ts
    utils/
      files.ts
      config.ts
      logger.ts
    types/
      index.ts
    index.ts
  package.json
  tsconfig.json

Step 1: Setup

mkdir scaffolder && cd scaffolder
npm init -y
npm install commander chalk inquirer fs-extra
npm install --save-dev typescript @types/node @types/fs-extra @types/inquirer tsx
npx tsc --init --target ES2022 --module Node16 --moduleResolution Node16 --outDir dist --rootDir src --declaration true

Add bin entry to package.json:

{
  "bin": {
    "scaffolder": "./dist/index.js"
  }
}

Step 2: Types

// src/types/index.ts
export interface ProjectConfig {
  name: string;
  version: string;
  description?: string;
  features: {
    testing?: boolean;
    linting?: boolean;
    ci?: boolean;
    docker?: boolean;
  };
  author?: string;
  license?: string;
}

export interface CliOptions {
  name: string;
  typescript: boolean;
  features: string[];
  output: string;
}

export interface FileTemplate {
  path: string;
  content: string;
}

export type LogLevel = 'info' | 'warn' | 'error' | 'success';

Step 3: Logger Utility

// src/utils/logger.ts
import chalk from 'chalk';
import type { LogLevel } from '../types';

const icons: Record<LogLevel, string> = {
  info: chalk.blue('ℹ'),
  warn: chalk.yellow('⚠'),
  error: chalk.red('✖'),
  success: chalk.green('✔'),
};

export function log(level: LogLevel, message: string): void {
  const timestamp = new Date().toLocaleTimeString();
  console.log(`${icons[level]} [${timestamp}] ${message}`);
}

export const logger = {
  info: (msg: string) => log('info', msg),
  warn: (msg: string) => log('warn', msg),
  error: (msg: string) => log('error', msg),
  success: (msg: string) => log('success', msg),
};

Step 4: File Generation

// src/utils/files.ts
import fs from 'fs-extra';
import path from 'path';
import type { FileTemplate, ProjectConfig } from '../types';
import { logger } from './logger';

const templates: FileTemplate[] = [
  {
    path: 'tsconfig.json',
    content: `{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src"]
}
`,
  },
  {
    path: 'src/index.ts',
    content: `export function greet(name: string): string {
  return \`Hello, \${name}! This is a TypeScript project.\`;
}

const result = greet('World');
console.log(result);
`,
  },
  {
    path: 'package.json',
    content: '', // Generated dynamically
  },
];

export async function generateProject(config: ProjectConfig): Promise<void> {
  const projectDir = path.join(process.cwd(), config.name);

  // Create directory structure
  await fs.ensureDir(path.join(projectDir, 'src'));
  logger.info(`Created project directory: ${config.name}`);

  // Write each template
  for (const template of templates) {
    const filePath = path.join(projectDir, template.path);

    if (template.path === 'package.json') {
      const packageJson = {
        name: config.name,
        version: config.version || '1.0.0',
        description: config.description || '',
        main: 'dist/index.js',
        types: 'dist/index.d.ts',
        scripts: {
          build: 'tsc',
          start: 'node dist/index.js',
          dev: 'tsx src/index.ts',
          ...(config.features.testing && { test: 'vitest run' }),
          ...(config.features.linting && { lint: 'eslint src/' }),
        },
        ...(config.author && { author: config.author }),
        license: config.license || 'MIT',
        devDependencies: {
          typescript: '^5.5.0',
          '@types/node': '^22.0.0',
          tsx: '^4.0.0',
          ...(config.features.testing && {
            vitest: '^2.0.0',
          }),
          ...(config.features.linting && {
            eslint: '^9.0.0',
            '@typescript-eslint/eslint-plugin': '^8.0.0',
            '@typescript-eslint/parser': '^8.0.0',
          }),
        },
      };
      await fs.writeJson(filePath, packageJson, { spaces: 2 });
    } else {
      await fs.writeFile(filePath, template.content);
    }
    logger.success(`Created: ${template.path}`);
  }

  logger.success(`Project ${config.name} created successfully!`);
  logger.info(`cd ${config.name} && npm install`);
}

Step 5: Commands

// src/commands/init.ts
import inquirer from 'inquirer';
import { generateProject } from '../utils/files';
import type { ProjectConfig } from '../types';

interface PromptAnswers {
  name: string;
  description: string;
  version: string;
  author: string;
  features: string[];
}

export async function initCommand(): Promise<void> {
  const answers = await inquirer.prompt<PromptAnswers>([
    { type: 'input', name: 'name', message: 'Project name:', default: 'my-typescript-app' },
    { type: 'input', name: 'description', message: 'Description:', default: '' },
    { type: 'input', name: 'version', message: 'Version:', default: '1.0.0' },
    { type: 'input', name: 'author', message: 'Author:', default: '' },
    {
      type: 'checkbox',
      name: 'features',
      message: 'Select features:',
      choices: [
        { name: 'Testing (Vitest)', value: 'testing', checked: true },
        { name: 'Linting (ESLint)', value: 'linting', checked: true },
        { name: 'CI/CD (GitHub Actions)', value: 'ci' },
        { name: 'Docker', value: 'docker' },
      ],
    },
  ]);

  const config: ProjectConfig = {
    name: answers.name,
    version: answers.version,
    description: answers.description || undefined,
    author: answers.author || undefined,
    features: {
      testing: answers.features.includes('testing'),
      linting: answers.features.includes('linting'),
      ci: answers.features.includes('ci'),
      docker: answers.features.includes('docker'),
    },
  };

  await generateProject(config);
}

// src/commands/add.ts
import inquirer from 'inquirer';
import fs from 'fs-extra';
import path from 'path';
import { logger } from '../utils/logger';

export async function addCommand(): Promise<void> {
  const { feature } = await inquirer.prompt([
    {
      type: 'list',
      name: 'feature',
      message: 'What feature would you like to add?',
      choices: [
        { name: 'Testing (Vitest)', value: 'testing' },
        { name: 'Linting (ESLint)', value: 'linting' },
        { name: 'CI/CD (GitHub Actions)', value: 'ci' },
        { name: 'Docker', value: 'docker' },
      ],
    },
  ]);

  switch (feature) {
    case 'testing':
      await addTesting();
      break;
    case 'linting':
      await addLinting();
      break;
    case 'ci':
      await addCI();
      break;
    case 'docker':
      await addDocker();
      break;
  }
}

async function addTesting(): Promise<void> {
  const packagePath = path.join(process.cwd(), 'package.json');
  const pkg = await fs.readJson(packagePath);

  pkg.scripts = pkg.scripts || {};
  pkg.scripts.test = 'vitest run';
  pkg.scripts['test:watch'] = 'vitest';

  pkg.devDependencies = pkg.devDependencies || {};
  pkg.devDependencies.vitest = '^2.0.0';

  await fs.writeJson(packagePath, pkg, { spaces: 2 });

  // Create test file
  const testDir = path.join(process.cwd(), 'tests');
  await fs.ensureDir(testDir);
  await fs.writeFile(
    path.join(testDir, 'example.test.ts'),
    `import { describe, it, expect } from 'vitest';

describe('example', () => {
  it('works', () => {
    expect(1 + 1).toBe(2);
  });
});
`
  );

  // Create vitest config
  await fs.writeFile(
    path.join(process.cwd(), 'vitest.config.ts'),
    `import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { globals: true } });\n`
  );

  logger.success('Testing setup complete!');
}

Step 6: Main Entry Point

// src/index.ts
#!/usr/bin/env node
import { Command } from 'commander';
import { initCommand } from './commands/init';
import { addCommand } from './commands/add';
import { logger } from './utils/logger';
import fs from 'fs-extra';

const program = new Command();

program
  .name('scaffolder')
  .description('TypeScript project scaffolding tool')
  .version('1.0.0');

program
  .command('init')
  .description('Scaffold a new TypeScript project')
  .option('-n, --name <name>', 'Project name')
  .option('-f, --features <features...>', 'Features to include')
  .action(async (options) => {
    try {
      logger.info('Starting project scaffolding...');
      await initCommand();
    } catch (error) {
      logger.error(`Failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
      process.exit(1);
    }
  });

program
  .command('add')
  .description('Add a feature to existing project')
  .action(async () => {
    try {
      await addCommand();
    } catch (error) {
      logger.error(`Failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
      process.exit(1);
    }
  });

program.parse(process.argv);

Step 7: Build and Run

# Build TypeScript
npx tsc

# Make CLI available locally
npm link

# Try it out
scaffolder init

Common Mistakes

1. Not using shebang in the entry file

The #!/usr/bin/env node at the top of index.ts is essential. Without it, the system doesn't know how to execute the file.

2. Forgetting to add bin to package.json

Without the bin entry, npm link and npm publish won't create the CLI command. The format is { "bin": { "command-name": "./path" } }.

3. Not handling Process exit codes

CLI tools should exit with code 0 for success and 1 for failure. Unhandled errors default to code 0, misleading CI systems.

4. Using relative paths without process.cwd()

Always resolve paths relative to process.cwd() (where the user runs the command), not __dirname (where the CLI is installed).

5. Not validating user input

Users can pass invalid names, empty strings, or paths. Validate all inputs and provide clear error messages.

6. Hardcoding colors or formatting

Not everyone's terminal supports colors. Use chalk which handles fallbacks, and respect the NO_COLOR environment variable.

7. Making the CLI synchronous for long operations

File generation, package installation, and git initialization should be async with progress indicators.

Practice Questions

  1. What does the shebang #!/usr/bin/env node do? It tells Unix systems to run the file with Node.js. The env finds Node.js in the user's PATH.

  2. How does bin in package.json create a CLI command? When the package is installed globally or linked, npm creates a symlink from the command name to the specified file, adding it to the PATH.

  3. Why use Commander.js over manual process.argv parsing? Commander.js handles flags, options, subcommands, help text, version output, and validation — reducing dozens of lines of manual parsing.

  4. How do you make a CLI interactive vs non-interactive? Use inquirer for interactive mode when no flags are provided. Check for flag values first — if present, skip prompts.

  5. What's the purpose of process.exit(1) in error handling? It signals to the shell that the command failed. CI systems check this exit code to determine if a step passed.

Challenge

Extend the scaffolder with: TypeScript library template (with proper exports), React app template (Vite-based), scaffolder upgrade command to update dependencies, and a --dry-run flag that shows what files would be created without writing.

FAQ

Can I publish a TypeScript CLI to npm?

Yes. Build to JavaScript with tsc, set "bin" in package.json, and run npm publish. Users install globally with npm install -g your-cli.

Should I use tsx to run the CLI directly?

tsx is fine for local development. For published packages, compile to JavaScript — users shouldn't need tsx installed.

How do I handle cross-platform paths?

Use path.join() and path.resolve() from Node.js path module. Never hardcode / or \ as path separators.

What's the best way to read configuration files?

Use cosmiconfig or read .json/.yaml files with fs.readJson (fs-extra). Support multiple config formats and locations.

How do I add progress bars to a CLI?

Use the progress or cli-progress package. Both provide TypeScript types and stream to stderr so they don't interfere with stdout output.

How do I test CLI tools?

Use execa to run CLI commands in tests, assert on stdout/stderr output, and check file system results for scaffolded files.

Project Summary

You've built a production-ready CLI tool with TypeScript! This project demonstrates:

  • Command argument parsing with Commander.js
  • Interactive user prompts with Inquirer
  • File system operations with fs-extra
  • Colored terminal output with Chalk
  • Typed configuration and error handling
  • npm publishing setup

The scaffolder is ready to publish and extend with additional templates and features.

What's Next

You've built a CLI tool with TypeScript. Now learn how to migrate existing JavaScript projects to TypeScript with {{< ref "58-migration-from-js" >}}, or explore the TypeScript ecosystem with {{< ref "59-ecosystem-overview" >}}.

For where to go after mastering TypeScript, see {{< ref "60-whats-next" >}}.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro