Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add commands option #205

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
62 changes: 58 additions & 4 deletions index.d.ts
Expand Up @@ -26,6 +26,8 @@ type NumberFlag = Flag<'number', number> | Flag<'number', number[], true>;
type AnyFlag = StringFlag | BooleanFlag | NumberFlag;
type AnyFlags = Record<string, AnyFlag>;

type CommandType<Flags extends AnyFlags> = (options: Options<Flags>) => typeof meow;

export interface Options<Flags extends AnyFlags> {
/**
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.
Expand Down Expand Up @@ -68,6 +70,53 @@ export interface Options<Flags extends AnyFlags> {
*/
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 <input>
`,
flags: {
unicorn: {alias: 'u', isRequired: true},
},
});
};
const cli = meow({
importMeta: import.meta,
description: 'Custom description',
help: `
Usage
foo unicorn <input>
`,
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<string, CommandType<AnyFlags>>;

/**
Description to show above the help text. Default: The package.json `"description"` property.

Expand Down Expand Up @@ -247,6 +296,11 @@ export interface Result<Flags extends AnyFlags> {
*/
flags: TypedFlags<Flags> & Record<string, unknown>;

/**
Parsed subcommands
*/
commands: Record<string, Result<AnyFlags>>;

/**
Flags converted camelCase including aliases.
*/
Expand Down Expand Up @@ -285,14 +339,14 @@ import foo from './index.js';

const cli = meow(`
Usage
$ foo <input>
$ foo <input>

Options
--rainbow, -r Include a rainbow
--rainbow, -r Include a rainbow

Examples
$ foo unicorns --rainbow
🌈 unicorns 🌈
$ foo unicorns --rainbow
🌈 unicorns 🌈
`, {
importMeta: import.meta,
flags: {
Expand Down
11 changes: 11 additions & 0 deletions index.js
Expand Up @@ -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};

Expand All @@ -222,6 +232,7 @@ const meow = (helpText, options = {}) => {

return {
input,
commands,
flags,
unnormalizedFlags,
pkg: package_,
Expand Down
49 changes: 49 additions & 0 deletions readme.md
Expand Up @@ -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`
Expand Down Expand Up @@ -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 <input>
`,
flags: {
unicorn: {alias: 'u', isRequired: true},
},
});
};
const cli = meow({
importMeta: import.meta,
description: 'Custom description',
help: `
Usage
foo unicorn <input>
`,
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`\
Expand Down
37 changes: 37 additions & 0 deletions 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 <input>
`,
flags: {
unicorn: {alias: 'u', isRequired: true},
},
});

const cli = meow({
importMeta: import.meta,
description: 'Custom description',
help: `
Usage
foo unicorn <input>
`,
commands: {
unicorn: subcommand,
},
flags: {
test: {
type: 'number',
alias: 't',
isRequired: () => false,
isMultiple: true,
},
},
});

console.log(JSON.stringify(cli));
42 changes: 42 additions & 0 deletions 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/);
});