I'm currently working on a pair of CLIs, one I've written about here and one I'll announce soon. I just love a good text-based interface so a lot of the tools I build for myself and built at work take on that form. I'm certainly no expert in this realm (yet) but I enjoy figuring out sane defaults for options, clear flag names, and helpful error messages. Despite that, I am also still pretty fun at parties.
In an attempt to save myself some pain and suffering while building the budgeting CLI last year, I looked into existing JavaScript CLI frameworks that are out there and decided to try out oclif. It looked like it had the right features and mostly got out of your way but, after playing with it for a few hours, I just could not get it to do much of anything so I scrapped it and went with the built-in Node utility for parsing arguments, util.parseArgs()
. You can see how I'm using that here.
Flash forward a year or so and I'm back in the same position with a new project. Currently I'm just compiling files and executing them directly with node ./dist/command.js
. This new CLI will have a much bigger footprint of commands and options so the flag and argument parsing is only a part of the job to be done. The oclif
package popped into my head again so I took a look and saw that there had been 2 major releases since I last tried it out with a number of features that I'm likely to use: plugins, hooks, and releases. It looked like I would be able to integrate it in the project without completely rearchitecting how everything works, which was a big plus. I don't like being trapped in a framework, if I can avoid it.
I dove in and got it running right away but was not quite sure exactly what was going on. The getting started tutorial is about a half-page long, counting the introduction, and doesn't do much to help to understand what I was doing. You generate a complete CLI project and are left without much to go on after that besides just reading through the code. The guides and API reference docs are solid but only if you know exactly what you're looking for. I spent about an hour reading the docs and ended up with a bit better sense of what's going on but I had to piece it together myself.
I believe a getting started tutorial should start at zero and work through the basics, building upon understanding as it goes. As a part of getting this working in my budgeting CLI, I walked through the foundational pieces that need to be added, contributed a command that adds these pieces without all the rest of the template boilerplate, and wrote the tutorial below in the process. Hopefully these pieces will help if you decide to write a CLI!
This tutorial assumes that you:
- Have Node and npm installed on your system
- Are or will be using TypeScript (TS going forward) to build the CLI
The oclif CLI has two options to create the files you need:
- The
generate
command that creates a new npm project from scratch in a new directory - The
init
command that adds the basic configuration to an existing project
The generate
command is the easiest way to get to a completely working CLI but it leaves you with a lot of boilerplate that you might not need and a number of unanswered questions about what comes next.
We're going to start this tutorial with an empty directory and work our way to a functional CLI step-by-step, starting with the init
command. We'll rely on links to the documentation to expand on what's here and, by the end, you should have a clear path forward for your own CLI project.
First, we need a new directory and a package.json
file, which we'll get by initializing npm and installing TS:
$ mkdir new-oclif-cli
$ cd new-oclif-cli
$ npm init
# ... answer all prompts, defaults are fine for this tutorial
$ npm install typescript
We're going to do the absolute bare minimum of setup to get TS compiling since that's not the focus of this tutorial. If you're just getting started with TS, the TypeScript Tooling in 5 minutes is a great place to start. For now, we just want to make sure that TS is compiling our files in the right place.
Starting where we left off, make a TS file that outputs to the console:
$ mkdir src
$ echo 'console.log("Hi!");' >> src/index.ts
Add a basic tsconfig.json
file in root of your project with configuration for the source files and output directory:
// tsconfig.json
{
"include": [ "src/**/*" ],
"compilerOptions": {
"outDir": "./dist",
"module": "nodenext"
}
}
Invoke the TS package we installed in this project to compile this new file to a dist
directory in our project and make sure it can be executed:
$ npm exec tsc
$ node ./dist/index.js
Hi!
If you're having trouble getting to this point, please refer to the TS documentation. If not, then congratulations, you have a CLI built in TS!
We're going to use npx
to invoke the oclif CLI, which will add the necessary npm module, bin files, and configuration:
npx oclif init
This command will ask a few questions:
- First, you'll be asked for what directory to use to install. Accept the default value to install in the current working directory.
- Next, you'll be asked for the command name that will be exported for your project. This becomes important when you're publishing your project but, for the purpose of this exercise, you can accept the default.
- Next, you'll be asked about your module type. The Node documentation has a thorough explanation of modules that's a great start if you're not sure which one to use. While this decision is important for your overall project, it doesn't matter much for this tutorial so pick the one you're most comfortable with and continue.
- The next step will happen automatically, since we already installed a package using
npm
. Theinit
command auto-detects what package manager you're using based on the name of the lock file. The command saw thepackage-lock.json
file and usednpm
to install the@oclif/core
package in the background.
If everything completed successfully, you should see a message like "Created CLI new-oclif-cli" and no errors in the console. You should also have:
- Four new files in a new
./bin
folder - An
oclif
object in yourpackage.json
configuring the bin name, data directory, and command discovery strategy. There are other configuration options, some of which we'll cover later in this tutorial
Before we move on, we need to update our package.json
file with the module type that we selected during the oclif init
command. Add a top-level property type
set to module
for ESM or commonjs
for CommonJS.
// package.json
{
// ... other properties
"type": "module"
// ... or
"type": "commonjs"
}
Now we're ready to create our first command! The oclif CLI includes the helpful oclif generate command COMMAND_NAME
that we can use but, like oclif generate
, it includes a lot of boilerplate so we'll build ours from scratch.
Create a directory commands
in ./src
and add a file called hello.ts
:
$ mkdir ./src/commands
$ touch ./src/commands/hello.ts
In the hello.ts
file, add the following:
// src/commands/hello.ts
import { Command } from "@oclif/core";
export default class Hello extends Command {
public async run(): Promise<void> {
this.log("Hello from oclif!");
}
}
This is the basic form that all commands will take: extending the Command
class and defining a run()
method. There are a number of methods that are available in the parent class, including the log()
method we're using here that outputs messages to stdout
.
We have not packaged up our CLI into an executable binary but we can easily test the command by using one of the files that was added during initialization:
$ npm exec tsc
$ ./bin/run.js hello
Hello from oclif!
Note: Going forward, we'll assume that you're running tsc
after TS files changes or are running tsc -w
in another tab to compile automatically on change.
One of oclif's selling points is it's ability to parse and validate the arguments and flags that are passed when the command is run.
We can add an argument to our command by defining a static args
property on the class we created set to an object. The keys in this object define the property names we'll use during runtime and the values indicate the type of argument we expect.
Let's add an argument to our command and simply output the value to the terminal:
// src/commands/hello.ts
import { Args, Command } from "@oclif/core";
export default class Hello extends Command {
static override args = {
arg1: Args.string(),
};
public async run(): Promise<void> {
const { args } = await this.parse(Hello);
this.log("Hello from oclif!");
this.log("arg1: %s", args.arg1);
}
}
In this case, we created a string
argument in the first position, parsed all the arguments from the command, then output the value using the formatting capability of this.log
. When we run the command with an argument, we can see the value immediately:
$ ./bin/run.js hello an_argument
Hello from oclif!
arg1: an_argument
If we add a second argument without modifying the command code, we'll see an error:
./bin/run.js hello an_argument another_argument
› Error: Unexpected argument: another_argument
› See more help with --help
USAGE
$ new-oclif-cli hello [ARG1]
The parse()
method does two jobs: it both validates the incoming arguments and makes them available to the logic in the run()
method. If your command is using arguments or flags then this should be called on the first line of the run()
function to avoid partial execution.
There is a lot more that's possible with command arguments, including documentation, pre-processing, default values and more. Take some time to play around with the different argument types and options to get a feel for what can be done.
Now, let's add a flag to our command. Flag parsing and validation in oclif is quite powerful and flexible so we'll only scratch the surface in this tutorial.
Let's adjust our command to add a simple flag. The code below excludes the argument code from above for simplicity but the two can co-exist:
import { Command, Flags } from "@oclif/core";
export default class Hello extends Command {
static override flags = {
flag: Flags.boolean(),
};
public async run(): Promise<void> {
const { flags } = await this.parse(Hello);
this.log("Hello from oclif!");
this.log("flag: %s", flags.flag ? "yes" : "no");
}
}
You'll notice that the syntax here is quite similar as that for arguments. We have a static property flags
set to an object with keys that define the flag name and values that indicate the flag type.
If we run our command with the flag present, the output should be:
$ ./bin/run.js hello --flag
Hello from oclif!
flag: yes
Similar to arguments, if we run a command with a flag we did not define, the result is an error and usage docs:
./bin/run.js hello --notflag
› Error: Nonexistent flag: --notflag
› See more help with --help
USAGE
$ new-oclif-cli hello [--flag]
FLAGS
--flag
There is a lot more you can do with command flags, including character aliases, dependencies on other flags, reversibility, and more.
Now that we understand more about how commands are built, the command that oclif can generate should make more sense. Run the following to use a template to create a new command:
$ npm exec oclif generate command hello2
Adding hello2 to new-oclif-cli!
Creating src/commands/hello2.ts
This will create a new file ./src/commands/hello2.ts
with both arguments and flags. Running the help flag for this new command will show how it's used:
./bin/run.js hello2 --help
describe the command here
USAGE
$ new-oclif-cli hello2 [FILE] [-f] [-n <value>]
ARGUMENTS
FILE file to read
FLAGS
-f, --force
-n, --name=<value> name to print
DESCRIPTION
describe the command here
EXAMPLES
$ new-oclif-cli hello2
Try running the base command with the --help
flag to see the output.
Finally, we want users to know how the CLI can be used so we'll use oclif to create a README file. First, create a README.md
file in your project directory or open the existing one. Add the following template anywhere in the file:
## Table of contents
<!-- toc -->
## Usage
<!-- usage -->
## Commands
<!-- commands -->
Note that the order, headlines, and which tags are used are all up to you. If you only want the commands to be output, just use the <!-- commands -->
tag. When you have everything where you want it, run the oclif readme
command:
$ npm exec oclif readme
replacing <!-- usage --> in README.md
replacing <!-- commands --> in README.md
replacing <!-- toc --> in README.md
You now have a functional CLI built and documented using oclif!
Recommended next steps are:
- If you're building a large CLI with multiple commands, look into adding a custom base class to manage duplicate arguments, inherited flags, and shared functionality.
- If your CLI needs user-defined functionality, look into plugins.
- If you need assistance troubleshooting, look into oclif's debugging features and error handling.
- When you're ready to put your command out there in the world, oclif has a number of ways it can help with releasing.
< Update 2024-04-10 >
Thanks to the oclif team for the kind attribution on their getting started page!
< Take Action >
Comment via:
Email › GitHub › Hacker News ›
Subscribe via:
< Read More >
Tags
Newer
Jul 31, 2024
Imagining a Personal Data Pipeline
I've been thinking a lot about personal data lately: where it's stored, how to extract it, and what to do with it. Here's where I landed.
Older
Feb 09, 2024
Goodbye Auth0
My 6 years at Auth0 ... how it all started, what Auth0 meant to me, and why I will proudly wear that shield for as long as the swag holds up.