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.
- package/package.json +14 -57
- package/src/bin.ts +34 -0
- package/src/browser.ts +16 -0
- package/src/config.ts +185 -0
- package/src/factory.ts +396 -0
- package/src/flags.ts +174 -0
- package/src/format.ts +45 -0
- package/src/interactive.ts +56 -0
- package/src/login.test.ts +131 -0
- package/src/login.ts +204 -0
- package/src/manifest.ts +702 -0
- package/src/mcp-add.ts +299 -0
- package/src/mcp-oauth.ts +175 -0
- package/src/output.ts +239 -0
- package/src/reporter.ts +73 -0
- package/src/skill-archive.ts +58 -0
- package/src/slack-integration.ts +225 -0
- package/tsconfig.json +7 -0
- package/README.md +0 -20
- package/bin/omnara.js +0 -26
- package/dist/auth/auth-provider.d.ts +0 -26
- package/dist/auth/cli-auth-service.d.ts +0 -26
- package/dist/auth/credential-manager.d.ts +0 -13
- package/dist/auth/credentials-store.d.ts +0 -20
- package/dist/auth/ensure-cli-credentials.d.ts +0 -8
- package/dist/auth/login-flow.d.ts +0 -11
- package/dist/auth/loopback-listener.d.ts +0 -20
- package/dist/auth/pkce.d.ts +0 -6
- package/dist/cli.d.ts +0 -3
- package/dist/cli.js +0 -19479
- package/dist/cli.js.map +0 -7
- package/dist/commands/agent.d.ts +0 -9
- package/dist/commands/auth.d.ts +0 -2
- package/dist/commands/daemon.d.ts +0 -2
- package/dist/commands/index.d.ts +0 -4
- package/dist/commands/kill.d.ts +0 -2
- package/dist/config/omnara-config.d.ts +0 -19
- package/dist/daemon/config-store.d.ts +0 -37
- package/dist/daemon/daemon-client.d.ts +0 -27
- package/dist/daemon/daemon-launcher.d.ts +0 -39
- package/dist/daemon/daemon-service.d.ts +0 -30
- package/dist/daemon/directory-registry.d.ts +0 -17
- package/dist/daemon/local-control-server.d.ts +0 -38
- package/dist/daemon/machine-supervisor.d.ts +0 -63
- package/dist/daemon/paths.d.ts +0 -16
- package/dist/daemon/run-daemon.d.ts +0 -1
- package/dist/daemon/session-pid-registry.d.ts +0 -11
- package/dist/daemon/session-runner.d.ts +0 -38
- package/dist/daemon/state-store.d.ts +0 -14
- package/dist/env/env-mode.d.ts +0 -2
- package/dist/http/api-client.d.ts +0 -19
- package/dist/http/machines.d.ts +0 -3
- package/dist/http/user-sessions.d.ts +0 -16
- package/dist/http/workspaces.d.ts +0 -4
- package/dist/index.d.ts +0 -1
- package/dist/orchestrator/agent-orchestrator.d.ts +0 -103
- package/dist/orchestrator/ai-client.d.ts +0 -33
- package/dist/orchestrator/orchestrator-prompt.d.ts +0 -8
- package/dist/orchestrator/orchestrator-prompt.md +0 -222
- package/dist/orchestrator/orchestrator-types.d.ts +0 -168
- package/dist/orchestrator/session.d.ts +0 -83
- package/dist/orchestrator/tools/worker-tools.d.ts +0 -10
- package/dist/orchestrator/utils/completions-manager.d.ts +0 -8
- package/dist/orchestrator/utils/envelope-utils.d.ts +0 -13
- package/dist/orchestrator/utils/index.d.ts +0 -2
- package/dist/orchestrator/utils/session-logger.d.ts +0 -7
- package/dist/orchestrator/utils/worker-events.d.ts +0 -7
- package/dist/orchestrator/utils/worktree-manager.d.ts +0 -51
- package/dist/orchestrator/worker-manager.d.ts +0 -81
- package/dist/orchestrator/worker-types.d.ts +0 -23
- package/dist/orchestrator-prompt.md +0 -222
- package/dist/package-info.d.ts +0 -12
- package/dist/relay/relay-bridge.d.ts +0 -65
- package/dist/relay/relay-service.d.ts +0 -19
- package/dist/schemas/index.d.ts +0 -2
- package/dist/utils/backoff.d.ts +0 -6
- package/dist/utils/browser.d.ts +0 -1
- package/dist/utils/claude-code-check.d.ts +0 -19
- package/dist/utils/executable.d.ts +0 -53
- package/dist/utils/logger.d.ts +0 -143
- package/dist/utils/prompt.d.ts +0 -1
- package/dist/utils/version-check.d.ts +0 -43
- package/dist/utils/websocket.d.ts +0 -5
- package/instrument.mjs +0 -14
- package/scripts/build-executable.ts +0 -244
- package/scripts/copy-assets.mjs +0 -41
- package/scripts/generate-release-json.ts +0 -136
- package/scripts/preinstall.js +0 -141
- package/scripts/test-preinstall.js +0 -124
- 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
|
+
}
|