omnara 0.6.1 → 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,38 +1,26 @@
1
1
  {
2
2
  "name": "omnara",
3
- "version": "0.6.1",
4
- "description": "Deprecated npm redirect for Omnara. Install the current CLI with curl -fsSL https://omnara.com/install.sh | bash.",
3
+ "version": "1.0.0-beta.0",
4
+ "type": "module",
5
5
  "bin": {
6
- "omnara": "./bin/omnara.js"
7
- },
8
- "files": [
9
- "bin",
10
- "README.md"
11
- ],
12
- "keywords": [
13
- "omnara",
14
- "cli",
15
- "deprecated",
16
- "installer",
17
- "ai",
18
- "agents"
19
- ],
20
- "author": "Omnara <contact@omnara.com>",
21
- "license": "Apache-2.0",
22
- "homepage": "https://omnara.com",
23
- "repository": {
24
- "type": "git",
25
- "url": "git+https://github.com/omnara/omnara-voice.git",
26
- "directory": "packaging/deprecated-npm-omnara"
27
- },
28
- "bugs": {
29
- "url": "https://github.com/omnara/omnara-voice/issues"
6
+ "omnara": "./src/bin.ts"
30
7
  },
31
8
  "scripts": {
32
- "test": "node tests/redirect.test.js",
33
- "pack:check": "npm pack --dry-run"
9
+ "omnara": "tsx src/bin.ts",
10
+ "test": "vitest run",
11
+ "typecheck": "tsc --noEmit -p tsconfig.json"
12
+ },
13
+ "dependencies": {
14
+ "@clack/prompts": "^1.7.0",
15
+ "@omnara/sdk": "workspace:*",
16
+ "commander": "^14.0.0",
17
+ "tar": "^7.5.22",
18
+ "zod": "^4.4.3"
34
19
  },
35
- "engines": {
36
- "node": ">=16"
20
+ "devDependencies": {
21
+ "@types/node": "^22.10.0",
22
+ "tsx": "^4.19.0",
23
+ "typescript": "^5.6.0",
24
+ "vitest": "4.1.9"
37
25
  }
38
26
  }
package/src/bin.ts ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env tsx
2
+ import { Command } from 'commander'
3
+
4
+ import { loadConfig, registerConfigCommand } from './config.ts'
5
+ import { registerGroup, registerOperation } from './factory.ts'
6
+ import { registerLoginCommand } from './login.ts'
7
+ import { commandGroups, topLevelOperations } from './manifest.ts'
8
+
9
+ process.stdout.on('error', (error: NodeJS.ErrnoException) => {
10
+ if (error.code === 'EPIPE') process.exit(0)
11
+ throw error
12
+ })
13
+
14
+ const program = new Command('omnara').description('Interact with the Omnara API')
15
+ program.configureHelp({
16
+ subcommandTerm: (cmd) => {
17
+ const args = cmd.registeredArguments
18
+ .map((arg) =>
19
+ arg.required
20
+ ? `<${arg.name()}${arg.variadic ? '...' : ''}>`
21
+ : `[${arg.name()}${arg.variadic ? '...' : ''}]`,
22
+ )
23
+ .join(' ')
24
+ return [cmd.name(), cmd.options.length > 0 ? '[options]' : '', args]
25
+ .filter((part) => part !== '')
26
+ .join(' ')
27
+ },
28
+ })
29
+ const config = loadConfig()
30
+ for (const group of commandGroups) registerGroup(program, config, group)
31
+ for (const operation of topLevelOperations) registerOperation(program, config, operation)
32
+ registerLoginCommand(program, config)
33
+ registerConfigCommand(program, config)
34
+ await program.parseAsync()
package/src/browser.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ function browserCommand(url: string): { command: string; args: string[] } {
4
+ if (process.platform === 'darwin') return { command: 'open', args: [url] }
5
+ if (process.platform === 'win32') return { command: 'cmd', args: ['/c', 'start', '', url] }
6
+ return { command: 'xdg-open', args: [url] }
7
+ }
8
+
9
+ export function openInBrowser(url: string, onError: (message: string) => void): void {
10
+ const { command, args } = browserCommand(url)
11
+ const child = spawn(command, args, { stdio: 'ignore', detached: true })
12
+ child.once('error', () => {
13
+ onError('could not open a browser; use the URL above')
14
+ })
15
+ child.unref()
16
+ }
package/src/config.ts ADDED
@@ -0,0 +1,185 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ import { bearerToken, createOmnaraClient, type OmnaraClient } from '@omnara/sdk'
6
+ import { zOrganizationId, zProjectId } from '@omnara/sdk/zod'
7
+ import type { Command } from 'commander'
8
+ import * as z from 'zod'
9
+
10
+ import {
11
+ canPromptInteractively,
12
+ promptOrgSelection,
13
+ promptProjectSelection,
14
+ } from './interactive.ts'
15
+ import { CliInputError, runCliAction } from './output.ts'
16
+
17
+ export const DEFAULT_BASE_URL = 'https://app.omnara.com'
18
+
19
+ const zConfigFile = z.looseObject({
20
+ base_url: z.url().optional(),
21
+ token: z.string().min(1).optional(),
22
+ org_id: zOrganizationId.optional(),
23
+ project_id: zProjectId.optional(),
24
+ })
25
+
26
+ export type ConfigFile = z.infer<typeof zConfigFile>
27
+
28
+ export interface CliConfig {
29
+ client: OmnaraClient
30
+ baseUrl: string
31
+ defaultOrgId?: string
32
+ defaultProjectId?: string
33
+ }
34
+
35
+ export function configFilePath(): string {
36
+ return join(homedir(), '.config', 'omnara', 'config.json')
37
+ }
38
+
39
+ let warnedAboutConfig = false
40
+
41
+ function warnConfigIgnored(message: string): void {
42
+ if (warnedAboutConfig) return
43
+ warnedAboutConfig = true
44
+ console.error(message)
45
+ }
46
+
47
+ const zConfigContents = z
48
+ .string()
49
+ .transform((raw, ctx): unknown => {
50
+ try {
51
+ return JSON.parse(raw)
52
+ } catch (error) {
53
+ ctx.addIssue(error instanceof Error ? error.message : String(error))
54
+ return z.NEVER
55
+ }
56
+ })
57
+ .pipe(zConfigFile)
58
+
59
+ function loadConfigFile(): z.ZodSafeParseResult<ConfigFile> {
60
+ const path = configFilePath()
61
+ if (!existsSync(path)) return { success: true, data: {} }
62
+ return zConfigContents.safeParse(readFileSync(path, 'utf8'))
63
+ }
64
+
65
+ export function readConfigFile(): ConfigFile {
66
+ const result = loadConfigFile()
67
+ if (result.success) return result.data
68
+ warnConfigIgnored(
69
+ `warning: ignoring unreadable config at ${configFilePath()}: ${z.prettifyError(result.error)}`,
70
+ )
71
+ return {}
72
+ }
73
+
74
+ export function readConfigFileForUpdate(): ConfigFile {
75
+ const result = loadConfigFile()
76
+ if (result.success) return result.data
77
+ throw new CliInputError(
78
+ `refusing to modify unreadable config at ${configFilePath()} (fix or delete it first): ${z.prettifyError(result.error)}`,
79
+ )
80
+ }
81
+
82
+ export function updateConfigFile(patch: Partial<ConfigFile>): ConfigFile {
83
+ const merged = Object.fromEntries(
84
+ Object.entries({ ...readConfigFileForUpdate(), ...patch }).filter(
85
+ ([, value]) => value !== undefined,
86
+ ),
87
+ )
88
+ const result = zConfigFile.safeParse(merged)
89
+ if (!result.success) {
90
+ throw new CliInputError(`refusing to save invalid config:\n${z.prettifyError(result.error)}`)
91
+ }
92
+ mkdirSync(dirname(configFilePath()), { recursive: true, mode: 0o700 })
93
+ writeFileSync(configFilePath(), `${JSON.stringify(result.data, null, 2)}\n`, { mode: 0o600 })
94
+ chmodSync(configFilePath(), 0o600)
95
+ return result.data
96
+ }
97
+
98
+ export function loadConfig(): CliConfig {
99
+ const file = readConfigFile()
100
+ const baseUrl = process.env.OMNARA_BASE_URL ?? file.base_url ?? DEFAULT_BASE_URL
101
+ const token = process.env.OMNARA_API_KEY ?? file.token
102
+ const client = createOmnaraClient({
103
+ baseUrl,
104
+ auth: token ? bearerToken(token) : undefined,
105
+ })
106
+ return {
107
+ client,
108
+ baseUrl,
109
+ defaultOrgId: process.env.OMNARA_ORG_ID ?? file.org_id,
110
+ defaultProjectId: process.env.OMNARA_PROJECT_ID ?? file.project_id,
111
+ }
112
+ }
113
+
114
+ interface ConfigOptions {
115
+ org?: string
116
+ project?: string
117
+ baseUrl?: string
118
+ }
119
+
120
+ function describeValue(
121
+ envName: string,
122
+ fileValue: string | undefined,
123
+ defaultValue?: string,
124
+ ): string {
125
+ const envValue = process.env[envName]
126
+ if (envValue !== undefined) {
127
+ const shadowed = fileValue !== undefined ? ` (config has ${fileValue})` : ''
128
+ return `${envValue} [from ${envName}${shadowed}]`
129
+ }
130
+ if (fileValue !== undefined) return `${fileValue} [from config file]`
131
+ if (defaultValue !== undefined) return `${defaultValue} [default]`
132
+ return '(not set)'
133
+ }
134
+
135
+ function printConfig(): void {
136
+ const file = readConfigFile()
137
+ console.log(`config file ${configFilePath()}`)
138
+ console.log(`org_id ${describeValue('OMNARA_ORG_ID', file.org_id)}`)
139
+ console.log(`project_id ${describeValue('OMNARA_PROJECT_ID', file.project_id)}`)
140
+ console.log(`base_url ${describeValue('OMNARA_BASE_URL', file.base_url, DEFAULT_BASE_URL)}`)
141
+ }
142
+
143
+ export function registerConfigCommand(program: Command, cli: CliConfig): void {
144
+ const config = program
145
+ .command('config')
146
+ .description('Show or set the default organization and project')
147
+ .option('--org <org-id>', 'save this organization ID as the default')
148
+ .option('--project <project-id>', 'save this project ID as the default')
149
+ .option('--base-url <url>', 'save this API base URL as the default')
150
+ .action(async (options: ConfigOptions) => {
151
+ await runCliAction(() => {
152
+ if (
153
+ options.org !== undefined ||
154
+ options.project !== undefined ||
155
+ options.baseUrl !== undefined
156
+ ) {
157
+ const clearStaleProject = options.org !== undefined && options.project === undefined
158
+ updateConfigFile({
159
+ ...(options.org !== undefined ? { org_id: options.org } : {}),
160
+ ...(clearStaleProject
161
+ ? { project_id: undefined }
162
+ : options.project !== undefined
163
+ ? { project_id: options.project }
164
+ : {}),
165
+ ...(options.baseUrl !== undefined ? { base_url: options.baseUrl } : {}),
166
+ })
167
+ }
168
+ printConfig()
169
+ })
170
+ })
171
+ config
172
+ .command('select')
173
+ .description('Interactively choose the default organization and project')
174
+ .action(async () => {
175
+ await runCliAction(async () => {
176
+ if (!canPromptInteractively()) {
177
+ throw new CliInputError('interactive selection needs a terminal')
178
+ }
179
+ const orgId = await promptOrgSelection(cli.client)
180
+ const projectId = await promptProjectSelection(cli.client, orgId)
181
+ updateConfigFile({ org_id: orgId, project_id: projectId })
182
+ printConfig()
183
+ })
184
+ })
185
+ }
package/src/factory.ts ADDED
@@ -0,0 +1,396 @@
1
+ import type { OmnaraClient } from '@omnara/sdk'
2
+ import { type Command, InvalidArgumentError } from 'commander'
3
+ import * as z from 'zod'
4
+
5
+ import { type CliConfig, updateConfigFile } from './config.ts'
6
+ import { deriveFlags, type FlagSpec, kebabCase } from './flags.ts'
7
+ import type { OutputFormat } from './format.ts'
8
+ import {
9
+ canPromptInteractively,
10
+ promptOrgSelection,
11
+ promptProjectSelection,
12
+ } from './interactive.ts'
13
+ import { CliInputError, renderResult, runCliAction } from './output.ts'
14
+ import { createFlowReporter, type FlowReporter } from './reporter.ts'
15
+
16
+ type SdkOperation = (options: never) => Promise<{ data?: unknown }>
17
+
18
+ type ResponseOf<F extends SdkOperation> = F extends (options: never) => PromiseLike<infer R>
19
+ ? R extends { data?: infer D }
20
+ ? Exclude<D, undefined>
21
+ : never
22
+ : never
23
+
24
+ export interface OperationSpec<Response = never, ParsedBody = never> {
25
+ type: 'op'
26
+ verb: string
27
+ summary: string
28
+ fn: SdkOperation
29
+ path?: z.ZodObject<z.ZodRawShape>
30
+ query?: z.ZodObject<z.ZodRawShape>
31
+ body?: z.ZodType
32
+ transformBody?: (body: ParsedBody) => unknown
33
+ positional?: string[]
34
+ format: OutputFormat<Response>
35
+ }
36
+
37
+ export interface FlowContext<Path, Body> {
38
+ client: OmnaraClient
39
+ path: Path
40
+ body: Body
41
+ report: FlowReporter
42
+ }
43
+
44
+ interface FlowInput {
45
+ client: OmnaraClient
46
+ path: Record<string, unknown>
47
+ body: unknown
48
+ report: FlowReporter
49
+ }
50
+
51
+ export interface FlowSpec {
52
+ type: 'flow'
53
+ verb: string
54
+ summary: string
55
+ aliases?: string[]
56
+ path: z.ZodObject<z.ZodRawShape>
57
+ body: z.ZodType
58
+ execute: (input: FlowInput) => Promise<void>
59
+ }
60
+
61
+ export type CommandSpec = OperationSpec | FlowSpec
62
+
63
+ export interface CommandGroup {
64
+ name: string
65
+ summary: string
66
+ aliases?: string[]
67
+ operations?: CommandSpec[]
68
+ groups?: CommandGroup[]
69
+ }
70
+
71
+ export function op<F extends SdkOperation, B extends z.ZodType = z.ZodNever>(
72
+ spec: Omit<OperationSpec<ResponseOf<F>, z.output<B>>, 'type' | 'fn' | 'body'> & {
73
+ fn: F
74
+ body?: B
75
+ },
76
+ ): OperationSpec {
77
+ return { ...spec, type: 'op' }
78
+ }
79
+
80
+ export function flowOp<P extends z.ZodObject<z.ZodRawShape>, B extends z.ZodType>(spec: {
81
+ verb: string
82
+ summary: string
83
+ aliases?: string[]
84
+ path: P
85
+ body: B
86
+ run: (context: FlowContext<z.output<P>, z.output<B>>) => Promise<void>
87
+ }): FlowSpec {
88
+ const { run, ...base } = spec
89
+ return {
90
+ ...base,
91
+ type: 'flow',
92
+ execute: (input) =>
93
+ run({
94
+ client: input.client,
95
+ path: parseWithSchema(spec.path, input.path, 'arguments'),
96
+ body: parseWithSchema(spec.body, input.body, 'flags'),
97
+ report: input.report,
98
+ }),
99
+ }
100
+ }
101
+
102
+ interface ConfigParam {
103
+ key: string
104
+ option: string
105
+ optionKey: string
106
+ configKey: 'org_id' | 'project_id'
107
+ describe: string
108
+ resolve: (config: CliConfig, path: Record<string, unknown>) => string | undefined
109
+ prompt: (client: OmnaraClient, path: Record<string, unknown>) => Promise<string>
110
+ }
111
+
112
+ const CONFIG_PARAMS: ConfigParam[] = [
113
+ {
114
+ key: 'orgID',
115
+ option: '--org <org-id>',
116
+ optionKey: 'org',
117
+ configKey: 'org_id',
118
+ describe: 'pass --org or set OMNARA_ORG_ID',
119
+ resolve: (config) => config.defaultOrgId,
120
+ prompt: (client) => promptOrgSelection(client),
121
+ },
122
+ {
123
+ key: 'projectID',
124
+ option: '--project <project-id>',
125
+ optionKey: 'project',
126
+ configKey: 'project_id',
127
+ describe: 'pass --project or set OMNARA_PROJECT_ID',
128
+ resolve: (config, path) =>
129
+ path.orgID === undefined || path.orgID === config.defaultOrgId
130
+ ? config.defaultProjectId
131
+ : undefined,
132
+ prompt: (client, path) => {
133
+ const orgId = path.orgID
134
+ if (typeof orgId !== 'string') {
135
+ throw new CliInputError('cannot select a project before an organization is set')
136
+ }
137
+ return promptProjectSelection(client, orgId)
138
+ },
139
+ },
140
+ ]
141
+
142
+ function parseJsonFlag(raw: string): unknown {
143
+ try {
144
+ const parsed: unknown = JSON.parse(raw)
145
+ return parsed
146
+ } catch {
147
+ throw new InvalidArgumentError('expects valid JSON')
148
+ }
149
+ }
150
+
151
+ const NUMBER_PATTERN = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/
152
+
153
+ function parseNumberFlag(raw: string): number {
154
+ if (!NUMBER_PATTERN.test(raw.trim())) {
155
+ throw new InvalidArgumentError('expects a number')
156
+ }
157
+ return Number(raw)
158
+ }
159
+
160
+ function registerFlag(command: Command, spec: FlagSpec): void {
161
+ const required = spec.required ? ' (required)' : ''
162
+ const description = `${spec.description}${required}`
163
+ switch (spec.kind) {
164
+ case 'boolean':
165
+ command.option(`--${spec.flag}`, description)
166
+ command.option(`--no-${spec.flag}`, `set ${spec.flag} to false`)
167
+ break
168
+ case 'number':
169
+ command.option(`--${spec.flag} <number>`, description, parseNumberFlag)
170
+ break
171
+ case 'stringArray':
172
+ command.option(
173
+ `--${spec.flag} <value>`,
174
+ description,
175
+ (value: string, previous: string[] | undefined) => [...(previous ?? []), value],
176
+ )
177
+ break
178
+ case 'json':
179
+ command.option(`--${spec.flag} <json>`, description, parseJsonFlag)
180
+ break
181
+ default:
182
+ command.option(`--${spec.flag} <value>`, description)
183
+ }
184
+ }
185
+
186
+ function collectFlagValues(
187
+ specs: FlagSpec[],
188
+ options: Record<string, unknown>,
189
+ ): Record<string, unknown> {
190
+ const root: Record<string, unknown> = {}
191
+ const containers = new Map<string, Record<string, unknown>>()
192
+ const containerFor = (path: readonly string[]): Record<string, unknown> => {
193
+ const name = path[path.length - 1]
194
+ if (name === undefined) return root
195
+ const pathKey = path.join('.')
196
+ let container = containers.get(pathKey)
197
+ if (container === undefined) {
198
+ container = {}
199
+ containers.set(pathKey, container)
200
+ containerFor(path.slice(0, -1))[name] = container
201
+ }
202
+ return container
203
+ }
204
+ for (const spec of specs) {
205
+ const value = options[spec.optionKey]
206
+ const name = spec.path[spec.path.length - 1]
207
+ if (value === undefined || name === undefined) continue
208
+ containerFor(spec.path.slice(0, -1))[name] = value
209
+ }
210
+ return root
211
+ }
212
+
213
+ function parseWithSchema<S extends z.ZodType>(
214
+ schema: S,
215
+ value: unknown,
216
+ label: string,
217
+ ): z.output<S> {
218
+ const result = schema.safeParse(value)
219
+ if (!result.success) {
220
+ throw new CliInputError(`invalid ${label}:\n${z.prettifyError(result.error)}`)
221
+ }
222
+ return result.data
223
+ }
224
+
225
+ interface CallInput {
226
+ client: OmnaraClient
227
+ path?: Record<string, unknown>
228
+ query?: Record<string, unknown>
229
+ body?: unknown
230
+ }
231
+
232
+ async function callOperation(spec: OperationSpec, input: CallInput): Promise<unknown> {
233
+ const result = await spec.fn(input as never)
234
+ return result.data
235
+ }
236
+
237
+ const zBodyObject = z.looseObject({})
238
+
239
+ function deepMerge(
240
+ base: Record<string, unknown>,
241
+ patch: Record<string, unknown>,
242
+ ): Record<string, unknown> {
243
+ const merged = { ...base }
244
+ for (const [key, value] of Object.entries(patch)) {
245
+ const existing = zBodyObject.safeParse(merged[key])
246
+ const incoming = zBodyObject.safeParse(value)
247
+ merged[key] =
248
+ existing.success && incoming.success ? deepMerge(existing.data, incoming.data) : value
249
+ }
250
+ return merged
251
+ }
252
+
253
+ function saveConfigDefault(configKey: 'org_id' | 'project_id', value: string): void {
254
+ try {
255
+ updateConfigFile(
256
+ configKey === 'org_id' ? { org_id: value, project_id: undefined } : { [configKey]: value },
257
+ )
258
+ console.error(`saved ${configKey}=${value} as your default (change with omnara config)`)
259
+ } catch (error) {
260
+ const reason = error instanceof Error ? error.message : String(error)
261
+ console.error(`warning: could not save ${configKey} as your default: ${reason}`)
262
+ }
263
+ }
264
+
265
+ interface PathPlan {
266
+ positionalParams: string[]
267
+ configParams: ConfigParam[]
268
+ }
269
+
270
+ function planPathParams(
271
+ path: z.ZodObject<z.ZodRawShape> | undefined,
272
+ positional: string[],
273
+ ): PathPlan {
274
+ const pathParams = path ? Object.keys(path.shape) : []
275
+ const configParams = CONFIG_PARAMS.filter(
276
+ (param) => pathParams.includes(param.key) && !positional.includes(param.key),
277
+ )
278
+ return {
279
+ positionalParams: pathParams.filter((name) => !configParams.some((param) => param.key === name)),
280
+ configParams,
281
+ }
282
+ }
283
+
284
+ function registerPathParams(command: Command, plan: PathPlan): void {
285
+ for (const param of plan.positionalParams) command.argument(`<${kebabCase(param)}>`)
286
+ for (const param of plan.configParams) {
287
+ command.option(param.option, `defaults from config (${param.describe})`)
288
+ }
289
+ }
290
+
291
+ async function resolvePathValues(
292
+ plan: PathPlan,
293
+ args: string[],
294
+ options: Record<string, unknown>,
295
+ config: CliConfig,
296
+ ): Promise<Record<string, unknown>> {
297
+ const path: Record<string, unknown> = {}
298
+ plan.positionalParams.forEach((param, index) => {
299
+ path[param] = args[index]
300
+ })
301
+ let usedExplicitOverride = false
302
+ for (const param of plan.configParams) {
303
+ const explicitOption = options[param.optionKey]
304
+ const explicit = typeof explicitOption === 'string' ? explicitOption : undefined
305
+ usedExplicitOverride = usedExplicitOverride || explicit !== undefined
306
+ let value = explicit ?? param.resolve(config, path)
307
+ if (value === undefined && canPromptInteractively()) {
308
+ value = await param.prompt(config.client, path)
309
+ if (!usedExplicitOverride) saveConfigDefault(param.configKey, value)
310
+ }
311
+ if (value === undefined) {
312
+ throw new CliInputError(`missing ${param.optionKey}: ${param.describe}`)
313
+ }
314
+ path[param.key] = value
315
+ }
316
+ return path
317
+ }
318
+
319
+ export function registerOperation(parent: Command, config: CliConfig, spec: OperationSpec): void {
320
+ const command = parent.command(spec.verb).description(spec.summary)
321
+ const plan = planPathParams(spec.path, spec.positional ?? [])
322
+ registerPathParams(command, plan)
323
+ const queryFlags = spec.query ? deriveFlags(spec.query) : []
324
+ const bodyFlags = spec.body ? deriveFlags(spec.body) : []
325
+ for (const flag of [...queryFlags, ...bodyFlags]) registerFlag(command, flag)
326
+ if (spec.body) {
327
+ command.option(
328
+ '--body <json>',
329
+ 'full request body as JSON; field flags override it',
330
+ parseJsonFlag,
331
+ )
332
+ }
333
+ command.option('--json', 'print the raw JSON response')
334
+ command.action(async (...args: string[]) => {
335
+ await runCliAction(async () => {
336
+ const options = command.opts<Record<string, unknown>>()
337
+ const input: CallInput = { client: config.client }
338
+ if (spec.path) {
339
+ const path = await resolvePathValues(plan, args, options, config)
340
+ input.path = parseWithSchema(spec.path, path, 'arguments')
341
+ }
342
+ if (spec.query) {
343
+ const query = collectFlagValues(queryFlags, options)
344
+ input.query = parseWithSchema(spec.query, query, 'query flags')
345
+ }
346
+ if (spec.body) {
347
+ const base =
348
+ options.body === undefined ? {} : parseWithSchema(zBodyObject, options.body, '--body')
349
+ const body = deepMerge(base, collectFlagValues(bodyFlags, options))
350
+ const parsed = parseWithSchema(spec.body, body, 'request body')
351
+ input.body = spec.transformBody ? await spec.transformBody(parsed as never) : parsed
352
+ }
353
+ const data = await callOperation(spec, input)
354
+ if (options.json === true) {
355
+ renderResult(data, true)
356
+ return
357
+ }
358
+ const formatted = spec.format(data as never)
359
+ renderResult(formatted.value, false, { columns: formatted.columns })
360
+ })
361
+ })
362
+ }
363
+
364
+ function registerFlow(parent: Command, config: CliConfig, spec: FlowSpec): void {
365
+ const command = parent
366
+ .command(spec.verb)
367
+ .aliases(spec.aliases ?? [])
368
+ .description(spec.summary)
369
+ const plan = planPathParams(spec.path, [])
370
+ registerPathParams(command, plan)
371
+ const bodyFlags = deriveFlags(spec.body)
372
+ for (const flag of bodyFlags) registerFlag(command, flag)
373
+ command.action(async (...args: string[]) => {
374
+ await runCliAction(async () => {
375
+ const options = command.opts<Record<string, unknown>>()
376
+ await spec.execute({
377
+ client: config.client,
378
+ path: await resolvePathValues(plan, args, options, config),
379
+ body: collectFlagValues(bodyFlags, options),
380
+ report: createFlowReporter(spec.summary),
381
+ })
382
+ })
383
+ })
384
+ }
385
+
386
+ export function registerGroup(program: Command, config: CliConfig, group: CommandGroup): void {
387
+ const groupCommand = program
388
+ .command(group.name)
389
+ .aliases(group.aliases ?? [])
390
+ .description(group.summary)
391
+ for (const spec of group.operations ?? []) {
392
+ if (spec.type === 'op') registerOperation(groupCommand, config, spec)
393
+ else registerFlow(groupCommand, config, spec)
394
+ }
395
+ for (const subgroup of group.groups ?? []) registerGroup(groupCommand, config, subgroup)
396
+ }