Skip to content

Commit 49c37f2

Browse files
committed
feat: add command-level aliases
1 parent d4559c3 commit 49c37f2

6 files changed

Lines changed: 147 additions & 18 deletions

File tree

.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). Added `skill` as a builtin alias for the `skills` 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: 63 additions & 16 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))
@@ -547,7 +555,8 @@ async function serveImpl(
547555
let skillsCta: FormattedCtaBlock | undefined
548556
if (!llms && !llmsFull && !schema && !help && !version) {
549557
const isSkillsAdd =
550-
filtered[0] === 'skills' || (filtered[0] === name && filtered[1] === 'skills')
558+
findBuiltin(filtered[0]!)?.name === 'skills' ||
559+
(filtered[0] === name && findBuiltin(filtered[1]!)?.name === 'skills')
551560
const isMcpAdd = filtered[0] === 'mcp' || (filtered[0] === name && filtered[1] === 'mcp')
552561
if (!isSkillsAdd && !isMcpAdd) {
553562
const stored = SyncSkills.readHash(name)
@@ -574,8 +583,9 @@ async function serveImpl(
574583
const prefix: string[] = []
575584
let scopedDescription: string | undefined = options.description
576585
for (const token of filtered) {
577-
const entry = scopedCommands.get(token)
578-
if (!entry) break
586+
const rawEntry = scopedCommands.get(token)
587+
if (!rawEntry) break
588+
const entry = resolveAlias(scopedCommands, rawEntry)
579589
if (isGroup(entry)) {
580590
scopedCommands = entry.commands
581591
scopedDescription = entry.description
@@ -653,8 +663,12 @@ async function serveImpl(
653663

654664
// skills add: generate skill files and install via `<pm>x skills add` (only when sync is configured)
655665
const skillsIdx =
656-
filtered[0] === 'skills' ? 0 : filtered[0] === name && filtered[1] === 'skills' ? 1 : -1
657-
if (skillsIdx !== -1 && filtered[skillsIdx] === 'skills') {
666+
findBuiltin(filtered[0]!)?.name === 'skills'
667+
? 0
668+
: filtered[0] === name && findBuiltin(filtered[1]!)?.name === 'skills'
669+
? 1
670+
: -1
671+
if (skillsIdx !== -1) {
658672
const skillsSub = filtered[skillsIdx + 1]
659673
if (skillsSub && skillsSub !== 'add' && skillsSub !== 'list') {
660674
const suggestion = suggest(skillsSub, ['add', 'list'])
@@ -681,13 +695,13 @@ async function serveImpl(
681695
return
682696
}
683697
if (!skillsSub) {
684-
const b = builtinCommands.find((c) => c.name === 'skills')!
698+
const b = findBuiltin('skills')!
685699
writeln(formatBuiltinHelp(name, b))
686700
return
687701
}
688702
if (skillsSub === 'list') {
689703
if (help) {
690-
const b = builtinCommands.find((c) => c.name === 'skills')!
704+
const b = findBuiltin('skills')!
691705
writeln(formatBuiltinSubcommandHelp(name, b, 'list'))
692706
return
693707
}
@@ -733,7 +747,7 @@ async function serveImpl(
733747
return
734748
}
735749
if (help) {
736-
const b = builtinCommands.find((c) => c.name === 'skills')!
750+
const b = findBuiltin('skills')!
737751
writeln(formatBuiltinSubcommandHelp(name, b, 'add'))
738752
return
739753
}
@@ -1008,7 +1022,7 @@ async function serveImpl(
10081022
writeln(
10091023
Help.formatCommand(commandName, {
10101024
alias: cmd.alias as Record<string, string> | undefined,
1011-
aliases: isRootCmd ? options.aliases : undefined,
1025+
aliases: isRootCmd ? options.aliases : cmd.aliases,
10121026
configFlag,
10131027
description: cmd.description,
10141028
version: isRootCmd ? options.version : undefined,
@@ -1897,7 +1911,7 @@ function resolveCommand(
18971911

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

1900-
let entry = commands.get(first)!
1914+
let entry = resolveAlias(commands, commands.get(first)!)
19011915
const path = [first]
19021916
let remaining = rest
19031917
let inheritedOutputPolicy: OutputPolicy | undefined
@@ -1927,15 +1941,16 @@ function resolveCommand(
19271941
commands: entry.commands,
19281942
}
19291943

1930-
const child = entry.commands.get(next)
1931-
if (!child) {
1944+
const rawChild = entry.commands.get(next)
1945+
if (!rawChild) {
19321946
return {
19331947
error: next,
19341948
path: path.join(' '),
19351949
commands: entry.commands,
19361950
rest: remaining.slice(1),
19371951
}
19381952
}
1953+
let child = resolveAlias(entry.commands, rawChild)
19391954

19401955
path.push(next)
19411956
remaining = remaining.slice(1)
@@ -2260,6 +2275,7 @@ function collectHelpCommands(
22602275
): { name: string; description?: string | undefined }[] {
22612276
const result: { name: string; description?: string | undefined }[] = []
22622277
for (const [name, entry] of commands) {
2278+
if (isAlias(entry)) continue
22632279
result.push({ name, description: entry.description })
22642280
}
22652281
return result.sort((a, b) => a.name.localeCompare(b.name))
@@ -2268,6 +2284,7 @@ function collectHelpCommands(
22682284
/** @internal Formats group-level help for a built-in command (e.g. `cli skills`). */
22692285
function formatBuiltinHelp(cli: string, builtin: (typeof builtinCommands)[number]): string {
22702286
return Help.formatRoot(`${cli} ${builtin.name}`, {
2287+
aliases: builtin.aliases,
22712288
description: builtin.description,
22722289
commands: builtin.subcommands?.map((s) => ({ name: s.name, description: s.description })),
22732290
})
@@ -2314,7 +2331,11 @@ export type CommandsMap = Record<
23142331
>
23152332

23162333
/** @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
2334+
type CommandEntry =
2335+
| CommandDefinition<any, any, any>
2336+
| InternalGroup
2337+
| InternalFetchGateway
2338+
| InternalAlias
23182339

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

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

@@ -2672,6 +2714,7 @@ function collectIndexCommands(
26722714
): { name: string; description?: string | undefined }[] {
26732715
const result: { name: string; description?: string | undefined }[] = []
26742716
for (const [name, entry] of commands) {
2717+
if (isAlias(entry)) continue
26752718
const path = [...prefix, name]
26762719
if (isGroup(entry)) {
26772720
result.push(...collectIndexCommands(entry.commands, path))
@@ -2706,6 +2749,7 @@ function collectCommands(
27062749
}[] {
27072750
const result: ReturnType<typeof collectCommands> = []
27082751
for (const [name, entry] of commands) {
2752+
if (isAlias(entry)) continue
27092753
const path = [...prefix, name]
27102754
if (isFetchGateway(entry)) {
27112755
const cmd: (typeof result)[number] = { name: path.join(' ') }
@@ -2762,6 +2806,7 @@ function collectSkillCommands(
27622806
result.push(cmd)
27632807
}
27642808
for (const [name, entry] of commands) {
2809+
if (isAlias(entry)) continue
27652810
const path = [...prefix, name]
27662811
if (isFetchGateway(entry)) {
27672812
const cmd: Skill.CommandInfo = { name: path.join(' ') }
@@ -2931,6 +2976,8 @@ type CommandDefinition<
29312976
vars extends z.ZodObject<any> | undefined = undefined,
29322977
cliEnv extends z.ZodObject<any> | undefined = undefined,
29332978
> = CommandMeta<options> & {
2979+
/** Alternative names for this command (e.g. `['extensions', 'ext']` for an `extension` command). */
2980+
aliases?: string[] | undefined
29342981
/** Zod schema for positional arguments. */
29352982
args?: args | undefined
29362983
/** 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 = [

src/internal/command.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ export const builtinCommands = [
412412
},
413413
{
414414
name: 'skills',
415+
aliases: ['skill'],
415416
description: 'Sync skill files to agents',
416417
subcommands: [
417418
subcommand({
@@ -430,8 +431,19 @@ export const builtinCommands = [
430431
},
431432
] satisfies {
432433
name: string
434+
aliases?: string[] | undefined
433435
args?: z.ZodObject<any> | undefined
434436
description: string
435437
hint?: ((name: string) => string) | undefined
436438
subcommands?: (CommandMeta<z.ZodObject<any>> & { name: string })[] | undefined
437439
}[]
440+
441+
/** @internal Finds a builtin command by its name or alias. */
442+
export function findBuiltin(token: string) {
443+
return builtinCommands.find((b) => b.name === token || b.aliases?.includes(token))
444+
}
445+
446+
/** @internal Checks if a token matches a builtin command by name or alias. */
447+
export function isBuiltin(token: string) {
448+
return builtinCommands.some((b) => b.name === token || b.aliases?.includes(token))
449+
}

0 commit comments

Comments
 (0)