Skip to content

Commit a3e62bd

Browse files
committed
Fixing nested command options parsing in the CLI gateway.
1 parent 473fb11 commit a3e62bd

3 files changed

Lines changed: 142 additions & 50 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@nexical/cli-core",
3-
"version": "0.1.10",
3+
"version": "0.1.11",
44
"type": "module",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/CLI.ts

Lines changed: 13 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,6 @@ export class CLI {
4242
}
4343

4444
async start() {
45-
// In built version, we are in dist/index.js (from cli/index.ts) -> core bundled? or just imported.
46-
// The core logic is now in src/cli/core/src/CLI.ts or dist/core/src/CLI.js
47-
48-
// Check for debug flag early
4945
if (process.argv.includes('--debug')) {
5046
setDebugMode(true);
5147
logger.debug('Debug mode enabled via --debug flag');
@@ -56,18 +52,6 @@ export class CLI {
5652
if (this.config.searchDirectories && this.config.searchDirectories.length > 0) {
5753
commandsDirs = [...this.config.searchDirectories];
5854
} else {
59-
// We assume the standard structure:
60-
// cli/
61-
// index.js
62-
// commands/
63-
// core/
64-
// src/
65-
// CLI.ts
66-
67-
// When running from source (ts-node src/cli/index.ts), specific commands are in src/cli/commands.
68-
// core is in src/cli/core/src.
69-
// Relative path from CLI.ts to commands: ../../../commands
70-
7155
const possibleDirs = [
7256
path.resolve(__dirname, './src/commands'),
7357
path.resolve(process.cwd(), 'commands') // Fallback relative to cwd
@@ -183,12 +167,6 @@ export class CLI {
183167
const options = args.pop(); // last is options
184168

185169
if (!subcommand || options.help) {
186-
// If --help is passed to 'module --help', subcommand might be caught as 'module' if args parsing is weird?
187-
// ACTUALLY: cac parses 'module add --help' as subcommand="add".
188-
// 'module --help' might trigger the command itself? No, 'module <subcommand>' expects a subcommand.
189-
// If I run 'module --help', it might fail validation or parse 'help' as subcommand if unlucky,
190-
// but likely it just prints help if we didn't override.
191-
192170
await this.runHelp([root, subcommand].filter(Boolean));
193171
return;
194172
}
@@ -205,17 +183,7 @@ export class CLI {
205183
}
206184

207185
const CommandClass = cmd.class;
208-
// Map remaining args?
209-
// The args array contains positional args AFTER subcommand.
210-
// But we didn't define them in CAC, so they are just strings.
211-
// We need to map them manually to the Target Command's args definition.
212-
// argsDef.args usually starts after the command.
213-
// For 'module add <url>', <url> is the first arg after 'add'.
214-
// So 'args' here corresponds to <url>.
215-
216186
const argsDef = CommandClass.args || {};
217-
// If using [...args], the variadic args are collected into the first argument array
218-
// args here is what remains after popping options.
219187
const positionalArgs = (args.length > 0 && Array.isArray(args[0])) ? args[0] : args;
220188

221189
const childOptions = { ...options }; // Copy options
@@ -242,25 +210,29 @@ export class CLI {
242210
});
243211
}
244212

213+
if (argsDef.options) {
214+
argsDef.options.forEach((opt: any) => {
215+
const name = opt.name.replace(/^-+/, ''); // remove leading dashes
216+
const camelName = name.split(' ')[0].replace(/-([a-z])/g, (g: string) => g[1].toUpperCase());
217+
218+
// Check both raw name and camelCase name
219+
// CAC usually provides camelCase options
220+
if (childOptions[camelName] === undefined && opt.default !== undefined) {
221+
childOptions[camelName] = opt.default;
222+
}
223+
});
224+
}
225+
245226
await this.runCommand(CommandClass, childOptions, cmdParts);
246227
});
247228
}
248229
}
249-
// Disable default help
250-
// this.cli.help();
251230

252231
// Manually register global help to ensure it's allowed
253232
this.cli.option('--help, -h', 'Display help');
254233

255234
this.cli.version(this.version);
256235

257-
// Global help interception for root command
258-
// If we run `app --help`, we need to catch it.
259-
// CAC doesn't expose a clean global action without a command content.
260-
// However, if we parse and no command matches, it usually errors or shows help.
261-
// If we have default logic, we can put it here?
262-
// Let's rely on standard parsing but maybe inspect raw args first?
263-
264236
if (process.argv.includes('--help') || process.argv.includes('-h')) {
265237
// Inspect non-option args to see if there's a command?
266238
const args = process.argv.slice(2).filter(a => !a.startsWith('-'));
@@ -280,14 +252,6 @@ export class CLI {
280252
// Simple heuristic: find first non-flag arg as command
281253
const args = process.argv.slice(2);
282254
const potentialCommand = args.find(a => !a.startsWith('-'));
283-
// If it matches a loaded command root, show help for it
284-
// Otherwise show global help
285-
286-
// We need to match 'module add' etc?
287-
// Just pass the potential command parts to runHelp.
288-
// runHelp handles filtering itself? No, runHelp takes commandParts to pass to HelpCommand.
289-
// HelpCommand expects `command` array.
290-
291255
const helpArgs = potentialCommand ? [potentialCommand] : [];
292256
await this.runHelp(helpArgs);
293257

test/unit/core/CLI.nested.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { CLI } from '../../../src/CLI.js';
3+
import { BaseCommand } from '../../../src/BaseCommand.js';
4+
import { cac } from 'cac';
5+
6+
// Mock CAC to capture command registration
7+
vi.mock('cac');
8+
9+
class MockNestedCommand extends BaseCommand {
10+
static usage = 'nested command';
11+
static description = 'A nested command with defaults';
12+
13+
// Define args/options
14+
static args = {
15+
options: [
16+
{
17+
name: '--repo <url>',
18+
description: 'Repository URL',
19+
default: 'https://github.com/default/repo'
20+
},
21+
{
22+
name: '--force',
23+
description: 'Force action',
24+
default: false
25+
},
26+
{
27+
name: '--dry-run',
28+
description: 'Dry run',
29+
default: false
30+
}
31+
]
32+
};
33+
34+
async run(options: any) { }
35+
}
36+
37+
describe('CLI Nested Command Defaults', () => {
38+
let cli: CLI;
39+
let mockCac: any;
40+
let mockCommand: any;
41+
42+
beforeEach(() => {
43+
vi.clearAllMocks();
44+
45+
mockCommand = {
46+
option: vi.fn().mockReturnThis(),
47+
action: vi.fn(),
48+
allowUnknownOptions: vi.fn().mockReturnThis(),
49+
};
50+
51+
mockCac = {
52+
command: vi.fn().mockReturnValue(mockCommand),
53+
help: vi.fn(),
54+
version: vi.fn(),
55+
parse: vi.fn(),
56+
option: vi.fn().mockReturnThis(),
57+
outputHelp: vi.fn(),
58+
};
59+
(cac as any).mockReturnValue(mockCac);
60+
61+
cli = new CLI({ commandName: 'test-cli' });
62+
});
63+
64+
afterEach(() => {
65+
vi.restoreAllMocks();
66+
});
67+
68+
it('should apply default option values for nested subcommands', async () => {
69+
const runCommandSpy = vi.spyOn(cli as any, 'runCommand');
70+
vi.spyOn(MockNestedCommand.prototype, 'init').mockResolvedValue(undefined);
71+
72+
// Mock loader
73+
vi.spyOn((cli as any).loader, 'load').mockResolvedValue(undefined);
74+
vi.spyOn((cli as any).loader, 'getCommands').mockReturnValue([
75+
{
76+
command: 'nested command',
77+
class: MockNestedCommand
78+
}
79+
]);
80+
81+
// Start to register commands
82+
await cli.start();
83+
84+
// Expect command to be registered
85+
// "nested" is root, "nested [subcommand] [...args]" is registered
86+
const commandCall = mockCac.command.mock.calls.find((call: any) => call[0].startsWith('nested'));
87+
expect(commandCall).toBeDefined();
88+
89+
// Get the action handler
90+
const actionFn = mockCommand.action.mock.calls[0][0];
91+
92+
// Simulate invocation: nested command (subcommand="command")
93+
// options undefined/empty implies defaults should be applied
94+
await actionFn('command', {});
95+
96+
expect(runCommandSpy).toHaveBeenCalled();
97+
const calledOptions = runCommandSpy.mock.calls[0][1];
98+
99+
expect(calledOptions).toHaveProperty('repo', 'https://github.com/default/repo');
100+
expect(calledOptions).toHaveProperty('force', false);
101+
expect(calledOptions).toHaveProperty('dryRun', false);
102+
});
103+
104+
it('should allow overriding default values', async () => {
105+
const runCommandSpy = vi.spyOn(cli as any, 'runCommand');
106+
vi.spyOn(MockNestedCommand.prototype, 'init').mockResolvedValue(undefined);
107+
108+
vi.spyOn((cli as any).loader, 'load').mockResolvedValue(undefined);
109+
vi.spyOn((cli as any).loader, 'getCommands').mockReturnValue([
110+
{
111+
command: 'nested command',
112+
class: MockNestedCommand
113+
}
114+
]);
115+
116+
await cli.start();
117+
118+
const actionFn = mockCommand.action.mock.calls[0][0];
119+
120+
// Simulate invocation with custom options
121+
await actionFn('command', { repo: 'custom-repo' });
122+
123+
expect(runCommandSpy).toHaveBeenCalled();
124+
const calledOptions = runCommandSpy.mock.calls[0][1];
125+
126+
expect(calledOptions).toHaveProperty('repo', 'custom-repo');
127+
});
128+
});

0 commit comments

Comments
 (0)