diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index b0044d2816b..476b62bd386 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1461,6 +1461,8 @@ export const CLI_CONTRACT: CliContract = { // `serviceId`, while `credentials create` matches a service-account provider // on `providerId`. Each is empty on the kind of row that does not use it. listCredentialProviders: { + // The providers group holds nothing but this list, so naming the group lists them. + defaultSubcommand: true, columns: [ { header: 'type' }, { header: 'service', path: 'serviceId' }, diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index ae81c6f7de4..e22af992008 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -255,6 +255,11 @@ export interface CommandSpec { command?: string /** Run this operation when its top-level group is invoked without a subcommand. */ groupDefault?: boolean + /** + * Run this leaf when its group is named without a subcommand, as the group's default. The group + * keeps no options of its own, so the leaf parses its options in either spelling. + */ + defaultSubcommand?: true /** Alternate leaf command names, such as `ls` for `list`. */ aliases?: readonly string[] /** diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 94e3f610efb..ac2f132eda9 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -477,11 +477,15 @@ function addLeafCommand( for (const segment of rest.slice(0, -1)) { parent = nestedGroup(parent, segment) } - parent.addCommand(buildLeaf(operation, spec, rest[rest.length - 1])) + parent.addCommand(buildLeaf(operation, spec, rest[rest.length - 1]), { + isDefault: spec.defaultSubcommand === true, + }) return } - group.addCommand(buildLeaf(operation, spec, rest[0])) + group.addCommand(buildLeaf(operation, spec, rest[0]), { + isDefault: spec.defaultSubcommand === true, + }) } function variantCommandSpec(spec: CommandSpec, variant: CommandVariantSpec): CommandSpec { @@ -489,6 +493,7 @@ function variantCommandSpec(spec: CommandSpec, variant: CommandVariantSpec): Com ...spec, command: variant.command, groupDefault: false, + defaultSubcommand: undefined, aliases: [], positionals: variant.positionals, requestFields: variant.requestFields, diff --git a/packages/sim-cli/src/runtime/group-default.test.ts b/packages/sim-cli/src/runtime/group-default.test.ts new file mode 100644 index 00000000000..9fa9c58eee4 --- /dev/null +++ b/packages/sim-cli/src/runtime/group-default.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { runEmbeddedCli } from '../embed' + +const identity = { endpoint: 'https://sim.test', apiKey: 'fixture', workspaceId: 'workspace' } + +/** Runs one embedded invocation, recording the path of each request it sends. */ +async function run(argv: string[]) { + const paths: string[] = [] + const result = await runEmbeddedCli(argv, { + ...identity, + transport: async (url) => { + paths.push(new URL(String(url)).pathname) + return Response.json({ data: [] }) + }, + }) + return { ...result, paths } +} + +describe('a nested group that defaults to its list', () => { + it('lists credential providers when the providers group is named, as list does', async () => { + const bare = await run(['credentials', 'providers']) + const listed = await run(['credentials', 'providers', 'list']) + expect(bare.exitCode).toBe(0) + expect(bare.paths).toEqual(['/api/v2/credentials/providers']) + expect(listed.paths).toEqual(bare.paths) + }) + + it('keeps each spelling’s own options: a leaf option reaches the leaf, the bare group takes it too', async () => { + const searchOf = async (argv: string[]) => { + const queries: string[] = [] + await runEmbeddedCli(argv, { + ...identity, + transport: async (url) => { + queries.push(new URL(String(url)).searchParams.get('search') ?? '') + return Response.json({ data: [] }) + }, + }) + return queries + } + expect(await searchOf(['credentials', 'providers', 'list', '--search', 'github'])).toEqual([ + 'github', + ]) + expect(await searchOf(['credentials', 'providers', '--search', 'github'])).toEqual(['github']) + }) + + it('leaves a group without a default printing its usage, sending nothing', async () => { + const group = await run(['credentials']) + expect(group.paths).toEqual([]) + expect(group.stderr + group.stdout).toContain('Usage: sim credentials') + }) +})