diff --git a/src/command.ts b/src/command.ts index b26123e..2f477bd 100644 --- a/src/command.ts +++ b/src/command.ts @@ -1,6 +1,6 @@ import { camelCase } from "scule"; -import type { CommandContext, CommandDef, ArgsDef } from "./types.ts"; -import { CLIError, resolveValue } from "./_utils.ts"; +import type { CommandContext, CommandDef, ArgsDef, SubCommandsDef } from "./types.ts"; +import { CLIError, resolveValue, toArray } from "./_utils.ts"; import { parseArgs } from "./args.ts"; import { cyan } from "./_color.ts"; @@ -43,15 +43,13 @@ export async function runCommand( const subCommandArgIndex = findSubCommandIndex(opts.rawArgs, cmdArgs); const subCommandName = opts.rawArgs[subCommandArgIndex]; if (subCommandName) { - if (!subCommands[subCommandName]) { + const subCommand = await _findSubCommand(subCommands, subCommandName); + if (!subCommand) { throw new CLIError(`Unknown command ${cyan(subCommandName)}`, "E_UNKNOWN_COMMAND"); } - const subCommand = await resolveValue(subCommands[subCommandName]); - if (subCommand) { - await runCommand(subCommand, { - rawArgs: opts.rawArgs.slice(subCommandArgIndex + 1), - }); - } + await runCommand(subCommand, { + rawArgs: opts.rawArgs.slice(subCommandArgIndex + 1), + }); } else if (!cmd.run) { throw new CLIError(`No command specified.`, "E_NO_COMMAND"); } @@ -79,7 +77,7 @@ export async function resolveSubCommand( const cmdArgs = await resolveValue(cmd.args || {}); const subCommandArgIndex = findSubCommandIndex(rawArgs, cmdArgs); const subCommandName = rawArgs[subCommandArgIndex]!; - const subCommand = await resolveValue(subCommands[subCommandName]); + const subCommand = await _findSubCommand(subCommands, subCommandName); if (subCommand) { return resolveSubCommand(subCommand, rawArgs.slice(subCommandArgIndex + 1), cmd); } @@ -89,6 +87,27 @@ export async function resolveSubCommand( // --- internal --- +async function _findSubCommand( + subCommands: SubCommandsDef, + name: string, +): Promise | undefined> { + // Direct key match (fast path — no resolution needed) + if (name in subCommands) { + return resolveValue(subCommands[name]); + } + // Alias lookup (resolves subcommands to check meta.alias) + for (const sub of Object.values(subCommands)) { + const resolved = await resolveValue(sub); + const meta = await resolveValue(resolved?.meta); + if (meta?.alias) { + const aliases = toArray(meta.alias); + if (aliases.includes(name)) { + return resolved; + } + } + } +} + function findSubCommandIndex(rawArgs: string[], argsDef: ArgsDef): number { for (let i = 0; i < rawArgs.length; i++) { const arg = rawArgs[i]!; diff --git a/src/types.ts b/src/types.ts index 111c6b8..1266aca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -89,6 +89,7 @@ export interface CommandMeta { version?: string; description?: string; hidden?: boolean; + alias?: string | string[]; } // Command: Definition diff --git a/src/usage.ts b/src/usage.ts index 6cb1c29..81feeef 100644 --- a/src/usage.ts +++ b/src/usage.ts @@ -1,5 +1,5 @@ import * as colors from "./_color.ts"; -import { formatLineColumns, resolveValue } from "./_utils.ts"; +import { formatLineColumns, resolveValue, toArray } from "./_utils.ts"; import type { ArgsDef, CommandDef } from "./types.ts"; import { resolveArgs } from "./args.ts"; @@ -93,8 +93,10 @@ export async function renderUsage( if (meta?.hidden) { continue; } - commandsLines.push([colors.cyan(name), meta?.description || ""]); - commandNames.push(name); + const aliases = toArray(meta?.alias); + const label = [name, ...aliases].join(", "); + commandsLines.push([colors.cyan(label), meta?.description || ""]); + commandNames.push(name, ...aliases); } usageLine.push(commandNames.join("|")); } diff --git a/test/main.test.ts b/test/main.test.ts index 642b730..2ee8870 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -143,6 +143,129 @@ describe("sub command", () => { }); }); +describe("sub command aliases", () => { + it("resolves subcommand by single alias", async () => { + const runMock = vi.fn(); + + const command = defineCommand({ + subCommands: { + install: { + meta: { alias: "i" }, + run: async () => { + runMock(); + }, + }, + }, + }); + + await runMain(command, { rawArgs: ["i"] }); + + expect(runMock).toHaveBeenCalledOnce(); + }); + + it("resolves subcommand by array of aliases", async () => { + const runMock = vi.fn(); + + const command = defineCommand({ + subCommands: { + install: { + meta: { alias: ["i", "add"] }, + run: async () => { + runMock(); + }, + }, + }, + }); + + await runMain(command, { rawArgs: ["add"] }); + + expect(runMock).toHaveBeenCalledOnce(); + }); + + it("resolves nested subcommand aliases", async () => { + const runMock = vi.fn(); + + const command = defineCommand({ + subCommands: { + workspace: { + meta: { alias: "ws" }, + subCommands: { + list: { + meta: { alias: "ls" }, + run: async () => { + runMock(); + }, + }, + }, + }, + }, + }); + + await runMain(command, { rawArgs: ["ws", "ls"] }); + + expect(runMock).toHaveBeenCalledOnce(); + }); + + it("prefers direct key match over alias", async () => { + const directMock = vi.fn(); + const aliasMock = vi.fn(); + + const command = defineCommand({ + subCommands: { + i: { + run: async () => { + directMock(); + }, + }, + install: { + meta: { alias: "i" }, + run: async () => { + aliasMock(); + }, + }, + }, + }); + + await runMain(command, { rawArgs: ["i"] }); + + expect(directMock).toHaveBeenCalledOnce(); + expect(aliasMock).not.toHaveBeenCalled(); + }); + + it("throws for unknown command even with aliases defined", async () => { + const command = defineCommand({ + subCommands: { + install: { + meta: { alias: "i" }, + run: async () => {}, + }, + }, + }); + + await expect(commandModule.runCommand(command, { rawArgs: ["unknown"] })).rejects.toThrow( + "Unknown command", + ); + }); + + it("shows aliases in usage output", async () => { + const command = defineCommand({ + meta: { name: "cli", description: "Test CLI" }, + subCommands: { + install: { + meta: { name: "install", alias: ["i", "add"], description: "Install packages" }, + }, + build: { + meta: { name: "build", alias: "b", description: "Build project" }, + }, + }, + }); + + const usage = await renderUsage(command); + expect(usage).toContain("install, i, add"); + expect(usage).toContain("build, b"); + }); +}); + describe("sub command with parent args", () => { it("resolves subcommand when parent has string arg", async () => { const runMock = vi.fn();