Skip to content

Commit 250e65f

Browse files
authored
feat: add command-level aliases (#121)
* feat: add command-level aliases * chore: tweaks * refactor: use iife for skillsIdx assignment * refactor: use shared builtinIdx helper for all builtin command matching
1 parent d4559c3 commit 250e65f

File tree

6 files changed

+160
-36
lines changed

6 files changed

+160
-36
lines changed

.changeset/command-aliases.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"incur": patch
3+
---
4+
5+
Added command-level `aliases` option for subcommands (e.g. `aliases: ['extensions', 'ext']` on an `extension` command).

src/Cli.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4551,3 +4551,61 @@ test('--token-offset with non-numeric value errors', async () => {
45514551
expect(exitCode).toBe(1)
45524552
expect(output).not.toContain('NaN')
45534553
})
4554+
4555+
describe('command aliases', () => {
4556+
function makeAliasedCli() {
4557+
return Cli.create('gh').command('extension', {
4558+
aliases: ['extensions', 'ext'],
4559+
description: 'Manage extensions',
4560+
run: () => ({ result: 'ok' }),
4561+
})
4562+
}
4563+
4564+
test('resolves canonical command name', async () => {
4565+
const { output } = await serve(makeAliasedCli(), ['extension'])
4566+
expect(output).toContain('ok')
4567+
})
4568+
4569+
test('resolves alias name', async () => {
4570+
const { output } = await serve(makeAliasedCli(), ['extensions'])
4571+
expect(output).toContain('ok')
4572+
})
4573+
4574+
test('resolves short alias name', async () => {
4575+
const { output } = await serve(makeAliasedCli(), ['ext'])
4576+
expect(output).toContain('ok')
4577+
})
4578+
4579+
test('root help does not show aliases', async () => {
4580+
const { output } = await serve(makeAliasedCli(), ['--help'])
4581+
const commandsSection = output.split('Commands:')[1]!.split('Integrations:')[0]!
4582+
const names = commandsSection
4583+
.trim()
4584+
.split('\n')
4585+
.map((l) => l.trim().split(/\s{2,}/)[0]!)
4586+
expect(names).toContain('extension')
4587+
expect(names).not.toContain('extensions')
4588+
expect(names).not.toContain('ext')
4589+
})
4590+
4591+
test('command help shows aliases line', async () => {
4592+
const { output } = await serve(makeAliasedCli(), ['extension', '--help'])
4593+
expect(output).toContain('Aliases: extensions, ext')
4594+
})
4595+
4596+
test('aliases work inside command groups', async () => {
4597+
const sub = Cli.create('repo', { description: 'Manage repos' }).command('list', {
4598+
aliases: ['ls'],
4599+
description: 'List repos',
4600+
run: () => ({ repos: [] }),
4601+
})
4602+
const cli = Cli.create('gh').command(sub)
4603+
const { output } = await serve(cli, ['repo', 'ls'])
4604+
expect(output).toContain('repos')
4605+
})
4606+
4607+
test('did-you-mean suggests aliases', async () => {
4608+
const { output } = await serve(makeAliasedCli(), ['exten'])
4609+
expect(output).toMatch(/did you mean.*extension/i)
4610+
})
4611+
})

src/Cli.ts

Lines changed: 76 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ import * as Fetch from './Fetch.js'
1111
import * as Filter from './Filter.js'
1212
import * as Formatter from './Formatter.js'
1313
import * as Help from './Help.js'
14-
import { builtinCommands, type CommandMeta, type Shell, shells } from './internal/command.js'
14+
import {
15+
builtinCommands,
16+
type CommandMeta,
17+
findBuiltin,
18+
type Shell,
19+
shells,
20+
} from './internal/command.js'
1521
import * as Command from './internal/command.js'
1622
import { isRecord, suggest } from './internal/helpers.js'
1723
import { detectRunner } from './internal/pm.js'
@@ -239,6 +245,8 @@ export function create(
239245
return cli
240246
}
241247
commands.set(nameOrCli, def)
248+
if (def.aliases)
249+
for (const a of def.aliases) commands.set(a, { _alias: true, target: nameOrCli })
242250
return cli
243251
}
244252
const mountedRootDef = toRootDefinition.get(nameOrCli)
@@ -530,8 +538,8 @@ async function serveImpl(
530538
})
531539
}
532540
} else if (nonFlags.length === 2) {
533-
const parent = nonFlags[nonFlags.length - 1]
534-
const builtin = builtinCommands.find((b) => b.name === parent && b.subcommands)
541+
const parent = nonFlags[nonFlags.length - 1]!
542+
const builtin = findBuiltin(parent)
535543
if (builtin?.subcommands)
536544
for (const sub of builtin.subcommands)
537545
if (sub.name.startsWith(current))
@@ -546,9 +554,8 @@ async function serveImpl(
546554
// Skills staleness check (skip for built-in commands)
547555
let skillsCta: FormattedCtaBlock | undefined
548556
if (!llms && !llmsFull && !schema && !help && !version) {
549-
const isSkillsAdd =
550-
filtered[0] === 'skills' || (filtered[0] === name && filtered[1] === 'skills')
551-
const isMcpAdd = filtered[0] === 'mcp' || (filtered[0] === name && filtered[1] === 'mcp')
557+
const isSkillsAdd = builtinIdx(filtered, name, 'skills') !== -1
558+
const isMcpAdd = builtinIdx(filtered, name, 'mcp') !== -1
552559
if (!isSkillsAdd && !isMcpAdd) {
553560
const stored = SyncSkills.readHash(name)
554561
if (stored) {
@@ -574,8 +581,9 @@ async function serveImpl(
574581
const prefix: string[] = []
575582
let scopedDescription: string | undefined = options.description
576583
for (const token of filtered) {
577-
const entry = scopedCommands.get(token)
578-
if (!entry) break
584+
const rawEntry = scopedCommands.get(token)
585+
if (!rawEntry) break
586+
const entry = resolveAlias(scopedCommands, rawEntry)
579587
if (isGroup(entry)) {
580588
scopedCommands = entry.commands
581589
scopedDescription = entry.description
@@ -613,19 +621,11 @@ async function serveImpl(
613621
}
614622

615623
// completions <shell>: print shell hook script to stdout
616-
const completionsIdx = (() => {
617-
// e.g. `completions bash`
618-
if (filtered[0] === 'completions') return 0
619-
// e.g. `my-cli completions bash`
620-
if (filtered[0] === name && filtered[1] === 'completions') return 1
621-
// not a completions invocation
622-
return -1
623-
})()
624-
// TODO: refactor built-in command handlers (completions, skills, mcp) into a generic dispatch loop on `builtinCommands`
625-
if (completionsIdx !== -1 && filtered[completionsIdx] === 'completions') {
624+
const completionsIdx = builtinIdx(filtered, name, 'completions')
625+
if (completionsIdx !== -1) {
626626
const shell = filtered[completionsIdx + 1]
627627
if (help || !shell) {
628-
const b = builtinCommands.find((c) => c.name === 'completions')!
628+
const b = findBuiltin('completions')!
629629
writeln(
630630
Help.formatCommand(`${name} completions`, {
631631
args: b.args,
@@ -652,9 +652,8 @@ async function serveImpl(
652652
}
653653

654654
// skills add: generate skill files and install via `<pm>x skills add` (only when sync is configured)
655-
const skillsIdx =
656-
filtered[0] === 'skills' ? 0 : filtered[0] === name && filtered[1] === 'skills' ? 1 : -1
657-
if (skillsIdx !== -1 && filtered[skillsIdx] === 'skills') {
655+
const skillsIdx = builtinIdx(filtered, name, 'skills')
656+
if (skillsIdx !== -1) {
658657
const skillsSub = filtered[skillsIdx + 1]
659658
if (skillsSub && skillsSub !== 'add' && skillsSub !== 'list') {
660659
const suggestion = suggest(skillsSub, ['add', 'list'])
@@ -681,13 +680,13 @@ async function serveImpl(
681680
return
682681
}
683682
if (!skillsSub) {
684-
const b = builtinCommands.find((c) => c.name === 'skills')!
683+
const b = findBuiltin('skills')!
685684
writeln(formatBuiltinHelp(name, b))
686685
return
687686
}
688687
if (skillsSub === 'list') {
689688
if (help) {
690-
const b = builtinCommands.find((c) => c.name === 'skills')!
689+
const b = findBuiltin('skills')!
691690
writeln(formatBuiltinSubcommandHelp(name, b, 'list'))
692691
return
693692
}
@@ -733,7 +732,7 @@ async function serveImpl(
733732
return
734733
}
735734
if (help) {
736-
const b = builtinCommands.find((c) => c.name === 'skills')!
735+
const b = findBuiltin('skills')!
737736
writeln(formatBuiltinSubcommandHelp(name, b, 'add'))
738737
return
739738
}
@@ -797,8 +796,8 @@ async function serveImpl(
797796
}
798797

799798
// mcp add: register CLI as MCP server via `npx add-mcp`
800-
const mcpIdx = filtered[0] === 'mcp' ? 0 : filtered[0] === name && filtered[1] === 'mcp' ? 1 : -1
801-
if (mcpIdx !== -1 && filtered[mcpIdx] === 'mcp') {
799+
const mcpIdx = builtinIdx(filtered, name, 'mcp')
800+
if (mcpIdx !== -1) {
802801
const mcpSub = filtered[mcpIdx + 1]
803802
if (mcpSub && mcpSub !== 'add') {
804803
const suggestion = suggest(mcpSub, ['add'])
@@ -822,12 +821,12 @@ async function serveImpl(
822821
return
823822
}
824823
if (!mcpSub) {
825-
const b = builtinCommands.find((c) => c.name === 'mcp')!
824+
const b = findBuiltin('mcp')!
826825
writeln(formatBuiltinHelp(name, b))
827826
return
828827
}
829828
if (help) {
830-
const b = builtinCommands.find((c) => c.name === 'mcp')!
829+
const b = findBuiltin('mcp')!
831830
writeln(formatBuiltinSubcommandHelp(name, b, 'add'))
832831
return
833832
}
@@ -1008,7 +1007,7 @@ async function serveImpl(
10081007
writeln(
10091008
Help.formatCommand(commandName, {
10101009
alias: cmd.alias as Record<string, string> | undefined,
1011-
aliases: isRootCmd ? options.aliases : undefined,
1010+
aliases: isRootCmd ? options.aliases : cmd.aliases,
10121011
configFlag,
10131012
description: cmd.description,
10141013
version: isRootCmd ? options.version : undefined,
@@ -1897,7 +1896,7 @@ function resolveCommand(
18971896

18981897
if (!first || !commands.has(first)) return { error: first ?? '(none)', path: '', commands, rest }
18991898

1900-
let entry = commands.get(first)!
1899+
let entry = resolveAlias(commands, commands.get(first)!)
19011900
const path = [first]
19021901
let remaining = rest
19031902
let inheritedOutputPolicy: OutputPolicy | undefined
@@ -1927,15 +1926,16 @@ function resolveCommand(
19271926
commands: entry.commands,
19281927
}
19291928

1930-
const child = entry.commands.get(next)
1931-
if (!child) {
1929+
const rawChild = entry.commands.get(next)
1930+
if (!rawChild) {
19321931
return {
19331932
error: next,
19341933
path: path.join(' '),
19351934
commands: entry.commands,
19361935
rest: remaining.slice(1),
19371936
}
19381937
}
1938+
let child = resolveAlias(entry.commands, rawChild)
19391939

19401940
path.push(next)
19411941
remaining = remaining.slice(1)
@@ -2260,14 +2260,26 @@ function collectHelpCommands(
22602260
): { name: string; description?: string | undefined }[] {
22612261
const result: { name: string; description?: string | undefined }[] = []
22622262
for (const [name, entry] of commands) {
2263+
if (isAlias(entry)) continue
22632264
result.push({ name, description: entry.description })
22642265
}
22652266
return result.sort((a, b) => a.name.localeCompare(b.name))
22662267
}
22672268

2269+
/** @internal Finds the index of a builtin command token in the filtered argv. Returns -1 if not found. */
2270+
function builtinIdx(filtered: string[], cliName: string, builtin: string): number {
2271+
// e.g. `skills add` or `skill add`
2272+
if (findBuiltin(filtered[0]!)?.name === builtin) return 0
2273+
// e.g. `my-cli skills add`
2274+
if (filtered[0] === cliName && findBuiltin(filtered[1]!)?.name === builtin) return 1
2275+
// not a match
2276+
return -1
2277+
}
2278+
22682279
/** @internal Formats group-level help for a built-in command (e.g. `cli skills`). */
22692280
function formatBuiltinHelp(cli: string, builtin: (typeof builtinCommands)[number]): string {
22702281
return Help.formatRoot(`${cli} ${builtin.name}`, {
2282+
aliases: builtin.aliases,
22712283
description: builtin.description,
22722284
commands: builtin.subcommands?.map((s) => ({ name: s.name, description: s.description })),
22732285
})
@@ -2314,7 +2326,11 @@ export type CommandsMap = Record<
23142326
>
23152327

23162328
/** @internal Entry stored in a command map — either a leaf definition, a group, or a fetch gateway. */
2317-
type CommandEntry = CommandDefinition<any, any, any> | InternalGroup | InternalFetchGateway
2329+
type CommandEntry =
2330+
| CommandDefinition<any, any, any>
2331+
| InternalGroup
2332+
| InternalFetchGateway
2333+
| InternalAlias
23182334

23192335
/** Controls when output data is displayed. `'all'` displays to both humans and agents. `'agent-only'` suppresses data output in human/TTY mode. */
23202336
export type OutputPolicy = 'agent-only' | 'all'
@@ -2350,6 +2366,27 @@ function isFetchGateway(entry: CommandEntry): entry is InternalFetchGateway {
23502366
return '_fetch' in entry
23512367
}
23522368

2369+
/** @internal An alias entry that points to another command by name. */
2370+
type InternalAlias = {
2371+
_alias: true
2372+
/** The canonical command name this alias resolves to. */
2373+
target: string
2374+
}
2375+
2376+
/** @internal Type guard for alias entries. */
2377+
function isAlias(entry: CommandEntry): entry is InternalAlias {
2378+
return '_alias' in entry
2379+
}
2380+
2381+
/** @internal Follows an alias entry to its canonical target. Returns the entry unchanged if not an alias. */
2382+
function resolveAlias(
2383+
commands: Map<string, CommandEntry>,
2384+
entry: CommandEntry,
2385+
): Exclude<CommandEntry, InternalAlias> {
2386+
if (isAlias(entry)) return commands.get(entry.target)! as Exclude<CommandEntry, InternalAlias>
2387+
return entry
2388+
}
2389+
23532390
/** @internal Maps CLI instances to their command maps. */
23542391
export const toCommands = new WeakMap<Cli, Map<string, CommandEntry>>()
23552392

@@ -2672,6 +2709,7 @@ function collectIndexCommands(
26722709
): { name: string; description?: string | undefined }[] {
26732710
const result: { name: string; description?: string | undefined }[] = []
26742711
for (const [name, entry] of commands) {
2712+
if (isAlias(entry)) continue
26752713
const path = [...prefix, name]
26762714
if (isGroup(entry)) {
26772715
result.push(...collectIndexCommands(entry.commands, path))
@@ -2706,6 +2744,7 @@ function collectCommands(
27062744
}[] {
27072745
const result: ReturnType<typeof collectCommands> = []
27082746
for (const [name, entry] of commands) {
2747+
if (isAlias(entry)) continue
27092748
const path = [...prefix, name]
27102749
if (isFetchGateway(entry)) {
27112750
const cmd: (typeof result)[number] = { name: path.join(' ') }
@@ -2762,6 +2801,7 @@ function collectSkillCommands(
27622801
result.push(cmd)
27632802
}
27642803
for (const [name, entry] of commands) {
2804+
if (isAlias(entry)) continue
27652805
const path = [...prefix, name]
27662806
if (isFetchGateway(entry)) {
27672807
const cmd: Skill.CommandInfo = { name: path.join(' ') }
@@ -2931,6 +2971,8 @@ type CommandDefinition<
29312971
vars extends z.ZodObject<any> | undefined = undefined,
29322972
cliEnv extends z.ZodObject<any> | undefined = undefined,
29332973
> = CommandMeta<options> & {
2974+
/** Alternative names for this command (e.g. `['extensions', 'ext']` for an `extension` command). */
2975+
aliases?: string[] | undefined
29342976
/** Zod schema for positional arguments. */
29352977
args?: args | undefined
29362978
/** Zod schema for environment variables. Keys are the variable names (e.g. `NPM_TOKEN`). */

src/Completions.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@ export type Candidate = {
1212
value: string
1313
}
1414

15-
/** @internal Entry stored in a command map — either a leaf definition or a group. */
15+
/** @internal Entry stored in a command map — either a leaf definition, a group, or an alias. */
1616
type CommandEntry = {
17+
_alias?: true | undefined
1718
_group?: true | undefined
1819
alias?: Record<string, string | undefined> | undefined
1920
args?: z.ZodObject<any> | undefined
2021
commands?: Map<string, CommandEntry> | undefined
2122
description?: string | undefined
2223
options?: z.ZodObject<any> | undefined
24+
target?: string | undefined
2325
}
2426

2527
/**
@@ -61,7 +63,10 @@ export function complete(
6163
for (let i = 0; i < index; i++) {
6264
const token = argv[i]!
6365
if (token.startsWith('-')) continue
64-
const entry = scope.commands.get(token)
66+
let entry = scope.commands.get(token)
67+
if (!entry) continue
68+
// Follow alias to canonical entry
69+
if (entry._alias && entry.target) entry = scope.commands.get(entry.target)
6570
if (!entry) continue
6671
if (entry._group && entry.commands) {
6772
scope = { commands: entry.commands }
@@ -116,6 +121,7 @@ export function complete(
116121

117122
// Suggest subcommands (groups get noSpace so user can keep typing subcommand)
118123
for (const [name, entry] of scope.commands) {
124+
if (entry._alias) continue
119125
if (name.startsWith(current))
120126
candidates.push({
121127
value: name,

src/Mcp.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ export function collectTools(
173173
): ToolEntry[] {
174174
const result: ToolEntry[] = []
175175
for (const [name, entry] of commands) {
176+
if ('_alias' in entry) continue
176177
const path = [...prefix, name]
177178
if ('_group' in entry && entry._group) {
178179
const groupMw = [

0 commit comments

Comments
 (0)