Skip to content

Commit 2c70a71

Browse files
committed
improvement(sim-cli): commands reach the API only through the operations they declare
Hand-written commands called SimClient by path and declared nothing, so the command inventory could not mark one Mothership-unavailable when it calls a refused route. Every command now gets its client from callsOperations: calls name an operation, route and method come from the operation table, and the client is typed to the declared operations.
1 parent 722510e commit 2c70a71

31 files changed

Lines changed: 635 additions & 428 deletions

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ const inventory: InventoryCommand[] = collectLeaves(program, []).map(
142142
const reference = op
143143
? commandReference(OPENAPI_DOCS, op, jsonFields, cursorSlot(op) !== null)
144144
: {}
145-
const called = calledOperations(command) ?? (operation ? [operation] : [])
145+
const called = calledOperations(command) ?? []
146146
return {
147147
path: cmdPath,
148148
description: command.description(),

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

Lines changed: 28 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,16 @@ import {
4545
writeCredentialsProfile,
4646
} from '../config/index'
4747
import { ProfileOverrideError, redact } from '../config/profile'
48-
import { clientFrom, globalsOf, profileFrom } from '../context'
48+
import { globalsOf, profileFrom } from '../context'
4949
import { setSoftExitCode } from '../embed-context'
50-
import {
51-
type GetMetaResponse,
52-
type GetWorkspaceResponse,
53-
type ListWorkspacesResponse,
54-
V2_OPERATIONS,
50+
import type {
51+
GetMetaResponse,
52+
GetWorkspaceResponse,
53+
ListWorkspacesResponse,
5554
} from '../generated/v2-api'
56-
import { requestAllPages, resolvePath, SimApiError, type SimClient } from '../http/client'
55+
import { SimApiError } from '../http/client'
5756
import { type Column, printList, printRecord, safeOneLine, text } from '../output/render'
58-
import { callsOperations } from '../runtime/called-operations'
57+
import { callsOperations, type OperationClient } from '../runtime/called-operations'
5958

6059
type SelectableWorkspace = ListWorkspacesResponse['data'][number]
6160

@@ -265,28 +264,26 @@ function requireStoredAuthentication(profile: ResolvedProfile): string {
265264
}
266265

267266
async function getWorkspaceById(
268-
client: Pick<SimClient, 'request'>,
267+
client: OperationClient<'getWorkspace'>,
269268
workspaceId: string
270269
): Promise<SelectableWorkspace> {
271-
const operation = V2_OPERATIONS.getWorkspace
272-
const response = await client.request<GetWorkspaceResponse>(
273-
resolvePath(operation.path, { workspaceId }),
274-
{ method: operation.method }
275-
)
270+
const response = await client.request<GetWorkspaceResponse>('getWorkspace', {
271+
params: { workspaceId },
272+
})
276273
return response.data
277274
}
278275

279-
async function chooseWorkspace(client: Pick<SimClient, 'request'>): Promise<SelectableWorkspace> {
276+
async function chooseWorkspace(
277+
client: OperationClient<'listWorkspaces'>
278+
): Promise<SelectableWorkspace> {
280279
if (!process.stdin.isTTY) {
281280
throw new SimApiError(
282281
'No workspace provided. Pass --workspace <id> when creating a profile non-interactively.',
283282
0
284283
)
285284
}
286285

287-
const operation = V2_OPERATIONS.listWorkspaces
288-
const workspaces = await requestAllPages<SelectableWorkspace>(client, operation.path, {
289-
method: operation.method,
286+
const workspaces = await client.requestAllPages<SelectableWorkspace>('listWorkspaces', {
290287
query: { sortBy: 'name', sortOrder: 'asc' },
291288
pageSize: 100,
292289
limit: MAX_INTERACTIVE_WORKSPACES + 1,
@@ -323,14 +320,16 @@ async function chooseWorkspace(client: Pick<SimClient, 'request'>): Promise<Sele
323320
}
324321

325322
function addProfileCommand(): Command {
326-
return new Command('add')
323+
const add = new Command('add')
324+
const connectAdd = callsOperations(add, ['getWorkspace', 'listWorkspaces'])
325+
return add
327326
.description('Add a workspace profile that shares the active stored login')
328327
.argument('<name>', 'Name for the new profile')
329328
.option('-w, --workspace <id>', 'Existing workspace to use; omit for an interactive picker')
330329
.action(async (profileName: string, _options: unknown, command: Command) => {
331330
validateNewProfileName(profileName)
332331

333-
const { client, profile } = clientFrom(command)
332+
const { client, profile } = connectAdd()
334333
const authProfile = requireStoredAuthentication(profile)
335334
const credential = readStoredCredential(authProfile)
336335
const workspaceId = globalsOf(command).workspace
@@ -866,7 +865,7 @@ const WHOAMI_EXIT_CODES = {
866865
* whose metadata endpoint is unavailable.
867866
*/
868867
async function verifyProfile(
869-
client: Pick<SimClient, 'request'>,
868+
client: OperationClient<'getMeta' | 'getWorkspace'>,
870869
profile: ResolvedProfile
871870
): Promise<Verification> {
872871
if (!profile.apiKey && !profile.oauth) {
@@ -881,11 +880,8 @@ async function verifyProfile(
881880

882881
let keyType: KeyType | null = null
883882
let authenticated: boolean | null = null
884-
const metaOperation = V2_OPERATIONS.getMeta
885883
try {
886-
const response = await client.request<unknown>(metaOperation.path, {
887-
method: metaOperation.method,
888-
})
884+
const response = await client.request<unknown>('getMeta')
889885
const reportedKeyType = toRecord(toRecord(response).data).keyType
890886
if (
891887
reportedKeyType === 'personal' ||
@@ -918,12 +914,10 @@ async function verifyProfile(
918914
}
919915
}
920916

921-
const operation = V2_OPERATIONS.getWorkspace
922917
try {
923-
const response = await client.request<GetWorkspaceResponse>(
924-
resolvePath(operation.path, { workspaceId: profile.workspaceId }),
925-
{ method: operation.method }
926-
)
918+
const response = await client.request<GetWorkspaceResponse>('getWorkspace', {
919+
params: { workspaceId: profile.workspaceId },
920+
})
927921
const { id, name, memberCount } = response.data
928922
// Projected field by field: the record carries display fields the machine
929923
// output has no business inventing a contract for.
@@ -969,10 +963,12 @@ function presentVerification(verification: Verification): string {
969963

970964
export function whoamiCommand(): Command {
971965
const whoami = new Command('whoami')
966+
const connectWhoami = callsOperations(whoami, ['getMeta', 'getWorkspace'])
967+
return whoami
972968
.description('Show the resolved profile, where each setting came from, and whether it works')
973969
.option('--no-verify', 'Skip the API check and only print the resolved settings')
974-
.action(async (options: { verify: boolean }, command: Command) => {
975-
const { client, profile } = clientFrom(command)
970+
.action(async (options: { verify: boolean }) => {
971+
const { client, profile } = connectWhoami()
976972
const { sources } = profile
977973
const authentication = presentAuthentication(sources.credential)
978974

@@ -1035,7 +1031,6 @@ export function whoamiCommand(): Command {
10351031
const exitCode = WHOAMI_EXIT_CODES[verification.status]
10361032
if (exitCode !== 0) setSoftExitCode(exitCode)
10371033
})
1038-
return callsOperations(whoami, ['getMeta', 'getWorkspace'])
10391034
}
10401035

10411036
interface ProfileRow {

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

Lines changed: 48 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { type Command, Option } from 'commander'
2-
import { clientFrom } from '../context'
32
import type { CommandSpec } from '../contract/types'
43
import {
54
type CreateCredentialConnectionResponse,
@@ -9,7 +8,7 @@ import {
98
} from '../generated/v2-api'
109
import { SimApiError } from '../http/client'
1110
import { describeOperation } from '../runtime/build'
12-
import { callsOperations, runsOperation } from '../runtime/called-operations'
11+
import { apiCommand, type Connection } from '../runtime/called-operations'
1312
import { coerce } from '../runtime/request'
1413
import { renderResult } from '../runtime/result'
1514

@@ -108,15 +107,12 @@ async function credentialValues(
108107
}
109108

110109
async function createServiceAccount(
111-
command: Command,
110+
{ client, profile }: Connection<'listCredentialProviders' | 'createServiceAccountCredential'>,
112111
providerId: string,
113112
options: CreateServiceAccountOptions
114113
): Promise<void> {
115-
const { client, profile } = clientFrom(command)
116114
const workspaceId = client.requireWorkspace()
117-
const discovery = V2_OPERATIONS.listCredentialProviders
118-
const catalog = await client.request<ListCredentialProvidersResponse>(discovery.path, {
119-
method: discovery.method,
115+
const catalog = await client.request<ListCredentialProvidersResponse>('listCredentialProviders', {
120116
query: { workspaceId },
121117
})
122118
const provider = serviceAccountProvider(catalog.data, providerId)
@@ -125,19 +121,20 @@ async function createServiceAccount(
125121
}
126122

127123
const credentialFields = await credentialValues(provider, options.credentials)
128-
const operation = V2_OPERATIONS.createServiceAccountCredential
129-
const response = await client.request<CreateServiceAccountCredentialResponse>(operation.path, {
130-
method: operation.method,
131-
body: {
132-
workspaceId,
133-
type: 'service_account',
134-
providerId,
135-
displayName: options.name,
136-
...(options.description ? { description: options.description } : {}),
137-
...(options.id ? { id: options.id } : {}),
138-
credentials: JSON.stringify(credentialFields),
139-
},
140-
})
124+
const response = await client.request<CreateServiceAccountCredentialResponse>(
125+
'createServiceAccountCredential',
126+
{
127+
body: {
128+
workspaceId,
129+
type: 'service_account',
130+
providerId,
131+
displayName: options.name,
132+
...(options.description ? { description: options.description } : {}),
133+
...(options.id ? { id: options.id } : {}),
134+
credentials: JSON.stringify(credentialFields),
135+
},
136+
}
137+
)
141138

142139
renderResult(
143140
'createServiceAccountCredential',
@@ -147,16 +144,19 @@ async function createServiceAccount(
147144
)
148145
}
149146

150-
async function createConnectionLink(command: Command, body: ConnectionBody): Promise<void> {
151-
const { client, profile } = clientFrom(command)
152-
const operation = V2_OPERATIONS.createCredentialConnection
153-
const response = await client.request<CreateCredentialConnectionResponse>(operation.path, {
154-
method: operation.method,
155-
body: {
156-
workspaceId: client.requireWorkspace(),
157-
...body,
158-
},
159-
})
147+
async function createConnectionLink(
148+
{ client, profile }: Connection<'createCredentialConnection'>,
149+
body: ConnectionBody
150+
): Promise<void> {
151+
const response = await client.request<CreateCredentialConnectionResponse>(
152+
'createCredentialConnection',
153+
{
154+
body: {
155+
workspaceId: client.requireWorkspace(),
156+
...body,
157+
},
158+
}
159+
)
160160

161161
renderResult('createCredentialConnection', profile.output, response.data, CONNECTION_RESULT)
162162
}
@@ -194,8 +194,13 @@ export function attachCredentialCommands(program: Command): void {
194194

195195
acceptNameOnUpdate(credentials)
196196

197-
const create = credentials
198-
.command('create')
197+
const [create, connectCreate] = apiCommand(
198+
credentials,
199+
'create',
200+
['listCredentialProviders', 'createServiceAccountCredential'],
201+
{ runs: 'createServiceAccountCredential' }
202+
)
203+
create
199204
.argument('<providerId>', 'Service-account provider to create a credential for')
200205
.description(
201206
describeOperation(
@@ -217,14 +222,12 @@ export function attachCredentialCommands(program: Command): void {
217222
'--id <credentialId>',
218223
'Client-generated credential ID when provider discovery requires it'
219224
)
220-
.action((providerId: string, options: CreateServiceAccountOptions, command: Command) =>
221-
createServiceAccount(command, providerId, options)
225+
.action((providerId: string, options: CreateServiceAccountOptions) =>
226+
createServiceAccount(connectCreate(), providerId, options)
222227
)
223-
callsOperations(create, ['listCredentialProviders', 'createServiceAccountCredential'])
224-
runsOperation(create, 'createServiceAccountCredential')
225228

226-
credentials
227-
.command('connect')
229+
const [connect, connectLink] = apiCommand(credentials, 'connect', ['createCredentialConnection'])
230+
connect
228231
.argument('<providerId>', 'OAuth provider to connect')
229232
.description(
230233
describeOperation(
@@ -233,20 +236,20 @@ export function attachCredentialCommands(program: Command): void {
233236
)
234237
)
235238
.requiredOption('--name <displayName>', 'Name shown for the new credential in Sim (required)')
236-
.action(async (providerId: string, options: { name: string }, command: Command) =>
237-
createConnectionLink(command, { providerId, displayName: options.name })
239+
.action(async (providerId: string, options: { name: string }) =>
240+
createConnectionLink(connectLink(), { providerId, displayName: options.name })
238241
)
239242

240-
credentials
241-
.command('reconnect')
243+
const [reconnect, reconnectLink] = apiCommand(credentials, 'reconnect', [
244+
'createCredentialConnection',
245+
])
246+
reconnect
242247
.argument('<credentialId>', 'Existing OAuth credential to re-authorize')
243248
.description(
244249
describeOperation(
245250
V2_OPERATIONS.createCredentialConnection,
246251
'Create a short-lived link for reconnecting an OAuth credential'
247252
)
248253
)
249-
.action((credentialId: string, _options: unknown, command: Command) =>
250-
createConnectionLink(command, { credentialId })
251-
)
254+
.action((credentialId: string) => createConnectionLink(reconnectLink(), { credentialId }))
252255
}

‎packages/sim-cli/src/commands/protocol/chat.ts‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@ import type { Command } from 'commander'
22
import { embedStore } from '#sim-cli/embed-context'
33
import { writeStderr, writeStdout } from '#sim-cli/output/io'
44
import { styles } from '#sim-cli/output/presentation'
5-
import { clientFrom } from '../../context'
6-
import { type ChatResponse, V2_OPERATIONS } from '../../generated/v2-api'
5+
import type { ChatResponse } from '../../generated/v2-api'
76
import { SimApiError } from '../../http/client'
87
import { readNdjson } from '../../http/ndjson'
98
import { sanitize } from '../../output/render'
10-
import { callsOperations } from '../../runtime/called-operations'
9+
import { apiCommand } from '../../runtime/called-operations'
1110
import { printProtocolResult } from './result'
1211

1312
/** The final payload, as `POST /api/v2/chat` answers it. */
@@ -99,8 +98,8 @@ function ignoreBrokenPipe(stream: NodeJS.WriteStream): () => void {
9998
*/
10099

101100
export function attachChat(program: Command): void {
102-
const chat = program
103-
.command('chat')
101+
const [chat, connectChat] = apiCommand(program, 'chat', ['chat'])
102+
chat
104103
.description('Ask Sim and print the reply')
105104
.argument('<message>', 'What to ask Sim')
106105
.allowExcessArguments(false)
@@ -119,7 +118,7 @@ Examples:
119118
$ sim --output json chat "Summarize yesterday's failed runs" | jq -r '.content'
120119
`
121120
)
122-
.action(async (message: string, options: ChatOptions, command: Command) => {
121+
.action(async (message: string, options: ChatOptions) => {
123122
/**
124123
* Refused here so the refusal names what the caller typed: the route
125124
* answers in its own field names, and this command builds its request by
@@ -138,11 +137,10 @@ Examples:
138137
)
139138
}
140139

141-
const { client, profile } = clientFrom(command)
140+
const { client, profile } = connectChat()
142141
const workspaceId = client.requireWorkspace()
143142

144-
const response = await client.requestRaw(V2_OPERATIONS.chat.path, {
145-
method: 'POST',
143+
const response = await client.requestRaw('chat', {
146144
body: {
147145
workspaceId,
148146
message,
@@ -198,5 +196,4 @@ Examples:
198196
restorePipeHandling?.()
199197
}
200198
})
201-
callsOperations(chat, ['chat'])
202199
}

0 commit comments

Comments
 (0)