How to create CLI tools in Node.js
Building CLI tools with Node.js lets you automate workflows and provide developer utilities as npm packages.
As the creator of CoreUI with over 25 years of software development experience since 2000, I’ve built CLI tools for code generation, project scaffolding, and automated deployments.
The standard approach uses commander for argument parsing, inquirer for interactive prompts, and chalk for colored output.
This produces professional CLI tools that feel native to the terminal.
Create a basic CLI with commander.
npm install commander chalk
#!/usr/bin/env node
// cli.js
const { Command } = require('commander')
const chalk = require('chalk')
const program = new Command()
program
.name('mytool')
.description('My CLI tool')
.version('1.0.0')
program
.command('greet <name>')
.description('Greet a user')
.option('-l, --loud', 'Shout the greeting')
.action((name, options) => {
const msg = `Hello, ${name}!`
console.log(options.loud ? chalk.bold(msg.toUpperCase()) : chalk.green(msg))
})
program.parse()
// package.json
{
"bin": {
"mytool": "./cli.js"
}
}
The shebang #!/usr/bin/env node makes the file executable. Command parses arguments and flags. chalk adds colors. The bin field in package.json registers the command name when installed globally.
Adding Interactive Prompts
Use inquirer for user input.
npm install inquirer
const { Command } = require('commander')
const inquirer = require('inquirer')
const chalk = require('chalk')
const fs = require('fs')
const program = new Command()
program
.command('create')
.description('Create a new project')
.action(async () => {
const answers = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Project name:',
validate: (v) => v.trim() ? true : 'Name is required'
},
{
type: 'list',
name: 'framework',
message: 'Choose a framework:',
choices: ['React', 'Vue', 'Angular']
},
{
type: 'confirm',
name: 'typescript',
message: 'Use TypeScript?',
default: true
}
])
console.log(chalk.cyan('\nCreating project...'))
fs.mkdirSync(answers.name)
console.log(chalk.green(`✓ Created ${answers.name} (${answers.framework})`))
})
program.parse()
inquirer.prompt() shows interactive questions. input type accepts free text. list shows a selection menu. confirm asks yes/no. validate provides inline error messages. Results come back as a plain object.
Adding a Progress Spinner
Show feedback during long operations.
npm install ora
const ora = require('ora')
const chalk = require('chalk')
async function deployProject(name) {
const spinner = ora(`Deploying ${name}...`).start()
try {
await simulateDeploy()
spinner.succeed(chalk.green(`Deployed ${name} successfully`))
} catch (err) {
spinner.fail(chalk.red(`Deployment failed: ${err.message}`))
process.exit(1)
}
}
async function simulateDeploy() {
await new Promise(resolve => setTimeout(resolve, 2000))
}
deployProject('my-app')
ora creates a loading spinner. spinner.start() begins animation. spinner.succeed() shows a checkmark. spinner.fail() shows an error mark. Always call one of the terminal methods or the spinner keeps spinning.
Reading and Writing Files
Work with the filesystem in CLI tools.
const fs = require('fs')
const path = require('path')
const chalk = require('chalk')
function readConfig(configPath) {
const absPath = path.resolve(process.cwd(), configPath)
if (!fs.existsSync(absPath)) {
console.error(chalk.red(`Config not found: ${absPath}`))
process.exit(1)
}
const content = fs.readFileSync(absPath, 'utf-8')
return JSON.parse(content)
}
function writeOutput(data, outputPath) {
const absPath = path.resolve(process.cwd(), outputPath)
fs.writeFileSync(absPath, JSON.stringify(data, null, 2))
console.log(chalk.green(`✓ Written to ${outputPath}`))
}
const config = readConfig('./config.json')
writeOutput({ processed: true, ...config }, './output.json')
process.cwd() returns the directory where the CLI was invoked. path.resolve builds absolute paths. Check for file existence before reading to give helpful error messages. Always use utf-8 encoding for text files.
Publishing to npm
Make the tool installable globally.
# Test locally
npm link
mytool greet World
# Publish
npm publish
{
"name": "mytool",
"version": "1.0.0",
"bin": { "mytool": "./cli.js" },
"files": ["cli.js"],
"engines": { "node": ">=18" }
}
npm link installs the tool locally for testing. The files array limits what gets published. The engines field declares the minimum Node version. After npm publish, anyone can install with npm install -g mytool.
Best Practice Note
This is the same CLI architecture we use for CoreUI’s code generation and scaffolding tools. Always handle errors gracefully and exit with non-zero status codes on failure - CI pipelines depend on this. Provide --help output automatically via commander. Add --version so users can report issues with the correct version. For distribution, consider bundling with pkg to create standalone binaries that don’t require Node.js installed on the target machine.



