Modern CLIs

Who am I?

Hi, I'm @arcanis 👋

Lead maintainer for Yarn

Clipanion's author (more on that later)

First generation

int main() {
  int area, perimeter, length, breadth;
  
  while ((option = getopt(argc, argv, "apl:b:")) != -1) {
    switch (option) {
    case 'a' : area = 0;
      break;
    case 'p' : perimeter = 0;
      break;
    case 'l' : length = atoi(optarg); 
      break;
    case 'b' : breadth = atoi(optarg);
      break;
    default:
      print_usage(); 
      exit(EXIT_FAILURE);
    }
  }
}

getopt (C)

while getopts "n:t:" options; do
  case "${options}" in
    n)
      NAME=${OPTARG}
      ;;
    t)
      TIMES=${OPTARG}
      ;;
  esac
done

getopts (Bash)

Second generation

import yargs     from 'yargs/yargs';
import {hideBin} from 'yargs/helpers';

const argv = yargs(hideBin(process.argv)).argv;

if (argv.ships > 3 && argv.distance < 53.5) {
  console.log(`Plunder more riffiwobbles!`);
} else {
  console.log(`Retreat from the xupptumblers!`);
}

Yargs

import minimist from 'minimist';

const argv = minimist(process.argv.slice(2));

if (argv.ships > 3 && argv.distance < 53.5) {
  console.log(`Plunder more riffiwobbles!`);
} else {
  console.log(`Retreat from the xupptumblers!`);
}

Minimist

import {Command} from 'commander';

const program = new Command();

program
  .option(`-d, --debug`, `output extra debugging`)
  .option(`-s, --small`, `small pizza size`)
  .option(`-p, --pizza-type <type>`, `flavour of pizza`);

const argv = program.parse(process.argv);

Commander

Second generation

  • Very simple
  • Implicit definitions
  • Flexible types

Great for beginners but ... does it scale?

import minimist from 'minimist';

const argv = minimist(process.argv.slice(2));

await execFile(`git`, [`show`, `-s`, `--format=%B`, argv.commitHash]);

// ./my-bin --commit-hash 0cd2af78
//   -> "My first commit"

// ./my-bin --commit-hash 04721586
//   -> ambiguous argument '4721586' 💥

Unwanted casts

import minimist from 'minimist';

const argv = minimist(process.argv.slice(2));

await execFile(`git`, [`show`, `-s`, `--format=%B`, argv.comitHash]);

// ./my-bin --commit-hash 0cd2af78
//   -> ambiguous argument '' 💥

Typos

(also: loose options)

import {Command} from 'commander';

const program = new Command();

program
  .option(`-d, --debug`, `output extra debugging`)
  .option(`-s, --small`, `small pizza size`)
  .option(`-p, --pizza-type <type>`, `flavour of pizza`);

const argv = program.parse(process.argv);

await cookPizza(argv.small, argv.pizzaType);

// All good, but what is the --debug option for? 🤔😥

Forgotten options

  let preCommandArgs;
  let commandName = '';
  if (firstNonFlagIndex > -1) {
    preCommandArgs = args.slice(0, firstNonFlagIndex);
    commandName = args[firstNonFlagIndex];
    args = args.slice(firstNonFlagIndex + 1);
  } else {
    preCommandArgs = args;
    args = [];
  }

  let isKnownCommand = Object.prototype.hasOwnProperty.call(commands, commandName);
  const isHelp = arg => arg === '--help' || arg === '-h';
  const helpInPre = preCommandArgs.findIndex(isHelp);
  const helpInArgs = args.findIndex(isHelp);
  const setHelpMode = () => {
    if (isKnownCommand) {
      args.unshift(commandName);
    }
    commandName = 'help';
    isKnownCommand = true;
  };

Custom behaviors

Enters TypeScript!

import yargs from 'yargs/yargs';

const argv = yargs(process.argv.slice(2)).options({
  a: { type: 'boolean', default: false },
  b: { type: 'string', demandOption: true },
  c: { type: 'number', alias: 'chill' },
  d: { type: 'array' },
  e: { type: 'count' },
  f: { choices: ['1', '2', '3'] }
}).argv;

// typeof argv = {
//   [x: string]: unknown;
//   a: boolean;
//   b: string;
//   c: number | undefined;
//   d: (string | number)[] | undefined;
//   e: number;
//   f: string | undefined;
//   _: string[];
//   $0: string;
// }

Third generation

import Command from '@oclif/command';

export class MyCLI extends Command {
  static args = [
    {name: `firstArg`},
    {name: `secondArg`},
  ];

  async run() {
    const {args} = this.parse(MyCLI);

    console.log(args.firstArg);
    console.log(args.secondArg);
  }
}

Oclif

import {Command, Option} from 'clipanion';

export class MyCommand extends Command {
  firstArg = Option.String();
  secondArg = Option.String();

  async run() {
    console.log(this.firstArg);
    console.log(this.secondArg);
  }
}

Clipanion 🌟

Clipanion vs Oclif

  • Remember: it's always about trade-offs
  • Clipanion has less features (no builtin prompt)
  • It however supports everything about CLI parsing
  • Leverages class properties for tooling integration
  • Single package with no runtime dependencies
  • Doesn't require a generator to get started

Time to get started

My Yarn CLI

  • Let's build a CLI interface for a Yarn-like program
  • We won't actually deal with the business logic 😉
  • Fully typechecked

My Yarn CLI

  • yarn install
  • yarn config get <name>
  • yarn add [... args]
  • yarn run <command> [... args]
  • yarn workspaces foreach run <command> [... args]
  • yarn (without any parameters) 
  • cp <source> <source> <source> ... <dest>

Protips

  • Automated documentation
  • Lazy evaluation
  • Command context
  • Command inheritance
  • Hidden commands

Bonus

  • Did you know you can lie to the compiler?
  • Not just with any, but with anything
  • Clipanion leverages it to implement decorators
  • Has significant drawbacks: not composable!
  • Use it with caution

I hope you

enjoyed the talk!

References

https://github.com/arcanis/clipanion

https://mael.dev/clipanion

@arcanis


See also: https://oclif.io

Modern CLIs

By arcanis

Modern CLIs

  • 485