omnara 0.6.0 → 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.
Files changed (90) hide show
  1. package/package.json +14 -57
  2. package/src/bin.ts +34 -0
  3. package/src/browser.ts +16 -0
  4. package/src/config.ts +185 -0
  5. package/src/factory.ts +396 -0
  6. package/src/flags.ts +174 -0
  7. package/src/format.ts +45 -0
  8. package/src/interactive.ts +56 -0
  9. package/src/login.test.ts +131 -0
  10. package/src/login.ts +204 -0
  11. package/src/manifest.ts +702 -0
  12. package/src/mcp-add.ts +299 -0
  13. package/src/mcp-oauth.ts +175 -0
  14. package/src/output.ts +239 -0
  15. package/src/reporter.ts +73 -0
  16. package/src/skill-archive.ts +58 -0
  17. package/src/slack-integration.ts +225 -0
  18. package/tsconfig.json +7 -0
  19. package/README.md +0 -20
  20. package/bin/omnara.js +0 -26
  21. package/dist/auth/auth-provider.d.ts +0 -26
  22. package/dist/auth/cli-auth-service.d.ts +0 -26
  23. package/dist/auth/credential-manager.d.ts +0 -13
  24. package/dist/auth/credentials-store.d.ts +0 -20
  25. package/dist/auth/ensure-cli-credentials.d.ts +0 -8
  26. package/dist/auth/login-flow.d.ts +0 -11
  27. package/dist/auth/loopback-listener.d.ts +0 -20
  28. package/dist/auth/pkce.d.ts +0 -6
  29. package/dist/cli.d.ts +0 -3
  30. package/dist/cli.js +0 -19479
  31. package/dist/cli.js.map +0 -7
  32. package/dist/commands/agent.d.ts +0 -9
  33. package/dist/commands/auth.d.ts +0 -2
  34. package/dist/commands/daemon.d.ts +0 -2
  35. package/dist/commands/index.d.ts +0 -4
  36. package/dist/commands/kill.d.ts +0 -2
  37. package/dist/config/omnara-config.d.ts +0 -19
  38. package/dist/daemon/config-store.d.ts +0 -37
  39. package/dist/daemon/daemon-client.d.ts +0 -27
  40. package/dist/daemon/daemon-launcher.d.ts +0 -39
  41. package/dist/daemon/daemon-service.d.ts +0 -30
  42. package/dist/daemon/directory-registry.d.ts +0 -17
  43. package/dist/daemon/local-control-server.d.ts +0 -38
  44. package/dist/daemon/machine-supervisor.d.ts +0 -63
  45. package/dist/daemon/paths.d.ts +0 -16
  46. package/dist/daemon/run-daemon.d.ts +0 -1
  47. package/dist/daemon/session-pid-registry.d.ts +0 -11
  48. package/dist/daemon/session-runner.d.ts +0 -38
  49. package/dist/daemon/state-store.d.ts +0 -14
  50. package/dist/env/env-mode.d.ts +0 -2
  51. package/dist/http/api-client.d.ts +0 -19
  52. package/dist/http/machines.d.ts +0 -3
  53. package/dist/http/user-sessions.d.ts +0 -16
  54. package/dist/http/workspaces.d.ts +0 -4
  55. package/dist/index.d.ts +0 -1
  56. package/dist/orchestrator/agent-orchestrator.d.ts +0 -103
  57. package/dist/orchestrator/ai-client.d.ts +0 -33
  58. package/dist/orchestrator/orchestrator-prompt.d.ts +0 -8
  59. package/dist/orchestrator/orchestrator-prompt.md +0 -222
  60. package/dist/orchestrator/orchestrator-types.d.ts +0 -168
  61. package/dist/orchestrator/session.d.ts +0 -83
  62. package/dist/orchestrator/tools/worker-tools.d.ts +0 -10
  63. package/dist/orchestrator/utils/completions-manager.d.ts +0 -8
  64. package/dist/orchestrator/utils/envelope-utils.d.ts +0 -13
  65. package/dist/orchestrator/utils/index.d.ts +0 -2
  66. package/dist/orchestrator/utils/session-logger.d.ts +0 -7
  67. package/dist/orchestrator/utils/worker-events.d.ts +0 -7
  68. package/dist/orchestrator/utils/worktree-manager.d.ts +0 -51
  69. package/dist/orchestrator/worker-manager.d.ts +0 -81
  70. package/dist/orchestrator/worker-types.d.ts +0 -23
  71. package/dist/orchestrator-prompt.md +0 -222
  72. package/dist/package-info.d.ts +0 -12
  73. package/dist/relay/relay-bridge.d.ts +0 -65
  74. package/dist/relay/relay-service.d.ts +0 -19
  75. package/dist/schemas/index.d.ts +0 -2
  76. package/dist/utils/backoff.d.ts +0 -6
  77. package/dist/utils/browser.d.ts +0 -1
  78. package/dist/utils/claude-code-check.d.ts +0 -19
  79. package/dist/utils/executable.d.ts +0 -53
  80. package/dist/utils/logger.d.ts +0 -143
  81. package/dist/utils/prompt.d.ts +0 -1
  82. package/dist/utils/version-check.d.ts +0 -43
  83. package/dist/utils/websocket.d.ts +0 -5
  84. package/instrument.mjs +0 -14
  85. package/scripts/build-executable.ts +0 -244
  86. package/scripts/copy-assets.mjs +0 -41
  87. package/scripts/generate-release-json.ts +0 -136
  88. package/scripts/preinstall.js +0 -141
  89. package/scripts/test-preinstall.js +0 -124
  90. package/scripts/upload-release.ts +0 -388
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
+ }
package/src/flags.ts ADDED
@@ -0,0 +1,174 @@
1
+ import * as z from 'zod'
2
+
3
+ export type FlagValueKind = 'string' | 'number' | 'boolean' | 'json' | 'stringArray'
4
+
5
+ export interface FlagSpec {
6
+ path: string[]
7
+ flag: string
8
+ optionKey: string
9
+ kind: FlagValueKind
10
+ required: boolean
11
+ description: string
12
+ }
13
+
14
+ type JsonSchema = z.core.JSONSchema.BaseSchema
15
+
16
+ export function kebabCase(name: string): string {
17
+ return name
18
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
19
+ .replaceAll('_', '-')
20
+ .toLowerCase()
21
+ }
22
+
23
+ function camelCase(flag: string): string {
24
+ return flag.replace(/-(\w)/g, (_, letter: string) => letter.toUpperCase())
25
+ }
26
+
27
+ interface ObjectMember {
28
+ schema: JsonSchema
29
+ conditional: boolean
30
+ }
31
+
32
+ function objectMembers(schema: JsonSchema, conditional: boolean): ObjectMember[] {
33
+ const members: ObjectMember[] = []
34
+ if (schema.properties !== undefined) members.push({ schema, conditional })
35
+ for (const member of schema.allOf ?? []) {
36
+ members.push(...objectMembers(member, conditional))
37
+ }
38
+ for (const variants of [schema.anyOf, schema.oneOf]) {
39
+ for (const variant of variants ?? []) {
40
+ members.push(...objectMembers(variant, true))
41
+ }
42
+ }
43
+ return members
44
+ }
45
+
46
+ function nonNullVariants(property: JsonSchema): JsonSchema[] | undefined {
47
+ const variants = property.anyOf ?? property.oneOf
48
+ if (variants === undefined) return undefined
49
+ return variants.filter((variant) => variant.type !== 'null')
50
+ }
51
+
52
+ function flagKind(property: JsonSchema): FlagValueKind {
53
+ if (property.const !== undefined) {
54
+ const kind = typeof property.const
55
+ return kind === 'string' || kind === 'number' || kind === 'boolean' ? kind : 'json'
56
+ }
57
+ if (property.enum !== undefined) return 'string'
58
+ const variants = nonNullVariants(property)
59
+ if (variants !== undefined) {
60
+ const kinds = new Set(variants.map(flagKind))
61
+ const [first] = kinds
62
+ return kinds.size === 1 && first !== undefined ? first : 'json'
63
+ }
64
+ switch (property.type) {
65
+ case 'string':
66
+ return 'string'
67
+ case 'integer':
68
+ case 'number':
69
+ return 'number'
70
+ case 'boolean':
71
+ return 'boolean'
72
+ case 'array': {
73
+ const items = property.items
74
+ return typeof items === 'object' &&
75
+ !Array.isArray(items) &&
76
+ items.type === 'string' &&
77
+ items.enum === undefined
78
+ ? 'stringArray'
79
+ : 'json'
80
+ }
81
+ default:
82
+ return 'json'
83
+ }
84
+ }
85
+
86
+ function enumChoices(property: JsonSchema): (string | number)[] | undefined {
87
+ if (typeof property.const === 'string' || typeof property.const === 'number') {
88
+ return [property.const]
89
+ }
90
+ if (property.enum !== undefined) {
91
+ const choices = property.enum.filter(
92
+ (value) => typeof value === 'string' || typeof value === 'number',
93
+ )
94
+ return choices.length > 0 ? choices : undefined
95
+ }
96
+ const variants = nonNullVariants(property)
97
+ const only = variants?.length === 1 ? variants[0] : undefined
98
+ return only === undefined ? undefined : enumChoices(only)
99
+ }
100
+
101
+ interface FlagDraft {
102
+ path: string[]
103
+ kind: FlagValueKind
104
+ required: boolean
105
+ description: string | undefined
106
+ choices: (string | number)[] | undefined
107
+ }
108
+
109
+ function expandsToFlags(property: JsonSchema): boolean {
110
+ return objectMembers(property, false).length > 0
111
+ }
112
+
113
+ function collectDrafts(
114
+ drafts: Map<string, FlagDraft>,
115
+ schema: JsonSchema,
116
+ prefix: string[],
117
+ conditional: boolean,
118
+ ): void {
119
+ for (const member of objectMembers(schema, conditional)) {
120
+ const required = new Set(member.schema.required ?? [])
121
+ for (const [key, property] of Object.entries(member.schema.properties ?? {})) {
122
+ if (typeof property === 'boolean') continue
123
+ const path = [...prefix, key]
124
+ const propertyRequired = !member.conditional && required.has(key)
125
+ if (expandsToFlags(property)) {
126
+ collectDrafts(drafts, property, path, !propertyRequired)
127
+ continue
128
+ }
129
+ const choices = enumChoices(property)
130
+ const description = property.description?.split('\n')[0]
131
+ const existing = drafts.get(path.join('.'))
132
+ if (existing !== undefined) {
133
+ existing.required = existing.required || propertyRequired
134
+ existing.description ??= description
135
+ existing.choices =
136
+ existing.choices !== undefined && choices !== undefined
137
+ ? [...new Set([...existing.choices, ...choices])]
138
+ : undefined
139
+ continue
140
+ }
141
+ drafts.set(path.join('.'), {
142
+ path,
143
+ kind: flagKind(property),
144
+ required: propertyRequired,
145
+ description,
146
+ choices,
147
+ })
148
+ }
149
+ }
150
+ }
151
+
152
+ function finalizeFlag(draft: FlagDraft): FlagSpec {
153
+ const flag = draft.path.map(kebabCase).join('-')
154
+ const parts: string[] = []
155
+ if (draft.description !== undefined) parts.push(draft.description)
156
+ if (draft.choices !== undefined) parts.push(`choices: ${draft.choices.join(', ')}`)
157
+ if (draft.kind === 'json') parts.push('JSON value')
158
+ if (draft.kind === 'stringArray') parts.push('repeatable')
159
+ return {
160
+ path: draft.path,
161
+ flag,
162
+ optionKey: camelCase(flag),
163
+ kind: draft.kind,
164
+ required: draft.required,
165
+ description: parts.join(' — '),
166
+ }
167
+ }
168
+
169
+ export function deriveFlags(schema: z.ZodType): FlagSpec[] {
170
+ const jsonSchema: JsonSchema = z.toJSONSchema(schema, { io: 'input' })
171
+ const drafts = new Map<string, FlagDraft>()
172
+ collectDrafts(drafts, jsonSchema, [], false)
173
+ return [...drafts.values()].map(finalizeFlag)
174
+ }
package/src/format.ts ADDED
@@ -0,0 +1,45 @@
1
+ export interface FormattedOutput {
2
+ value: unknown
3
+ columns?: readonly string[]
4
+ }
5
+
6
+ export type OutputFormat<Response> = (data: Response) => FormattedOutput
7
+
8
+ interface ListEnvelope<Item> {
9
+ data: Item[]
10
+ next_cursor: string | null
11
+ }
12
+
13
+ type FieldName<Value> = Extract<keyof Value, string>
14
+
15
+ function pickFields<Value extends object>(
16
+ source: Value,
17
+ fields: readonly FieldName<Value>[],
18
+ ): Record<string, unknown> {
19
+ const picked: Record<string, unknown> = {}
20
+ for (const field of fields) {
21
+ const value = source[field]
22
+ if (value !== undefined) picked[field] = value
23
+ }
24
+ return picked
25
+ }
26
+
27
+ export function formatTable<Item extends object>(
28
+ columns: readonly FieldName<Item>[],
29
+ ): OutputFormat<ListEnvelope<Item>> {
30
+ return (response) => ({
31
+ value: {
32
+ data: response.data.map((item) => pickFields(item, columns)),
33
+ next_cursor: response.next_cursor,
34
+ },
35
+ columns,
36
+ })
37
+ }
38
+
39
+ export function formatRecord<Value extends object>(): OutputFormat<Value> {
40
+ return (data) => ({ value: data })
41
+ }
42
+
43
+ export function formatVoid(message = 'ok'): OutputFormat<void> {
44
+ return () => ({ value: message })
45
+ }
@@ -0,0 +1,56 @@
1
+ import { isCancel, select } from '@clack/prompts'
2
+ import { type OmnaraClient, sdk } from '@omnara/sdk'
3
+
4
+ import { CliInputError } from './output.ts'
5
+
6
+ export function canPromptInteractively(): boolean {
7
+ return process.stdin.isTTY && process.stdout.isTTY
8
+ }
9
+
10
+ interface Choice {
11
+ id: string
12
+ label: string
13
+ }
14
+
15
+ async function selectFrom(message: string, choices: Choice[]): Promise<string> {
16
+ const [only] = choices
17
+ if (choices.length === 1 && only !== undefined) {
18
+ console.log(`${message}: ${only.label} (only option)`)
19
+ return only.id
20
+ }
21
+ const picked = await select({
22
+ message,
23
+ options: choices.map((choice) => ({ value: choice.id, label: choice.label, hint: choice.id })),
24
+ })
25
+ if (isCancel(picked)) throw new CliInputError('selection cancelled')
26
+ return picked
27
+ }
28
+
29
+ export async function promptOrgSelection(client: OmnaraClient): Promise<string> {
30
+ const { data } = await sdk.getCurrentUser({ client })
31
+ if (data.orgs.length === 0) {
32
+ throw new CliInputError('this account has no organizations')
33
+ }
34
+ return selectFrom(
35
+ 'Select an organization',
36
+ data.orgs.map((org) => ({ id: org.id, label: org.name })),
37
+ )
38
+ }
39
+
40
+ export async function promptProjectSelection(client: OmnaraClient, orgId: string): Promise<string> {
41
+ const projects: Choice[] = []
42
+ let cursor: string | undefined
43
+ do {
44
+ const { data } = await sdk.listVisibleProjects({
45
+ client,
46
+ path: { orgID: orgId },
47
+ query: cursor === undefined ? {} : { cursor },
48
+ })
49
+ projects.push(...data.data.map((project) => ({ id: project.id, label: project.name })))
50
+ cursor = data.next_cursor ?? undefined
51
+ } while (cursor !== undefined)
52
+ if (projects.length === 0) {
53
+ throw new CliInputError(`no projects visible in organization ${orgId}`)
54
+ }
55
+ return selectFrom('Select a project', projects)
56
+ }