@sixb/cli-core 0.1.9 → 0.2.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 (62) hide show
  1. package/README.md +21 -16
  2. package/dist/api-client.d.ts +1 -0
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/bin/sixb +26 -0
  5. package/dist/commands/data.d.ts +5 -0
  6. package/dist/commands/data.d.ts.map +1 -0
  7. package/dist/commands/files.d.ts.map +1 -1
  8. package/dist/commands/history.d.ts +7 -0
  9. package/dist/commands/history.d.ts.map +1 -0
  10. package/dist/commands/index.d.ts +1 -1
  11. package/dist/commands/index.d.ts.map +1 -1
  12. package/dist/commands/memory.d.ts +3 -0
  13. package/dist/commands/memory.d.ts.map +1 -0
  14. package/dist/commands/metadata.d.ts +11 -4
  15. package/dist/commands/metadata.d.ts.map +1 -1
  16. package/dist/commands/runs.d.ts +8 -1
  17. package/dist/commands/runs.d.ts.map +1 -1
  18. package/dist/commands/shared.d.ts +2 -0
  19. package/dist/commands/shared.d.ts.map +1 -1
  20. package/dist/commands/system.d.ts +21 -0
  21. package/dist/commands/system.d.ts.map +1 -0
  22. package/dist/commands/users.d.ts.map +1 -1
  23. package/dist/generated/sixb.mjs +2658 -0
  24. package/dist/index.d.ts +8 -3
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +298 -58
  27. package/dist/index.js.map +21 -17
  28. package/dist/main.d.ts +3 -0
  29. package/dist/main.d.ts.map +1 -0
  30. package/dist/memory.d.ts +21 -0
  31. package/dist/memory.d.ts.map +1 -0
  32. package/dist/policies.d.ts +14 -0
  33. package/dist/policies.d.ts.map +1 -1
  34. package/dist/run.d.ts +1 -0
  35. package/dist/run.d.ts.map +1 -1
  36. package/dist/sandbox-cli.d.ts +8 -0
  37. package/dist/sandbox-cli.d.ts.map +1 -0
  38. package/package.json +15 -1
  39. package/src/api-client.ts +70 -2
  40. package/src/bin/sixb +26 -0
  41. package/src/commands/actions.ts +3 -3
  42. package/src/commands/data.ts +90 -0
  43. package/src/commands/files.ts +27 -3
  44. package/src/commands/history.ts +204 -0
  45. package/src/commands/index.ts +16 -0
  46. package/src/commands/memory.ts +358 -0
  47. package/src/commands/metadata.ts +76 -29
  48. package/src/commands/models.ts +3 -3
  49. package/src/commands/ontology.ts +2 -2
  50. package/src/commands/runs.ts +22 -16
  51. package/src/commands/shared.ts +6 -0
  52. package/src/commands/system.ts +67 -0
  53. package/src/commands/telemetry.ts +2 -2
  54. package/src/commands/users.ts +22 -2
  55. package/src/commands/workflows.ts +3 -3
  56. package/src/generated/sixb.mjs +2658 -0
  57. package/src/index.ts +12 -3
  58. package/src/main.ts +41 -0
  59. package/src/memory.ts +30 -0
  60. package/src/policies.ts +5 -0
  61. package/src/run.ts +3 -2
  62. package/src/sandbox-cli.ts +18 -0
@@ -0,0 +1,358 @@
1
+ import { lstat, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
2
+ import { dirname, join } from "node:path"
3
+ import { isHelp } from "../arguments"
4
+ import {
5
+ AGENT_MEMORY_ENTRY,
6
+ AGENT_MEMORY_MANIFEST,
7
+ AGENT_MEMORY_STARTER,
8
+ type AgentMemoryManifest,
9
+ type AgentMemoryManifestRepo,
10
+ type AgentMemoryRepo,
11
+ agentMemoryFileHash,
12
+ } from "../memory"
13
+ import { CliError, EXIT_API, fail, writeJson, writeText } from "../output"
14
+ import { createInstanceApiClient, type SandboxInstanceCliMode } from "../run"
15
+ import { asRecord, isFileError } from "./shared"
16
+
17
+ const HELP = `Usage:
18
+ sixb memory save <repo> -m <message> Save local edits as one revision
19
+ sixb memory pull [repo] Bring the local copy up to the latest revision
20
+
21
+ <repo> is user or project. Files live under $SIXB_MEMORY_DIR/<repo>/.
22
+
23
+ A save is rejected for files another conversation changed since they were loaded. Their
24
+ current contents are written next to yours as <file>.theirs: merge each into its file, delete
25
+ it, then save again. Changes others made to other files are brought in by save and pull.`
26
+
27
+ interface RemoteRepo {
28
+ readonly revision: number
29
+ readonly files: ReadonlyMap<string, string>
30
+ }
31
+
32
+ interface MemoryConflict {
33
+ readonly path: string
34
+ /** Where their version was written, or `null` when another save deleted the file. */
35
+ readonly theirs: string | null
36
+ }
37
+
38
+ const THEIRS = ".theirs"
39
+ const CONFLICT_HINT = "Merge each .theirs file into its file, delete it, then save again."
40
+
41
+ export async function memory(args: readonly string[], mode: SandboxInstanceCliMode): Promise<void> {
42
+ const [sub, ...rest] = args
43
+ if (!sub || isHelp(sub) || rest.some(isHelp)) return writeText(HELP)
44
+ const local = localMemory()
45
+ if (sub === "save") return save(local, rest, mode)
46
+ if (sub === "pull") return pull(local, rest, mode)
47
+ fail(`Unknown memory command '${sub}'. Run 'sixb memory --help'.`)
48
+ }
49
+
50
+ async function save(
51
+ local: LocalMemory,
52
+ args: readonly string[],
53
+ mode: SandboxInstanceCliMode
54
+ ): Promise<void> {
55
+ const { repo, message } = parseSaveArgs(args)
56
+ const manifest = await local.readManifest()
57
+ const loaded = manifest.repos[repo]
58
+ if (!loaded)
59
+ fail(
60
+ `Memory repo '${repo}' is not installed.`,
61
+ "invalid_arguments",
62
+ `Run 'sixb memory pull ${repo}'.`
63
+ )
64
+ const files = await local.readRepo(repo)
65
+ const unresolved = [...files.keys()].filter((path) => path.endsWith(THEIRS))
66
+ if (unresolved.length > 0) {
67
+ fail(`Resolve ${unresolved.join(", ")} first.`, "unresolved_conflicts", CONFLICT_HINT)
68
+ }
69
+ const changes = [...new Set([...files.keys(), ...Object.keys(loaded.files)])]
70
+ .sort()
71
+ .flatMap((path) => {
72
+ const contents = files.get(path)
73
+ const changed =
74
+ (contents === undefined ? undefined : agentMemoryFileHash(contents)) !== loaded.files[path]
75
+ return changed ? [{ path, contents: contents ?? null }] : []
76
+ })
77
+ if (changes.length === 0) {
78
+ return writeJson({
79
+ repo,
80
+ saved: false,
81
+ changed: [],
82
+ hint: "No file changed since it was loaded.",
83
+ })
84
+ }
85
+
86
+ const api = createInstanceApiClient(mode)
87
+ let saved: Record<string, unknown>
88
+ try {
89
+ saved = asRecord(
90
+ await api.post(`/api/agent-memory/${repo}/revisions`, {
91
+ baseRevision: loaded.revision,
92
+ message,
93
+ changes,
94
+ })
95
+ )
96
+ } catch (error) {
97
+ if (!(error instanceof CliError) || error.body.status !== 409) throw error
98
+ const { revision, conflicts } = await update(local, repo, manifest, await fetchRepo(api, repo))
99
+ process.exitCode = 1
100
+ return writeJson({ repo, saved: false, revision, conflicts, hint: CONFLICT_HINT })
101
+ }
102
+
103
+ // The saved files are now what the repo holds, so updating does not report them as conflicts.
104
+ const savedFiles = { ...loaded.files }
105
+ for (const change of changes) {
106
+ if (change.contents === null) delete savedFiles[change.path]
107
+ else savedFiles[change.path] = agentMemoryFileHash(change.contents)
108
+ }
109
+ const after: AgentMemoryManifest = {
110
+ repos: { ...manifest.repos, [repo]: { ...loaded, files: savedFiles } },
111
+ }
112
+ const { revision, conflicts } = await update(local, repo, after, await fetchRepo(api, repo))
113
+ const warnings = Array.isArray(saved.warnings) ? saved.warnings : []
114
+ writeJson({
115
+ repo,
116
+ saved: true,
117
+ revision,
118
+ changed: changes.map((change) => change.path),
119
+ ...(warnings.length > 0 ? { warnings } : {}),
120
+ ...(conflicts.length > 0 ? { conflicts, hint: CONFLICT_HINT } : {}),
121
+ })
122
+ }
123
+
124
+ async function pull(
125
+ local: LocalMemory,
126
+ args: readonly string[],
127
+ mode: SandboxInstanceCliMode
128
+ ): Promise<void> {
129
+ if (args.length > 1) fail("Invalid arguments for 'memory pull'.")
130
+ const manifest = await local.readManifest()
131
+ const repos = args[0] === undefined ? installedRepos(manifest) : [parseRepo(args[0])]
132
+ if (repos.length === 0) {
133
+ fail("No memory repo is installed.", "invalid_arguments", "Run 'sixb memory pull user'.")
134
+ }
135
+
136
+ const api = createInstanceApiClient(mode)
137
+ let current = manifest
138
+ for (const repo of repos) {
139
+ const result = await update(local, repo, current, await fetchRepo(api, repo))
140
+ current = result.manifest
141
+ writeJson({
142
+ repo,
143
+ revision: result.revision,
144
+ updated: result.updated,
145
+ conflicts: result.conflicts,
146
+ ...(result.conflicts.length > 0 ? { hint: CONFLICT_HINT } : {}),
147
+ })
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Bring the local copy of `repo` to `remote`, three ways against the manifest: a file only the
153
+ * repo changed is updated, a file only this copy changed is kept, and a file both changed gets
154
+ * the repo's version next to it as `.theirs`. The manifest then records `remote`, so the next save
155
+ * sends what this copy holds against the latest revision.
156
+ */
157
+ async function update(
158
+ local: LocalMemory,
159
+ repo: AgentMemoryRepo,
160
+ manifest: AgentMemoryManifest,
161
+ remote: RemoteRepo
162
+ ): Promise<{
163
+ readonly manifest: AgentMemoryManifest
164
+ readonly revision: number
165
+ readonly updated: readonly string[]
166
+ readonly conflicts: readonly MemoryConflict[]
167
+ }> {
168
+ const base = manifest.repos[repo]?.files ?? {}
169
+ const files = await local.readRepo(repo)
170
+ const updated: string[] = []
171
+ const conflicts: MemoryConflict[] = []
172
+
173
+ for (const path of [
174
+ ...new Set([...files.keys(), ...remote.files.keys(), ...Object.keys(base)]),
175
+ ].sort()) {
176
+ if (path.endsWith(THEIRS)) continue
177
+ const mine = files.get(path)
178
+ const theirs = remote.files.get(path)
179
+ const mineHash = mine === undefined ? undefined : agentMemoryFileHash(mine)
180
+ const theirsHash = theirs === undefined ? undefined : agentMemoryFileHash(theirs)
181
+ if (theirsHash === base[path] || theirsHash === mineHash) continue
182
+ if (mineHash === base[path]) {
183
+ await local.write(repo, path, theirs)
184
+ updated.push(path)
185
+ } else if (theirs === undefined) {
186
+ conflicts.push({ path, theirs: null })
187
+ } else {
188
+ await local.write(repo, `${path}${THEIRS}`, theirs)
189
+ conflicts.push({ path, theirs: `${path}${THEIRS}` })
190
+ }
191
+ }
192
+ if (
193
+ !remote.files.has(AGENT_MEMORY_ENTRY) &&
194
+ !(await local.readRepo(repo)).has(AGENT_MEMORY_ENTRY)
195
+ ) {
196
+ await local.write(repo, AGENT_MEMORY_ENTRY, AGENT_MEMORY_STARTER)
197
+ }
198
+
199
+ const next: AgentMemoryManifest = {
200
+ repos: {
201
+ ...manifest.repos,
202
+ [repo]: {
203
+ revision: remote.revision,
204
+ files: Object.fromEntries(
205
+ [...remote.files].map(([path, contents]) => [path, agentMemoryFileHash(contents)])
206
+ ),
207
+ },
208
+ },
209
+ }
210
+ await local.writeManifest(next)
211
+ return { manifest: next, revision: remote.revision, updated, conflicts }
212
+ }
213
+
214
+ async function fetchRepo(
215
+ api: ReturnType<typeof createInstanceApiClient>,
216
+ repo: AgentMemoryRepo
217
+ ): Promise<RemoteRepo> {
218
+ const body = asRecord(await api.get(`/api/agent-memory/${repo}`))
219
+ const files = Array.isArray(body.files) ? body.files.map(asRecord) : null
220
+ if (
221
+ typeof body.revision !== "number" ||
222
+ !files?.every((file) => typeof file.path === "string" && typeof file.contents === "string")
223
+ ) {
224
+ throw new CliError(
225
+ { code: "invalid_api_response", message: "The Sixb API returned an invalid memory repo." },
226
+ EXIT_API
227
+ )
228
+ }
229
+ return {
230
+ revision: body.revision,
231
+ files: new Map(files.map((file) => [String(file.path), String(file.contents)])),
232
+ }
233
+ }
234
+
235
+ interface LocalMemory {
236
+ readManifest(): Promise<AgentMemoryManifest>
237
+ writeManifest(manifest: AgentMemoryManifest): Promise<void>
238
+ /** Every file of the local copy, by path from the repo root. */
239
+ readRepo(repo: AgentMemoryRepo): Promise<Map<string, string>>
240
+ /** `undefined` deletes the file. */
241
+ write(repo: AgentMemoryRepo, path: string, contents: string | undefined): Promise<void>
242
+ }
243
+
244
+ function localMemory(): LocalMemory {
245
+ const root = process.env.SIXB_MEMORY_DIR
246
+ const contextDir = process.env.SIXB_CONTEXT_DIR
247
+ if (!root || !contextDir) {
248
+ fail("Agent memory is not available in this run.", "runtime_unavailable")
249
+ }
250
+ const manifestPath = join(contextDir, ...AGENT_MEMORY_MANIFEST.split("/"))
251
+ const fileOf = (repo: AgentMemoryRepo, path: string) => join(root, repo, ...path.split("/"))
252
+
253
+ return {
254
+ async readManifest() {
255
+ let text: string
256
+ try {
257
+ text = await readFile(manifestPath, "utf8")
258
+ } catch (error) {
259
+ if (isFileError(error, "ENOENT")) return { repos: {} }
260
+ throw error
261
+ }
262
+ try {
263
+ return parseManifest(JSON.parse(text))
264
+ } catch {
265
+ fail(`Memory manifest '${manifestPath}' is not valid.`, "invalid_json")
266
+ }
267
+ },
268
+
269
+ async writeManifest(manifest) {
270
+ await mkdir(dirname(manifestPath), { recursive: true })
271
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
272
+ },
273
+
274
+ async readRepo(repo) {
275
+ const files = new Map<string, string>()
276
+ const walk = async (directory: string, prefix: string): Promise<void> => {
277
+ let entries: string[]
278
+ try {
279
+ entries = await readdir(directory)
280
+ } catch (error) {
281
+ if (isFileError(error, "ENOENT")) return
282
+ throw error
283
+ }
284
+ for (const name of entries) {
285
+ const path = prefix ? `${prefix}/${name}` : name
286
+ const stats = await lstat(join(directory, name))
287
+ if (stats.isDirectory()) await walk(join(directory, name), path)
288
+ else if (stats.isFile()) files.set(path, await readFile(join(directory, name), "utf8"))
289
+ else fail(`Memory file '${repo}/${path}' is not a regular file.`)
290
+ }
291
+ }
292
+ await walk(join(root, repo), "")
293
+ return files
294
+ },
295
+
296
+ async write(repo, path, contents) {
297
+ const file = fileOf(repo, path)
298
+ if (contents === undefined) {
299
+ await rm(file, { force: true })
300
+ return
301
+ }
302
+ await mkdir(dirname(file), { recursive: true })
303
+ await writeFile(file, contents)
304
+ },
305
+ }
306
+ }
307
+
308
+ function parseSaveArgs(args: readonly string[]): { repo: AgentMemoryRepo; message: string } {
309
+ const positionals: string[] = []
310
+ let message: string | undefined
311
+ for (let index = 0; index < args.length; index++) {
312
+ const argument = args[index] ?? ""
313
+ if (argument === "-m" || argument === "--message") {
314
+ if (message !== undefined) fail(`${argument} may only be provided once.`)
315
+ message = args[++index]
316
+ if (!message) fail(`${argument} requires a value.`)
317
+ } else if (argument.startsWith("-")) {
318
+ fail(`Unknown memory save option '${argument}'.`)
319
+ } else {
320
+ positionals.push(argument)
321
+ }
322
+ }
323
+ if (positionals.length !== 1) fail("Invalid arguments for 'memory save'.")
324
+ if (message === undefined || message.trim().length === 0) {
325
+ fail("memory save requires -m <what you learned>.")
326
+ }
327
+ return { repo: parseRepo(positionals[0] ?? ""), message }
328
+ }
329
+
330
+ function parseRepo(value: string): AgentMemoryRepo {
331
+ if (value === "user" || value === "project") return value
332
+ fail(`Unknown memory repo '${value}'. Use 'user' or 'project'.`)
333
+ }
334
+
335
+ function installedRepos(manifest: AgentMemoryManifest): AgentMemoryRepo[] {
336
+ return (["user", "project"] as const).filter((repo) => manifest.repos[repo] !== undefined)
337
+ }
338
+
339
+ function parseManifest(value: unknown): AgentMemoryManifest {
340
+ const repos = asRecord(asRecord(value).repos)
341
+ const parsed: Partial<Record<AgentMemoryRepo, AgentMemoryManifestRepo>> = {}
342
+ for (const repo of ["user", "project"] as const) {
343
+ if (repos[repo] === undefined) continue
344
+ const entry = asRecord(repos[repo])
345
+ const files = asRecord(entry.files)
346
+ if (
347
+ typeof entry.revision !== "number" ||
348
+ !Object.values(files).every((digest) => typeof digest === "string")
349
+ ) {
350
+ throw new Error("invalid memory manifest")
351
+ }
352
+ parsed[repo] = {
353
+ revision: entry.revision,
354
+ files: files as Record<string, string>,
355
+ }
356
+ }
357
+ return { repos: parsed }
358
+ }
@@ -5,17 +5,17 @@ import {
5
5
  DEFAULT_TELEMETRY_ORDER,
6
6
  } from "../policies"
7
7
 
8
- const SANDBOX_MAIN_HELP = `Sixb agent CLI
8
+ const SANDBOX_MAIN_HELP = `Sixb CLI
9
9
 
10
10
  Usage:
11
11
  sixb <command> [options]
12
12
 
13
- The CLI uses the run-scoped SIXB_API_BASE_URL. Do not configure authentication or another origin.
13
+ The CLI uses the run's SIXB_API_BASE_URL, which adds its credentials. Do not configure authentication or another origin.
14
14
  API commands emit JSON. Help, version, and examples emit text. Downloads write to --output and
15
15
  emit a JSON receipt.
16
16
 
17
17
  Discovery:
18
- sixb doctor Check the sandbox and API gateway
18
+ sixb status Check the sandbox and its API access
19
19
  sixb context Print the current run context
20
20
  sixb project show Show project metadata
21
21
  sixb ontology list Compact visible type and relationship catalog
@@ -50,6 +50,13 @@ Actions and workflows:
50
50
  sixb workflows list|get|start ...
51
51
  sixb workflow-runs list|get ...
52
52
 
53
+ Data:
54
+ sixb datasets list|get|versions|rows ...
55
+ sixb syncs list|get ... Syncs you may run, each with its latest run
56
+ sixb sync-runs list ...
57
+ sixb pipelines list|get ... Pipelines you may run, each with its latest run
58
+ sixb pipeline-runs list|get ...
59
+
53
60
  Files:
54
61
  sixb files upload|download ...
55
62
 
@@ -63,28 +70,9 @@ A user parameter or property is {"type":"user","id":"<user-id>"}. Find the id wi
63
70
  Run \`sixb <group> --help\` or \`sixb <group> <command> --help\` for exact arguments.
64
71
  For query IR, run \`sixb objects query --help\` and \`sixb objects query --example list\`.`
65
72
 
66
- const LOCAL_MAIN_HELP = SANDBOX_MAIN_HELP.replace("Sixb agent CLI", "Sixb instance CLI")
67
- .replace(
68
- "The CLI uses the run-scoped SIXB_API_BASE_URL. Do not configure authentication or another origin.",
69
- "The CLI uses the selected local profile and its API credentials."
70
- )
71
- .replace(
72
- " sixb doctor Check the sandbox and API gateway\n sixb context Print the current run context\n",
73
- ""
74
- )
75
- .replace(
76
- "\nWhen you do not know a type's properties or links, read its file under $SIXB_ONTOLOGY_DIR. Use\n`sixb ontology get` for a type that has no file there.\n",
77
- ""
78
- )
79
-
80
- // The agent reaches models through its run's gateway once the model routes allow it.
81
- .replace(
82
- "Files:\n sixb files upload|download ...\n",
83
- "Files:\n sixb files upload|download ...\n\nModels:\n sixb models list [--kind <kind>] Configured models of every kind, with prices\n sixb models get <model> One model's options and rate card\n sixb models text|image|speech|transcribe ... Call a model; see sixb models --help\n"
84
- )
85
-
86
- export function renderInstanceHelp(mode: "local" | "sandbox"): string {
87
- return mode === "sandbox" ? SANDBOX_MAIN_HELP : LOCAL_MAIN_HELP
73
+ /** The sandbox CLI's help, which the agent's prompt includes. People's comes from @sixb/cli. */
74
+ export function renderInstanceHelp(): string {
75
+ return SANDBOX_MAIN_HELP
88
76
  }
89
77
 
90
78
  const SEARCH_DETAILS = `Without --type, search matches primary-id prefixes and full-text fields across visible types.
@@ -214,8 +202,15 @@ Vector profile names are the keys of the type's search.vectors. Vector input is
214
202
  optionally under filters; k bounds the ranked matches and results carry a score.`
215
203
 
216
204
  export const GROUP_HELP = {
217
- doctor: "Usage: sixb doctor",
205
+ status: "Usage: sixb status",
218
206
  context: "Usage: sixb context",
207
+ history: `Usage:
208
+ sixb history [--offset <n>] [--limit <1-${CLI_LIMITS.history.maximum}>]
209
+ sixb history <seq>
210
+
211
+ Prints this conversation's messages as text, oldest first, including those summarized when it
212
+ was compacted: what was said, the files sent, and each tool call with its result. A page holds
213
+ ${CLI_LIMITS.history.default} messages by default and cuts long text; \`sixb history <seq>\` prints one message whole.`,
219
214
  project: "Usage: sixb project show",
220
215
  ontology: `Usage:
221
216
  sixb ontology list [--full]
@@ -256,7 +251,8 @@ List options:
256
251
  files: `Usage:
257
252
  sixb files upload <local-path> [--logical-path <path>]
258
253
  sixb files download object <type> <id> --path <json-pointer> --output <local-path>
259
- sixb files download action-run|workflow-run <run-id> --path <json-pointer> --output <local-path>`,
254
+ sixb files download action-run|workflow-run <run-id> --path <json-pointer> --output <local-path>
255
+ sixb files download url <content-url> --output <local-path>`,
260
256
  workflows: `Usage:
261
257
  sixb workflows list
262
258
  sixb workflows get <workflow-id>
@@ -267,14 +263,15 @@ for the user's approval prints \`"status": "awaiting_approval"\` and exits with
267
263
  users: `Usage:
268
264
  sixb users list [options]
269
265
  sixb users search <text> [options]
270
- sixb users get <user-id>
266
+ sixb users get <user-id|email>
271
267
 
272
268
  Options:
273
269
  --status active|suspended|all Defaults to active
274
270
  --limit <1-${CLI_LIMITS.users.maximum}> Defaults to ${CLI_LIMITS.users.default}
275
271
  --offset <n> Non-negative; defaults to 0
276
272
 
277
- \`users search\` matches part of a display name or email, ignoring case. Each member is
273
+ \`users search\` matches part of a display name or email, ignoring case; \`users get\` takes an
274
+ exact email, ignoring case, and finds suspended members too. Each member is
278
275
  {"id","email","displayName","avatarUrl","status"}; a user parameter takes
279
276
  {"type":"user","id":"<id>"}. Reading members requires can.view(project.users).`,
280
277
  models: `Usage:
@@ -313,6 +310,56 @@ List options:
313
310
  --limit <1-${CLI_LIMITS.list.maximum}> Defaults to ${CLI_LIMITS.list.default}
314
311
  --offset <n> Non-negative; defaults to 0
315
312
  --order <asc|desc> Started-time order; defaults to ${DEFAULT_LIST_ORDER}`,
313
+ datasets: `Usage:
314
+ sixb datasets list
315
+ sixb datasets get <dataset-id>
316
+ sixb datasets versions <dataset-id> [--limit <1-${CLI_LIMITS.datasetVersions.maximum}>]
317
+ sixb datasets rows <dataset-id> [options]
318
+
319
+ Rows options:
320
+ --version <version-id> Defaults to the latest version
321
+ --columns <a,b,...> Defaults to every column
322
+ --limit <1-${CLI_LIMITS.datasetRows.maximum}> Defaults to ${CLI_LIMITS.datasetRows.default}
323
+ --offset <n> Non-negative; defaults to 0
324
+
325
+ \`list\` and \`get\` show a dataset's schema, its latest version, and the syncs and pipelines you
326
+ may run that read or write it. \`versions\` lists the newest first. \`rows\` leaves out the
327
+ columns your clearances do not cover and names them under redactions.`,
328
+ syncs: `Usage:
329
+ sixb syncs list
330
+ sixb syncs get <sync-id>
331
+
332
+ Lists the syncs you may run, each with its latest run. \`sixb sync-runs list\` shows the history.`,
333
+ "sync-runs": `Usage:
334
+ sixb sync-runs list [options]
335
+
336
+ List options:
337
+ --sync <sync-id>
338
+ --dataset <dataset-id>
339
+ --status queued|running|succeeded|failed|cancelled
340
+ --started-after|--started-before <RFC3339>
341
+ --limit <1-${CLI_LIMITS.list.maximum}> Defaults to ${CLI_LIMITS.list.default}
342
+ --offset <n> Non-negative; defaults to 0
343
+ --order <asc|desc> Started-time order; defaults to ${DEFAULT_LIST_ORDER}`,
344
+ pipelines: `Usage:
345
+ sixb pipelines list
346
+ sixb pipelines get <pipeline-id>
347
+
348
+ Lists the pipelines you may run, each with its latest run. \`sixb pipeline-runs\` shows the
349
+ history and each run's steps.`,
350
+ "pipeline-runs": `Usage:
351
+ sixb pipeline-runs list [options]
352
+ sixb pipeline-runs get <run-id>
353
+
354
+ List options:
355
+ --pipeline <pipeline-id>
356
+ --status queued|running|succeeded|failed|cancelled
357
+ --started-after|--started-before <RFC3339>
358
+ --limit <1-${CLI_LIMITS.list.maximum}> Defaults to ${CLI_LIMITS.list.default}
359
+ --offset <n> Non-negative; defaults to 0
360
+ --order <asc|desc> Started-time order; defaults to ${DEFAULT_LIST_ORDER}
361
+
362
+ \`get\` includes the run's steps.`,
316
363
  } as const
317
364
 
318
365
  export const QUERY_EXAMPLES: Readonly<Record<string, string>> = {
@@ -4,7 +4,7 @@ import type { ApiClient } from "../api-client"
4
4
  import { enumValue, integerInRange, isHelp, parseCommandArgs, requestsHelp } from "../arguments"
5
5
  import { fail, writeJson, writeText } from "../output"
6
6
  import { GROUP_HELP } from "./metadata"
7
- import { readText } from "./shared"
7
+ import { idSegment, readText } from "./shared"
8
8
 
9
9
  export const MODEL_KINDS = ["language", "image", "speech", "transcription"] as const
10
10
  export type ModelKind = (typeof MODEL_KINDS)[number]
@@ -277,7 +277,7 @@ async function calls(api: ApiClient, args: readonly string[]): Promise<void> {
277
277
  1
278
278
  )
279
279
  const callId = positionals[0] ?? ""
280
- const call = (await api.get(`/api/ai/model-calls/${encodeURIComponent(callId)}/files`)) as {
280
+ const call = (await api.get(`/api/ai/model-calls/${idSegment(callId)}/files`)) as {
281
281
  readonly callId: string
282
282
  readonly files: readonly FileRef[]
283
283
  }
@@ -390,7 +390,7 @@ async function saveFiles(
390
390
  })
391
391
  for (const [index, path] of paths.entries()) {
392
392
  await mkdir(dirname(path), { recursive: true })
393
- await api.download(`/api/ai/model-calls/${encodeURIComponent(callId)}/files/content`, path, {
393
+ await api.download(`/api/ai/model-calls/${idSegment(callId)}/files/content`, path, {
394
394
  path: `/files/${index}`,
395
395
  })
396
396
  }
@@ -2,7 +2,7 @@ import type { ApiClient } from "../api-client"
2
2
  import { isHelp, parseCommandArgs, requestsHelp } from "../arguments"
3
3
  import { fail, writeJson, writeText } from "../output"
4
4
  import { GROUP_HELP } from "./metadata"
5
- import { asRecord, asRecords, vectorProfileNames } from "./shared"
5
+ import { asRecord, asRecords, idSegment, vectorProfileNames } from "./shared"
6
6
 
7
7
  export async function ontology(api: ApiClient, args: readonly string[]): Promise<void> {
8
8
  const [sub, ...rest] = args
@@ -46,7 +46,7 @@ export async function ontology(api: ApiClient, args: readonly string[]): Promise
46
46
  if (sub === "get") {
47
47
  if (requestsHelp(rest)) return writeText("Usage: sixb ontology get <object-type>")
48
48
  const { positionals } = parseCommandArgs(rest, {}, "ontology get", 1)
49
- return writeJson(await api.get(`/api/object-types/${encodeURIComponent(positionals[0] ?? "")}`))
49
+ return writeJson(await api.get(`/api/object-types/${idSegment(positionals[0])}`))
50
50
  }
51
51
  fail(`Unknown ontology command '${sub}'.`)
52
52
  }
@@ -21,16 +21,33 @@ const WORKFLOW_RUN_STATUSES = [
21
21
  "failed",
22
22
  "cancelled",
23
23
  ] as const
24
+ const DATA_RUN_STATUSES = ["queued", "running", "succeeded", "failed", "cancelled"] as const
25
+
26
+ const STATUSES = {
27
+ action: ACTION_RUN_STATUSES,
28
+ workflow: WORKFLOW_RUN_STATUSES,
29
+ sync: DATA_RUN_STATUSES,
30
+ pipeline: DATA_RUN_STATUSES,
31
+ } as const
32
+
33
+ /** The list filters each kind adds to the common window, by flag and query parameter. */
34
+ const FILTERS = {
35
+ action: { "--action": "actionId", "--type": "objectTypeId", "--id": "primaryId" },
36
+ workflow: { "--workflow": "workflowId" },
37
+ sync: { "--sync": "syncId", "--dataset": "datasetId" },
38
+ pipeline: { "--pipeline": "pipelineId" },
39
+ } as const
24
40
 
25
41
  export async function runs(
26
42
  api: ApiClient,
27
- kind: "action" | "workflow",
43
+ kind: keyof typeof STATUSES,
28
44
  args: readonly string[]
29
45
  ): Promise<void> {
30
46
  const [sub, ...rest] = args
31
- const group = `${kind}-runs` as "action-runs" | "workflow-runs"
47
+ const group = `${kind}-runs` as const
32
48
  if (!sub || isHelp(sub) || requestsHelp(rest)) return writeText(GROUP_HELP[group])
33
- if (sub === "get") {
49
+ // The API lists sync runs but has no route for one.
50
+ if (sub === "get" && kind !== "sync") {
34
51
  const { positionals } = parseCommandArgs(rest, {}, `${group} get`, 1)
35
52
  return writeJson(await api.get(`/api/${group}/${encodeURIComponent(positionals[0] ?? "")}`))
36
53
  }
@@ -43,15 +60,8 @@ export async function runs(
43
60
  "--offset": "offset",
44
61
  "--order": "order",
45
62
  }
46
- const action = {
47
- ...common,
48
- "--action": "actionId",
49
- "--type": "objectTypeId",
50
- "--id": "primaryId",
51
- }
52
- const workflow = { ...common, "--workflow": "workflowId" }
53
63
  const options = normalizeWindowOptions(
54
- parseQueryOptions(rest, kind === "action" ? action : workflow, `${group} list`),
64
+ parseQueryOptions(rest, { ...common, ...FILTERS[kind] }, `${group} list`),
55
65
  {
56
66
  defaultLimit: CLI_LIMITS.list.default,
57
67
  maximumLimit: CLI_LIMITS.list.maximum,
@@ -60,11 +70,7 @@ export async function runs(
60
70
  }
61
71
  )
62
72
  if (options.status !== undefined) {
63
- options.status = enumValue(
64
- "--status",
65
- options.status,
66
- kind === "action" ? ACTION_RUN_STATUSES : WORKFLOW_RUN_STATUSES
67
- )
73
+ options.status = enumValue("--status", options.status, STATUSES[kind])
68
74
  }
69
75
  for (const [name, flag] of [
70
76
  ["startedAfter", "--started-after"],
@@ -4,6 +4,12 @@ import { fail } from "../output"
4
4
 
5
5
  export type QueryOptions = Record<string, string | undefined>
6
6
 
7
+ /** An id as one segment of an API path: `.` or `..` would name another route. */
8
+ export function idSegment(value: string | undefined): string {
9
+ if (!value || value === "." || value === "..") fail(`'${value ?? ""}' is not an id.`)
10
+ return encodeURIComponent(value)
11
+ }
12
+
7
13
  /** Parse flag/value pairs into API query parameters. */
8
14
 
9
15
  export function parseQueryOptions(