Skip to content

Commit c1514f9

Browse files
boneskullclaude
andauthored
feat(nested): add factory pattern for nested commands with typed parent globals (#18)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 24d145a commit c1514f9

File tree

5 files changed

+367
-133
lines changed

5 files changed

+367
-133
lines changed

README.md

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -180,34 +180,36 @@ All tasks
180180

181181
### Nested Commands (Subcommands)
182182

183-
Commands can be nested to arbitrary depth by passing a `CliBuilder` as the second argument to `.command()`:
183+
Commands can be nested to arbitrary depth. Use the **factory pattern** for full type inference of parent globals:
184184

185185
```typescript
186186
import { bargs, opt, pos } from '@boneskull/bargs';
187187

188-
// Define subcommands as a separate builder
189-
const remoteCommands = bargs('remote')
190-
.command(
191-
'add',
192-
pos.positionals(
193-
pos.string({ name: 'name', required: true }),
194-
pos.string({ name: 'url', required: true }),
195-
),
196-
({ positionals, values }) => {
197-
const [name, url] = positionals;
198-
// Parent globals (verbose) are available here!
199-
if (values.verbose) console.log(`Adding ${name}: ${url}`);
200-
},
201-
'Add a remote',
202-
)
203-
.command('remove' /* ... */)
204-
.defaultCommand('add');
205-
206-
// Nest under parent CLI
207188
await bargs('git')
208189
.globals(opt.options({ verbose: opt.boolean({ aliases: ['v'] }) }))
209-
.command('remote', remoteCommands, 'Manage remotes') // ← CliBuilder
210-
.command('commit', commitParser, commitHandler) // ← Regular command
190+
// Factory pattern: receives a builder with parent globals already typed
191+
.command(
192+
'remote',
193+
(remote) =>
194+
remote
195+
.command(
196+
'add',
197+
pos.positionals(
198+
pos.string({ name: 'name', required: true }),
199+
pos.string({ name: 'url', required: true }),
200+
),
201+
({ positionals, values }) => {
202+
const [name, url] = positionals;
203+
// values.verbose is fully typed! (from parent globals)
204+
if (values.verbose) console.log(`Adding ${name}: ${url}`);
205+
},
206+
'Add a remote',
207+
)
208+
.command('remove' /* ... */)
209+
.defaultCommand('add'),
210+
'Manage remotes',
211+
)
212+
.command('commit', commitParser, commitHandler) // Regular command
211213
.parseAsync();
212214
```
213215

@@ -218,7 +220,9 @@ Adding origin: https://github.com/...
218220
$ git remote remove origin
219221
```
220222

221-
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.
223+
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.
224+
225+
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.
222226

223227
## API
224228

@@ -260,7 +264,7 @@ Register a command. The handler receives merged global + command types.
260264

261265
### .command(name, cliBuilder, description?)
262266

263-
Register a nested command group. The `cliBuilder` is another `CliBuilder` whose commands become subcommands. Parent globals are passed down to nested handlers.
267+
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.
264268

265269
```typescript
266270
const subCommands = bargs('sub').command('foo', ...).command('bar', ...);
@@ -273,6 +277,26 @@ bargs('main')
273277
// $ main nested bar
274278
```
275279

280+
### .command(name, factory, description?)
281+
282+
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.
283+
284+
```typescript
285+
bargs('main')
286+
.globals(opt.options({ verbose: opt.boolean() }))
287+
.command(
288+
'nested',
289+
(nested) =>
290+
nested
291+
.command('foo', fooParser, ({ values }) => {
292+
// values.verbose is typed correctly!
293+
})
294+
.command('bar', barParser, barHandler),
295+
'Nested commands',
296+
)
297+
.parseAsync();
298+
```
299+
276300
### .defaultCommand(name)
277301

278302
> Or `.defaultCommand(parser, handler)`

examples/nested-commands.ts

Lines changed: 120 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* A git-like CLI that demonstrates:
66
*
77
* - Nested command groups (e.g., `git remote add`)
8+
* - Factory pattern for full type inference of parent globals
89
* - Unlimited nesting depth
910
* - Parent globals flowing to nested handlers
1011
* - Default subcommands
@@ -31,124 +32,143 @@ const config: Map<string, string> = new Map([
3132
]);
3233

3334
// ═══════════════════════════════════════════════════════════════════════════════
34-
// NESTED COMMAND GROUPS
35-
// ═══════════════════════════════════════════════════════════════════════════════
36-
37-
// "remote" command group with subcommands: add, remove, list
38-
const remoteCommands = bargs('remote')
39-
.command(
40-
'add',
41-
pos.positionals(
42-
pos.string({ name: 'name', required: true }),
43-
pos.string({ name: 'url', required: true }),
44-
),
45-
({ positionals, values }) => {
46-
const [name, url] = positionals;
47-
if (remotes.has(name)) {
48-
console.error(`Remote '${name}' already exists`);
49-
process.exit(1);
50-
}
51-
remotes.set(name, url);
52-
// We can access parent globals (verbose) in nested handlers!
53-
if (values.verbose) {
54-
console.log(`Added remote '${name}' with URL: ${url}`);
55-
} else {
56-
console.log(`Added remote '${name}'`);
57-
}
58-
},
59-
'Add a remote',
60-
)
61-
.command(
62-
'remove',
63-
pos.positionals(pos.string({ name: 'name', required: true })),
64-
({ positionals, values }) => {
65-
const [name] = positionals;
66-
if (!remotes.has(name)) {
67-
console.error(`Remote '${name}' not found`);
68-
process.exit(1);
69-
}
70-
remotes.delete(name);
71-
if (values.verbose) {
72-
console.log(`Removed remote '${name}'`);
73-
}
74-
},
75-
'Remove a remote',
76-
)
77-
.command(
78-
'list',
79-
opt.options({}),
80-
({ values }) => {
81-
if (remotes.size === 0) {
82-
console.log('No remotes configured');
83-
return;
84-
}
85-
for (const [name, url] of remotes) {
86-
if (values.verbose) {
87-
console.log(`${name}\t${url}`);
88-
} else {
89-
console.log(name);
90-
}
91-
}
92-
},
93-
'List remotes',
94-
)
95-
.defaultCommand('list');
96-
97-
// "config" command group with subcommands: get, set
98-
const configCommands = bargs('config')
99-
.command(
100-
'get',
101-
pos.positionals(pos.string({ name: 'key', required: true })),
102-
({ positionals }) => {
103-
const [key] = positionals;
104-
const value = config.get(key);
105-
if (value === undefined) {
106-
console.error(`Config key '${key}' not found`);
107-
process.exit(1);
108-
}
109-
console.log(value);
110-
},
111-
'Get a config value',
112-
)
113-
.command(
114-
'set',
115-
pos.positionals(
116-
pos.string({ name: 'key', required: true }),
117-
pos.string({ name: 'value', required: true }),
118-
),
119-
({ positionals, values }) => {
120-
const [key, value] = positionals;
121-
config.set(key, value);
122-
if (values.verbose) {
123-
console.log(`Set ${key} = ${value}`);
124-
}
125-
},
126-
'Set a config value',
127-
);
128-
129-
// ═══════════════════════════════════════════════════════════════════════════════
130-
// MAIN CLI
35+
// GLOBAL OPTIONS
13136
// ═══════════════════════════════════════════════════════════════════════════════
13237

13338
// Global options that flow down to ALL nested commands
13439
const globals = opt.options({
13540
verbose: opt.boolean({ aliases: ['v'], default: false }),
13641
});
13742

43+
// ═══════════════════════════════════════════════════════════════════════════════
44+
// MAIN CLI
45+
// ═══════════════════════════════════════════════════════════════════════════════
46+
13847
await bargs('git-like', {
13948
description: 'A git-like CLI demonstrating nested commands',
14049
version: '1.0.0',
14150
})
14251
.globals(globals)
143-
// Register nested command groups
144-
.command('remote', remoteCommands, 'Manage remotes')
145-
.command('config', configCommands, 'Manage configuration')
52+
53+
// ─────────────────────────────────────────────────────────────────────────────
54+
// FACTORY PATTERN: Full type inference for parent globals!
55+
// The factory receives a builder that already has parent globals typed.
56+
// ─────────────────────────────────────────────────────────────────────────────
57+
.command(
58+
'remote',
59+
(remote) =>
60+
remote
61+
.command(
62+
'add',
63+
pos.positionals(
64+
pos.string({ name: 'name', required: true }),
65+
pos.string({ name: 'url', required: true }),
66+
),
67+
({ positionals, values }) => {
68+
const [name, url] = positionals;
69+
if (remotes.has(name)) {
70+
console.error(`Remote '${name}' already exists`);
71+
process.exit(1);
72+
}
73+
remotes.set(name, url);
74+
// values.verbose is fully typed! (from parent globals)
75+
if (values.verbose) {
76+
console.log(`Added remote '${name}' with URL: ${url}`);
77+
} else {
78+
console.log(`Added remote '${name}'`);
79+
}
80+
},
81+
'Add a remote',
82+
)
83+
.command(
84+
'remove',
85+
pos.positionals(pos.string({ name: 'name', required: true })),
86+
({ positionals, values }) => {
87+
const [name] = positionals;
88+
if (!remotes.has(name)) {
89+
console.error(`Remote '${name}' not found`);
90+
process.exit(1);
91+
}
92+
remotes.delete(name);
93+
// values.verbose is typed!
94+
if (values.verbose) {
95+
console.log(`Removed remote '${name}'`);
96+
}
97+
},
98+
'Remove a remote',
99+
)
100+
.command(
101+
'list',
102+
opt.options({}),
103+
({ values }) => {
104+
if (remotes.size === 0) {
105+
console.log('No remotes configured');
106+
return;
107+
}
108+
for (const [name, url] of remotes) {
109+
// values.verbose is typed!
110+
if (values.verbose) {
111+
console.log(`${name}\t${url}`);
112+
} else {
113+
console.log(name);
114+
}
115+
}
116+
},
117+
'List remotes',
118+
)
119+
.defaultCommand('list'),
120+
'Manage remotes',
121+
)
122+
123+
// ─────────────────────────────────────────────────────────────────────────────
124+
// Another nested command group using the factory pattern
125+
// ─────────────────────────────────────────────────────────────────────────────
126+
.command(
127+
'config',
128+
(cfg) =>
129+
cfg
130+
.command(
131+
'get',
132+
pos.positionals(pos.string({ name: 'key', required: true })),
133+
({ positionals }) => {
134+
const [key] = positionals;
135+
const value = config.get(key);
136+
if (value === undefined) {
137+
console.error(`Config key '${key}' not found`);
138+
process.exit(1);
139+
}
140+
console.log(value);
141+
},
142+
'Get a config value',
143+
)
144+
.command(
145+
'set',
146+
pos.positionals(
147+
pos.string({ name: 'key', required: true }),
148+
pos.string({ name: 'value', required: true }),
149+
),
150+
({ positionals, values }) => {
151+
const [key, value] = positionals;
152+
config.set(key, value);
153+
// values.verbose is typed!
154+
if (values.verbose) {
155+
console.log(`Set ${key} = ${value}`);
156+
}
157+
},
158+
'Set a config value',
159+
),
160+
'Manage configuration',
161+
)
162+
163+
// ─────────────────────────────────────────────────────────────────────────────
146164
// Regular leaf commands work alongside nested ones
165+
// ─────────────────────────────────────────────────────────────────────────────
147166
.command(
148167
'status',
149168
opt.options({}),
150169
({ values }) => {
151170
console.log('On branch main');
171+
// values.verbose is typed for leaf commands too!
152172
if (values.verbose) {
153173
console.log(`Remotes: ${remotes.size}`);
154174
console.log(`Config entries: ${config.size}`);

0 commit comments

Comments
 (0)