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
5 changes: 5 additions & 0 deletions .changeset/command-aliases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"incur": patch
---

Added command-level `aliases` option for subcommands (e.g. `aliases: ['extensions', 'ext']` on an `extension` command).
58 changes: 58 additions & 0 deletions src/Cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
110 changes: 76 additions & 34 deletions src/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -613,19 +621,11 @@ async function serveImpl(
}

// completions <shell>: 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,
Expand All @@ -652,9 +652,8 @@ async function serveImpl(
}

// skills add: generate skill files and install via `<pm>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'])
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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'])
Expand All @@ -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
}
Expand Down Expand Up @@ -1008,7 +1007,7 @@ async function serveImpl(
writeln(
Help.formatCommand(commandName, {
alias: cmd.alias as Record<string, string> | undefined,
aliases: isRootCmd ? options.aliases : undefined,
aliases: isRootCmd ? options.aliases : cmd.aliases,
configFlag,
description: cmd.description,
version: isRootCmd ? options.version : undefined,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1927,15 +1926,16 @@ 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(' '),
commands: entry.commands,
rest: remaining.slice(1),
}
}
let child = resolveAlias(entry.commands, rawChild)

path.push(next)
remaining = remaining.slice(1)
Expand Down Expand Up @@ -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 })),
})
Expand Down Expand Up @@ -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<any, any, any> | InternalGroup | InternalFetchGateway
type CommandEntry =
| CommandDefinition<any, any, any>
| 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'
Expand Down Expand Up @@ -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<string, CommandEntry>,
entry: CommandEntry,
): Exclude<CommandEntry, InternalAlias> {
if (isAlias(entry)) return commands.get(entry.target)! as Exclude<CommandEntry, InternalAlias>
return entry
}

/** @internal Maps CLI instances to their command maps. */
export const toCommands = new WeakMap<Cli, Map<string, CommandEntry>>()

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -2706,6 +2744,7 @@ function collectCommands(
}[] {
const result: ReturnType<typeof collectCommands> = []
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(' ') }
Expand Down Expand Up @@ -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(' ') }
Expand Down Expand Up @@ -2931,6 +2971,8 @@ type CommandDefinition<
vars extends z.ZodObject<any> | undefined = undefined,
cliEnv extends z.ZodObject<any> | undefined = undefined,
> = CommandMeta<options> & {
/** 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`). */
Expand Down
10 changes: 8 additions & 2 deletions src/Completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> | undefined
args?: z.ZodObject<any> | undefined
commands?: Map<string, CommandEntry> | undefined
description?: string | undefined
options?: z.ZodObject<any> | undefined
target?: string | undefined
}

/**
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/Mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Loading
Loading