Back to Blog
CLINode.jsDeveloper ToolsTutorial

Building CLI Tools with AI: From Script to Product

Learn how to create professional command-line tools with AI assistance, from simple scripts to full-featured CLIs.

B
Bootspring Team
Engineering
October 2, 2025
6 min read

Command-line tools remain essential for developers. They're fast, scriptable, and powerful. Building a good CLI requires attention to user experience details that AI can help you get right from the start.

Why Build CLI Tools?#

  • Automation: Script repetitive tasks
  • Integration: Connect systems and workflows
  • Distribution: Share tools easily
  • Productivity: Fast interaction without UI overhead

Getting Started#

Project Setup#

AI helps scaffold a proper CLI project:

Create a Node.js CLI project structure: Name: myctl Features: - TypeScript - Subcommands (init, build, deploy) - Configuration file support - Environment variables - Help text and documentation Include: - package.json with bin configuration - tsconfig.json - Project structure - Basic command implementations

Basic Command Structure#

1// src/cli.ts 2import { Command } from 'commander'; 3import { version } from '../package.json'; 4 5const program = new Command(); 6 7program 8 .name('myctl') 9 .description('My awesome CLI tool') 10 .version(version); 11 12program 13 .command('init') 14 .description('Initialize a new project') 15 .option('-t, --template <type>', 'project template', 'default') 16 .action(async (options) => { 17 await initProject(options); 18 }); 19 20program 21 .command('build') 22 .description('Build the project') 23 .option('-w, --watch', 'watch for changes') 24 .option('-p, --production', 'production build') 25 .action(async (options) => { 26 await buildProject(options); 27 }); 28 29program.parse();

User Experience Patterns#

Interactive Prompts#

Implement interactive prompts for project initialization: Questions: 1. Project name (validate: no spaces, lowercase) 2. Description 3. Template (choice: minimal, full, api) 4. Include testing? (confirm) 5. Package manager (choice: npm, yarn, pnpm) Use inquirer or prompts library. Show defaults and allow skipping with flags.
1import inquirer from 'inquirer'; 2 3async function interactiveInit(defaults: Partial<InitOptions>) { 4 const answers = await inquirer.prompt([ 5 { 6 type: 'input', 7 name: 'name', 8 message: 'Project name:', 9 default: defaults.name || path.basename(process.cwd()), 10 validate: (input) => /^[a-z0-9-]+$/.test(input) || 'Use lowercase letters, numbers, and hyphens only' 11 }, 12 { 13 type: 'list', 14 name: 'template', 15 message: 'Select template:', 16 choices: ['minimal', 'full', 'api'], 17 default: defaults.template 18 }, 19 { 20 type: 'confirm', 21 name: 'testing', 22 message: 'Include testing setup?', 23 default: true 24 } 25 ]); 26 27 return answers; 28}

Progress Indicators#

1import ora from 'ora'; 2 3async function buildProject(options: BuildOptions) { 4 const spinner = ora('Building project...').start(); 5 6 try { 7 spinner.text = 'Compiling TypeScript...'; 8 await compileTypeScript(); 9 10 spinner.text = 'Bundling assets...'; 11 await bundleAssets(); 12 13 spinner.text = 'Optimizing...'; 14 await optimize(); 15 16 spinner.succeed('Build complete!'); 17 } catch (error) { 18 spinner.fail(`Build failed: ${error.message}`); 19 process.exit(1); 20 } 21}

Colored Output#

1import chalk from 'chalk'; 2 3function logInfo(message: string) { 4 console.log(chalk.blue('ℹ'), message); 5} 6 7function logSuccess(message: string) { 8 console.log(chalk.green('✓'), message); 9} 10 11function logWarning(message: string) { 12 console.log(chalk.yellow('⚠'), message); 13} 14 15function logError(message: string) { 16 console.error(chalk.red('✗'), message); 17} 18 19// Usage 20logSuccess('Project created successfully!'); 21logInfo(`Next steps: 22 ${chalk.cyan('cd')} ${projectName} 23 ${chalk.cyan('npm install')} 24 ${chalk.cyan('npm run dev')} 25`);

Tables and Lists#

1import Table from 'cli-table3'; 2 3function displayProjects(projects: Project[]) { 4 const table = new Table({ 5 head: ['Name', 'Status', 'Last Deploy'], 6 colWidths: [30, 15, 25] 7 }); 8 9 projects.forEach(project => { 10 table.push([ 11 project.name, 12 project.status === 'active' ? chalk.green('Active') : chalk.gray('Inactive'), 13 project.lastDeploy || 'Never' 14 ]); 15 }); 16 17 console.log(table.toString()); 18}

Configuration Management#

Config File Support#

Implement configuration file support: Locations to check (in order): 1. --config flag 2. .myctlrc.json in current directory 3. myctl.config.js in current directory 4. ~/.myctlrc (global config) Support: - JSON and JavaScript formats - Environment variable expansion - Schema validation - Config merging (local + global)
1import { cosmiconfig } from 'cosmiconfig'; 2import { z } from 'zod'; 3 4const configSchema = z.object({ 5 apiUrl: z.string().url(), 6 token: z.string().optional(), 7 defaults: z.object({ 8 template: z.enum(['minimal', 'full', 'api']).default('minimal'), 9 packageManager: z.enum(['npm', 'yarn', 'pnpm']).default('npm') 10 }).optional() 11}); 12 13type Config = z.infer<typeof configSchema>; 14 15async function loadConfig(): Promise<Config> { 16 const explorer = cosmiconfig('myctl'); 17 const result = await explorer.search(); 18 19 if (!result) { 20 return configSchema.parse({}); 21 } 22 23 // Expand environment variables 24 const expanded = expandEnvVars(result.config); 25 26 return configSchema.parse(expanded); 27}

Environment Variables#

1import dotenv from 'dotenv'; 2 3// Load .env files 4dotenv.config(); 5 6const config = { 7 apiToken: process.env.MYCTL_TOKEN, 8 apiUrl: process.env.MYCTL_API_URL || 'https://api.myctl.dev', 9 debug: process.env.MYCTL_DEBUG === 'true' 10}; 11 12// Validate required variables 13if (!config.apiToken) { 14 console.error(chalk.red('Error: MYCTL_TOKEN environment variable is required')); 15 console.error(chalk.gray('Set it with: export MYCTL_TOKEN=your-token')); 16 process.exit(1); 17}

Error Handling#

User-Friendly Errors#

1class CLIError extends Error { 2 constructor( 3 message: string, 4 public readonly code: string, 5 public readonly suggestions?: string[] 6 ) { 7 super(message); 8 } 9} 10 11function handleError(error: unknown) { 12 if (error instanceof CLIError) { 13 console.error(chalk.red(`Error [${error.code}]: ${error.message}`)); 14 15 if (error.suggestions?.length) { 16 console.error(chalk.yellow('\nSuggestions:')); 17 error.suggestions.forEach(s => console.error(`${s}`)); 18 } 19 20 process.exit(1); 21 } 22 23 if (error instanceof z.ZodError) { 24 console.error(chalk.red('Configuration error:')); 25 error.errors.forEach(e => { 26 console.error(` ${e.path.join('.')}: ${e.message}`); 27 }); 28 process.exit(1); 29 } 30 31 // Unexpected error 32 console.error(chalk.red('Unexpected error:'), error); 33 if (process.env.DEBUG) { 34 console.error(error); 35 } 36 process.exit(1); 37}

Testing CLI Tools#

Unit Testing Commands#

Generate tests for this CLI command: ```typescript async function deployCommand(options: DeployOptions) { const config = await loadConfig(); const project = await resolveProject(options.project); await validateProject(project); await build(project); const result = await deploy(project, options.environment); return result; }

Test:

  • Missing project
  • Invalid configuration
  • Build failure
  • Deploy failure
  • Successful deploy

Use Jest with mocked dependencies.

### Integration Testing ```typescript import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); describe('myctl CLI', () => { it('should display help', async () => { const { stdout } = await execAsync('npx myctl --help'); expect(stdout).toContain('Usage:'); expect(stdout).toContain('Commands:'); }); it('should initialize project', async () => { const { stdout } = await execAsync( 'npx myctl init --name test-project --template minimal --no-interactive', { cwd: tempDir } ); expect(stdout).toContain('Project created'); expect(fs.existsSync(path.join(tempDir, 'test-project'))).toBe(true); }); });

Distribution#

npm Publishing#

1{ 2 "name": "myctl", 3 "version": "1.0.0", 4 "bin": { 5 "myctl": "./dist/cli.js" 6 }, 7 "files": [ 8 "dist" 9 ], 10 "engines": { 11 "node": ">=18" 12 } 13}

Standalone Binaries#

Create standalone binaries using pkg: Targets: - macOS (x64, arm64) - Linux (x64) - Windows (x64) Include: - Package.json configuration - Build script - GitHub Release workflow

Advanced Patterns#

Plugin System#

Design a plugin system for the CLI: Requirements: - Plugins add new commands - Plugins can hook into existing commands - Plugins installed via npm - Local plugins for project-specific commands Provide: - Plugin interface - Plugin loading mechanism - Plugin registration

Auto-Update#

1import updateNotifier from 'update-notifier'; 2import pkg from '../package.json'; 3 4// Check for updates (non-blocking) 5const notifier = updateNotifier({ 6 pkg, 7 updateCheckInterval: 1000 * 60 * 60 * 24 // Daily 8}); 9 10// Display notification if update available 11notifier.notify({ 12 message: 'Update available: {currentVersion} → {latestVersion}\nRun {updateCommand} to update' 13});

Conclusion#

Building CLI tools with AI assistance accelerates development while maintaining quality. AI helps with:

  • Initial scaffolding and structure
  • User experience patterns (prompts, spinners, colors)
  • Error handling and validation
  • Testing strategies
  • Distribution and updates

Start with a clear purpose, focus on user experience, and iterate based on feedback. A well-designed CLI becomes an indispensable tool in developers' workflows.

Share this article

Help spread the word about Bootspring