HomePostsApr 08, 2024

Building a CLI from scratch with TypeScript and oclif

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:

The oclif CLI has two options to create the files you need:

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:

  1. First, you'll be asked for what directory to use to install. Accept the default value to install in the current working directory.
  2. 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.
  3. 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.
  4. The next step will happen automatically, since we already installed a package using npm. The init command auto-detects what package manager you're using based on the name of the lock file. The command saw the package-lock.json file and used npm 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:

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:

< Update 2024-04-10 >

Thanks to the oclif team for the kind attribution on their getting started page!

< Take Action >

Suggest changes on GitHub ›

Comment via:

Email › GitHub › Hacker News ›

Subscribe via:
RSS › Twitter › GitHub ›

< Read More >

Tags
JavaScript + TypeScript Software Engineering
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.