@sixb/cli-core 0.1.7 → 0.1.9

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.
@@ -0,0 +1,508 @@
1
+ import { access, mkdir, stat } from "node:fs/promises"
2
+ import { dirname, extname, join, parse } from "node:path"
3
+ import type { ApiClient } from "../api-client"
4
+ import { enumValue, integerInRange, isHelp, parseCommandArgs, requestsHelp } from "../arguments"
5
+ import { fail, writeJson, writeText } from "../output"
6
+ import { GROUP_HELP } from "./metadata"
7
+ import { readText } from "./shared"
8
+
9
+ export const MODEL_KINDS = ["language", "image", "speech", "transcription"] as const
10
+ export type ModelKind = (typeof MODEL_KINDS)[number]
11
+
12
+ type TokenPrice =
13
+ | string
14
+ | { readonly default: string; readonly tiers: readonly { readonly price: string }[] }
15
+ type VariantPrice =
16
+ | string
17
+ | {
18
+ readonly default?: string
19
+ readonly variants: readonly (Readonly<Record<string, string>> & { readonly price: string })[]
20
+ }
21
+
22
+ /** The prices a binding bills by, as GET /api/models returns them. */
23
+ export interface RateCard {
24
+ readonly input?: TokenPrice
25
+ readonly output?: TokenPrice
26
+ readonly cacheReadInput?: TokenPrice
27
+ readonly audioOutput?: TokenPrice
28
+ readonly images?: VariantPrice
29
+ readonly characters?: VariantPrice
30
+ readonly audioInputSeconds?: VariantPrice
31
+ }
32
+
33
+ interface CatalogEntry {
34
+ readonly provider: string
35
+ readonly modelId: string
36
+ readonly isDefault: boolean
37
+ readonly tier?: string
38
+ readonly name?: string
39
+ readonly description?: string
40
+ readonly rateCard?: RateCard
41
+ readonly [option: string]: unknown
42
+ }
43
+
44
+ interface Catalog {
45
+ readonly language: readonly CatalogEntry[]
46
+ readonly image: readonly CatalogEntry[]
47
+ readonly audio: {
48
+ readonly speech: readonly CatalogEntry[]
49
+ readonly transcription: readonly CatalogEntry[]
50
+ }
51
+ }
52
+
53
+ export async function models(api: ApiClient, args: readonly string[]): Promise<void> {
54
+ const [sub, ...rest] = args
55
+ if (!sub || isHelp(sub) || requestsHelp(rest)) return writeText(GROUP_HELP.models)
56
+ if (sub === "list") {
57
+ const { options } = parseCommandArgs(rest, { "--kind": "string" }, "models list", 0)
58
+ const kind = options["--kind"] === undefined ? undefined : modelKind(options["--kind"])
59
+ const entries = catalogEntries((await api.get("/api/models")) as Catalog)
60
+ return writeJson(
61
+ entries
62
+ .filter((entry) => kind === undefined || entry.kind === kind)
63
+ .map(({ kind, entry }) => ({
64
+ kind,
65
+ model: entry.modelId,
66
+ provider: entry.provider,
67
+ ...(entry.tier ? { tier: entry.tier } : {}),
68
+ ...(entry.isDefault ? { default: true } : {}),
69
+ ...(entry.description ? { description: entry.description } : {}),
70
+ price: describePrice(entry.rateCard),
71
+ }))
72
+ )
73
+ }
74
+ if (sub === "get") {
75
+ const { positionals, options } = parseCommandArgs(rest, { "--kind": "string" }, "models get", 1)
76
+ const kind = options["--kind"] === undefined ? undefined : modelKind(options["--kind"])
77
+ const entries = catalogEntries((await api.get("/api/models")) as Catalog)
78
+ const { kind: found, entry } = findModel(entries, positionals[0] ?? "", kind)
79
+ const { provider, modelId, isDefault, rateCard, ...details } = entry
80
+ return writeJson({
81
+ kind: found,
82
+ model: modelId,
83
+ provider,
84
+ default: isDefault,
85
+ ...details,
86
+ price: describePrice(rateCard),
87
+ ...(rateCard ? { rateCard } : {}),
88
+ })
89
+ }
90
+ if (sub === "text") return generateText(api, rest)
91
+ if (sub === "image") return generateImages(api, rest)
92
+ if (sub === "speech") return generateSpeech(api, rest)
93
+ if (sub === "transcribe") return transcribe(api, rest)
94
+ if (sub === "calls") return calls(api, rest)
95
+ fail(`Unknown models command '${sub}'.`)
96
+ }
97
+
98
+ interface FileRef {
99
+ readonly blobId: string
100
+ readonly mediaType?: string
101
+ readonly sizeBytes: number
102
+ }
103
+
104
+ interface CallResult {
105
+ readonly callId: string
106
+ readonly model: { readonly provider: string; readonly modelId: string }
107
+ readonly cost: { readonly status: string; readonly money?: { readonly amountNanos: string } }
108
+ readonly text?: string
109
+ readonly finishReason?: string
110
+ readonly files?: readonly FileRef[]
111
+ readonly warnings?: readonly string[]
112
+ }
113
+
114
+ const SPEECH_FORMATS = {
115
+ mp3: "audio/mpeg",
116
+ wav: "audio/wav",
117
+ ogg: "audio/ogg",
118
+ aac: "audio/aac",
119
+ flac: "audio/flac",
120
+ } as const
121
+ const SPEECH_FORMAT_NAMES = Object.keys(SPEECH_FORMATS) as (keyof typeof SPEECH_FORMATS)[]
122
+
123
+ // Files the commands upload, and the extension a downloaded file takes from its media type.
124
+ const MEDIA_TYPES: Readonly<Record<string, string>> = {
125
+ ".png": "image/png",
126
+ ".jpg": "image/jpeg",
127
+ ".jpeg": "image/jpeg",
128
+ ".webp": "image/webp",
129
+ ".gif": "image/gif",
130
+ ".mp3": "audio/mpeg",
131
+ ".wav": "audio/wav",
132
+ ".ogg": "audio/ogg",
133
+ ".aac": "audio/aac",
134
+ ".flac": "audio/flac",
135
+ ".opus": "audio/opus",
136
+ ".m4a": "audio/mp4",
137
+ ".webm": "audio/webm",
138
+ }
139
+ const EXTENSIONS: Readonly<Record<string, string>> = Object.fromEntries(
140
+ Object.entries(MEDIA_TYPES)
141
+ .filter(([extension]) => extension !== ".jpeg")
142
+ .map(([extension, mediaType]) => [mediaType, extension])
143
+ )
144
+
145
+ async function generateText(api: ApiClient, args: readonly string[]): Promise<void> {
146
+ const { positionals, options } = parseCommandArgs(
147
+ args,
148
+ {
149
+ "--model": "string",
150
+ "--instructions": "string",
151
+ "--max-output-tokens": "string",
152
+ "--reasoning": "string",
153
+ "--file": "string",
154
+ },
155
+ "models text",
156
+ [0, 1]
157
+ )
158
+ const prompt = await readPrompt(positionals[0], options["--file"])
159
+ const maxOutputTokens = options["--max-output-tokens"]
160
+ const result = (await api.post("/api/models/language/generations", {
161
+ ...(await languageModelRef(api, options["--model"])),
162
+ prompt,
163
+ ...(options["--instructions"] === undefined ? {} : { instructions: options["--instructions"] }),
164
+ ...(maxOutputTokens === undefined
165
+ ? {}
166
+ : { maxOutputTokens: integerInRange("--max-output-tokens", maxOutputTokens, 1, 1_000_000) }),
167
+ ...(options["--reasoning"] === undefined ? {} : { reasoning: options["--reasoning"] }),
168
+ })) as CallResult
169
+ writeJson({
170
+ callId: result.callId,
171
+ model: result.model.modelId,
172
+ text: result.text,
173
+ finishReason: result.finishReason,
174
+ cost: describeCost(result.cost),
175
+ })
176
+ }
177
+
178
+ async function generateImages(api: ApiClient, args: readonly string[]): Promise<void> {
179
+ const { positionals, options } = parseCommandArgs(
180
+ args,
181
+ {
182
+ "--output": "string",
183
+ "--model": "string",
184
+ "--n": "string",
185
+ "--size": "string",
186
+ "--aspect-ratio": "string",
187
+ "--quality": "string",
188
+ "--style": "string",
189
+ "--seed": "string",
190
+ "--image": "strings",
191
+ },
192
+ "models image",
193
+ 1
194
+ )
195
+ const output = requireOutput(options["--output"], "models image")
196
+ const seed = options["--seed"]
197
+ if (seed !== undefined && !/^-?[0-9]+$/.test(seed)) fail("--seed must be an integer.")
198
+ const model = await modelRef(api, options["--model"], "image")
199
+ const images = []
200
+ for (const path of options["--image"] ?? []) images.push(await uploadInput(api, path, "image"))
201
+ const result = (await api.post("/api/models/image/generations", {
202
+ ...model,
203
+ prompt: positionals[0],
204
+ ...(options["--n"] === undefined ? {} : { n: integerInRange("--n", options["--n"], 1, 100) }),
205
+ ...optional("size", options["--size"]),
206
+ ...optional("aspectRatio", options["--aspect-ratio"]),
207
+ ...optional("quality", options["--quality"]),
208
+ ...optional("style", options["--style"]),
209
+ ...(seed === undefined ? {} : { seed: Number(seed) }),
210
+ ...(images.length > 0 ? { images } : {}),
211
+ })) as CallResult
212
+ writeJson(await savedResult(api, result, output))
213
+ }
214
+
215
+ async function generateSpeech(api: ApiClient, args: readonly string[]): Promise<void> {
216
+ const { positionals, options } = parseCommandArgs(
217
+ args,
218
+ {
219
+ "--output": "string",
220
+ "--model": "string",
221
+ "--voice": "string",
222
+ "--format": "string",
223
+ "--instructions": "string",
224
+ "--speed": "string",
225
+ "--language": "string",
226
+ },
227
+ "models speech",
228
+ 1
229
+ )
230
+ const output = requireOutput(options["--output"], "models speech")
231
+ const format = options["--format"]
232
+ const speed = options["--speed"]
233
+ if (speed !== undefined && !(Number(speed) > 0)) fail("--speed must be a positive number.")
234
+ const result = (await api.post("/api/models/audio/speech", {
235
+ ...(await modelRef(api, options["--model"], "speech")),
236
+ text: positionals[0],
237
+ ...optional("voice", options["--voice"]),
238
+ ...(format === undefined
239
+ ? {}
240
+ : { mediaType: SPEECH_FORMATS[enumValue("--format", format, SPEECH_FORMAT_NAMES)] }),
241
+ ...optional("instructions", options["--instructions"]),
242
+ ...(speed === undefined ? {} : { speed: Number(speed) }),
243
+ ...optional("language", options["--language"]),
244
+ })) as CallResult
245
+ writeJson(await savedResult(api, result, output))
246
+ }
247
+
248
+ async function transcribe(api: ApiClient, args: readonly string[]): Promise<void> {
249
+ const { positionals, options } = parseCommandArgs(
250
+ args,
251
+ { "--model": "string" },
252
+ "models transcribe",
253
+ 1
254
+ )
255
+ const model = await modelRef(api, options["--model"], "transcription")
256
+ const audio = await uploadInput(api, positionals[0] ?? "", "audio")
257
+ const result = (await api.post("/api/models/audio/transcriptions", {
258
+ ...model,
259
+ audio,
260
+ })) as CallResult
261
+ writeJson({
262
+ callId: result.callId,
263
+ model: result.model.modelId,
264
+ text: result.text,
265
+ cost: describeCost(result.cost),
266
+ })
267
+ }
268
+
269
+ async function calls(api: ApiClient, args: readonly string[]): Promise<void> {
270
+ const [sub, ...rest] = args
271
+ if (sub !== "files")
272
+ fail(`Unknown models calls command '${sub ?? ""}'. Use 'models calls files'.`)
273
+ const { positionals, options } = parseCommandArgs(
274
+ rest,
275
+ { "--output": "string" },
276
+ "models calls files",
277
+ 1
278
+ )
279
+ const callId = positionals[0] ?? ""
280
+ const call = (await api.get(`/api/ai/model-calls/${encodeURIComponent(callId)}/files`)) as {
281
+ readonly callId: string
282
+ readonly files: readonly FileRef[]
283
+ }
284
+ const output = options["--output"]
285
+ if (output === undefined) return writeJson(call)
286
+ writeJson({ callId: call.callId, files: await saveFiles(api, call.callId, call.files, output) })
287
+ }
288
+
289
+ /** A --model reference as the request body names it; absent, the kind's default answers. */
290
+ async function modelRef(
291
+ api: ApiClient,
292
+ reference: string | undefined,
293
+ kind: ModelKind
294
+ ): Promise<{ model?: { provider: string; modelId: string } }> {
295
+ if (reference === undefined) return {}
296
+ const { entry } = findModel(
297
+ catalogEntries((await api.get("/api/models")) as Catalog),
298
+ reference,
299
+ kind
300
+ )
301
+ return { model: { provider: entry.provider, modelId: entry.modelId } }
302
+ }
303
+
304
+ // The tiers of `models.language`, restated: this package depends on no other Sixb package.
305
+ const LANGUAGE_TIERS = ["light", "standard", "heavy"] as const
306
+
307
+ /**
308
+ * A text request's --model: a tier goes to the server as is, which answers with that tier's model,
309
+ * or the default when the project configures no such tier. Anything else names a model.
310
+ */
311
+ async function languageModelRef(
312
+ api: ApiClient,
313
+ reference: string | undefined
314
+ ): Promise<{ model?: string | { provider: string; modelId: string } }> {
315
+ const tier = LANGUAGE_TIERS.find((candidate) => candidate === reference)
316
+ if (tier !== undefined) return { model: tier }
317
+ return modelRef(api, reference, "language")
318
+ }
319
+
320
+ /** The prompt given as an argument, or read from --file: a path, or - for standard input. */
321
+ async function readPrompt(
322
+ argument: string | undefined,
323
+ source: string | undefined
324
+ ): Promise<string> {
325
+ if (argument !== undefined && source !== undefined) {
326
+ fail("models text takes its prompt as an argument or from --file, not both.")
327
+ }
328
+
329
+ const prompt = source === undefined ? argument : await readText(source, "Prompt file")
330
+ if (prompt === undefined) {
331
+ fail('models text requires a prompt: "<prompt>", or --file <path|-> to read it.')
332
+ }
333
+ if (!prompt.trim()) fail("The prompt is empty.")
334
+ return prompt
335
+ }
336
+
337
+ function requireOutput(output: string | undefined, command: string): string {
338
+ if (!output) fail(`${command} requires --output <path>: a file, or a directory ending in /.`)
339
+ return output
340
+ }
341
+
342
+ function optional(field: string, value: string | undefined): Record<string, string> {
343
+ return value === undefined ? {} : { [field]: value }
344
+ }
345
+
346
+ async function uploadInput(api: ApiClient, path: string, kind: "image" | "audio") {
347
+ try {
348
+ await access(path)
349
+ } catch {
350
+ fail(`Input file '${path}' does not exist.`)
351
+ }
352
+ const mediaType = MEDIA_TYPES[extname(path).toLowerCase()]
353
+ if (!mediaType?.startsWith(`${kind}/`)) {
354
+ const accepted = Object.entries(MEDIA_TYPES)
355
+ .filter(([, type]) => type.startsWith(`${kind}/`))
356
+ .map(([extension]) => extension)
357
+ fail(`'${path}' is not an ${kind} file. Use ${accepted.join(", ")}.`)
358
+ }
359
+ return api.upload("/api/files", path, undefined, mediaType)
360
+ }
361
+
362
+ async function savedResult(api: ApiClient, result: CallResult, output: string) {
363
+ return {
364
+ callId: result.callId,
365
+ model: result.model.modelId,
366
+ files: await saveFiles(api, result.callId, result.files ?? [], output),
367
+ ...(result.warnings?.length ? { warnings: result.warnings } : {}),
368
+ cost: describeCost(result.cost),
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Download a call's files. A directory (an existing one, or a path ending in /) receives
374
+ * `<call-id>.<ext>`; a file path is used as given, numbered when there are several. Either way the
375
+ * extension follows the file's media type, so the paths written are the ones returned.
376
+ */
377
+ async function saveFiles(
378
+ api: ApiClient,
379
+ callId: string,
380
+ files: readonly FileRef[],
381
+ output: string
382
+ ): Promise<string[]> {
383
+ const directory = output.endsWith("/") || (await stat(output).catch(() => null))?.isDirectory()
384
+ const paths = files.map((file, index) => {
385
+ const number = files.length > 1 ? `-${index + 1}` : ""
386
+ const extension = (file.mediaType && EXTENSIONS[file.mediaType]) ?? ""
387
+ if (directory) return join(output, `${callId}${number}${extension || ".bin"}`)
388
+ const { dir, name, ext } = parse(output)
389
+ return join(dir, `${name}${number}${extension || ext}`)
390
+ })
391
+ for (const [index, path] of paths.entries()) {
392
+ await mkdir(dirname(path), { recursive: true })
393
+ await api.download(`/api/ai/model-calls/${encodeURIComponent(callId)}/files/content`, path, {
394
+ path: `/files/${index}`,
395
+ })
396
+ }
397
+ return paths
398
+ }
399
+
400
+ /** What a call cost, in USD, as the provider reported it or the binding's card rated it. */
401
+ export function describeCost(cost: CallResult["cost"]): string {
402
+ if (!cost.money) return "unknown"
403
+ const nanos = BigInt(cost.money.amountNanos)
404
+ const whole = nanos / 1_000_000_000n
405
+ const fraction = (nanos % 1_000_000_000n).toString().padStart(9, "0").replace(/0+$/, "")
406
+ return `$${whole}${fraction ? `.${fraction}` : ""}`
407
+ }
408
+
409
+ function modelKind(value: string): ModelKind {
410
+ const kind = MODEL_KINDS.find((candidate) => candidate === value)
411
+ if (!kind) fail(`Unknown model kind '${value}'. Use one of: ${MODEL_KINDS.join(", ")}.`)
412
+ return kind
413
+ }
414
+
415
+ function catalogEntries(catalog: Catalog): { kind: ModelKind; entry: CatalogEntry }[] {
416
+ return [
417
+ ...catalog.language.map((entry) => ({ kind: "language" as const, entry })),
418
+ ...catalog.image.map((entry) => ({ kind: "image" as const, entry })),
419
+ ...catalog.audio.speech.map((entry) => ({ kind: "speech" as const, entry })),
420
+ ...catalog.audio.transcription.map((entry) => ({ kind: "transcription" as const, entry })),
421
+ ]
422
+ }
423
+
424
+ /**
425
+ * A model by id, or provider:modelId when two providers share an id. An id may itself hold a
426
+ * colon, so the whole reference is tried as an id first. An id that names models of several
427
+ * kinds needs --kind.
428
+ */
429
+ export function findModel(
430
+ entries: readonly { readonly kind: ModelKind; readonly entry: CatalogEntry }[],
431
+ reference: string,
432
+ kind: ModelKind | undefined
433
+ ): { kind: ModelKind; entry: CatalogEntry } {
434
+ const ofKind = entries.filter((candidate) => kind === undefined || candidate.kind === kind)
435
+ let matches = ofKind.filter((candidate) => candidate.entry.modelId === reference)
436
+ const separator = reference.indexOf(":")
437
+ if (matches.length === 0 && separator > 0) {
438
+ const provider = reference.slice(0, separator)
439
+ const modelId = reference.slice(separator + 1)
440
+ matches = ofKind.filter(
441
+ (candidate) => candidate.entry.provider === provider && candidate.entry.modelId === modelId
442
+ )
443
+ }
444
+ const [match] = matches
445
+ if (!match) {
446
+ fail(
447
+ `No ${kind === undefined ? "" : `${kind} `}model '${reference}' is configured. Run 'sixb models list'.`
448
+ )
449
+ }
450
+ if (matches.length > 1) {
451
+ const kinds = new Set(matches.map((candidate) => candidate.kind))
452
+ fail(
453
+ kinds.size > 1
454
+ ? `'${reference}' is configured as ${[...kinds].join(" and ")}. Add --kind <kind>.`
455
+ : `'${reference}' is configured by ${matches.map((candidate) => candidate.entry.provider).join(" and ")}. Use provider:${reference}.`
456
+ )
457
+ }
458
+ return match
459
+ }
460
+
461
+ /**
462
+ * A rate card as people read it: "$0.04 per image", "per image: $0.041 at 768x768, otherwise
463
+ * $0.048", "$3 input · $15 output per 1M tokens".
464
+ */
465
+ export function describePrice(card: RateCard | undefined): string {
466
+ if (!card) return "price unknown"
467
+ const parts = [
468
+ variantPrice(card.images, "per image", "at"),
469
+ variantPrice(card.characters, "per 1M characters", "for"),
470
+ variantPrice(card.audioInputSeconds, "per second of audio", "for"),
471
+ card.input !== undefined && card.output !== undefined
472
+ ? `${dollars(tokenPrice(card.input))} input · ${dollars(tokenPrice(card.output))} output per 1M tokens`
473
+ : undefined,
474
+ card.audioOutput === undefined
475
+ ? undefined
476
+ : `${dollars(tokenPrice(card.audioOutput))} per 1M audio tokens`,
477
+ ].filter((part): part is string => part !== undefined)
478
+ return parts.length === 0 ? "price unknown" : parts.join(" · ")
479
+ }
480
+
481
+ function variantPrice(
482
+ price: VariantPrice | undefined,
483
+ unit: string,
484
+ joiner: string
485
+ ): string | undefined {
486
+ if (price === undefined) return undefined
487
+ if (typeof price === "string") return `${dollars(price)} ${unit}`
488
+ // Variants that all cost the default say nothing a single price doesn't.
489
+ const only = price.default
490
+ if (only !== undefined && price.variants.every((variant) => variant.price === only)) {
491
+ return `${dollars(only)} ${unit}`
492
+ }
493
+ const variants = price.variants.map(({ price: amount, ...dimensions }) => {
494
+ const label = Object.values(dimensions).join(" ")
495
+ return `${dollars(amount)} ${joiner} ${label}`
496
+ })
497
+ const otherwise = price.default === undefined ? [] : [`otherwise ${dollars(price.default)}`]
498
+ return `${unit}: ${[...variants, ...otherwise].join(", ")}`
499
+ }
500
+
501
+ // A tiered price reads as its starting rate; `sixb models get` shows the tiers.
502
+ function tokenPrice(price: TokenPrice): string {
503
+ return typeof price === "string" ? price : price.default
504
+ }
505
+
506
+ function dollars(amount: string): string {
507
+ return `$${amount}`
508
+ }
@@ -9,9 +9,16 @@ import {
9
9
  rfc3339Value,
10
10
  } from "../arguments"
11
11
  import { inspectGraph } from "../graph"
12
- import { fail, writeJson, writeText } from "../output"
12
+ import { CliError, fail, writeJson, writeText } from "../output"
13
13
  import { CLI_LIMITS, DEFAULT_LIST_ORDER, DEFAULT_OBJECT_ORDER_BY } from "../policies"
14
- import { FACETS_EXAMPLE, OBJECTS_HELP, QUERY_EXAMPLES, QUERY_HELP, SEARCH_HELP } from "./metadata"
14
+ import {
15
+ FACETS_EXAMPLE,
16
+ GET_BY_HELP,
17
+ OBJECTS_HELP,
18
+ QUERY_EXAMPLES,
19
+ QUERY_HELP,
20
+ SEARCH_HELP,
21
+ } from "./metadata"
15
22
  import {
16
23
  asRecord,
17
24
  normalizeWindowOptions,
@@ -31,6 +38,8 @@ export async function objects(api: ApiClient, args: readonly string[]): Promise<
31
38
  return objectsList(api, rest)
32
39
  case "get":
33
40
  return objectsGet(api, rest)
41
+ case "get-by":
42
+ return objectsGetBy(api, rest)
34
43
  case "search":
35
44
  return objectsSearch(api, rest)
36
45
  case "query":
@@ -155,6 +164,42 @@ async function objectsGet(api: ApiClient, args: readonly string[]): Promise<void
155
164
  )
156
165
  }
157
166
 
167
+ /** Prints the object holding the value, or `null` when none does or its type is not visible. */
168
+ async function objectsGetBy(api: ApiClient, args: readonly string[]): Promise<void> {
169
+ if (requestsHelp(args)) return writeText(GET_BY_HELP)
170
+ const { positionals, options } = parseCommandArgs(
171
+ args,
172
+ { "--json": "boolean" },
173
+ "objects get-by",
174
+ 3
175
+ )
176
+ const [objectTypeId = "", propertyId = "", text = ""] = positionals
177
+ const value = options["--json"] ? parseJsonValue(text) : text
178
+
179
+ const path = `/api/objects/${encodeURIComponent(objectTypeId)}/by/${encodeURIComponent(propertyId)}`
180
+ try {
181
+ writeJson(await api.post(path, { value }))
182
+ } catch (error) {
183
+ if (!isObjectNotFound(error)) throw error
184
+ writeJson(null)
185
+ }
186
+ }
187
+
188
+ function parseJsonValue(text: string): unknown {
189
+ try {
190
+ return JSON.parse(text)
191
+ } catch {
192
+ fail('--json needs the value as JSON, such as 42 or \'{"type":"user","id":"usr_1"}\'.')
193
+ }
194
+ }
195
+
196
+ /** The route's own answer when no visible object holds the value. */
197
+ function isObjectNotFound(error: unknown): boolean {
198
+ return (
199
+ error instanceof CliError && error.body.status === 404 && error.body.code === "object.not_found"
200
+ )
201
+ }
202
+
158
203
  async function objectsSearch(api: ApiClient, args: readonly string[]): Promise<void> {
159
204
  if (requestsHelp(args)) return writeText(SEARCH_HELP)
160
205
  const { positionals, options } = parseCommandArgs(
@@ -12,7 +12,7 @@ import { CLI_LIMITS, DEFAULT_LIST_ORDER } from "../policies"
12
12
  import { GROUP_HELP } from "./metadata"
13
13
  import { normalizeWindowOptions, parseQueryOptions } from "./shared"
14
14
 
15
- const ACTION_RUN_STATUSES = ["queued", "running", "succeeded", "failed", "cancelled"] as const
15
+ const ACTION_RUN_STATUSES = ["succeeded", "failed"] as const
16
16
  const WORKFLOW_RUN_STATUSES = [
17
17
  "queued",
18
18
  "running",
@@ -55,13 +55,7 @@ export function singleFileOption(args: readonly string[], command: string): stri
55
55
  }
56
56
 
57
57
  export async function readJson(source: string): Promise<unknown> {
58
- let text: string
59
- try {
60
- text = source === "-" ? await readStdin() : await readFile(source, "utf8")
61
- } catch (error) {
62
- if (isFileError(error, "ENOENT")) fail(`JSON file '${source}' does not exist.`)
63
- throw error
64
- }
58
+ const text = await readText(source, "JSON file")
65
59
  try {
66
60
  return JSON.parse(text)
67
61
  } catch {
@@ -74,6 +68,16 @@ export async function readJson(source: string): Promise<unknown> {
74
68
  }
75
69
  }
76
70
 
71
+ /** A file's text, or standard input's when the source is `-`. */
72
+ export async function readText(source: string, noun: string): Promise<string> {
73
+ try {
74
+ return source === "-" ? await readStdin() : await readFile(source, "utf8")
75
+ } catch (error) {
76
+ if (isFileError(error, "ENOENT")) fail(`${noun} '${source}' does not exist.`)
77
+ throw error
78
+ }
79
+ }
80
+
77
81
  export function asRecord(value: unknown): Record<string, unknown> {
78
82
  return typeof value === "object" && value !== null && !Array.isArray(value)
79
83
  ? (value as Record<string, unknown>)
@@ -0,0 +1,67 @@
1
+ import type { ApiClient } from "../api-client"
2
+ import {
3
+ enumValue,
4
+ integerInRange,
5
+ isHelp,
6
+ nonNegativeInteger,
7
+ parseCommandArgs,
8
+ requestsHelp,
9
+ } from "../arguments"
10
+ import { fail, writeJson, writeText } from "../output"
11
+ import { CLI_LIMITS } from "../policies"
12
+ import { GROUP_HELP } from "./metadata"
13
+ import type { QueryOptions } from "./shared"
14
+
15
+ const USER_STATUSES = ["active", "suspended", "all"] as const
16
+
17
+ const LIST_FLAGS = {
18
+ "--status": "string",
19
+ "--limit": "string",
20
+ "--offset": "string",
21
+ } as const
22
+
23
+ export async function users(api: ApiClient, args: readonly string[]): Promise<void> {
24
+ const [sub, ...rest] = args
25
+ if (!sub || isHelp(sub) || requestsHelp(rest)) return writeText(GROUP_HELP.users)
26
+
27
+ if (sub === "get") {
28
+ const { positionals } = parseCommandArgs(rest, {}, "users get", 1)
29
+ return writeJson(await api.get(`/api/users/${encodeURIComponent(positionals[0] ?? "")}`))
30
+ }
31
+
32
+ if (sub === "list") {
33
+ const { options } = parseCommandArgs(rest, LIST_FLAGS, "users list")
34
+ return writeJson(await api.get("/api/users", listQuery(options)))
35
+ }
36
+
37
+ if (sub === "search") {
38
+ const { positionals, options } = parseCommandArgs(rest, LIST_FLAGS, "users search", 1)
39
+ const search = positionals[0]?.trim()
40
+ if (!search) fail("users search requires the text to find in a name or email.")
41
+
42
+ return writeJson(await api.get("/api/users", { ...listQuery(options), search }))
43
+ }
44
+
45
+ fail(`Unknown users command '${sub}'.`)
46
+ }
47
+
48
+ function listQuery(options: {
49
+ readonly "--status"?: string
50
+ readonly "--limit"?: string
51
+ readonly "--offset"?: string
52
+ }): QueryOptions {
53
+ const limit = integerInRange(
54
+ "--limit",
55
+ options["--limit"] ?? String(CLI_LIMITS.users.default),
56
+ 1,
57
+ CLI_LIMITS.users.maximum
58
+ )
59
+
60
+ return {
61
+ status: enumValue("--status", options["--status"] ?? "active", USER_STATUSES),
62
+ limit: String(limit),
63
+ ...(options["--offset"] === undefined
64
+ ? {}
65
+ : { offset: String(nonNegativeInteger("--offset", options["--offset"])) }),
66
+ }
67
+ }