From 49c37f2e5157be67f2a7aeaa9d3f15bca51f6129 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 8 Apr 2026 19:08:22 -0400 Subject: [PATCH 1/5] feat: add command-level aliases --- .changeset/command-aliases.md | 5 +++ src/Cli.test.ts | 58 +++++++++++++++++++++++++ src/Cli.ts | 79 ++++++++++++++++++++++++++++------- src/Completions.ts | 10 ++++- src/Mcp.ts | 1 + src/internal/command.ts | 12 ++++++ 6 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 .changeset/command-aliases.md diff --git a/.changeset/command-aliases.md b/.changeset/command-aliases.md new file mode 100644 index 0000000..d8d75f1 --- /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). Added `skill` as a builtin alias for the `skills` 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..5c801f1 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)) @@ -547,7 +555,8 @@ async function serveImpl( let skillsCta: FormattedCtaBlock | undefined if (!llms && !llmsFull && !schema && !help && !version) { const isSkillsAdd = - filtered[0] === 'skills' || (filtered[0] === name && filtered[1] === 'skills') + findBuiltin(filtered[0]!)?.name === 'skills' || + (filtered[0] === name && findBuiltin(filtered[1]!)?.name === 'skills') const isMcpAdd = filtered[0] === 'mcp' || (filtered[0] === name && filtered[1] === 'mcp') if (!isSkillsAdd && !isMcpAdd) { const stored = SyncSkills.readHash(name) @@ -574,8 +583,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 @@ -653,8 +663,12 @@ 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') { + findBuiltin(filtered[0]!)?.name === 'skills' + ? 0 + : filtered[0] === name && findBuiltin(filtered[1]!)?.name === 'skills' + ? 1 + : -1 + if (skillsIdx !== -1) { const skillsSub = filtered[skillsIdx + 1] if (skillsSub && skillsSub !== 'add' && skillsSub !== 'list') { const suggestion = suggest(skillsSub, ['add', 'list']) @@ -681,13 +695,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 +747,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 } @@ -1008,7 +1022,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 +1911,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 +1941,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 +1950,7 @@ function resolveCommand( rest: remaining.slice(1), } } + let child = resolveAlias(entry.commands, rawChild) path.push(next) remaining = remaining.slice(1) @@ -2260,6 +2275,7 @@ 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)) @@ -2268,6 +2284,7 @@ function collectHelpCommands( /** @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 +2331,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 +2371,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 +2714,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 +2749,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 +2806,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 +2976,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)) +} From 781935253f6c5b7e751f413f2caf7ccf8851b93e Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 8 Apr 2026 19:12:33 -0400 Subject: [PATCH 2/5] chore: tweaks --- .changeset/command-aliases.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/command-aliases.md b/.changeset/command-aliases.md index d8d75f1..9552488 100644 --- a/.changeset/command-aliases.md +++ b/.changeset/command-aliases.md @@ -2,4 +2,4 @@ "incur": patch --- -Added command-level `aliases` option for subcommands (e.g. `aliases: ['extensions', 'ext']` on an `extension` command). Added `skill` as a builtin alias for the `skills` command. +Added command-level `aliases` option for subcommands (e.g. `aliases: ['extensions', 'ext']` on an `extension` command). From 7f228bac32cb6b2e07a9945ebc2d9bc3e42ec62c Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 8 Apr 2026 19:14:03 -0400 Subject: [PATCH 3/5] refactor: use iife for skillsIdx assignment --- src/Cli.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Cli.ts b/src/Cli.ts index 5c801f1..0add917 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -662,12 +662,14 @@ async function serveImpl( } // skills add: generate skill files and install via `x skills add` (only when sync is configured) - const skillsIdx = - findBuiltin(filtered[0]!)?.name === 'skills' - ? 0 - : filtered[0] === name && findBuiltin(filtered[1]!)?.name === 'skills' - ? 1 - : -1 + const skillsIdx = (() => { + // e.g. `skills add` or `skill add` + if (findBuiltin(filtered[0]!)?.name === 'skills') return 0 + // e.g. `my-cli skills add` + if (filtered[0] === name && findBuiltin(filtered[1]!)?.name === 'skills') return 1 + // not a skills invocation + return -1 + })() if (skillsIdx !== -1) { const skillsSub = filtered[skillsIdx + 1] if (skillsSub && skillsSub !== 'add' && skillsSub !== 'list') { From b8fad99130263168cf6dfaa5a8f5c182082d0053 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 8 Apr 2026 19:16:54 -0400 Subject: [PATCH 4/5] refactor: use shared builtinIdx helper for all builtin command matching --- src/Cli.ts | 47 ++++++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/src/Cli.ts b/src/Cli.ts index 0add917..252b7b4 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -554,10 +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 = - findBuiltin(filtered[0]!)?.name === 'skills' || - (filtered[0] === name && findBuiltin(filtered[1]!)?.name === '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) { @@ -623,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, @@ -662,14 +652,7 @@ async function serveImpl( } // skills add: generate skill files and install via `x skills add` (only when sync is configured) - const skillsIdx = (() => { - // e.g. `skills add` or `skill add` - if (findBuiltin(filtered[0]!)?.name === 'skills') return 0 - // e.g. `my-cli skills add` - if (filtered[0] === name && findBuiltin(filtered[1]!)?.name === 'skills') return 1 - // not a skills invocation - return -1 - })() + const skillsIdx = builtinIdx(filtered, name, 'skills') if (skillsIdx !== -1) { const skillsSub = filtered[skillsIdx + 1] if (skillsSub && skillsSub !== 'add' && skillsSub !== 'list') { @@ -813,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']) @@ -838,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 } @@ -2283,6 +2266,16 @@ function collectHelpCommands( 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}`, { From aee80bc04433faa9716a91cdeb6b15844a2eafe9 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 8 Apr 2026 19:20:47 -0400 Subject: [PATCH 5/5] fix: show 'did you mean?' instead of root fallback for command typos --- .changeset/did-you-mean-root-fallback.md | 5 +++++ src/Cli.test.ts | 9 +++++++++ src/Cli.ts | 15 ++++++++++++--- 3 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 .changeset/did-you-mean-root-fallback.md diff --git a/.changeset/did-you-mean-root-fallback.md b/.changeset/did-you-mean-root-fallback.md new file mode 100644 index 0000000..7b3089c --- /dev/null +++ b/.changeset/did-you-mean-root-fallback.md @@ -0,0 +1,5 @@ +--- +'incur': patch +--- + +Fixed root fetch/command fallback bypassing "Did you mean?" suggestions when the input is a typo of a known command. diff --git a/src/Cli.test.ts b/src/Cli.test.ts index c4174f9..083df23 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -3732,6 +3732,15 @@ describe('fetch', async () => { `) }) + test('root-level fetch with typo of known command → did you mean', async () => { + const cli = Cli.create('api', { description: 'API', fetch: app.fetch }).command('upgrade', { + run: () => ({ upgraded: true }), + }) + const { output, exitCode } = await serve(cli, ['upgra']) + expect(exitCode).toBe(1) + expect(output).toContain("Did you mean 'upgrade'?") + }) + test('root-level fetch with no args → root path', async () => { const cli = Cli.create('api', { description: 'API', fetch: app.fetch }) // Hono returns 404 for / since we don't have a root route diff --git a/src/Cli.ts b/src/Cli.ts index 252b7b4..414626f 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -1079,9 +1079,18 @@ async function serveImpl( const resolvedFormat = 'command' in resolved && (resolved as any).command.format const format = formatExplicit ? formatFlag : resolvedFormat || options.format || 'toon' - // Fall back to root fetch when no subcommand matches + // Fall back to root fetch/command when no subcommand matches, + // but only if the token doesn't look like a typo of a known command. + const rootFallbackBlocked = + 'error' in resolved && + !resolved.path && + (() => { + const candidates = [...resolved.commands.keys()] + for (const b of builtinCommands) candidates.push(b.name) + return suggest(resolved.error, candidates) !== undefined + })() const effective = - 'error' in resolved && options.rootFetch && !resolved.path + 'error' in resolved && options.rootFetch && !resolved.path && !rootFallbackBlocked ? { fetchGateway: { _fetch: true as const, @@ -1092,7 +1101,7 @@ async function serveImpl( path: name, rest: filtered, } - : 'error' in resolved && options.rootCommand && !resolved.path + : 'error' in resolved && options.rootCommand && !resolved.path && !rootFallbackBlocked ? { command: options.rootCommand, path: name, rest: filtered } : resolved