CLI tools require a different design mindset than web applications: output must be parseable by other tools, errors need to print to stderr with the right exit codes, and interactive prompts need to degrade gracefully in CI. Claude Code generates CLI code that follows Unix conventions — from argument parsing and help text to config file handling and shell completion.
This guide covers CLI development with Claude Code: Node.js with Commander, Go with Cobra, interactive prompts, and distribution.
Node.js CLI with Commander
CLAUDE.md for CLI Projects
## CLI Tool
- Language: TypeScript, compiled to Node.js
- Framework: Commander.js for argument parsing
- Prompts: Inquirer.js (interactive mode)
- Output: chalk for colors, ora for spinners, cli-table3 for tables
- Config: ~/.config/mytool/config.json via conf package
- Distribution: npm (global install), also brew tap
## CLI conventions
- stdout: actual output (pipe-friendly)
- stderr: progress, warnings, errors
- Exit codes: 0 success, 1 general error, 2 invalid usage
- --json flag: output as JSON for scripting
- --quiet / -q: suppress progress output
- --no-color: disable colors (respected automatically by chalk if NO_COLOR set)
- Never prompt in CI (detect with !process.stdout.isTTY)
Command Structure
// src/cli.ts
#!/usr/bin/env node
import { Command } from 'commander';
import { deployCommand } from './commands/deploy.js';
import { configCommand } from './commands/config.js';
import { listCommand } from './commands/list.js';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
const pkg = JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf-8')
);
const program = new Command()
.name('deploy-tool')
.description('Deploy applications to multiple environments')
.version(pkg.version)
.option('--json', 'output as JSON')
.option('-q, --quiet', 'suppress progress output')
.option('--no-color', 'disable colored output');
program.addCommand(deployCommand);
program.addCommand(configCommand);
program.addCommand(listCommand);
// Error handling: invalid commands → exit 2
program.showHelpAfterError(true);
program.exitOverride();
try {
await program.parseAsync(process.argv);
} catch (err: any) {
if (err.code === 'commander.unknownCommand') {
process.exitCode = 2;
} else if (err.code !== 'commander.helpDisplayed') {
console.error(`Error: ${err.message}`);
process.exitCode = 1;
}
}
// src/commands/deploy.ts
import { Command } from 'commander';
import ora from 'ora';
import chalk from 'chalk';
import { select, confirm } from '@inquirer/prompts';
import { deployToEnvironment } from '../lib/deployer.js';
import { getConfig } from '../lib/config.js';
export const deployCommand = new Command('deploy')
.description('Deploy to an environment')
.argument('[environment]', 'target environment (staging, production)')
.option('-f, --force', 'skip confirmation prompt')
.option('--dry-run', 'simulate deployment without executing')
.action(async (environment: string | undefined, options) => {
const config = await getConfig();
// Interactive prompt if environment not specified and we're in a TTY
if (!environment) {
if (!process.stdout.isTTY) {
console.error('Error: environment argument required in non-interactive mode');
process.exit(2);
}
environment = await select({
message: 'Select deployment target:',
choices: config.environments.map(env => ({
value: env.name,
description: env.url,
})),
});
}
// Confirmation for production
if (environment === 'production' && !options.force) {
if (!process.stdout.isTTY) {
console.error('Error: use --force flag for production deploys in CI');
process.exit(2);
}
const confirmed = await confirm({
message: chalk.yellow(`Deploy to ${chalk.bold('production')}?`),
default: false,
});
if (!confirmed) {
console.log('Cancelled.');
process.exit(0);
}
}
if (options.dryRun) {
console.log(chalk.blue('Dry run:'), `Would deploy to ${environment}`);
return;
}
// Use stderr for progress, stdout for results
const spinner = ora({ stream: process.stderr });
if (!options.quiet) {
spinner.start(`Deploying to ${environment}...`);
}
try {
const result = await deployToEnvironment(environment, config);
spinner.succeed(`Deployed to ${environment}`);
// JSON mode: structured output for scripting
if (program.opts().json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(chalk.green('✓'), result.url);
}
} catch (error: any) {
spinner.fail(`Deployment failed`);
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
});
Config Management
// src/lib/config.ts
import Conf from 'conf';
interface Config {
apiToken?: string;
defaultEnvironment: string;
environments: Array<{ name: string; url: string; id: string }>;
}
const conf = new Conf<Config>({
projectName: 'deploy-tool',
defaults: {
defaultEnvironment: 'staging',
environments: [],
},
});
export const configCommand = new Command('config')
.description('Manage configuration');
configCommand
.command('set <key> <value>')
.description('Set a configuration value')
.action((key, value) => {
conf.set(key, value);
console.log(chalk.green('✓'), `Set ${key}`);
});
configCommand
.command('get [key]')
.description('Get configuration value(s)')
.action((key) => {
if (key) {
console.log(conf.get(key));
} else {
console.log(JSON.stringify(conf.store, null, 2));
}
});
// Config path for user documentation
configCommand
.command('path')
.description('Print config file location')
.action(() => {
console.log(conf.path);
});
Go CLI with Cobra
Build a Go CLI for a developer productivity tool.
Subcommands: init, add, list, sync.
// cmd/root.go
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
cfgFile string
quiet bool
jsonOut bool
)
var rootCmd = &cobra.Command{
Use: "devtool",
Short: "Developer productivity tool",
Long: `A tool for managing development workflows across multiple projects.`,
}
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: ~/.config/devtool/config.yaml)")
rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "suppress progress output")
rootCmd.PersistentFlags().BoolVar(&jsonOut, "json", false, "output as JSON")
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
home, err := os.UserHomeDir()
cobra.CheckErr(err)
viper.AddConfigPath(home + "/.config/devtool")
viper.SetConfigName("config")
viper.SetConfigType("yaml")
}
viper.AutomaticEnv()
// Ignore "file not found" — config is optional
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
fmt.Fprintln(os.Stderr, "Error reading config:", err)
os.Exit(1)
}
}
}
// cmd/sync.go
package cmd
import (
"encoding/json"
"fmt"
"os"
"github.com/briandowns/spinner"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
var syncCmd = &cobra.Command{
Use: "sync [project]",
Short: "Sync project dependencies",
Long: `Download and install dependencies for a project or all projects.`,
Args: cobra.MaximumNArgs(1),
RunE: runSync,
}
func init() {
rootCmd.AddCommand(syncCmd)
syncCmd.Flags().BoolVar(&dryRun, "dry-run", false, "print what would be synced without doing it")
}
func runSync(cmd *cobra.Command, args []string) error {
var projectName string
if len(args) > 0 {
projectName = args[0]
}
// Spinner to stderr so stdout remains clean for scripting
s := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithWriter(os.Stderr))
if !quiet {
s.Start()
defer s.Stop()
}
results, err := syncProjects(projectName)
if err != nil {
return fmt.Errorf("sync failed: %w", err)
}
if jsonOut {
return json.NewEncoder(os.Stdout).Encode(results)
}
green := color.New(color.FgGreen)
for _, r := range results {
green.Fprintf(os.Stdout, "✓ %s\n", r.Name)
}
return nil
}
Shell Completion
# Generate completions — output to the right place for each shell
devtool completion bash > /etc/bash_completion.d/devtool
devtool completion zsh > "${fpath[1]}/_devtool"
devtool completion fish > ~/.config/fish/completions/devtool.fish
Both Commander.js and Cobra generate shell completions automatically from command definitions — Claude Code adds the completion subcommand with the standard generation logic.
For CLI tools that interact with APIs, the API design guide covers the patterns that make APIs scriptable. For distributing Go CLI tools as binaries via Homebrew, GitHub releases, and package managers, the GitHub Actions guide covers the release automation. The Claude Skills 360 bundle includes CLI development skill sets for both Node.js and Go. Start with the free tier to generate CLI scaffolding for your tool.