Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 48 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
```

Expand All @@ -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

Expand Down Expand Up @@ -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', ...);
Expand All @@ -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)`
Expand Down
220 changes: 120 additions & 100 deletions examples/nested-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,124 +32,143 @@ const config: Map<string, string> = 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
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}`);
Expand Down
Loading