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
- 527