Learn how to build a command line interface in 10 minutes

Adding a cli

I recently noticed that shadcn's cli is really well organized. Sometimes it's useful to have a single one off command to run in a project. There's also a lot of useful utilities to use within scripts but finding the right library can be challenging.

There's lots of other options in cli frameworks such as ink and oclif but commander can often solve a lot of the one off script writing issues quickly and with the template below we can require minimal doc reading before getting the part we all came for, writing the script. clipanion, yargs and others have all been on the menu at some stage but I keep coming back to commander. It's pretty easy to get setup, has a decent default help setup, and has support for multiple commands.

By using Commander, dotenv and zod (or extra-typings) we can make a type safe command really quickly. Also we'll go into a set of useful utility libraries to solve some more common cli issues. I'll try to update this as I find more useful libraries.

I've had to write a lot of bash scripts and I much prefer writing conditionals, handling async work, and writing the helpfiles in ts for anything beyond a few lines of shell scripts.

Get your deps

yarn/pnpm add dotenv commander and zod

Setup the cli

Setup the cli, customize the schema and add the command.


#!/usr/bin/env node
import { config } from 'dotenv'
import { Command } from 'commander'
import { z } from 'zod'
config() // initialize anything from .env
const exampleCommand = new Command()
.command('example-command')
.argument('[toppings...]', 'toppings args') // [optional...] array of strings
.description('example command')
.option('-e, --example <example>', 'example option', 'default')
// The action handler gets passed a parameter for each `argument` declared, and two additional parameters which are the parsed options and the command object itself.
.action(async (toppings, opts, command) => {
try {
const exampleCommandSchema = z.object({
toppings: z.array(z.string()).optional(),
example: z.string().optional(),
})
const options = exampleCommandSchema.parse({
toppings,
...opts,
})
// do something with those options
console.log({ options, command })
} catch (error) {
console.error(error)
process.exit(1)
}
})
const program = new Command()
.name('example-program')
.description('example program')
program.addCommand(exampleCommand, { isDefault: true })
program.parse(process.argv)

Arguments to action

The action handler gets passed a parameter for each argument declared, and two additional parameters which are the parsed options and the command object itself. So when we copy a command we need to be careful of the position of the arguments to the action handler.

{9}

const argsParseExample = new Command()
.command('args-parse-example')
.description('another way to parse args')
// these determine the order of the arguments to the action handler
.argument('<source>', 'topping arg') // <required>
.argument('[destination]', 'oven') // [optional]
// these are all in the second to last options parameter
.option('-e, --example <example>', 'example option', 'default')
.action(async (...args) => {
try {
const [source, destination, opts, command] = args
// if we only are ever interested in opts and command we can always take them from the end of the args array
// const opts = args?.[args.length - 2]
// const command = args?.[args.length - 1]
const exampleCommandSchema = z.object({
source: z.string(),
destination: z.string().optional(),
example: z.string().optional(),
})
const options = exampleCommandSchema.parse({
source,
destination,
...opts,
})
// do something with those options
console.log({ options, command })
} catch (error) {
console.error(error)
process.exit(1)
}
})

Alternative to zod

If we don't want to use zod, we can use the extra-typings package.

{2}

#!/usr/bin/env node
import { Command } from '@commander-js/extra-typings'
const program = new Command()
.argument('[toppings...]', 'toppings args')
.description('example command')
.option('-e, --example <example>', 'example option', 'default')
// The action handler gets passed a parameter for each `argument` declared, and two additional parameters which are the parsed options and the command object itself.
.action(async (toppings, opts, command) => {
try {
// do something with those options
console.log({ toppings, opts })
} catch (error) {
console.error(error)
process.exit(1)
}
})
program.parse(process.argv)

Adding more commands

Adding more commands just takes adding a new command and calling the addCommand method. As this expands, the commands can be moved into their own files as we can see from shadcn's cli.

{24}

#!/usr/bin/env node
import { add } from '@/src/commands/add'
import { diff } from '@/src/commands/diff'
import { init } from '@/src/commands/init'
import { Command } from 'commander'
import { getPackageInfo } from './utils/get-package-info'
process.on('SIGINT', () => process.exit(0))
process.on('SIGTERM', () => process.exit(0))
async function main() {
const packageInfo = await getPackageInfo()
const program = new Command()
.name('shadcn-ui')
.description('add components and dependencies to your project')
.version(
packageInfo.version || '1.0.0',
'-v, --version',
'display the version number'
)
program.addCommand(init).addCommand(add).addCommand(diff)
program.parse(process.argv)
}
main()

Running the command

If there aren't any types defined, a .mjs and adding a "type": "module" to the package.json will allow the command to be run.

When using a .ts file, tsx, bun or ts-node --swc can be used to run the command.


# pick your favorite runner
$ tsx ./src/example-cli.ts
$ ts-node --swc ./src/example-cli.ts
$ bun ./src/example-cli.ts

Optionally include the shebang to run the command directly and chmod +x the file.


#!/usr/bin/env ts-node --swc

Adding to package json to run as an installed binary

Running from cli will require some bundling, adding a build script, a bin.


"scripts": {
"build:cli": "esbuild --bundle --outdir=dist --platform=node --target=node18 ./src/example-cli.ts"
},
"bin": {
"examplecommand": "./dist/example-cli.js",
},

Then publishing to npm or run npm link from the package folder if there's only have a repo. If using volta and npm link to link the package, use volta which example-cli to find the package location afterwards.

More tools and libraries

Running external commands

I've been using zx from google to run external commands. It's a nice way to run external commands and scripts. Other options in this space are execa which also supports the scripts interface which is a really nice alternative for calling other bash shell commands.


import { $ } from 'zx'
await $`docker build -f ${dockerfile} -t ${imageUri} ${dockerContext}`

Prompts

Use prompts to get user input. Don't forget those types.


$ pnpm add prompts @types/prompts


import prompts from 'prompts'
const response = await prompts({
type: 'number',
name: 'howMuch',
message: 'How much would you pay for this?',
validate: (value) =>
value < 5 ? `Sorry, please spend a bit more to run this cli` : true,
})
console.log(response.howMuch)

Spinners

Try ora for spinners.


import ora from 'ora'
import { $ } from 'execa'
const spinner = ora(`Working...`).start()
await $`sleep 2`
spinner.text = 'More work...'
await $`sleep 2`
spinner.succeed(`Done.`)

Colors

Use chalk for colors.


import chalk from 'chalk'
// import { chalk } from 'zx' // also included with zx
console.log(chalk.green('Success!'))

Copy text to clipboard

Copy text to clipboard for mac/windows/wsl.


import clipboardy from 'clipboardy'
import isWsl from 'is-wsl'
export function writeToClipboard({ text }) {
if (isWsl) {
exec(`echo "${text}" | clip.exe`)
} else {
clipboardy.write(text)
}
}

Processing files

It's often useful to list a pattern of files or all files in a subdirectory using a wildcard. Fast glob patterns are a nice way to specify everything within a subdirectory.

{

import fg from 'fast-glob'
import zlib from 'zlib'
import { z } from 'zod'
import fs from 'fs'
import { Command } from 'commander'
const program = new Command()
.name('list of files')
.option(
'-f, --files <file>',
'file glob see https://github.com/mrmlnc/fast-glob ',
'./src/**/*.tsx'
)
.action(async (opts) => {
try {
const fileGlobSchema = z.object({
files: z.string().min(1),
})
const options = fileGlobSchema.parse(opts)
const filesGlob = await fg([options.files], { dot: false })
filesGlob.forEach((filePath) => {
console.log(filePath)
let fileContents = ''
if (filePath.endsWith('.gz')) {
fileContents = zlib.gunzipSync(fs.readFileSync(filePath)).toString()
} else {
fileContents = fs.readFileSync(filePath, 'utf8').toString()
}
})
} catch (error) {
console.error(error)
process.exit(1)
}
})
.parse(process.argv)

A config file

When config goes beyond a .env file, cosmicconfig helps to load all forms of config, including functions from js or ts files and not just json.


import { cosmiconfig } from 'cosmiconfig'
async function getConfig({ region }) {
const moduleName = 'example'
const explorer = cosmiconfig(moduleName)
try {
const result = await explorer.search()
let config = result.config
if (typeof result.config === 'function') {
const accountId = await getAccountId()
config = await result.config({ region, accountId })
}
return {
...result,
config,
}
} catch (error) {
console.log(`Error parsing or no ${moduleName} config found. Searched
'package.json',
.${moduleName}rc,
.${moduleName}rc.json,
.${moduleName}rc.yaml,
.${moduleName}rc.yml,
.${moduleName}rc.js,
.${moduleName}rc.cjs,
.${moduleName}rc.ts,
${moduleName}.config.js,
${moduleName}.config.ts,
${moduleName}.config.cjs,`)
console.error(error)
return null
}
}

Parallelization

If there's a lot of work to be done, execution in parallel can speed things up. One thing to keep in mind while doing this is that we also don't want to overwhelm the target or hit api limits. p-map allows for concurrency limits.


import pMap from 'p-map'
import { setTimeout } from 'node:timers/promises'
const responses = await pMap(
items,
async (item) => {
console.log(item?.id)
await setTimeout(1000)
},
{
concurrency: 5,
stopOnError: false, // this will still reject everything and throw an AggregateError
}
)

Summary

Adding a cli is a nice way to run scripts and commands in a project. Hopefully this will help get started with building your own cli. So go out, build your cli and may your time in bash scripts be limited.