How to Build a Simple CLI with oclif

Photo by SpaceX on Unsplash

How to Build a Simple CLI with oclif

Not a fan of shell scripting? Build CLI tools with Node.js!

·

11 min read

Salesforce developers have contributed much to the open-source community. Among their many contributions is an important, but perhaps lesser-known, project named oclif. The Open CLI Framework was announced in early 2018 and has since grown to become the foundation for the Salesforce CLI and the Heroku CLI.

In this post, we will provide a brief overview of oclif, and then we’ll walk through how to build a simple CLI with oclif.

A Brief History of oclif

Oclif started as an internal Heroku project. Heroku has always been focused on developer experience, and its CLI sets the standard for working with a service via the API. After all, Heroku is the creator of git push heroku for deployment—a standard now widely used across the industry.

If you've ever run heroku ps or sfdx auth:list, then you've used oclif. From the start, oclif was designed to be an open, extensible, lightweight framework for quickly building CLIs, both simple and complex.

More than four years after release, oclif has become the authoritative framework for building CLIs. Some of the most popular oclif components see more than a million weekly downloads. The oclif project is still under active development.

Some examples of high-profile companies or projects built via oclif include:

Why would a developer choose oclif today?

There are many reasons one might want to build a CLI. Perhaps your company has an API, and you'd like to make it easier for customers to consume it. Maybe you work with an internal API, and you'd like to run commands via the CLI to automate daily tasks. In these scenarios, you could always write Powershell or Bash scripts or build your own CLI from scratch, but oclif is the best option.

Oclif is built on Node.js. It runs on all major operating systems and has multiple distribution options. Along with being fast, oclif is also self-documenting and supports plugins, allowing developers to build and share reusable functionality. As oclif rapidly gains adoption, more and more libraries, plugins, and useful packages are becoming available.

For example, cli-ux comes pre-packaged with the @oclif/core package and provides common UX functionality such as spinners and tables, and progress bars, which you can add to your CLI.

It's easy to see why oclif is a success and should be your choice for building a CLI.

Introduction to Our Mini-project

Let’s set the scene for the CLI you will build. You want to build your own CLI for one of your passions: space travel.

You love space travel so much that you watch every SpaceX launch live, and you check the HowManyPeopleAreInSpaceRightNow.com page more than you care to admit. You want to streamline this obsession by building a CLI for space travel details, starting with a simple command that will show you the number of people currently in space. Recently, you discovered a service called Open Notify that has an API endpoint for this purpose.

We'll use the oclif generate command to create our project, which will scaffold a new CLI project with some sensible defaults. Projects created with this command use TypeScript by default—which is what we'll use for our project—but can be configured to use vanilla JavaScript as well.

Creating the Project

To start, you’ll need Node.js locally if you don’t already have it. The oclif project requires the use of an active LTS version of Node.js.

You can verify the version of Node.js that you have installed via this command:

/ $ node -v
v16.15.0

Next, install the oclif CLI globally:

/ $ npm install -g oclif

Now, it’s time to create the oclif project using the generate command:

/ $ oclif generate space-cli

     _-----_
    |       |    ╭──────────────────────────╮
    |--(o)--|    │  Time to build an oclif  │
   `---------´   │    CLI! Version: 3.0.1   │
    ( _´U`_ )    ╰──────────────────────────╯
    /___A___\   /
     |  ~  |
   __'.___.'__
 ´   `  |° ´ Y `

Cloning into '/space-cli'...

At this point, you will be presented with some setup questions. For this project, you can leave them all blank to use the defaults (indicated by the parentheses), or you can choose to fill them out yourself. The final question will ask you to select a package manager. For our example, choose npm.

Starting with oclif’s hello world command

From here, oclif will finish creating your CLI project for you. In the bin/ folder, you'll find some node scripts that you can run to test out your CLI while you're developing. These scripts will run the command from the built files in the dist/ folder. If you just run the script as is, you'll see something like this message:

/ $ cd space-cli/
/space-cli $ ./bin/run
oclif example Hello World CLI

VERSION
  space-cli/0.0.0 darwin-arm64 node-v16.15.0

USAGE
  $ space-cli [COMMAND]

TOPICS
  hello    Say hello to the world and others
  plugins  List installed plugins.

COMMANDS
  hello    Say hello
  help     Display help for space-cli.
  plugins  List installed plugins.

By default, if you don’t specify a command to run for the CLI, it will display the help message. Let’s try again:

/space-cli $ ./bin/run hello
 >   Error: Missing 1 required arg:
 >   person  Person to say hello to
 >   See more help with --help

This time, we received an error. We’re missing a required argument: We need to specify who we’re greeting!

/space-cli $ ./bin/run hello John
 >   Error: Missing required flag:
 >    -f, --from FROM  Whom is saying hello
 >   See more help with --help

We received another helpful error message. We need to specify the greeter as well, this time with a flag:

/space-cli $ ./bin/run hello John --from Jane
hello John from Jane! (./src/commands/hello/index.ts)

Finally, we’ve properly greeted John, and we can take a look at the hello command’s code, which can be found in src/commands/hello/index.ts. It looks like this:

import {Command, Flags} from '@oclif/core'

export default class Hello extends Command {
  static description = 'Say hello'

  static examples = [
    `$ oex hello friend --from oclif
hello friend from oclif! (./src/commands/hello/index.ts)
`,
  ]

  static flags = {
    from: Flags.string({char: 'f', description: 'Whom is saying hello', required: true}),
  }

  static args = [{name: 'person', description: 'Person to say hello to', required: true}]

  async run(): Promise<void> {
    const {args, flags} = await this.parse(Hello)

    this.log(`hello ${args.person} from ${flags.from}! (./src/commands/hello/index.ts)`)
  }
}

As you can see, an oclif command is simply defined as a class with an async run() method, which unsurprisingly contains the code that is executed when the command runs. In addition, some static properties provide additional functionality, although they're all optional.

  • The description and examples properties are used for the help message.
  • The flags property is an object which defines the flags available for the command, where the keys of the object correspond to the flag name. We'll dig into those a bit more later.
  • Finally, args is an array of objects representing arguments the command can take with some options.

The run() method parses the arguments and flags and then prints out a message using the person argument and from flag using this.log() (a non-blocking alternative to console.log). Notice both the flag and argument are configured with required: true, which is all it takes to get validation and helpful error messages like those we saw in our earlier testing.

Creating our own command

Now that we understand the anatomy of a command, we’re ready to write our own. We’ll call it humans, and it will print out the number of people currently in space. You can delete the hello folder in src/commands, since we won't need it anymore. The oclif CLI can help us scaffold new commands, too:

/space-cli $ oclif generate command humans

     _-----_
    |       |    ╭──────────────────────────╮
    |--(o)--|    │    Adding a command to   │
   `---------´   │ space-cli Version: 3.0.1 │
    ( _´U`_ )    ╰──────────────────────────╯
    /___A___\   /
     |  ~  |     
   __'.___.'__   
 ´   `  |° ´ Y ` 

   create src\commands\humans.ts
   create test\commands\humans.test.ts

No change to package.json was detected. No package manager install will be executed.

Now we have a humans.ts file we can edit, and we can start writing our command. The Open Notify API endpoint we will use can be found at the following URL: http://open-notify.org/Open-Notify-API/People-In-Space/

As you can see in the description, the endpoint returns a simple JSON response with details about the humans currently in space. Replace the code in src/commands/humans.ts with the following:

import {Command} from '@oclif/core'
import {get} from 'node:http'

export default class HumanCommand extends Command {
  static description = 'Get the number of humans currently in space.'

  static examples = [
    '$ space-cli humans\nNumber of humans currently in space: 7',
  ]

  public async run(): Promise<void> {
    get('http://api.open-notify.org/astros.json', res => {
      res.on('data', d => {
        const details = JSON.parse(d)
        this.log(`Number of humans currently in space: ${details.number}`)
      })
    }).on('error', e => {
      this.error(e)
    })
  }
}

Here’s a breakdown of what we’re doing in the code above:

  1. Send a request to the Open Notify endpoint using the http package.
  2. Parse the JSON response.
  3. Output the number with a message.
  4. Catch and print any errors we may encounter along the way.

For this first iteration of the command, we didn’t need any flags or arguments, so we’re not defining any properties for those.

Testing our basic command

Now, we can test out our new command. First, we’ll have to rebuild the dist/ files, and then we can run our command just like the hello world example from before:

/spacecli $ npm run build

> space-cli@0.0.0 build
> shx rm -rf dist && tsc -b

/spacecli $ ./bin/run humans
Number of humans currently in space: 7

Pretty straightforward, isn’t it? You now have a simple CLI project, built via the oclif framework, that can instantly tell you the number of people in space.

Enhancing our command with flags and a nicer UI

Knowing how many people are currently in space is nice, but we can get even more space data! The endpoint we’re using provides more details about the spacefarers, including their names and which spacecraft they are on.

We’ll take our command one step further, demonstrating how to use flags and giving our command a nicer UI. We can output our data as a table with the cli-ux package, which has been rolled into @oclif/core (as of version 1.2.0). To ensure we have access to cli-ux, let’s update our packages.

/spacecli $ npm update

We can add an optional --table flag to our humans command to print out this data in a table. We use the CliUx.ux.table() function for this pretty output.

import {Command, Flags, CliUx} from '@oclif/core'
import {get} from 'node:http'

export default class HumansCommand extends Command {
  static description = 'Get the number of humans currently in space.'

  static examples = [
    '$ space-cli\nNumber of humans currently in space: 7',
  ]

  static flags = {
    table: Flags.boolean({char: 't', description: 'display who is in space and where with a table'}),
  }

  public async run(): Promise<void> {
    const {flags} = await this.parse(HumansCommand)

    get('http://api.open-notify.org/astros.json', res => {
      res.on('data', d => {
        const details = JSON.parse(d)
        this.log(`Number of humans currently in space: ${details.number}`)
        if (flags.table) {
          CliUx.ux.table(details.people, {name: {}, craft: {}})
        }
      })
    }).on('error', e => {
      this.error(e)
    })
  }
}

In our updated code, our first step was to bring back the flags property. This time we're defining a boolean flag—it's either there, or it isn’t—as opposed to string flags which take a string as an argument. We also define a description and a shorthand -t for the flag in the options object that we're passing in.

Next, we parse the flag in our run method. If it’s present, we display a table with CliUx.ux.table(). The first argument, details.people, is the data we want to display in the table, while the second argument is an object that defines the columns in the table. In this case, we define a name and a craft column, each with an empty object. (There are some configuration options for the table columns, but we don't need any in this case.) Oclif will look for those properties on the data object that we pass in and take care of everything else for us!

We can build and rerun the command with the new table flag to see what that looks like:

/spacecli $ ./bin/run humans --table
Number of humans currently in space: 10
 Name                   Craft    
 ───────────────── ──────── 
 Oleg Artemyev          ISS      
 Denis Matveev          ISS      
 Sergey Korsakov        ISS      
 Kjell Lindgren         ISS      
 Bob Hines              ISS      
 Samantha Cristoforetti ISS      
 Jessica Watkins        ISS      
 Cai Xuzhe              Tiangong 
 Chen Dong              Tiangong 
 Liu Yang               Tiangong

Beautiful!

Add some more functionality on your own

At this point, our example project is complete, but you can easily build more on top of it. The Open Notify service provides an API endpoint to get the current location of the International Space Station. You might add that functionality, too, with a command such as space-cli iss to return the location when run.

What about distribution?

You might be thinking about distribution options for sharing your awesome new CLI. You could publish this project to npm via a simple command. You could create a tarball to distribute the project internally to your team or coworkers. You could also create a Homebrew formula if you wanted to share it with macOS users. Oclif can help you with any of these options.

Conclusion

We started this article by reviewing the history of oclif, along with the many reasons why it should be your first choice when creating a CLI. Some of its advantages include speed, extensibility, and a variety of distribution options. We learned how to scaffold a CLI project and add new commands to it, and built a simple CLI as an example.

Now that you’ve been equipped with knowledge and a new tool, go out and be dangerous.