diff --git a/index.d.ts b/index.d.ts index 00054f6..8bbeb84 100644 --- a/index.d.ts +++ b/index.d.ts @@ -26,6 +26,8 @@ type NumberFlag = Flag<'number', number> | Flag<'number', number[], true>; type AnyFlag = StringFlag | BooleanFlag | NumberFlag; type AnyFlags = Record; +type CommandType = (options: Options) => typeof meow; + export interface Options { /** Pass in [`import.meta`](https://nodejs.org/dist/latest/docs/api/esm.html#esm_import_meta). This is used to find the correct package.json file. @@ -68,6 +70,53 @@ export interface Options { */ readonly flags?: Flags; + /** + Define subcommands. Subcommands don't actually call any commands for you, it only takes care of parsing + the subcommand flags, inputs, and showing the subcommand helptext. + + The key is the name of the subcommand and the value is a function that returns an instance of `meow`. + + The following values get passed to the subcommand function: + - `options`: The options from the parent `meow` instance. + + @example + ``` + const commands = { + subcommand = (options) => meow({ + ...options, + description: 'Subcommand description', + help: ` + Unicorn command + Usage: + foo unicorn + `, + flags: { + unicorn: {alias: 'u', isRequired: true}, + }, + }); + }; + const cli = meow({ + importMeta: import.meta, + description: 'Custom description', + help: ` + Usage + foo unicorn + `, + commands: { + unicorn: commands.subcommand, + }, + flags: {}, + }); + + // call subcommand + const [command, parsedCli] = Object.entries(cli.commands ?? {})?.[0] ?? []; + // command => "unicorn" + // parsedCli => parsed options of unicorn subcommand + commands[command](parsedCli); + ``` + */ + readonly commands?: Record>; + /** Description to show above the help text. Default: The package.json `"description"` property. @@ -247,6 +296,11 @@ export interface Result { */ flags: TypedFlags & Record; + /** + Parsed subcommands + */ + commands: Record>; + /** Flags converted camelCase including aliases. */ @@ -285,14 +339,14 @@ import foo from './index.js'; const cli = meow(` Usage - $ foo + $ foo Options - --rainbow, -r Include a rainbow + --rainbow, -r Include a rainbow Examples - $ foo unicorns --rainbow - 🌈 unicorns 🌈 + $ foo unicorns --rainbow + 🌈 unicorns 🌈 `, { importMeta: import.meta, flags: { diff --git a/index.js b/index.js index b255b73..3e24544 100644 --- a/index.js +++ b/index.js @@ -205,6 +205,16 @@ const meow = (helpText, options = {}) => { } } + // Subcommands + const commands = {}; + for (const [command, meowInstance] of Object.entries(options.commands ?? {})) { + if (input[0] !== command) { + continue; + } + + commands[command] = meowInstance({...options, argv: process.argv.slice(3), commands: {}, help: helpText}); + } + const flags = camelCaseKeys(argv, {exclude: ['--', /^\w$/]}); const unnormalizedFlags = {...flags}; @@ -222,6 +232,7 @@ const meow = (helpText, options = {}) => { return { input, + commands, flags, unnormalizedFlags, pkg: package_, diff --git a/readme.md b/readme.md index 188d32e..a9f6d19 100644 --- a/readme.md +++ b/readme.md @@ -72,6 +72,7 @@ Returns an `object` with: - `input` *(Array)* - Non-flag arguments - `flags` *(Object)* - Flags converted to camelCase excluding aliases +- `commands` *(Object)* - Subcommands with values parsed with respect to subcommand's meow instance. - `unnormalizedFlags` *(Object)* - Flags converted to camelCase including aliases - `pkg` *(Object)* - The `package.json` object - `help` *(string)* - The help text used with `--help` @@ -135,6 +136,54 @@ flags: { } ``` +##### commands + +Type: `object` + +Define subcommands. Subcommands don't actually call any commands for you, it only takes care of parsing the subcommand flags, inputs, and showing the subcommand helptext. + +The key is the name of the subcommand and the value is a function that returns an instance of `meow`. + +The following values get passed to the subcommand function: + - `options`: The options from the parent `meow` instance. + +Example: + +```js +const commands = { + subcommand = (options) => meow({ + ...options, + description: 'Subcommand description', + help: ` + Unicorn command + Usage: + foo unicorn + `, + flags: { + unicorn: {alias: 'u', isRequired: true}, + }, + }); +}; +const cli = meow({ + importMeta: import.meta, + description: 'Custom description', + help: ` + Usage + foo unicorn + `, + commands: { + unicorn: commands.subcommand, + }, + flags: {}, +}); + +// call subcommand +const [command, parsedCli] = Object.entries(cli.commands ?? {})?.[0] ?? []; +// command => "unicorn" +// parsedCli => parsed meow instance of unicorn subcommand +commands[command](parsedCli); +``` + ##### description Type: `string | boolean`\ diff --git a/test/fixtures/fixture-commands.js b/test/fixtures/fixture-commands.js new file mode 100755 index 0000000..ac40c50 --- /dev/null +++ b/test/fixtures/fixture-commands.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import meow from '../../index.js'; + +const subcommand = options => meow({ + ...options, + description: 'Subcommand description', + help: ` + Unicorn command + Usage: + foo unicorn + `, + flags: { + unicorn: {alias: 'u', isRequired: true}, + }, +}); + +const cli = meow({ + importMeta: import.meta, + description: 'Custom description', + help: ` + Usage + foo unicorn + `, + commands: { + unicorn: subcommand, + }, + flags: { + test: { + type: 'number', + alias: 't', + isRequired: () => false, + isMultiple: true, + }, + }, +}); + +console.log(JSON.stringify(cli)); diff --git a/test/subcommands.js b/test/subcommands.js new file mode 100644 index 0000000..81f31d8 --- /dev/null +++ b/test/subcommands.js @@ -0,0 +1,42 @@ +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import test from 'ava'; +import execa from 'execa'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixtureSubcommands = path.join(__dirname, 'fixtures', 'fixture-commands.js'); + +test('spawn CLI and test subcommands', async t => { + const {stdout} = await execa(fixtureSubcommands, [ + 'unicorn', + '--unicorn', + ]); + const {commands} = JSON.parse(stdout); + t.assert('unicorn' in commands); + t.deepEqual(commands.unicorn.input, []); + t.deepEqual(commands.unicorn.commands, {}); + t.deepEqual(commands.unicorn.flags, {unicorn: true}); +}); + +test('spawn CLI and test subcommand flags', async t => { + const error = await t.throwsAsync(execa(fixtureSubcommands, ['unicorn'])); + const {stderr} = error; + t.regex(stderr, /Missing required flag/); + t.regex(stderr, /--unicorn/); +}); + +test('spawn CLI and test subcommand help text', async t => { + const {stdout} = await execa(fixtureSubcommands, [ + 'unicorn', + '--help', + ]); + t.regex(stdout, /Subcommand description/); + t.regex(stdout, /Unicorn command/); +}); + +test('spawn CLI and test CLI help text', async t => { + const {stdout} = await execa(fixtureSubcommands, [ + '--help', + ]); + t.regex(stdout, /Custom description/); +});