Framework Comparison

The same CLI implemented in multiple frameworks. A greet command with two subcommands (hello and goodbye), string options, and boolean flags — showing how each library approaches the same problem.

12 FILEScomparisonTry in Playground

Each file below implements the same CLI: a greet command with hello and goodbye subcommands, string options, and boolean flags. Compare how each framework handles subcommand registration, option definition, and handler execution.

CLI Forge

Uses a fluent builder with full TypeScript inference — each .option() call expands the handler's type.

1import { cli } from 'cli-forge';
2
3cli('greet')
4  .command('hello', {
5    description: 'Say hello to someone',
6    builder: (args) =>
7      args
8        .option('name', {
9          type: 'string',
10          description: 'Name to greet',
11          default: 'World',
12        })
13        .option('uppercase', {
14          type: 'boolean',
15          description: 'Print greeting in uppercase',
16          default: false,
17        }),
18    handler: (args) => {
19      const msg = `Hello, ${args.name}!`;
20      console.log(args.uppercase ? msg.toUpperCase() : msg);
21    },
22  })
23  .command('goodbye', {
24    description: 'Say goodbye to someone',
25    builder: (args) =>
26      args
27        .option('name', {
28          type: 'string',
29          description: 'Name to bid farewell',
30          default: 'World',
31        })
32        .option('formal', {
33          type: 'boolean',
34          description: 'Use formal farewell',
35          default: false,
36        }),
37    handler: (args) => {
38      console.log(
39        args.formal ? `Farewell, ${args.name}.` : `Bye, ${args.name}!`
40      );
41    },
42  })
43  .forge();

yargs

Similar fluent builder API. Type inference relies on @types/yargs; complex CLIs often need manual interfaces.

1import yargs from 'yargs';
2import { hideBin } from 'yargs/helpers';
3
4yargs(hideBin(process.argv))
5  .command(
6    'hello',
7    'Say hello to someone',
8    (yargs) =>
9      yargs
10        .option('name', {
11          type: 'string',
12          description: 'Name to greet',
13          default: 'World',
14        })
15        .option('uppercase', {
16          type: 'boolean',
17          description: 'Print greeting in uppercase',
18          default: false,
19        }),
20    (args) => {
21      const msg = `Hello, ${args.name}!`;
22      console.log(args.uppercase ? msg.toUpperCase() : msg);
23    }
24  )
25  .command(
26    'goodbye',
27    'Say goodbye to someone',
28    (yargs) =>
29      yargs
30        .option('name', {
31          type: 'string',
32          description: 'Name to bid farewell',
33          default: 'World',
34        })
35        .option('formal', {
36          type: 'boolean',
37          description: 'Use formal farewell',
38          default: false,
39        }),
40    (args) => {
41      console.log(
42        args.formal ? `Farewell, ${args.name}.` : `Bye, ${args.name}!`
43      );
44    }
45  )
46  .demandCommand(1)
47  .help()
48  .parse();

commander

String-based option definitions ('--name <string>'). Type-safe options require the separate @commander-js/extra-typings package.

1import { Command } from 'commander';
2
3const program = new Command('greet');
4
5program
6  .command('hello')
7  .description('Say hello to someone')
8  .option('--name <string>', 'Name to greet', 'World')
9  .option('--uppercase', 'Print greeting in uppercase', false)
10  .action((opts) => {
11    const msg = `Hello, ${opts.name}!`;
12    console.log(opts.uppercase ? msg.toUpperCase() : msg);
13  });
14
15program
16  .command('goodbye')
17  .description('Say goodbye to someone')
18  .option('--name <string>', 'Name to bid farewell', 'World')
19  .option('--formal', 'Use formal farewell', false)
20  .action((opts) => {
21    console.log(
22      opts.formal ? `Farewell, ${opts.name}.` : `Bye, ${opts.name}!`
23    );
24  });
25
26program.parse();

oclif

Class-based commands with static flag declarations. In a real project, each command lives in its own file and is auto-discovered from the directory structure.

1import { Command, Flags } from '@oclif/core';
2
3// oclif uses class-based commands, typically in separate files.
4// A real oclif project would use `oclif generate` scaffolding
5// with filesystem-based command discovery. This single-file
6// example adds a manual dispatcher for demonstration purposes.
7
8class Hello extends Command {
9  static override description = 'Say hello to someone';
10
11  static override flags = {
12    name: Flags.string({
13      description: 'Name to greet',
14      default: 'World',
15    }),
16    uppercase: Flags.boolean({
17      description: 'Print greeting in uppercase',
18      default: false,
19    }),
20  };
21
22  async run() {
23    const { flags } = await this.parse(Hello);
24    const msg = `Hello, ${flags.name}!`;
25    this.log(flags.uppercase ? msg.toUpperCase() : msg);
26  }
27}
28
29class Goodbye extends Command {
30  static override description = 'Say goodbye to someone';
31
32  static override flags = {
33    name: Flags.string({
34      description: 'Name to bid farewell',
35      default: 'World',
36    }),
37    formal: Flags.boolean({
38      description: 'Use formal farewell',
39      default: false,
40    }),
41  };
42
43  async run() {
44    const { flags } = await this.parse(Goodbye);
45    this.log(
46      flags.formal ? `Farewell, ${flags.name}.` : `Bye, ${flags.name}!`
47    );
48  }
49}
50
51// Manual dispatcher — in a real oclif project, the framework
52// handles routing via the command manifest.
53(async () => {
54  const [cmd, ...args] = process.argv.slice(2);
55  if (cmd === 'hello') {
56    await Hello.run(args);
57  } else if (cmd === 'goodbye') {
58    await Goodbye.run(args);
59  } else {
60    console.log('Usage: greet <hello|goodbye> [options]');
61    console.log('Commands:');
62    console.log('  hello    Say hello to someone');
63    console.log('  goodbye  Say goodbye to someone');
64  }
65})();

clipanion

Class-based commands compiled into a state machine. Powered by decorators and the typanion validation library.

1import { Builtins, Cli, Command, Option } from 'clipanion';
2
3class HelloCommand extends Command {
4  static override paths = [['hello']];
5
6  static override usage = Command.Usage({
7    description: 'Say hello to someone',
8  });
9
10  name = Option.String('--name', 'World', {
11    description: 'Name to greet',
12  });
13
14  uppercase = Option.Boolean('--uppercase', false, {
15    description: 'Print greeting in uppercase',
16  });
17
18  async execute() {
19    const msg = `Hello, ${this.name}!`;
20    this.context.stdout.write(
21      (this.uppercase ? msg.toUpperCase() : msg) + '\n'
22    );
23  }
24}
25
26class GoodbyeCommand extends Command {
27  static override paths = [['goodbye']];
28
29  static override usage = Command.Usage({
30    description: 'Say goodbye to someone',
31  });
32
33  name = Option.String('--name', 'World', {
34    description: 'Name to bid farewell',
35  });
36
37  formal = Option.Boolean('--formal', false, {
38    description: 'Use formal farewell',
39  });
40
41  async execute() {
42    const msg = this.formal
43      ? `Farewell, ${this.name}.`
44      : `Bye, ${this.name}!`;
45    this.context.stdout.write(msg + '\n');
46  }
47}
48
49const cli = new Cli({ binaryName: 'greet' });
50cli.register(HelloCommand);
51cli.register(GoodbyeCommand);
52cli.register(Builtins.HelpCommand);
53cli.runExit(process.argv.slice(2));

cac

Lightweight fluent builder. Options are parsed at runtime with no compile-time type inference.

1import cac from 'cac';
2
3const cli = cac('greet');
4
5cli
6  .command('hello', 'Say hello to someone')
7  .option('--name <name>', 'Name to greet', { default: 'World' })
8  .option('--uppercase', 'Print greeting in uppercase', { default: false })
9  .action((opts) => {
10    const msg = `Hello, ${opts.name}!`;
11    console.log(opts.uppercase ? msg.toUpperCase() : msg);
12  });
13
14cli
15  .command('goodbye', 'Say goodbye to someone')
16  .option('--name <name>', 'Name to bid farewell', { default: 'World' })
17  .option('--formal', 'Use formal farewell', { default: false })
18  .action((opts) => {
19    console.log(
20      opts.formal ? `Farewell, ${opts.name}.` : `Bye, ${opts.name}!`
21    );
22  });
23
24cli.help();
25cli.parse();

meow

Minimal single-function parser. Subcommands are positional arguments handled manually with if/else.

1import meow from 'meow';
2import { pathToFileURL } from 'node:url';
3
4// meow is a minimal parser — subcommands are handled manually
5// by reading positional arguments from cli.input.
6
7const cli = meow(
8  `
9  Usage
10    $ greet <command>
11
12  Commands
13    hello    Say hello to someone
14    goodbye  Say goodbye to someone
15
16  Options
17    --name        Name to greet (default: World)
18    --uppercase   Print greeting in uppercase
19    --formal      Use formal farewell
20`,
21  {
22    // meow v14 requires import.meta for ESM package resolution.
23    // Construct a compatible object from __filename for CJS contexts.
24    importMeta: { url: pathToFileURL(__filename).href } as ImportMeta,
25    flags: {
26      name: { type: 'string', default: 'World' },
27      uppercase: { type: 'boolean', default: false },
28      formal: { type: 'boolean', default: false },
29    },
30  }
31);
32
33const [command] = cli.input;
34
35if (command === 'hello') {
36  const msg = `Hello, ${cli.flags.name}!`;
37  console.log(cli.flags.uppercase ? msg.toUpperCase() : msg);
38} else if (command === 'goodbye') {
39  console.log(
40    cli.flags.formal
41      ? `Farewell, ${cli.flags.name}.`
42      : `Bye, ${cli.flags.name}!`
43  );
44} else {
45  cli.showHelp();
46}

citty

Declarative command definitions from the UnJS ecosystem, built on Node.js util.parseArgs.

1import { defineCommand, runMain } from 'citty';
2
3const hello = defineCommand({
4  meta: { name: 'hello', description: 'Say hello to someone' },
5  args: {
6    name: {
7      type: 'string',
8      description: 'Name to greet',
9      default: 'World',
10    },
11    uppercase: {
12      type: 'boolean',
13      description: 'Print greeting in uppercase',
14      default: false,
15    },
16  },
17  run({ args }) {
18    const msg = `Hello, ${args.name}!`;
19    console.log(args.uppercase ? msg.toUpperCase() : msg);
20  },
21});
22
23const goodbye = defineCommand({
24  meta: { name: 'goodbye', description: 'Say goodbye to someone' },
25  args: {
26    name: {
27      type: 'string',
28      description: 'Name to bid farewell',
29      default: 'World',
30    },
31    formal: {
32      type: 'boolean',
33      description: 'Use formal farewell',
34      default: false,
35    },
36  },
37  run({ args }) {
38    console.log(
39      args.formal ? `Farewell, ${args.name}.` : `Bye, ${args.name}!`
40    );
41  },
42});
43
44const main = defineCommand({
45  meta: { name: 'greet', description: 'A greeting CLI' },
46  subCommands: { hello, goodbye },
47});
48
49runMain(main);

cleye

Declarative CLI builder with TypeScript command narrowing — checking argv.command narrows the available flags.

1import { cli, command } from 'cleye';
2
3// cleye returns a parsed argv object with command narrowing.
4// Subcommands are defined with command() and passed to cli().
5
6const argv = cli({
7  name: 'greet',
8  commands: [
9    command({
10      name: 'hello',
11      flags: {
12        name: {
13          type: String,
14          description: 'Name to greet',
15          default: 'World',
16        },
17        uppercase: {
18          type: Boolean,
19          description: 'Print greeting in uppercase',
20          default: false,
21        },
22      },
23    }),
24    command({
25      name: 'goodbye',
26      flags: {
27        name: {
28          type: String,
29          description: 'Name to bid farewell',
30          default: 'World',
31        },
32        formal: {
33          type: Boolean,
34          description: 'Use formal farewell',
35          default: false,
36        },
37      },
38    }),
39  ],
40});
41
42if (argv.command === 'hello') {
43  const msg = `Hello, ${argv.flags.name}!`;
44  console.log(argv.flags.uppercase ? msg.toUpperCase() : msg);
45} else if (argv.command === 'goodbye') {
46  console.log(
47    argv.flags.formal
48      ? `Farewell, ${argv.flags.name}.`
49      : `Bye, ${argv.flags.name}!`
50  );
51}

@effect/cli

Commands are Effect values with typed errors and dependency injection. Requires the full Effect ecosystem.

1import { Command, Options } from '@effect/cli';
2import { NodeContext, NodeRuntime } from '@effect/platform-node';
3import { Console, Effect } from 'effect';
4
5// @effect/cli models commands as Effect values with typed
6// errors and dependency injection via layers.
7
8const nameOpt = Options.text('name').pipe(
9  Options.withDefault('World')
10);
11
12const hello = Command.make(
13  'hello',
14  { name: nameOpt, uppercase: Options.boolean('uppercase') },
15  ({ name, uppercase }) => {
16    const msg = `Hello, ${name}!`;
17    return Console.log(uppercase ? msg.toUpperCase() : msg);
18  }
19);
20
21const goodbye = Command.make(
22  'goodbye',
23  { name: nameOpt, formal: Options.boolean('formal') },
24  ({ name, formal }) =>
25    Console.log(formal ? `Farewell, ${name}.` : `Bye, ${name}!`)
26);
27
28const greet = Command.make('greet').pipe(
29  Command.withSubcommands([hello, goodbye])
30);
31
32const app = Command.run(greet, {
33  name: 'greet',
34  version: '1.0.0',
35});
36
37app(process.argv).pipe(Effect.provide(NodeContext.layer), NodeRuntime.runMain);

Node.js util.parseArgs

Built-in parser with no subcommand concept. Everything beyond basic string/boolean parsing is manual.

1import { parseArgs } from 'node:util';
2
3// Node.js util.parseArgs is deliberately minimal — it has no
4// concept of subcommands, help generation, or type coercion
5// beyond string/boolean. Everything else is manual.
6
7const { values, positionals } = parseArgs({
8  args: process.argv.slice(2),
9  options: {
10    name: { type: 'string', default: 'World' },
11    uppercase: { type: 'boolean', default: false },
12    formal: { type: 'boolean', default: false },
13  },
14  allowPositionals: true,
15  strict: true,
16});
17
18const [command] = positionals;
19
20if (command === 'hello') {
21  const msg = `Hello, ${values.name}!`;
22  console.log(values.uppercase ? msg.toUpperCase() : msg);
23} else if (command === 'goodbye') {
24  console.log(
25    values.formal
26      ? `Farewell, ${values.name}.`
27      : `Bye, ${values.name}!`
28  );
29} else {
30  console.log('Usage: greet <hello|goodbye> [options]');
31  console.log('  --name <string>   Name to greet (default: World)');
32  console.log('  --uppercase       Print greeting in uppercase');
33  console.log('  --formal          Use formal farewell');
34}

All Example Files

FILE EXPLORER
cac.ts
1import cac from 'cac';
2
3const cli = cac('greet');
4
5cli
6  .command('hello', 'Say hello to someone')
7  .option('--name <name>', 'Name to greet', { default: 'World' })
8  .option('--uppercase', 'Print greeting in uppercase', { default: false })
9  .action((opts) => {
10    const msg = `Hello, ${opts.name}!`;
11    console.log(opts.uppercase ? msg.toUpperCase() : msg);
12  });
13
14cli
15  .command('goodbye', 'Say goodbye to someone')
16  .option('--name <name>', 'Name to bid farewell', { default: 'World' })
17  .option('--formal', 'Use formal farewell', { default: false })
18  .action((opts) => {
19    console.log(
20      opts.formal ? `Farewell, ${opts.name}.` : `Bye, ${opts.name}!`
21    );
22  });
23
24cli.help();
25cli.parse();
26