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 +18 -30
- 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 -33
- package/bin/omnara.js +0 -65
package/package.json
CHANGED
|
@@ -1,38 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omnara",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
3
|
+
"version": "1.0.0-beta.0",
|
|
4
|
+
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
|
-
"omnara": "./bin
|
|
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
|
-
"
|
|
33
|
-
"
|
|
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
|
-
"
|
|
36
|
-
"node": "
|
|
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
|
+
}
|