diff --git a/README.md b/README.md index 6fb46c8..ea0d6c1 100644 --- a/README.md +++ b/README.md @@ -180,34 +180,36 @@ All tasks ### Nested Commands (Subcommands) -Commands can be nested to arbitrary depth by passing a `CliBuilder` as the second argument to `.command()`: +Commands can be nested to arbitrary depth. Use the **factory pattern** for full type inference of parent globals: ```typescript import { bargs, opt, pos } from '@boneskull/bargs'; -// Define subcommands as a separate builder -const remoteCommands = bargs('remote') - .command( - 'add', - pos.positionals( - pos.string({ name: 'name', required: true }), - pos.string({ name: 'url', required: true }), - ), - ({ positionals, values }) => { - const [name, url] = positionals; - // Parent globals (verbose) are available here! - if (values.verbose) console.log(`Adding ${name}: ${url}`); - }, - 'Add a remote', - ) - .command('remove' /* ... */) - .defaultCommand('add'); - -// Nest under parent CLI await bargs('git') .globals(opt.options({ verbose: opt.boolean({ aliases: ['v'] }) })) - .command('remote', remoteCommands, 'Manage remotes') // ← CliBuilder - .command('commit', commitParser, commitHandler) // ← Regular command + // Factory pattern: receives a builder with parent globals already typed + .command( + 'remote', + (remote) => + remote + .command( + 'add', + pos.positionals( + pos.string({ name: 'name', required: true }), + pos.string({ name: 'url', required: true }), + ), + ({ positionals, values }) => { + const [name, url] = positionals; + // values.verbose is fully typed! (from parent globals) + if (values.verbose) console.log(`Adding ${name}: ${url}`); + }, + 'Add a remote', + ) + .command('remove' /* ... */) + .defaultCommand('add'), + 'Manage remotes', + ) + .command('commit', commitParser, commitHandler) // Regular command .parseAsync(); ``` @@ -218,7 +220,9 @@ Adding origin: https://github.com/... $ git remote remove origin ``` -Parent globals automatically flow to nested command handlers. You can nest as deep as you like—just nest `CliBuilder`s inside `CliBuilder`s. See `examples/nested-commands.ts` for a full example. +The factory function receives a `CliBuilder` that already has parent globals typed, so all nested command handlers get full type inference for merged `global + command` options. + +You can also pass a pre-built `CliBuilder` directly (see [.command(name, cliBuilder)](#commandname-clibuilder-description)), but handlers won't have parent globals typed at compile time. See `examples/nested-commands.ts` for a full example. ## API @@ -260,7 +264,7 @@ Register a command. The handler receives merged global + command types. ### .command(name, cliBuilder, description?) -Register a nested command group. The `cliBuilder` is another `CliBuilder` whose commands become subcommands. Parent globals are passed down to nested handlers. +Register a nested command group. The `cliBuilder` is another `CliBuilder` whose commands become subcommands. Parent globals are passed down to nested handlers at runtime, but **handlers won't have parent globals typed** at compile time. ```typescript const subCommands = bargs('sub').command('foo', ...).command('bar', ...); @@ -273,6 +277,26 @@ bargs('main') // $ main nested bar ``` +### .command(name, factory, description?) + +Register a nested command group using a factory function. **This is the recommended form** because the factory receives a builder that already has parent globals typed, giving full type inference in nested handlers. + +```typescript +bargs('main') + .globals(opt.options({ verbose: opt.boolean() })) + .command( + 'nested', + (nested) => + nested + .command('foo', fooParser, ({ values }) => { + // values.verbose is typed correctly! + }) + .command('bar', barParser, barHandler), + 'Nested commands', + ) + .parseAsync(); +``` + ### .defaultCommand(name) > Or `.defaultCommand(parser, handler)` diff --git a/examples/nested-commands.ts b/examples/nested-commands.ts index 4286ac0..77b93bb 100644 --- a/examples/nested-commands.ts +++ b/examples/nested-commands.ts @@ -5,6 +5,7 @@ * A git-like CLI that demonstrates: * * - Nested command groups (e.g., `git remote add`) + * - Factory pattern for full type inference of parent globals * - Unlimited nesting depth * - Parent globals flowing to nested handlers * - Default subcommands @@ -31,103 +32,7 @@ const config: Map = new Map([ ]); // ═══════════════════════════════════════════════════════════════════════════════ -// NESTED COMMAND GROUPS -// ═══════════════════════════════════════════════════════════════════════════════ - -// "remote" command group with subcommands: add, remove, list -const remoteCommands = bargs('remote') - .command( - 'add', - pos.positionals( - pos.string({ name: 'name', required: true }), - pos.string({ name: 'url', required: true }), - ), - ({ positionals, values }) => { - const [name, url] = positionals; - if (remotes.has(name)) { - console.error(`Remote '${name}' already exists`); - process.exit(1); - } - remotes.set(name, url); - // We can access parent globals (verbose) in nested handlers! - if (values.verbose) { - console.log(`Added remote '${name}' with URL: ${url}`); - } else { - console.log(`Added remote '${name}'`); - } - }, - 'Add a remote', - ) - .command( - 'remove', - pos.positionals(pos.string({ name: 'name', required: true })), - ({ positionals, values }) => { - const [name] = positionals; - if (!remotes.has(name)) { - console.error(`Remote '${name}' not found`); - process.exit(1); - } - remotes.delete(name); - if (values.verbose) { - console.log(`Removed remote '${name}'`); - } - }, - 'Remove a remote', - ) - .command( - 'list', - opt.options({}), - ({ values }) => { - if (remotes.size === 0) { - console.log('No remotes configured'); - return; - } - for (const [name, url] of remotes) { - if (values.verbose) { - console.log(`${name}\t${url}`); - } else { - console.log(name); - } - } - }, - 'List remotes', - ) - .defaultCommand('list'); - -// "config" command group with subcommands: get, set -const configCommands = bargs('config') - .command( - 'get', - pos.positionals(pos.string({ name: 'key', required: true })), - ({ positionals }) => { - const [key] = positionals; - const value = config.get(key); - if (value === undefined) { - console.error(`Config key '${key}' not found`); - process.exit(1); - } - console.log(value); - }, - 'Get a config value', - ) - .command( - 'set', - pos.positionals( - pos.string({ name: 'key', required: true }), - pos.string({ name: 'value', required: true }), - ), - ({ positionals, values }) => { - const [key, value] = positionals; - config.set(key, value); - if (values.verbose) { - console.log(`Set ${key} = ${value}`); - } - }, - 'Set a config value', - ); - -// ═══════════════════════════════════════════════════════════════════════════════ -// MAIN CLI +// GLOBAL OPTIONS // ═══════════════════════════════════════════════════════════════════════════════ // Global options that flow down to ALL nested commands @@ -135,20 +40,135 @@ const globals = opt.options({ verbose: opt.boolean({ aliases: ['v'], default: false }), }); +// ═══════════════════════════════════════════════════════════════════════════════ +// MAIN CLI +// ═══════════════════════════════════════════════════════════════════════════════ + await bargs('git-like', { description: 'A git-like CLI demonstrating nested commands', version: '1.0.0', }) .globals(globals) - // Register nested command groups - .command('remote', remoteCommands, 'Manage remotes') - .command('config', configCommands, 'Manage configuration') + + // ───────────────────────────────────────────────────────────────────────────── + // FACTORY PATTERN: Full type inference for parent globals! + // The factory receives a builder that already has parent globals typed. + // ───────────────────────────────────────────────────────────────────────────── + .command( + 'remote', + (remote) => + remote + .command( + 'add', + pos.positionals( + pos.string({ name: 'name', required: true }), + pos.string({ name: 'url', required: true }), + ), + ({ positionals, values }) => { + const [name, url] = positionals; + if (remotes.has(name)) { + console.error(`Remote '${name}' already exists`); + process.exit(1); + } + remotes.set(name, url); + // values.verbose is fully typed! (from parent globals) + if (values.verbose) { + console.log(`Added remote '${name}' with URL: ${url}`); + } else { + console.log(`Added remote '${name}'`); + } + }, + 'Add a remote', + ) + .command( + 'remove', + pos.positionals(pos.string({ name: 'name', required: true })), + ({ positionals, values }) => { + const [name] = positionals; + if (!remotes.has(name)) { + console.error(`Remote '${name}' not found`); + process.exit(1); + } + remotes.delete(name); + // values.verbose is typed! + if (values.verbose) { + console.log(`Removed remote '${name}'`); + } + }, + 'Remove a remote', + ) + .command( + 'list', + opt.options({}), + ({ values }) => { + if (remotes.size === 0) { + console.log('No remotes configured'); + return; + } + for (const [name, url] of remotes) { + // values.verbose is typed! + if (values.verbose) { + console.log(`${name}\t${url}`); + } else { + console.log(name); + } + } + }, + 'List remotes', + ) + .defaultCommand('list'), + 'Manage remotes', + ) + + // ───────────────────────────────────────────────────────────────────────────── + // Another nested command group using the factory pattern + // ───────────────────────────────────────────────────────────────────────────── + .command( + 'config', + (cfg) => + cfg + .command( + 'get', + pos.positionals(pos.string({ name: 'key', required: true })), + ({ positionals }) => { + const [key] = positionals; + const value = config.get(key); + if (value === undefined) { + console.error(`Config key '${key}' not found`); + process.exit(1); + } + console.log(value); + }, + 'Get a config value', + ) + .command( + 'set', + pos.positionals( + pos.string({ name: 'key', required: true }), + pos.string({ name: 'value', required: true }), + ), + ({ positionals, values }) => { + const [key, value] = positionals; + config.set(key, value); + // values.verbose is typed! + if (values.verbose) { + console.log(`Set ${key} = ${value}`); + } + }, + 'Set a config value', + ), + 'Manage configuration', + ) + + // ───────────────────────────────────────────────────────────────────────────── // Regular leaf commands work alongside nested ones + // ───────────────────────────────────────────────────────────────────────────── .command( 'status', opt.options({}), ({ values }) => { console.log('On branch main'); + // values.verbose is typed for leaf commands too! if (values.verbose) { console.log(`Remotes: ${remotes.size}`); console.log(`Config entries: ${config.size}`); diff --git a/src/bargs.ts b/src/bargs.ts index 60860ed..d676864 100644 --- a/src/bargs.ts +++ b/src/bargs.ts @@ -473,19 +473,56 @@ const createCliBuilder = ( | Promise & { command?: string }>; }, - // Overloaded command(): accepts (name, Command, desc?), (name, Parser, handler, desc?), or (name, CliBuilder, desc?) + // Overloaded command(): accepts (name, factory, desc?), (name, CliBuilder, desc?), + // (name, Command, desc?), or (name, Parser, handler, desc?) command( name: string, - cmdOrParserOrBuilder: + cmdOrParserOrBuilderOrFactory: + | ((builder: CliBuilder) => CliBuilder) | CliBuilder | Command | Parser, handlerOrDesc?: HandlerFn | string, maybeDesc?: string, ): CliBuilder { + // Form 4: command(name, factory, description?) - factory for nested commands with parent globals + // Check this FIRST before isCliBuilder/isParser since those check for __brand which a plain function won't have + if ( + typeof cmdOrParserOrBuilderOrFactory === 'function' && + !isParser(cmdOrParserOrBuilderOrFactory) && + !isCommand(cmdOrParserOrBuilderOrFactory) && + !isCliBuilder(cmdOrParserOrBuilderOrFactory) + ) { + const factory = cmdOrParserOrBuilderOrFactory as ( + b: CliBuilder, + ) => CliBuilder; + const description = handlerOrDesc as string | undefined; + + // Create a child builder with parent global TYPES (for type inference) + // but NOT the globalParser (parent globals are passed via parentGlobals at runtime, + // not re-parsed from args) + const childBuilder = createCliBuilder({ + commands: new Map(), + globalParser: undefined, // Parent globals come via parentGlobals, not re-parsing + name, + options: state.options, + theme: state.theme, + }); + + // Call factory to let user add commands + const nestedBuilder = factory(childBuilder); + + state.commands.set(name, { + builder: nestedBuilder as CliBuilder, + description, + type: 'nested', + }); + return this; + } + // Form 3: command(name, CliBuilder, description?) - nested commands - if (isCliBuilder(cmdOrParserOrBuilder)) { - const builder = cmdOrParserOrBuilder; + if (isCliBuilder(cmdOrParserOrBuilderOrFactory)) { + const builder = cmdOrParserOrBuilderOrFactory; const description = handlerOrDesc as string | undefined; state.commands.set(name, { builder: builder as CliBuilder, @@ -498,13 +535,13 @@ const createCliBuilder = ( let cmd: Command; let description: string | undefined; - if (isCommand(cmdOrParserOrBuilder)) { + if (isCommand(cmdOrParserOrBuilderOrFactory)) { // Form 1: command(name, Command, description?) - cmd = cmdOrParserOrBuilder; + cmd = cmdOrParserOrBuilderOrFactory; description = handlerOrDesc as string | undefined; - } else if (isParser(cmdOrParserOrBuilder)) { + } else if (isParser(cmdOrParserOrBuilderOrFactory)) { // Form 2: command(name, Parser, handler, description?) - const parser = cmdOrParserOrBuilder; + const parser = cmdOrParserOrBuilderOrFactory; const handler = handlerOrDesc as HandlerFn; description = maybeDesc; @@ -525,7 +562,7 @@ const createCliBuilder = ( cmd = newCmd as Command; } else { throw new Error( - 'command() requires a Command, Parser, or CliBuilder as second argument', + 'command() requires a Command, Parser, CliBuilder, or factory function as second argument', ); } diff --git a/src/types.ts b/src/types.ts index a15488d..24a9b84 100644 --- a/src/types.ts +++ b/src/types.ts @@ -96,6 +96,36 @@ export interface CliBuilder< description?: string, ): CliBuilder; + /** + * Register a nested command group using a factory function. + * + * This form provides full type inference - the factory receives a builder + * that already has parent globals typed, so all nested command handlers see + * the merged types. + * + * @example + * + * ```typescript + * bargs('main') + * .globals(opt.options({ verbose: opt.boolean() })) + * .command( + * 'remote', + * (remote) => + * remote.command('add', addParser, ({ values }) => { + * // values.verbose is typed correctly! + * }), + * 'Manage remotes', + * ); + * ``` + */ + command( + name: string, + factory: ( + builder: CliBuilder, + ) => CliBuilder, + description?: string, + ): CliBuilder; + /** * Set the default command by name (must be registered first). */ diff --git a/test/bargs.test.ts b/test/bargs.test.ts index 82502ad..f9354a1 100644 --- a/test/bargs.test.ts +++ b/test/bargs.test.ts @@ -514,3 +514,126 @@ describe('nested commands (subcommands)', () => { expect(cli, 'to be defined'); }); }); + +describe('nested commands via factory pattern', () => { + it('supports nested commands via factory function', async () => { + let result: unknown; + + const cli = bargs('git').command( + 'remote', + (remote) => + remote.command( + 'add', + pos.positionals( + pos.string({ name: 'name', required: true }), + pos.string({ name: 'url', required: true }), + ), + ({ positionals }) => { + result = { command: 'remote add', positionals }; + }, + 'Add a remote', + ), + 'Manage remotes', + ); + + await cli.parseAsync(['remote', 'add', 'origin', 'https://github.com/...']); + + expect(result, 'to satisfy', { + command: 'remote add', + positionals: ['origin', 'https://github.com/...'], + }); + }); + + it('passes parent globals to factory-created nested command handlers', async () => { + let result: unknown; + + const cli = bargs('git') + .globals(opt.options({ verbose: opt.boolean({ aliases: ['v'] }) })) + .command('remote', (remote) => + remote.command( + 'add', + pos.positionals(pos.string({ name: 'name', required: true })), + ({ positionals, values }) => { + result = { positionals, values }; + }, + ), + ); + + await cli.parseAsync(['--verbose', 'remote', 'add', 'origin']); + + expect(result, 'to satisfy', { + positionals: ['origin'], + values: { verbose: true }, + }); + }); + + it('supports deeply nested commands via factory', async () => { + let result: unknown; + + const cli = bargs('git').command('remote', (remote) => + remote.command('origin', (origin) => + origin.command( + 'set', + pos.positionals(pos.string({ name: 'url', required: true })), + ({ positionals }) => { + result = { command: 'remote origin set', positionals }; + }, + ), + ), + ); + + await cli.parseAsync(['remote', 'origin', 'set', 'https://new.url']); + + expect(result, 'to satisfy', { + command: 'remote origin set', + positionals: ['https://new.url'], + }); + }); + + it('supports default subcommand in factory-created nested commands', async () => { + let result: unknown; + + const cli = bargs('git').command('remote', (remote) => + remote + .command( + 'list', + opt.options({}), + () => { + result = 'list called'; + }, + 'List remotes', + ) + .command('add', opt.options({}), () => { + result = 'add called'; + }) + .defaultCommand('list'), + ); + + await cli.parseAsync(['remote']); + + expect(result, 'to be', 'list called'); + }); + + it('merges command-local options with parent globals in factory', async () => { + let result: unknown; + + const cli = bargs('git') + .globals(opt.options({ verbose: opt.boolean() })) + .command('remote', (remote) => + remote.command( + 'add', + opt.options({ force: opt.boolean() }), + ({ values }) => { + result = values; + }, + ), + ); + + await cli.parseAsync(['--verbose', 'remote', 'add', '--force']); + + expect(result, 'to satisfy', { + force: true, + verbose: true, + }); + }); +});