From c5aaf7d547dc86e5c85b92add9b570bd4194a821 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 20 Oct 2026 10:42:09 -0700 Subject: [PATCH] improvement(sim-cli): publish each command's path aliases and renamed paths in the command inventory The worker builds its agent grammar from this inventory and did not know that sim runs files mv, workflows folders ls or kb as their commands, or still runs retired paths such as tables count create and workflows deployment list as the commands they were renamed to, so it refused or misread them. Each command now carries the commander aliases of its path segments (pathAliases, aligned with path) and the paths it was renamed from (renamedFrom). --- .../scripts/print-command-inventory.ts | 37 ++++++++-- .../sim-cli/src/contract/reference.test.ts | 71 +++++++++++++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/packages/sim-cli/scripts/print-command-inventory.ts b/packages/sim-cli/scripts/print-command-inventory.ts index 7fe511d2080..861e273c7ce 100644 --- a/packages/sim-cli/scripts/print-command-inventory.ts +++ b/packages/sim-cli/scripts/print-command-inventory.ts @@ -60,6 +60,17 @@ interface InventoryCommand extends CommandReference { mothershipUnavailable?: true /** In a group hidden from `--help` until launch; the worker exposes it only when Sim enables it. */ unpublished?: true + /** + * The commander aliases of each `path` segment, aligned with `path` (`kb` for `knowledge`, + * `ls` for `list`), so the worker reads `sim kb folders ls` as the command it runs. Absent + * when no segment has one. + */ + pathAliases?: string[][] + /** + * Paths this command used to have (`tables count create` for `tables rows count`). The CLI + * still runs each as this command, hidden from help, so the worker reads it as this one. + */ + renamedFrom?: string[][] } function isHiddenCommand(command: Command): boolean { @@ -75,10 +86,17 @@ function subcommands(command: Command, atRoot: boolean): Command[] { ) } -function collectLeaves(command: Command, prefix: string[]): { path: string[]; command: Command }[] { - const children = subcommands(command, prefix.length === 0) - if (children.length === 0) return [{ path: prefix, command }] - return children.flatMap((child) => collectLeaves(child, [...prefix, child.name()])) +interface Leaf { + path: string[] + /** The commands `path` names, outermost first. */ + chain: Command[] + command: Command +} + +function collectLeaves(command: Command, chain: Command[]): Leaf[] { + const children = subcommands(command, chain.length === 0) + if (children.length === 0) return [{ path: chain.map((node) => node.name()), chain, command }] + return children.flatMap((child) => collectLeaves(child, [...chain, child])) } /** @@ -135,7 +153,7 @@ function mothershipRefuses(operations: readonly V2OperationName[]): boolean { const program = buildProgram() const inventory: InventoryCommand[] = collectLeaves(program, []).map( - ({ path: cmdPath, command }) => { + ({ path: cmdPath, chain, command }) => { const args: InventoryArgument[] = command.registeredArguments.map((argument) => ({ name: argument.name(), required: argument.required, @@ -173,6 +191,13 @@ const inventory: InventoryCommand[] = collectLeaves(program, []).map( ? commandReference(OPENAPI_DOCS, op, jsonFields, cursorSlot(op) !== null) : {} const called = calledOperations(command) ?? [] + const pathAliases = chain.map((node) => node.aliases()) + const contract = operation ? CLI_CONTRACT[operation] : undefined + const renamedFrom = + contract?.renamedFrom && + (contract.command === undefined || contract.command === cmdPath.join(' ')) + ? contract.renamedFrom.map((from) => from.split(' ')) + : [] return { path: cmdPath, description: command.description(), @@ -181,6 +206,8 @@ const inventory: InventoryCommand[] = collectLeaves(program, []).map( ...reference, ...(mothershipRefuses(called) ? { mothershipUnavailable: true as const } : {}), ...(UNPUBLISHED_GROUPS.has(cmdPath[0] ?? '') ? { unpublished: true as const } : {}), + ...(pathAliases.some((aliases) => aliases.length > 0) ? { pathAliases } : {}), + ...(renamedFrom.length > 0 ? { renamedFrom } : {}), } } ) diff --git a/packages/sim-cli/src/contract/reference.test.ts b/packages/sim-cli/src/contract/reference.test.ts index 478d9e185f7..0ed4d6c4001 100644 --- a/packages/sim-cli/src/contract/reference.test.ts +++ b/packages/sim-cli/src/contract/reference.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' +import type { Command } from 'commander' import { describe, expect, it } from 'vitest' import { commandReference, @@ -9,6 +10,7 @@ import { import { runEmbeddedCli } from '#sim-cli/embed' import { V2_OPERATIONS } from '#sim-cli/generated/v2-api' import { buildProgram, UNPUBLISHED_GROUPS } from '#sim-cli/program' +import { operationOf } from '#sim-cli/runtime/called-operations' import { cursorSlot } from '#sim-cli/runtime/request' const ROOT = fileURLToPath(new URL('../../../../', import.meta.url)) @@ -19,10 +21,30 @@ interface InventoryEntry { body?: string mothershipUnavailable?: true unpublished?: true + pathAliases?: string[][] + renamedFrom?: string[][] } let produced: InventoryEntry[] | undefined +/** The requests the embedded CLI sends for `argv`, answered with one generic success. */ +async function requests(argv: string[]) { + const seen: string[] = [] + const result = await runEmbeddedCli(['--output', 'json', ...argv], { + endpoint: 'https://sim.internal.test', + apiKey: 'test-key', + workspaceId: 'a2e3ab27-2f9d-4b8a-a2f2-3c47a1b0c9d1', + transport: async (input, init) => { + seen.push(`${init?.method ?? 'GET'} ${String(input)} ${String(init?.body ?? '')}`) + return new Response(JSON.stringify({ data: { id: 'folder-1', count: 0 } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }, + }) + return { exitCode: result.exitCode, stderr: result.stderr, seen } +} + /** Read the actual producer, including command aliases and generated API documents. */ function inventory(): InventoryEntry[] { produced ??= JSON.parse( @@ -118,6 +140,55 @@ describe('CLI reference producer', () => { expect(flag('files get')).toBeUndefined() }) + it('publishes the aliases of each path segment, which the real CLI runs as the command', async () => { + const commands = inventory() + const aliasesOf = (path: string) => + commands.find((entry) => entry.path.join(' ') === path)?.pathAliases + expect(aliasesOf('files move')).toEqual([['file'], ['mv']]) + expect(aliasesOf('knowledge folders list')).toEqual([['kb'], [], ['ls']]) + expect(aliasesOf('workflows deploy')).toEqual([['workflow'], []]) + expect(aliasesOf('blocks get')).toBeUndefined() + + const canonical = await requests(['files', 'move', '--file-ids', 'f1', '--to', 'folder-1']) + expect(canonical.exitCode, canonical.stderr).toBe(0) + expect(canonical.seen.length).toBeGreaterThan(0) + expect(await requests(['file', 'mv', '--file-ids', 'f1', '--to', 'folder-1'])).toEqual( + canonical + ) + }) + + it('publishes each renamed path on the command the real CLI still runs it as', async () => { + const commands = inventory() + const renamed = commands.flatMap((entry) => + (entry.renamedFrom ?? []).map((from) => ({ from, to: entry.path })) + ) + expect(renamed.map(({ from }) => from.join(' ')).sort()).toEqual([ + 'files restore create', + 'tables columns run', + 'tables count create', + 'tables rows find', + 'workflows deployment list', + ]) + const program = buildProgram() + const resolve = (path: readonly string[]) => + path.reduce( + (node, name) => + node?.commands.find((child) => child.name() === name || child.aliases().includes(name)), + program + ) + for (const { from, to } of renamed) { + const old = resolve(from) + expect(old, from.join(' ')).toBeDefined() + expect(old && operationOf(old), from.join(' ')).toBe(operationOf(resolve(to) ?? program)) + } + + const current = await requests(['tables', 'rows', 'count', 'table-1']) + expect(current.exitCode, current.stderr).toBe(0) + const retired = await requests(['tables', 'count', 'create', 'table-1']) + expect(retired.exitCode, retired.stderr).toBe(0) + expect(retired.seen).toEqual(current.seen) + }) + it('the real CLI accepts both row bodies and prints their distinct 201 payloads', async () => { const identity = { endpoint: 'https://sim.internal.test',