Skip to content

Commit 69252d4

Browse files
wyattjohpi0claude
authored
fix: subcommand resolution incorrectly consumes flag values (#231)
Co-authored-by: Pooya Parsa <pooya@pi0.io> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c80f64c commit 69252d4

2 files changed

Lines changed: 167 additions & 2 deletions

File tree

src/command.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { camelCase } from "scule";
12
import type { CommandContext, CommandDef, ArgsDef } from "./types.ts";
23
import { CLIError, resolveValue } from "./_utils.ts";
34
import { parseArgs } from "./args.ts";
@@ -39,7 +40,7 @@ export async function runCommand<T extends ArgsDef = ArgsDef>(
3940
try {
4041
const subCommands = await resolveValue(cmd.subCommands);
4142
if (subCommands && Object.keys(subCommands).length > 0) {
42-
const subCommandArgIndex = opts.rawArgs.findIndex((arg) => !arg.startsWith("-"));
43+
const subCommandArgIndex = findSubCommandIndex(opts.rawArgs, cmdArgs);
4344
const subCommandName = opts.rawArgs[subCommandArgIndex];
4445
if (subCommandName) {
4546
if (!subCommands[subCommandName]) {
@@ -75,7 +76,8 @@ export async function resolveSubCommand<T extends ArgsDef = ArgsDef>(
7576
): Promise<[CommandDef<T>, CommandDef<T>?]> {
7677
const subCommands = await resolveValue(cmd.subCommands);
7778
if (subCommands && Object.keys(subCommands).length > 0) {
78-
const subCommandArgIndex = rawArgs.findIndex((arg) => !arg.startsWith("-"));
79+
const cmdArgs = await resolveValue(cmd.args || {});
80+
const subCommandArgIndex = findSubCommandIndex(rawArgs, cmdArgs);
7981
const subCommandName = rawArgs[subCommandArgIndex]!;
8082
const subCommand = await resolveValue(subCommands[subCommandName]);
8183
if (subCommand) {
@@ -84,3 +86,32 @@ export async function resolveSubCommand<T extends ArgsDef = ArgsDef>(
8486
}
8587
return [cmd, parent];
8688
}
89+
90+
// --- internal ---
91+
92+
function findSubCommandIndex(rawArgs: string[], argsDef: ArgsDef): number {
93+
for (let i = 0; i < rawArgs.length; i++) {
94+
const arg = rawArgs[i]!;
95+
if (arg === "--") return -1;
96+
if (arg.startsWith("-")) {
97+
if (!arg.includes("=") && _isValueFlag(arg, argsDef)) {
98+
i++; // skip the flag's value
99+
}
100+
continue;
101+
}
102+
return i;
103+
}
104+
return -1;
105+
}
106+
107+
function _isValueFlag(flag: string, argsDef: ArgsDef): boolean {
108+
const name = flag.replace(/^-{1,2}/, "");
109+
const normalized = camelCase(name);
110+
for (const [key, def] of Object.entries(argsDef)) {
111+
if (def.type !== "string" && def.type !== "enum") continue;
112+
if (normalized === camelCase(key)) return true;
113+
const aliases = Array.isArray(def.alias) ? def.alias : def.alias ? [def.alias] : [];
114+
if (aliases.includes(name)) return true;
115+
}
116+
return false;
117+
}

test/main.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,141 @@ describe("sub command", () => {
143143
});
144144
});
145145

146+
describe("sub command with parent args", () => {
147+
it("resolves subcommand when parent has string arg", async () => {
148+
const runMock = vi.fn();
149+
150+
const command = defineCommand({
151+
args: {
152+
name: { type: "string" },
153+
},
154+
subCommands: {
155+
build: {
156+
run: async () => {
157+
runMock();
158+
},
159+
},
160+
},
161+
});
162+
163+
await runMain(command, { rawArgs: ["--name", "Citty", "build"] });
164+
165+
expect(runMock).toHaveBeenCalledOnce();
166+
});
167+
168+
it("resolves subcommand when parent has string arg with = syntax", async () => {
169+
const runMock = vi.fn();
170+
171+
const command = defineCommand({
172+
args: {
173+
name: { type: "string" },
174+
},
175+
subCommands: {
176+
build: {
177+
run: async () => {
178+
runMock();
179+
},
180+
},
181+
},
182+
});
183+
184+
await runMain(command, { rawArgs: ["--name=Citty", "build"] });
185+
186+
expect(runMock).toHaveBeenCalledOnce();
187+
});
188+
189+
it("resolves subcommand when parent has string arg with alias", async () => {
190+
const runMock = vi.fn();
191+
192+
const command = defineCommand({
193+
args: {
194+
name: { type: "string", alias: "n" },
195+
},
196+
subCommands: {
197+
build: {
198+
run: async () => {
199+
runMock();
200+
},
201+
},
202+
},
203+
});
204+
205+
await runMain(command, { rawArgs: ["-n", "Citty", "build"] });
206+
207+
expect(runMock).toHaveBeenCalledOnce();
208+
});
209+
210+
it("resolves subcommand when parent has enum arg", async () => {
211+
const runMock = vi.fn();
212+
213+
const command = defineCommand({
214+
args: {
215+
env: { type: "enum", options: ["dev", "prod"] },
216+
},
217+
subCommands: {
218+
build: {
219+
run: async () => {
220+
runMock();
221+
},
222+
},
223+
},
224+
});
225+
226+
await runMain(command, { rawArgs: ["--env", "prod", "build"] });
227+
228+
expect(runMock).toHaveBeenCalledOnce();
229+
});
230+
231+
it("boolean arg does not consume next token as value", async () => {
232+
const runMock = vi.fn();
233+
234+
const command = defineCommand({
235+
args: {
236+
verbose: { type: "boolean" },
237+
},
238+
subCommands: {
239+
build: {
240+
run: async () => {
241+
runMock();
242+
},
243+
},
244+
},
245+
});
246+
247+
await runMain(command, { rawArgs: ["--verbose", "build"] });
248+
249+
expect(runMock).toHaveBeenCalledOnce();
250+
});
251+
});
252+
146253
describe("resolveSubCommand", () => {
254+
it("resolves subcommand with parent string args", async () => {
255+
const command = defineCommand({
256+
args: {
257+
name: { type: "string" },
258+
},
259+
subCommands: {
260+
build: {
261+
args: {
262+
target: { type: "positional" },
263+
},
264+
},
265+
},
266+
});
267+
268+
const [subCommand] = await commandModule.resolveSubCommand(command, [
269+
"--name",
270+
"Citty",
271+
"build",
272+
"prod",
273+
]);
274+
275+
expect(subCommand).toBeDefined();
276+
expect(subCommand.args).toEqual({
277+
target: { type: "positional" },
278+
});
279+
});
280+
147281
it("resolves the sub command", async () => {
148282
const command = defineCommand({
149283
subCommands: {

0 commit comments

Comments
 (0)