Skip to content

Commit 6840275

Browse files
pi0afifsaad27HigherOrderLogicratiofuclaude
authored
feat: support subcommand aliases (#236)
Add `alias` field to `CommandMeta` allowing subcommands to be invoked by alternate names (e.g. `i` for `install`). Direct key matches are preferred over alias lookups. Aliases are displayed in help/usage output. Closes #152 Co-authored-by: 苏向夜 <67710306+fu050409@users.noreply.github.com> Co-authored-by: Horu <HigherOrderLogic@users.noreply.github.com> Co-authored-by: TJ <ratiofu@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 69252d4 commit 6840275

4 files changed

Lines changed: 158 additions & 13 deletions

File tree

src/command.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { camelCase } from "scule";
2-
import type { CommandContext, CommandDef, ArgsDef } from "./types.ts";
3-
import { CLIError, resolveValue } from "./_utils.ts";
2+
import type { CommandContext, CommandDef, ArgsDef, SubCommandsDef } from "./types.ts";
3+
import { CLIError, resolveValue, toArray } from "./_utils.ts";
44
import { parseArgs } from "./args.ts";
55
import { cyan } from "./_color.ts";
66

@@ -43,15 +43,13 @@ export async function runCommand<T extends ArgsDef = ArgsDef>(
4343
const subCommandArgIndex = findSubCommandIndex(opts.rawArgs, cmdArgs);
4444
const subCommandName = opts.rawArgs[subCommandArgIndex];
4545
if (subCommandName) {
46-
if (!subCommands[subCommandName]) {
46+
const subCommand = await _findSubCommand(subCommands, subCommandName);
47+
if (!subCommand) {
4748
throw new CLIError(`Unknown command ${cyan(subCommandName)}`, "E_UNKNOWN_COMMAND");
4849
}
49-
const subCommand = await resolveValue(subCommands[subCommandName]);
50-
if (subCommand) {
51-
await runCommand(subCommand, {
52-
rawArgs: opts.rawArgs.slice(subCommandArgIndex + 1),
53-
});
54-
}
50+
await runCommand(subCommand, {
51+
rawArgs: opts.rawArgs.slice(subCommandArgIndex + 1),
52+
});
5553
} else if (!cmd.run) {
5654
throw new CLIError(`No command specified.`, "E_NO_COMMAND");
5755
}
@@ -79,7 +77,7 @@ export async function resolveSubCommand<T extends ArgsDef = ArgsDef>(
7977
const cmdArgs = await resolveValue(cmd.args || {});
8078
const subCommandArgIndex = findSubCommandIndex(rawArgs, cmdArgs);
8179
const subCommandName = rawArgs[subCommandArgIndex]!;
82-
const subCommand = await resolveValue(subCommands[subCommandName]);
80+
const subCommand = await _findSubCommand(subCommands, subCommandName);
8381
if (subCommand) {
8482
return resolveSubCommand(subCommand, rawArgs.slice(subCommandArgIndex + 1), cmd);
8583
}
@@ -89,6 +87,27 @@ export async function resolveSubCommand<T extends ArgsDef = ArgsDef>(
8987

9088
// --- internal ---
9189

90+
async function _findSubCommand(
91+
subCommands: SubCommandsDef,
92+
name: string,
93+
): Promise<CommandDef<any> | undefined> {
94+
// Direct key match (fast path — no resolution needed)
95+
if (name in subCommands) {
96+
return resolveValue(subCommands[name]);
97+
}
98+
// Alias lookup (resolves subcommands to check meta.alias)
99+
for (const sub of Object.values(subCommands)) {
100+
const resolved = await resolveValue(sub);
101+
const meta = await resolveValue(resolved?.meta);
102+
if (meta?.alias) {
103+
const aliases = toArray(meta.alias);
104+
if (aliases.includes(name)) {
105+
return resolved;
106+
}
107+
}
108+
}
109+
}
110+
92111
function findSubCommandIndex(rawArgs: string[], argsDef: ArgsDef): number {
93112
for (let i = 0; i < rawArgs.length; i++) {
94113
const arg = rawArgs[i]!;

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export interface CommandMeta {
8989
version?: string;
9090
description?: string;
9191
hidden?: boolean;
92+
alias?: string | string[];
9293
}
9394

9495
// Command: Definition

src/usage.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as colors from "./_color.ts";
2-
import { formatLineColumns, resolveValue } from "./_utils.ts";
2+
import { formatLineColumns, resolveValue, toArray } from "./_utils.ts";
33
import type { ArgsDef, CommandDef } from "./types.ts";
44
import { resolveArgs } from "./args.ts";
55

@@ -93,8 +93,10 @@ export async function renderUsage<T extends ArgsDef = ArgsDef>(
9393
if (meta?.hidden) {
9494
continue;
9595
}
96-
commandsLines.push([colors.cyan(name), meta?.description || ""]);
97-
commandNames.push(name);
96+
const aliases = toArray(meta?.alias);
97+
const label = [name, ...aliases].join(", ");
98+
commandsLines.push([colors.cyan(label), meta?.description || ""]);
99+
commandNames.push(name, ...aliases);
98100
}
99101
usageLine.push(commandNames.join("|"));
100102
}

test/main.test.ts

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

146+
describe("sub command aliases", () => {
147+
it("resolves subcommand by single alias", async () => {
148+
const runMock = vi.fn();
149+
150+
const command = defineCommand({
151+
subCommands: {
152+
install: {
153+
meta: { alias: "i" },
154+
run: async () => {
155+
runMock();
156+
},
157+
},
158+
},
159+
});
160+
161+
await runMain(command, { rawArgs: ["i"] });
162+
163+
expect(runMock).toHaveBeenCalledOnce();
164+
});
165+
166+
it("resolves subcommand by array of aliases", async () => {
167+
const runMock = vi.fn();
168+
169+
const command = defineCommand({
170+
subCommands: {
171+
install: {
172+
meta: { alias: ["i", "add"] },
173+
run: async () => {
174+
runMock();
175+
},
176+
},
177+
},
178+
});
179+
180+
await runMain(command, { rawArgs: ["add"] });
181+
182+
expect(runMock).toHaveBeenCalledOnce();
183+
});
184+
185+
it("resolves nested subcommand aliases", async () => {
186+
const runMock = vi.fn();
187+
188+
const command = defineCommand({
189+
subCommands: {
190+
workspace: {
191+
meta: { alias: "ws" },
192+
subCommands: {
193+
list: {
194+
meta: { alias: "ls" },
195+
run: async () => {
196+
runMock();
197+
},
198+
},
199+
},
200+
},
201+
},
202+
});
203+
204+
await runMain(command, { rawArgs: ["ws", "ls"] });
205+
206+
expect(runMock).toHaveBeenCalledOnce();
207+
});
208+
209+
it("prefers direct key match over alias", async () => {
210+
const directMock = vi.fn();
211+
const aliasMock = vi.fn();
212+
213+
const command = defineCommand({
214+
subCommands: {
215+
i: {
216+
run: async () => {
217+
directMock();
218+
},
219+
},
220+
install: {
221+
meta: { alias: "i" },
222+
run: async () => {
223+
aliasMock();
224+
},
225+
},
226+
},
227+
});
228+
229+
await runMain(command, { rawArgs: ["i"] });
230+
231+
expect(directMock).toHaveBeenCalledOnce();
232+
expect(aliasMock).not.toHaveBeenCalled();
233+
});
234+
235+
it("throws for unknown command even with aliases defined", async () => {
236+
const command = defineCommand({
237+
subCommands: {
238+
install: {
239+
meta: { alias: "i" },
240+
run: async () => {},
241+
},
242+
},
243+
});
244+
245+
await expect(commandModule.runCommand(command, { rawArgs: ["unknown"] })).rejects.toThrow(
246+
"Unknown command",
247+
);
248+
});
249+
250+
it("shows aliases in usage output", async () => {
251+
const command = defineCommand({
252+
meta: { name: "cli", description: "Test CLI" },
253+
subCommands: {
254+
install: {
255+
meta: { name: "install", alias: ["i", "add"], description: "Install packages" },
256+
},
257+
build: {
258+
meta: { name: "build", alias: "b", description: "Build project" },
259+
},
260+
},
261+
});
262+
263+
const usage = await renderUsage(command);
264+
expect(usage).toContain("install, i, add");
265+
expect(usage).toContain("build, b");
266+
});
267+
});
268+
146269
describe("sub command with parent args", () => {
147270
it("resolves subcommand when parent has string arg", async () => {
148271
const runMock = vi.fn();

0 commit comments

Comments
 (0)