diff --git a/.changeset/command-aliases.md b/.changeset/command-aliases.md new file mode 100644 index 0000000..9552488 --- /dev/null +++ b/.changeset/command-aliases.md @@ -0,0 +1,5 @@ +--- +"incur": patch +--- + +Added command-level `aliases` option for subcommands (e.g. `aliases: ['extensions', 'ext']` on an `extension` command). diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 7749bf5..c4174f9 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -4551,3 +4551,61 @@ test('--token-offset with non-numeric value errors', async () => { expect(exitCode).toBe(1) expect(output).not.toContain('NaN') }) + +describe('command aliases', () => { + function makeAliasedCli() { + return Cli.create('gh').command('extension', { + aliases: ['extensions', 'ext'], + description: 'Manage extensions', + run: () => ({ result: 'ok' }), + }) + } + + test('resolves canonical command name', async () => { + const { output } = await serve(makeAliasedCli(), ['extension']) + expect(output).toContain('ok') + }) + + test('resolves alias name', async () => { + const { output } = await serve(makeAliasedCli(), ['extensions']) + expect(output).toContain('ok') + }) + + test('resolves short alias name', async () => { + const { output } = await serve(makeAliasedCli(), ['ext']) + expect(output).toContain('ok') + }) + + test('root help does not show aliases', async () => { + const { output } = await serve(makeAliasedCli(), ['--help']) + const commandsSection = output.split('Commands:')[1]!.split('Integrations:')[0]! + const names = commandsSection + .trim() + .split('\n') + .map((l) => l.trim().split(/\s{2,}/)[0]!) + expect(names).toContain('extension') + expect(names).not.toContain('extensions') + expect(names).not.toContain('ext') + }) + + test('command help shows aliases line', async () => { + const { output } = await serve(makeAliasedCli(), ['extension', '--help']) + expect(output).toContain('Aliases: extensions, ext') + }) + + test('aliases work inside command groups', async () => { + const sub = Cli.create('repo', { description: 'Manage repos' }).command('list', { + aliases: ['ls'], + description: 'List repos', + run: () => ({ repos: [] }), + }) + const cli = Cli.create('gh').command(sub) + const { output } = await serve(cli, ['repo', 'ls']) + expect(output).toContain('repos') + }) + + test('did-you-mean suggests aliases', async () => { + const { output } = await serve(makeAliasedCli(), ['exten']) + expect(output).toMatch(/did you mean.*extension/i) + }) +}) diff --git a/src/Cli.ts b/src/Cli.ts index 6831a52..252b7b4 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -11,7 +11,13 @@ import * as Fetch from './Fetch.js' import * as Filter from './Filter.js' import * as Formatter from './Formatter.js' import * as Help from './Help.js' -import { builtinCommands, type CommandMeta, type Shell, shells } from './internal/command.js' +import { + builtinCommands, + type CommandMeta, + findBuiltin, + type Shell, + shells, +} from './internal/command.js' import * as Command from './internal/command.js' import { isRecord, suggest } from './internal/helpers.js' import { detectRunner } from './internal/pm.js' @@ -239,6 +245,8 @@ export function create( return cli } commands.set(nameOrCli, def) + if (def.aliases) + for (const a of def.aliases) commands.set(a, { _alias: true, target: nameOrCli }) return cli } const mountedRootDef = toRootDefinition.get(nameOrCli) @@ -530,8 +538,8 @@ async function serveImpl( }) } } else if (nonFlags.length === 2) { - const parent = nonFlags[nonFlags.length - 1] - const builtin = builtinCommands.find((b) => b.name === parent && b.subcommands) + const parent = nonFlags[nonFlags.length - 1]! + const builtin = findBuiltin(parent) if (builtin?.subcommands) for (const sub of builtin.subcommands) if (sub.name.startsWith(current)) @@ -546,9 +554,8 @@ async function serveImpl( // Skills staleness check (skip for built-in commands) let skillsCta: FormattedCtaBlock | undefined if (!llms && !llmsFull && !schema && !help && !version) { - const isSkillsAdd = - filtered[0] === 'skills' || (filtered[0] === name && filtered[1] === 'skills') - const isMcpAdd = filtered[0] === 'mcp' || (filtered[0] === name && filtered[1] === 'mcp') + const isSkillsAdd = builtinIdx(filtered, name, 'skills') !== -1 + const isMcpAdd = builtinIdx(filtered, name, 'mcp') !== -1 if (!isSkillsAdd && !isMcpAdd) { const stored = SyncSkills.readHash(name) if (stored) { @@ -574,8 +581,9 @@ async function serveImpl( const prefix: string[] = [] let scopedDescription: string | undefined = options.description for (const token of filtered) { - const entry = scopedCommands.get(token) - if (!entry) break + const rawEntry = scopedCommands.get(token) + if (!rawEntry) break + const entry = resolveAlias(scopedCommands, rawEntry) if (isGroup(entry)) { scopedCommands = entry.commands scopedDescription = entry.description @@ -613,19 +621,11 @@ async function serveImpl( } // completions : print shell hook script to stdout - const completionsIdx = (() => { - // e.g. `completions bash` - if (filtered[0] === 'completions') return 0 - // e.g. `my-cli completions bash` - if (filtered[0] === name && filtered[1] === 'completions') return 1 - // not a completions invocation - return -1 - })() - // TODO: refactor built-in command handlers (completions, skills, mcp) into a generic dispatch loop on `builtinCommands` - if (completionsIdx !== -1 && filtered[completionsIdx] === 'completions') { + const completionsIdx = builtinIdx(filtered, name, 'completions') + if (completionsIdx !== -1) { const shell = filtered[completionsIdx + 1] if (help || !shell) { - const b = builtinCommands.find((c) => c.name === 'completions')! + const b = findBuiltin('completions')! writeln( Help.formatCommand(`${name} completions`, { args: b.args, @@ -652,9 +652,8 @@ async function serveImpl( } // skills add: generate skill files and install via `x skills add` (only when sync is configured) - const skillsIdx = - filtered[0] === 'skills' ? 0 : filtered[0] === name && filtered[1] === 'skills' ? 1 : -1 - if (skillsIdx !== -1 && filtered[skillsIdx] === 'skills') { + const skillsIdx = builtinIdx(filtered, name, 'skills') + if (skillsIdx !== -1) { const skillsSub = filtered[skillsIdx + 1] if (skillsSub && skillsSub !== 'add' && skillsSub !== 'list') { const suggestion = suggest(skillsSub, ['add', 'list']) @@ -681,13 +680,13 @@ async function serveImpl( return } if (!skillsSub) { - const b = builtinCommands.find((c) => c.name === 'skills')! + const b = findBuiltin('skills')! writeln(formatBuiltinHelp(name, b)) return } if (skillsSub === 'list') { if (help) { - const b = builtinCommands.find((c) => c.name === 'skills')! + const b = findBuiltin('skills')! writeln(formatBuiltinSubcommandHelp(name, b, 'list')) return } @@ -733,7 +732,7 @@ async function serveImpl( return } if (help) { - const b = builtinCommands.find((c) => c.name === 'skills')! + const b = findBuiltin('skills')! writeln(formatBuiltinSubcommandHelp(name, b, 'add')) return } @@ -797,8 +796,8 @@ async function serveImpl( } // mcp add: register CLI as MCP server via `npx add-mcp` - const mcpIdx = filtered[0] === 'mcp' ? 0 : filtered[0] === name && filtered[1] === 'mcp' ? 1 : -1 - if (mcpIdx !== -1 && filtered[mcpIdx] === 'mcp') { + const mcpIdx = builtinIdx(filtered, name, 'mcp') + if (mcpIdx !== -1) { const mcpSub = filtered[mcpIdx + 1] if (mcpSub && mcpSub !== 'add') { const suggestion = suggest(mcpSub, ['add']) @@ -822,12 +821,12 @@ async function serveImpl( return } if (!mcpSub) { - const b = builtinCommands.find((c) => c.name === 'mcp')! + const b = findBuiltin('mcp')! writeln(formatBuiltinHelp(name, b)) return } if (help) { - const b = builtinCommands.find((c) => c.name === 'mcp')! + const b = findBuiltin('mcp')! writeln(formatBuiltinSubcommandHelp(name, b, 'add')) return } @@ -1008,7 +1007,7 @@ async function serveImpl( writeln( Help.formatCommand(commandName, { alias: cmd.alias as Record | undefined, - aliases: isRootCmd ? options.aliases : undefined, + aliases: isRootCmd ? options.aliases : cmd.aliases, configFlag, description: cmd.description, version: isRootCmd ? options.version : undefined, @@ -1897,7 +1896,7 @@ function resolveCommand( if (!first || !commands.has(first)) return { error: first ?? '(none)', path: '', commands, rest } - let entry = commands.get(first)! + let entry = resolveAlias(commands, commands.get(first)!) const path = [first] let remaining = rest let inheritedOutputPolicy: OutputPolicy | undefined @@ -1927,8 +1926,8 @@ function resolveCommand( commands: entry.commands, } - const child = entry.commands.get(next) - if (!child) { + const rawChild = entry.commands.get(next) + if (!rawChild) { return { error: next, path: path.join(' '), @@ -1936,6 +1935,7 @@ function resolveCommand( rest: remaining.slice(1), } } + let child = resolveAlias(entry.commands, rawChild) path.push(next) remaining = remaining.slice(1) @@ -2260,14 +2260,26 @@ function collectHelpCommands( ): { name: string; description?: string | undefined }[] { const result: { name: string; description?: string | undefined }[] = [] for (const [name, entry] of commands) { + if (isAlias(entry)) continue result.push({ name, description: entry.description }) } return result.sort((a, b) => a.name.localeCompare(b.name)) } +/** @internal Finds the index of a builtin command token in the filtered argv. Returns -1 if not found. */ +function builtinIdx(filtered: string[], cliName: string, builtin: string): number { + // e.g. `skills add` or `skill add` + if (findBuiltin(filtered[0]!)?.name === builtin) return 0 + // e.g. `my-cli skills add` + if (filtered[0] === cliName && findBuiltin(filtered[1]!)?.name === builtin) return 1 + // not a match + return -1 +} + /** @internal Formats group-level help for a built-in command (e.g. `cli skills`). */ function formatBuiltinHelp(cli: string, builtin: (typeof builtinCommands)[number]): string { return Help.formatRoot(`${cli} ${builtin.name}`, { + aliases: builtin.aliases, description: builtin.description, commands: builtin.subcommands?.map((s) => ({ name: s.name, description: s.description })), }) @@ -2314,7 +2326,11 @@ export type CommandsMap = Record< > /** @internal Entry stored in a command map — either a leaf definition, a group, or a fetch gateway. */ -type CommandEntry = CommandDefinition | InternalGroup | InternalFetchGateway +type CommandEntry = + | CommandDefinition + | InternalGroup + | InternalFetchGateway + | InternalAlias /** Controls when output data is displayed. `'all'` displays to both humans and agents. `'agent-only'` suppresses data output in human/TTY mode. */ export type OutputPolicy = 'agent-only' | 'all' @@ -2350,6 +2366,27 @@ function isFetchGateway(entry: CommandEntry): entry is InternalFetchGateway { return '_fetch' in entry } +/** @internal An alias entry that points to another command by name. */ +type InternalAlias = { + _alias: true + /** The canonical command name this alias resolves to. */ + target: string +} + +/** @internal Type guard for alias entries. */ +function isAlias(entry: CommandEntry): entry is InternalAlias { + return '_alias' in entry +} + +/** @internal Follows an alias entry to its canonical target. Returns the entry unchanged if not an alias. */ +function resolveAlias( + commands: Map, + entry: CommandEntry, +): Exclude { + if (isAlias(entry)) return commands.get(entry.target)! as Exclude + return entry +} + /** @internal Maps CLI instances to their command maps. */ export const toCommands = new WeakMap>() @@ -2672,6 +2709,7 @@ function collectIndexCommands( ): { name: string; description?: string | undefined }[] { const result: { name: string; description?: string | undefined }[] = [] for (const [name, entry] of commands) { + if (isAlias(entry)) continue const path = [...prefix, name] if (isGroup(entry)) { result.push(...collectIndexCommands(entry.commands, path)) @@ -2706,6 +2744,7 @@ function collectCommands( }[] { const result: ReturnType = [] for (const [name, entry] of commands) { + if (isAlias(entry)) continue const path = [...prefix, name] if (isFetchGateway(entry)) { const cmd: (typeof result)[number] = { name: path.join(' ') } @@ -2762,6 +2801,7 @@ function collectSkillCommands( result.push(cmd) } for (const [name, entry] of commands) { + if (isAlias(entry)) continue const path = [...prefix, name] if (isFetchGateway(entry)) { const cmd: Skill.CommandInfo = { name: path.join(' ') } @@ -2931,6 +2971,8 @@ type CommandDefinition< vars extends z.ZodObject | undefined = undefined, cliEnv extends z.ZodObject | undefined = undefined, > = CommandMeta & { + /** Alternative names for this command (e.g. `['extensions', 'ext']` for an `extension` command). */ + aliases?: string[] | undefined /** Zod schema for positional arguments. */ args?: args | undefined /** Zod schema for environment variables. Keys are the variable names (e.g. `NPM_TOKEN`). */ diff --git a/src/Completions.ts b/src/Completions.ts index ffbf947..ed4623d 100644 --- a/src/Completions.ts +++ b/src/Completions.ts @@ -12,14 +12,16 @@ export type Candidate = { value: string } -/** @internal Entry stored in a command map — either a leaf definition or a group. */ +/** @internal Entry stored in a command map — either a leaf definition, a group, or an alias. */ type CommandEntry = { + _alias?: true | undefined _group?: true | undefined alias?: Record | undefined args?: z.ZodObject | undefined commands?: Map | undefined description?: string | undefined options?: z.ZodObject | undefined + target?: string | undefined } /** @@ -61,7 +63,10 @@ export function complete( for (let i = 0; i < index; i++) { const token = argv[i]! if (token.startsWith('-')) continue - const entry = scope.commands.get(token) + let entry = scope.commands.get(token) + if (!entry) continue + // Follow alias to canonical entry + if (entry._alias && entry.target) entry = scope.commands.get(entry.target) if (!entry) continue if (entry._group && entry.commands) { scope = { commands: entry.commands } @@ -116,6 +121,7 @@ export function complete( // Suggest subcommands (groups get noSpace so user can keep typing subcommand) for (const [name, entry] of scope.commands) { + if (entry._alias) continue if (name.startsWith(current)) candidates.push({ value: name, diff --git a/src/Mcp.ts b/src/Mcp.ts index 2f58fcc..c622a02 100644 --- a/src/Mcp.ts +++ b/src/Mcp.ts @@ -173,6 +173,7 @@ export function collectTools( ): ToolEntry[] { const result: ToolEntry[] = [] for (const [name, entry] of commands) { + if ('_alias' in entry) continue const path = [...prefix, name] if ('_group' in entry && entry._group) { const groupMw = [ diff --git a/src/internal/command.ts b/src/internal/command.ts index e3bff84..4004116 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -412,6 +412,7 @@ export const builtinCommands = [ }, { name: 'skills', + aliases: ['skill'], description: 'Sync skill files to agents', subcommands: [ subcommand({ @@ -430,8 +431,19 @@ export const builtinCommands = [ }, ] satisfies { name: string + aliases?: string[] | undefined args?: z.ZodObject | undefined description: string hint?: ((name: string) => string) | undefined subcommands?: (CommandMeta> & { name: string })[] | undefined }[] + +/** @internal Finds a builtin command by its name or alias. */ +export function findBuiltin(token: string) { + return builtinCommands.find((b) => b.name === token || b.aliases?.includes(token)) +} + +/** @internal Checks if a token matches a builtin command by name or alias. */ +export function isBuiltin(token: string) { + return builtinCommands.some((b) => b.name === token || b.aliases?.includes(token)) +}