Repository navigation
Expand file tree
/
Copy pathoptions.ts
More file actions
363 lines (335 loc) · 13.5 KB
/
Copy pathoptions.ts
File metadata and controls
363 lines (335 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
import { type Command, Option } from 'commander'
import type { CommandSpec } from '../contract/types'
import type { V2OperationName } from '../generated/v2-api'
import {
cursorSlot,
type FieldSpec,
flagNameFor,
flagSpecFor,
otherSpellings,
PROFILE_INJECTED_FIELD,
pathFlagNameFor,
takesJson,
} from './request'
import type { OperationSpec } from './types'
export const DEFAULT_PAGE_SIZE = 100
/** Resource inventories fetch all pages; data and history commands retain a bounded default. */
const COMPLETE_LIST_OPERATIONS: ReadonlySet<V2OperationName> = new Set([
'listBlocks',
'listChatDeployments',
'listCredentialMembers',
'listCredentials',
'listCustomTools',
'listPermissionGroups',
'listPermissionGroupMembers',
'listOrganizations',
'listOrganizationDomains',
'listOrganizationMembers',
'listOrganizationWorkspaces',
'listFiles',
'listProjectFiles',
'listKnowledgeBases',
'listKnowledgeConnectors',
'listMcpServers',
'listSandboxes',
'listSecrets',
'listSkillEditors',
'listSkills',
'listSsoProviders',
'listTables',
'listTools',
'listWorkflowMcpServers',
'listWorkflows',
'listWorkspaceMembers',
'listWorkspaces',
])
/** Zero fetches every page. Unclassified operations keep the bounded default. */
export function defaultListLimit(operation: V2OperationName): number {
return COMPLETE_LIST_OPERATIONS.has(operation) ? 0 : 100
}
/**
* Help text for one flag, best source first.
*
* The CLI contract wins, because an entry there exists precisely to say
* something the schema cannot — that `workflowIds` is really a list, or that
* `conflictTarget` reads better as `--on`. Otherwise the field's own
* `.describe()` from the route contract carries through: it is the same prose
* the OpenAPI specs publish, so the terminal and the API reference explain a
* field the same way instead of diverging.
*
* `Set <name>` remains as a last resort for a field that documents itself
* nowhere. It is not documentation — it restates the flag name — so it is worth
* treating a fallback that shows up in `--help` as a missing `.describe()` on
* the contract rather than as finished work.
*/
function describeField(
flag: { describe?: string },
descriptor: FieldSpec,
name: string,
field: string
): string {
return flag.describe ?? descriptor.describe ?? `Set ${name.replaceAll('-', ' ') || field}`
}
/**
* The one instruction a route contract gives that a terminal cannot follow.
*
* Several v2 body fields document themselves as changed by sending JSON `null`,
* which is accurate for the API and untypeable here: those fields are string
* flags, so `--description null` sends the four characters. The contract prose
* is correct and stays as written; this warns the reader that the literal is
* all they can type, without inventing a substitute — an empty string empties a
* description but is not what `null` means on, say, `oauthClientSecret`.
*/
function literalNullHint(documented: string, name: string): string {
return /\bnull\b/i.test(documented) ? ` (--${name} null sends the word, not JSON null)` : ''
}
/**
* The wire's boolean vocabulary, which a bare flag cannot offer.
*
* A boolean query param documents the spellings an HTTP caller may send
* (`true`, `1`, `yes`, `on`, …) and closes by saying the listed ones are the
* whole accepted set. That is correct for the API and publishes unchanged in
* the OpenAPI specs, but this CLI renders those fields as a bare `--flag` and
* its `--no-flag` twin, neither of which takes a value — so the sentence points
* at a list the reader is never shown, in `--help` and in the generated
* reference alike. Dropping it here keeps the terminal honest without weakening
* the prose REST callers actually need.
*/
const WIRE_VOCABULARY_SENTENCE = /\s*The listed spellings[^.]*\.\s*/g
function withoutWireVocabulary(documented: string): string {
return documented.replace(WIRE_VOCABULARY_SENTENCE, ' ').trim()
}
/**
* What `--limit` means on a filtered batch operation that does not paginate.
*
* The pager's `0 for everything` spelling is false here: these routes bound the
* field at `1`, and the unbounded form is the flag left off entirely. Said in
* `--help` because nothing else in the terminal says it — the refusal only
* arrives from the server, after the caller has already typed the command.
*
* Gated on the operation actually taking a `--filter`, because a non-paginating
* `limit` is not always a match cap: `files read --limit` bounds a line range,
* and telling its reader the flag "caps a --filter match" names a flag that
* command does not have.
*/
const NON_PAGINATED_LIMIT_HINT =
' (caps a --filter match only; omit it to act on every match, and note 0 is not accepted)'
function addFieldOption(
command: Command,
operation: V2OperationName,
field: string,
descriptor: FieldSpec,
slot: 'query' | 'body' | 'headers',
paginates: boolean,
capsAFilter: boolean
): void {
if (field === PROFILE_INJECTED_FIELD) return
if (field === 'cursor') {
if (paginates && defaultListLimit(operation) > 0) {
command.option('--cursor <value>', 'Continue from nextCursor returned by a previous result')
}
return
}
const flag = flagSpecFor(operation, field)
if (flag.omit) return
const name = flagNameFor(operation, field)
const short = flag.short ? `-${flag.short}, ` : ''
// Gated on the operation actually paginating, not on the field's name: a
// `limit` on a non-paginating operation is a row cap the wire reads
// literally, and commander's `100` default rode into the body of every
// filtered `tables rows batch-delete` as a silent ceiling on what it deleted.
if (
paginates &&
field === 'limit' &&
(descriptor.kind === 'number' || descriptor.kind === 'integer')
) {
command.option(
'--limit <n>',
'Maximum items to return (0 for everything)',
String(defaultListLimit(operation))
)
return
}
const fieldDescription = `${describeField(flag, descriptor, name, field)}${
capsAFilter &&
field === 'limit' &&
(descriptor.kind === 'number' || descriptor.kind === 'integer')
? NON_PAGINATED_LIMIT_HINT
: ''
}`
const effectiveDefault = flag.requestDefault ?? descriptor.default
const documented =
effectiveDefault !== undefined && !/\bdefault(?:s|ing)?\b/i.test(fieldDescription)
? `${fieldDescription}${/[.!?]$/.test(fieldDescription) ? '' : '.'} Defaults to ${JSON.stringify(effectiveDefault)}.`
: fieldDescription
if (descriptor.kind === 'boolean' || flag.boolean) {
const booleanDoc = withoutWireVocabulary(documented)
if (descriptor.required) {
command.addOption(
new Option(`${short}--${name} <true|false>`, `${booleanDoc} (required)`)
.choices(['true', 'false'])
.makeOptionMandatory()
)
return
}
command.option(`${short}--${name}`, booleanDoc)
// The twin exists to send an explicit `false`. Restating the positive
// flag's prose here inverts its meaning ("Return only deployed workflows"
// on the flag that stops doing exactly that), so it names its counterpart
// instead and lets the reader look up one description, not two.
if (!flag.boolean || flag.negatable) {
command.option(`--no-${name}`, `Send --${name} as false`)
}
for (const { name: previous } of otherSpellings(flag)) {
command.addOption(new Option(`--${previous}`).hideHelp())
if (!flag.boolean || flag.negatable)
command.addOption(new Option(`--no-${previous}`).hideHelp())
}
return
}
const takesList = flag.list === true
const wantsJson = takesJson(descriptor, flag)
const placeholder = takesList
? '<value...>'
: flag.rowCap
? '<n>'
: wantsJson
? '<json|@file>'
: flag.textSource
? '<value|@file>'
: descriptor.nullable
? '<number|null>'
: '<value>'
const choices = flag.choices ?? descriptor.values
/**
* Only a body field reaches the wire as JSON, and only a plain scalar flag is
* stuck with the literal: a `<json|@file>` flag parses `null` into the value.
*/
const literalNull = slot === 'body' && !takesList && !wantsJson && !descriptor.nullable
const describe = `${documented}${
takesList
? flag.manifest
? ' (space-separated, or @path / @- with one value per line; in a file, blank lines and # comments are ignored, while inline values are sent as typed and may not be empty; @@value for a literal leading @)'
: ' (space-separated, or @path / @- with one value per line; @@value for a literal leading @)'
: wantsJson
? ' (JSON, or @path / @- to read a file or stdin)'
: flag.textSource
? ' (@path / @- reads a file or stdin verbatim, including trailing newlines; @@value for a literal leading @)'
: ''
}${
descriptor.required
? flag.trailing
? ' (required, or pass the values as arguments)'
: ' (required)'
: ''
}${literalNull ? literalNullHint(documented, name) : ''}`
const hiddenNames = otherSpellings(flag).map(({ name }) => name)
const option = new Option(`${short}--${name} ${placeholder}`, describe)
if (flag.hidden) option.hideHelp()
if (choices && !takesList) option.choices([...choices])
if (descriptor.default !== undefined && field !== 'limit') {
option.default(undefined, String(descriptor.default))
}
// Commander's mandatory check runs before `executeOperation` can fold another
// spelling or trailing arguments onto the flag, so a required field supplied
// either way would be rejected. The requirement is not lost: `buildRequest`
// raises it once every spelling has had its chance.
if (descriptor.required && hiddenNames.length === 0 && !flag.trailing) {
option.makeOptionMandatory()
}
command.addOption(option)
for (const previous of hiddenNames) {
const hidden = new Option(`--${previous} ${placeholder}`).hideHelp()
if (choices && !takesList) hidden.choices([...choices])
command.addOption(hidden)
}
}
/** Adds request-field and safety options for one generated operation. */
export function addOperationOptions(
command: Command,
operation: V2OperationName,
commandSpec: CommandSpec,
operationSpec: OperationSpec
): void {
for (const param of operationSpec.pathParams) {
const flag = commandSpec.pathFlags?.[param]
if (!flag) continue
const name = pathFlagNameFor(commandSpec, param)
const short = flag.short ? `-${flag.short}, ` : ''
command.addOption(
new Option(
`${short}--${name} <${flag.placeholder ?? 'value'}>`,
`${flag.describe ?? operationSpec.pathParamDocs?.[param] ?? `Set ${name.replaceAll('-', ' ')}`} (required)`
).makeOptionMandatory()
)
}
const paginates = cursorSlot(operationSpec) !== null
const capsAFilter = (['query', 'body', 'headers'] as const).some(
(slot) => operationSpec[slot] !== undefined && 'filter' in operationSpec[slot]
)
for (const slot of ['query', 'body', 'headers'] as const) {
for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) {
if (commandSpec.requestFields && !commandSpec.requestFields.includes(field)) continue
if (commandSpec.positionals?.includes(field)) continue
addFieldOption(command, operation, field, descriptor, slot, paginates, capsAFilter)
}
}
if (commandSpec.allWorkspaces) {
command.option(
'--all-workspaces',
'Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access)'
)
}
if (commandSpec.expandedTrace) {
command.option(
'--trace',
'Show expanded trace spans with inputs, outputs, errors, timing, and cost'
)
}
if (operationSpec.opaqueBody) {
if (commandSpec.bodyVariants) {
for (const variant of commandSpec.bodyVariants) {
command.option(
`--${variant.name} <json|@file>`,
`${variant.describe} (JSON, or @path / @-; choose exactly one body flag)`
)
}
} else {
command.requiredOption(
'--body <json|@file>',
'Request body as JSON (or @path / @- to read a file or stdin) (required)'
)
}
}
if (commandSpec.workspaceOperation) {
command.option(
'--wait',
'Wait for the committed operation to finish; missing configuration and failure exit nonzero'
)
command.option(
'--wait-timeout <seconds>',
'Maximum operation wait in seconds (default 3600; 0 waits indefinitely)'
)
}
if (commandSpec.confirm) {
// There is no prompt to skip: a `confirm` command refuses outright when the
// flag is absent, in a TTY or not. Calling it "Skip the confirmation" sent
// readers looking for a question the CLI never asks, and hid that the flag
// is the only way the command ever runs.
// A dry run is exempt at the gate itself, so on an operation that accepts
// `--dry-run` the flag is not unconditionally required and saying so
// outright would send a caller reaching for `--yes` to preview a change.
const exemptedByDryRun =
operationSpec.query?.dryRun !== undefined || operationSpec.body?.dryRun !== undefined
// Not "destructive": the gate also covers operations that only add —
// `files unzip` writes the archive's contents into the workspace and
// destroys nothing — so the adjective was wrong on the help line while the
// refusal itself, which states the operation's own consequence, was right.
command.option(
'-y, --yes',
exemptedByDryRun
? 'Confirm this operation (required unless --dry-run)'
: 'Confirm this operation (required)'
)
}
}