Skip to content

Commit 722510e

Browse files
authored
fix(sim-cli): the command inventory describes the operation each command runs (#8874)
The inventory mapped paths back to operations by re-deriving every route, so an operation whose derived path equals a sibling's (undeployWorkflow derives `workflows deploy`) overwrote it. Generated commands now record the operation they run and the inventory reads it.
1 parent 1f570d3 commit 722510e

7 files changed

Lines changed: 61 additions & 29 deletions

File tree

‎packages/sim-cli/scripts/print-command-inventory.ts‎

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ import {
2222
} from '#sim-cli/contract/reference'
2323
import { V2_OPERATIONS, type V2OperationName } from '#sim-cli/generated/v2-api'
2424
import { buildProgram } from '#sim-cli/program'
25-
import { calledOperations } from '#sim-cli/runtime/called-operations'
26-
import { camel, deriveCommandPath } from '#sim-cli/runtime/derive'
25+
import { calledOperations, operationOf } from '#sim-cli/runtime/called-operations'
26+
import { camel } from '#sim-cli/runtime/derive'
2727
import { cursorSlot, flagNameFor } from '#sim-cli/runtime/request'
2828

2929
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')
@@ -76,21 +76,6 @@ function collectLeaves(command: Command, prefix: string[]): { path: string[]; co
7676
return children.flatMap((child) => collectLeaves(child, [...prefix, child.name()]))
7777
}
7878

79-
/**
80-
* Command path → v2 operation name. Every operation names its command the way the
81-
* program builder does (`deriveCommandPath`); a contract entry's explicit `command`
82-
* string overrides that, exactly as it does when the program is built.
83-
*/
84-
const OPERATION_BY_PATH = new Map<string, V2OperationName>()
85-
const operations = Object.keys(V2_OPERATIONS) as V2OperationName[]
86-
for (const operation of operations) {
87-
OPERATION_BY_PATH.set(deriveCommandPath(operation).join(' '), operation)
88-
}
89-
for (const operation of operations) {
90-
const command = CLI_CONTRACT[operation]?.command
91-
if (command) OPERATION_BY_PATH.set(command, operation)
92-
}
93-
9479
const OPENAPI_DOCS: ReferenceDocument[] = fs
9580
.readdirSync(path.join(ROOT, 'apps/docs'))
9681
.filter((name) => /^openapi-v2-.*\.json$/.test(name))
@@ -138,7 +123,7 @@ const inventory: InventoryCommand[] = collectLeaves(program, []).map(
138123
...(option.defaultValue !== undefined ? { defaultValue: String(option.defaultValue) } : {}),
139124
...(option.argChoices ? { choices: option.argChoices } : {}),
140125
}))
141-
const operation = OPERATION_BY_PATH.get(cmdPath.join(' '))
126+
const operation = operationOf(command)
142127
const jsonOptions = new Set(
143128
options.filter((option) => /<json\|@file>/.test(option.flags)).map((option) => option.name)
144129
)

‎packages/sim-cli/src/commands/credentials.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from '../generated/v2-api'
1010
import { SimApiError } from '../http/client'
1111
import { describeOperation } from '../runtime/build'
12+
import { callsOperations, runsOperation } from '../runtime/called-operations'
1213
import { coerce } from '../runtime/request'
1314
import { renderResult } from '../runtime/result'
1415

@@ -193,7 +194,7 @@ export function attachCredentialCommands(program: Command): void {
193194

194195
acceptNameOnUpdate(credentials)
195196

196-
credentials
197+
const create = credentials
197198
.command('create')
198199
.argument('<providerId>', 'Service-account provider to create a credential for')
199200
.description(
@@ -219,6 +220,8 @@ export function attachCredentialCommands(program: Command): void {
219220
.action((providerId: string, options: CreateServiceAccountOptions, command: Command) =>
220221
createServiceAccount(command, providerId, options)
221222
)
223+
callsOperations(create, ['listCredentialProviders', 'createServiceAccountCredential'])
224+
runsOperation(create, 'createServiceAccountCredential')
222225

223226
credentials
224227
.command('connect')

‎packages/sim-cli/src/commands/protocol/files-get.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ async function downloadToOutput(
337337
}
338338

339339
export function attachFileGet(files: Command): void {
340-
files
340+
const get = files
341341
.command('get')
342342
.argument('<fileId>', 'File whose content to read')
343343
.allowExcessArguments(false)
@@ -347,6 +347,7 @@ export function attachFileGet(files: Command): void {
347347
.action((fileId: string, options: DownloadOutputOptions, command: Command) =>
348348
downloadToOutput(command, V2_OPERATIONS.downloadFile, { fileId }, options)
349349
)
350+
callsOperations(get, ['downloadFile'])
350351
}
351352

352353
export function attachFileVersionDownload(versions: Command): void {

‎packages/sim-cli/src/commands/protocol/knowledge-export.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { clientFrom } from '../../context'
44
import { embedStore } from '../../embed-context'
55
import { V2_OPERATIONS } from '../../generated/v2-api'
66
import { resolvePath, SimApiError } from '../../http/client'
7+
import { callsOperations } from '../../runtime/called-operations'
78
import { isTerminalSafeContentType, saveToFile, streamToStdout } from './files-get'
89
import { printProtocolResult } from './result'
910

@@ -44,7 +45,7 @@ function safeBaseName(name: string): string | null {
4445
}
4546

4647
export function attachKnowledgeExport(knowledge: Command): void {
47-
knowledge
48+
const exportCommand = knowledge
4849
.command('export')
4950
.argument('<knowledgeBaseId>', 'Knowledge base to export')
5051
.allowExcessArguments(false)
@@ -106,4 +107,5 @@ export function attachKnowledgeExport(knowledge: Command): void {
106107
vectors: options.vectors,
107108
})
108109
})
110+
callsOperations(exportCommand, ['exportKnowledgeBase'])
109111
}

‎packages/sim-cli/src/contract/reference.test.ts‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,24 @@ import { cursorSlot } from '#sim-cli/runtime/request'
1212

1313
const ROOT = fileURLToPath(new URL('../../../../', import.meta.url))
1414

15-
/** Read the actual producer, including command aliases and generated API documents. */
16-
function inventory(): {
15+
interface InventoryEntry {
1716
path: string[]
1817
shape?: string
1918
body?: string
2019
mothershipUnavailable?: true
21-
}[] {
22-
return JSON.parse(
20+
}
21+
22+
let produced: InventoryEntry[] | undefined
23+
24+
/** Read the actual producer, including command aliases and generated API documents. */
25+
function inventory(): InventoryEntry[] {
26+
produced ??= JSON.parse(
2327
execFileSync('bun', ['run', 'packages/sim-cli/scripts/print-command-inventory.ts'], {
2428
cwd: ROOT,
2529
encoding: 'utf8',
2630
})
27-
)
31+
) as InventoryEntry[]
32+
return produced
2833
}
2934

3035
function document(schema: ReferenceSchema): ReferenceDocument {
@@ -51,6 +56,7 @@ describe('CLI reference producer', () => {
5156
}
5257
expect(find('tables create').shape).toContain('id:string')
5358
expect(find('workflows create').shape).toContain('blocks:')
59+
expect(find('credentials create').shape).toContain('id:string')
5460
const rows = find('tables rows create')
5561
expect(rows.body).toBe('{rows:object[]}|{data:object}')
5662
expect(rows.shape).toContain('id:string')
@@ -67,6 +73,21 @@ describe('CLI reference producer', () => {
6773
expect(find('files share get').shape).toContain('|{data:null}')
6874
})
6975

76+
it('describes each command by the operation it runs, not one whose route derives the same name', () => {
77+
// `undeployWorkflow`, `deleteTableRows` and `deleteKnowledgeTagDefinitions` derive the
78+
// path their sibling owns; a lookup by path described the sibling with their output.
79+
const commands = inventory()
80+
const shape = (path: string) => commands.find((entry) => entry.path.join(' ') === path)?.shape
81+
expect(shape('workflows deploy')).toContain('lint:')
82+
expect(shape('workflows deploy')).not.toContain('archivedMcpTools')
83+
expect(shape('workflows undeploy')).toContain('archivedMcpTools')
84+
expect(shape('tables rows delete')).toContain('deleted:true')
85+
expect(shape('tables rows batch-delete')).toContain('deletedCount:')
86+
expect(shape('knowledge tags delete')).toContain('tagSlot:')
87+
expect(shape('knowledge tags cleanup')).toContain('unused:')
88+
expect(shape('workflows mv')).toContain('folderPath:')
89+
})
90+
7091
it('marks every command that calls a route Mothership is refused', () => {
7192
const commands = inventory()
7293
const flag = (path: string) =>

‎packages/sim-cli/src/runtime/build.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Command } from 'commander'
22
import { CLI_CONTRACT } from '../contract/commands'
33
import type { CommandSpec, CommandVariantSpec } from '../contract/types'
44
import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api'
5+
import { runsOperation } from './called-operations'
56
import { deriveCommandPath } from './derive'
67
import { executeOperation } from './execute'
78
import { retypeApiError } from './naming'
@@ -248,6 +249,7 @@ function configureOperation(
248249
spec: CommandSpec
249250
): Command {
250251
const operationSpec = V2_OPERATIONS[operation] as OperationSpec
252+
runsOperation(command, operation)
251253
command.allowExcessArguments(false)
252254

253255
for (const alias of spec.aliases ?? []) command.alias(alias)

‎packages/sim-cli/src/runtime/called-operations.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,31 @@ import type { Command } from 'commander'
22
import type { V2OperationName } from '../generated/v2-api'
33

44
const CALLED_OPERATIONS = new WeakMap<Command, readonly V2OperationName[]>()
5+
const RUNS_OPERATION = new WeakMap<Command, V2OperationName>()
6+
7+
/**
8+
* Records the one v2 operation whose request and response a command presents as its
9+
* own: every generated command, and a hand-written one that prints a single call's result.
10+
*
11+
* The command inventory reads it here rather than mapping paths back to operations: a
12+
* derived path can equal another operation's (`undeployWorkflow` derives
13+
* `workflows deploy`), and a reverse lookup by path then describes the wrong one.
14+
*/
15+
export function runsOperation(command: Command, operation: V2OperationName): Command {
16+
RUNS_OPERATION.set(command, operation)
17+
return command
18+
}
19+
20+
export function operationOf(command: Command): V2OperationName | undefined {
21+
return RUNS_OPERATION.get(command)
22+
}
523

624
/**
725
* Records the v2 operations a hand-written command calls.
826
*
9-
* A generated command is one operation and the command inventory finds it by path. A
10-
* hand-written one is opaque until it says what it calls, and the inventory needs that
11-
* to describe it truthfully, such as whether every call is open to a Mothership caller.
27+
* A hand-written command is opaque until it says what it calls, and the inventory needs
28+
* that to describe it truthfully, such as whether every call is open to a Mothership
29+
* caller.
1230
*/
1331
export function callsOperations(command: Command, operations: readonly V2OperationName[]): Command {
1432
CALLED_OPERATIONS.set(command, operations)

0 commit comments

Comments
 (0)