Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions src/command.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -43,15 +43,13 @@ export async function runCommand<T extends ArgsDef = ArgsDef>(
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");
}
Expand Down Expand Up @@ -79,7 +77,7 @@ export async function resolveSubCommand<T extends ArgsDef = ArgsDef>(
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);
}
Expand All @@ -89,6 +87,27 @@ export async function resolveSubCommand<T extends ArgsDef = ArgsDef>(

// --- internal ---

async function _findSubCommand(
subCommands: SubCommandsDef,
name: string,
): Promise<CommandDef<any> | 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]!;
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export interface CommandMeta {
version?: string;
description?: string;
hidden?: boolean;
alias?: string | string[];
}

// Command: Definition
Expand Down
8 changes: 5 additions & 3 deletions src/usage.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -93,8 +93,10 @@ export async function renderUsage<T extends ArgsDef = ArgsDef>(
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("|"));
}
Expand Down
123 changes: 123 additions & 0 deletions test/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading