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.