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 nodeimport { config } from 'dotenv'import { Command } from 'commander'import { z } from 'zod'config() // initialize anything from .envconst 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.
Alternative to zod
If we don't want to use zod, we can use the extra-typings package.
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.
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 zxconsole.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.
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.