@sanlabs/sanbox-cli 0.0.3 → 0.0.4

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/dist/cli.js CHANGED
@@ -2,15 +2,16 @@
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { formatActivityJsonl, formatActivityLine, parseActivityView, shouldRenderEvent } from "./activity.js";
5
- import { flagList, flagNumber, flagString, hasFlag, parseArgs } from "./args.js";
5
+ import { ArtifactExistsError, downloadArtifacts } from "./artifacts.js";
6
+ import { booleanFlags, flagList, flagString, hasFlag, parseArgs } from "./args.js";
6
7
  import { SanboxApiError, SanboxClient } from "./api.js";
7
8
  import { defaultApiUrl, readConfig, readLocalConfig, readTemplateSelection } from "./config.js";
8
- import { inspectDossierFile, previewTaskDossier, writeTaskDossier } from "./dossier.js";
9
9
  import { CliError, commandAction, consoleAction } from "./errors.js";
10
+ import { previewInputs } from "./inputs.js";
10
11
  import { printError, printJsonlError, printRun, printSuccess, publicRun, publicRunPayload } from "./output.js";
11
12
  import { createRun, isTerminalRun, readTasks, runPool, stableBatchId, waitForRun } from "./runs.js";
12
13
  import { version } from "./version.js";
13
- import { WatchInterruptedError, watchRun } from "./watch.js";
14
+ import { WatchInterruptedError, watchEventsUntil, watchRun } from "./watch.js";
14
15
  const help = `Sanbox CLI
15
16
 
16
17
  Environment:
@@ -20,7 +21,9 @@ Environment:
20
21
  SANBOX_TEMPLATE Explicit template id or slug for run and batch
21
22
 
22
23
  Commands:
24
+ sanbox --version
23
25
  sanbox auth check [--json]
26
+ sanbox orgs list [--json]
24
27
  sanbox context [--json]
25
28
  sanbox doctor [--json]
26
29
  sanbox model-providers list [--json]
@@ -29,32 +32,30 @@ Commands:
29
32
  sanbox templates list [--json]
30
33
  sanbox templates get <template-id> [--json]
31
34
  sanbox templates validate <template-id> [--json]
32
- sanbox templates create --name "..." --model-provider <provider-id> --model <model-id> [--web-access] [--json]
33
- sanbox run "task" --template <template-id> [--include "app/**"] [--wait | --watch] [--json | --jsonl]
34
- sanbox run --task "..." --template <template-id> [--include "app/**"] [--wait | --watch] [--json | --jsonl]
35
- sanbox run --dossier ./task.zip --template <template-id> [--wait | --watch] [--json | --jsonl]
36
- sanbox batch --tasks tasks.json --template <template-id> [--include "app/**"] [--max-parallel 5] [--wait] [--json]
37
- sanbox bundle preview [--include "app/**"] [--json]
38
- sanbox bundle create "task" --out ./run.zip [--include "app/**"]
39
- sanbox bundle inspect ./run.zip [--json]
35
+ sanbox templates create --name "..." --model-provider <provider-id> --model <model-id> [--llm-budget-usd <amount>] [--web-access] [--json]
36
+ sanbox run "task" --template <template-id> [--input <path>] [--wait | --watch] [--json | --jsonl]
37
+ sanbox run --task "..." --template <template-id> [--input <path>] [--wait | --watch] [--json | --jsonl]
38
+ sanbox batch --tasks tasks.json --template <template-id> [--input <path>] [--max-parallel 5] [--wait] [--json]
39
+ sanbox runs list [--limit 50] [--json]
40
40
  sanbox runs get <run-id> [--json]
41
41
  sanbox runs events <run-id> [--after-event-id 0] [--json]
42
+ sanbox runs messages <run-id> [--json]
43
+ sanbox runs artifacts <run-id> [--json]
44
+ sanbox runs download <run-id> --output <directory> [--artifact <path>] [--overwrite] [--json]
42
45
  sanbox runs watch <run-id> [--after-event-id 0] [--view activity|logs|compact] [--jsonl]
43
46
  sanbox runs cancel <run-id> [--json]
44
- sanbox runs message <run-id> --message "..." [--json]
47
+ sanbox runs message <run-id> "..." [--wait | --watch] [--json | --jsonl]
45
48
  sanbox init [--force]
46
49
  sanbox init agent [--write]
47
50
  `;
48
51
  const runHelp = `Sanbox run
49
52
 
50
53
  Usage:
51
- sanbox run "Review this code" --template <template-id> --include "app/**" --wait
52
- sanbox run --task "Review this code" --template <template-id> --include "app/**" --wait --json
53
- sanbox run --dossier ./run.zip --template <template-id> --wait
54
+ sanbox run "Review these files" --template <template-id> --input report.pdf --input data/ --wait
55
+ sanbox run --task "Review these files" --template <template-id> --input report.pdf --wait --json
54
56
 
55
57
  Options:
56
- --include <glob> File glob or directory to include. Repeatable.
57
- --dossier <path> Submit an existing dossier/run-bundle ZIP.
58
+ --input <path> File, directory, or glob to upload. Repeatable.
58
59
  --template <id> Template id or slug. Required unless SANBOX_TEMPLATE or project config sets it.
59
60
  --external-run-id <id> Idempotency key for retries.
60
61
  --retention-ttl-seconds <n> Workspace retention TTL. Default: 86400.
@@ -67,16 +68,6 @@ Options:
67
68
  --cancel-on-interrupt Request run cancellation when Ctrl-C is pressed.
68
69
  --json Print JSON.
69
70
  `;
70
- const bundleHelp = `Sanbox bundle
71
-
72
- Usage:
73
- sanbox bundle preview --include "app/**"
74
- sanbox bundle create "Review this repo" --include "app/**" --out ./run.zip
75
- sanbox bundle inspect ./run.zip
76
-
77
- Bundle is the public name for the portable ZIP contract. The runner still accepts
78
- the same ZIP dossier shape: RUNBOOK.md, manifest.json, and input/.
79
- `;
80
71
  const doctorHelp = `Sanbox doctor
81
72
 
82
73
  Usage:
@@ -101,42 +92,85 @@ Usage:
101
92
  sanbox templates list [--json]
102
93
  sanbox templates get <template-id> [--json]
103
94
  sanbox templates validate <template-id> [--json]
104
- sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--web-access] [--json]
95
+ sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--llm-budget-usd <amount>] [--web-access] [--json]
105
96
 
106
97
  Template creation requires an exact provider id and that provider's exact model id.
98
+ LiteLLM budgets are optional USD amounts and apply separately to each run.
107
99
  `;
108
- const agentInstructions = `# Sanbox Agent Integration
100
+ const agentInstructions = `# Operate Sanbox Autonomously
109
101
 
110
- Use Sanbox when a task is independent, long-running, risky to run locally, or can be split into parallel subtasks.
102
+ Use the \`sanbox\` CLI for focused, isolated, long-running, risky, or parallel work. The canonical
103
+ protocol is https://console.sanbox.cloud/agent.md. This workflow requires CLI 0.0.4 or newer.
111
104
 
112
- Environment expected by the CLI:
113
- - SANBOX_API_URL
114
- - SANBOX_ORG
115
- - SANBOX_API_KEY
116
- - SANBOX_TEMPLATE, or an explicit --template flag/project default
105
+ Human bootstrap:
106
+ - A human/admin supplies SANBOX_API_KEY and configures provider credentials plus a runnable template.
107
+ - Never print, persist, prompt with, or upload API keys or provider credentials.
108
+ - If the key or a runnable template is unavailable, stop with the exact human action required.
117
109
 
118
- Useful commands:
110
+ Deterministic startup:
119
111
  \`\`\`bash
120
- sanbox auth check
121
- sanbox doctor
112
+ sanbox --version
113
+ sanbox orgs list --json
114
+ export SANBOX_ORG=<returned-org-slug>
115
+ sanbox auth check --json
122
116
  sanbox context --json
123
- sanbox model-providers list --json
124
- sanbox model-providers models <provider-id> --json
125
117
  sanbox templates list --json
126
- sanbox templates validate <template-id> --json
127
- sanbox run "Investigate one focused task" --template <template-id> --include "app/**" --watch
128
- sanbox batch --tasks .sanbox/tasks.json --template <template-id> --include "app/**" --max-parallel 5 --wait --json
118
+ export SANBOX_TEMPLATE=<returned-template-id-or-slug>
119
+ sanbox templates validate "$SANBOX_TEMPLATE" --json
120
+ sanbox doctor --json
121
+ \`\`\`
122
+
123
+ Never guess opaque IDs. Select a discovered org/template automatically only when exactly one valid
124
+ choice exists; otherwise ask the user. Provider credentials and template administration are
125
+ console-only.
126
+
127
+ Use --json for request/response commands and --jsonl for streams. Parse the versioned envelope:
128
+ schema_version, ok, command, context, data or error, and next_actions. Execute command actions as
129
+ argv arrays, never shell strings. Exit 0 means command success, 1 local/API failure, 2 readiness or
130
+ waited remote failure, and 130 detached while remote work continues.
131
+
132
+ Preview and submit each logical task with a stable idempotency key:
133
+ \`\`\`bash
134
+ sanbox run "Investigate one focused task and write output/report.md" --input app/ --dry-run --json
135
+ sanbox run "Investigate one focused task and write output/report.md" \\
136
+ --template "$SANBOX_TEMPLATE" --external-run-id "<stable-project-task-id>" \\
137
+ --input app/ --wait --json
138
+ \`\`\`
139
+
140
+ Reuse the same --external-run-id after ambiguous failures. Retry network errors, HTTP 429, HTTP 5xx,
141
+ and workspace_busy with bounded backoff. Do not retry other 4xx errors unless next_actions directs
142
+ recovery.
143
+
144
+ Recover and retrieve results:
145
+ \`\`\`bash
146
+ sanbox runs list --limit 50 --json
129
147
  sanbox runs get <run-id> --json
130
- sanbox runs watch <run-id>
131
- sanbox runs events <run-id> --json
132
- sanbox runs message <run-id> --message "Summarize the retained output" --json
148
+ sanbox runs events <run-id> --after-event-id <cursor> --json
149
+ sanbox runs watch <run-id> --after-event-id <cursor> --jsonl
150
+ sanbox runs artifacts <run-id> --json
151
+ sanbox runs download <run-id> --output .sanbox/output/<run-id> --json
152
+ \`\`\`
153
+
154
+ Tasks must put durable files under /workspace/output. Downloads preserve relative paths and report
155
+ byte counts plus SHA-256 digests; existing files require explicit --overwrite.
156
+
157
+ Continue through the retained workspace:
158
+ \`\`\`bash
159
+ sanbox runs messages <run-id> --json
160
+ sanbox runs message <run-id> "Summarize the retained output" --wait --json
133
161
  \`\`\`
134
162
 
135
- Do not put secrets in run bundles. The CLI excludes common env and secret-like filenames by default.
136
- Provider credentials are console-only and must never be passed in CLI flags or prompts.
163
+ Follow-up waits are correlated to the returned chat_job.id. Keep dependent follow-ups sequential.
164
+ For independent fan-out, use \`sanbox batch\` with a stable external_run_id per task and keep the
165
+ client alive until submission completes.
166
+
167
+ Do not claim completion until the run is completed, required artifacts are downloaded and verified,
168
+ and required follow-ups succeeded. Report run/external/template IDs, status, artifact paths/digests,
169
+ and blockers. The CLI excludes common secrets by default; add .sanboxignore for project rules.
137
170
  `;
138
171
  const cwd = () => process.cwd();
139
172
  const makeClient = (flags) => new SanboxClient(readConfig(flags));
173
+ const makeAuthClient = (flags) => new SanboxClient(readConfig(flags, { requireOrg: false }));
140
174
  const jsonContext = (client) => ({
141
175
  api_url: client.config.apiUrl,
142
176
  org: client.config.org
@@ -239,7 +273,149 @@ const formatBytes = (bytes) => {
239
273
  };
240
274
  const positionalText = (command, startIndex) => command.slice(startIndex).join(" ").trim();
241
275
  const runTask = (command, flags) => flagString(flags, "task") || positionalText(command, 1);
242
- const bundleTask = (command, flags) => flagString(flags, "task") || positionalText(command, 2);
276
+ const commonFlags = ["api-url", "org", "json", "help"];
277
+ const flagSets = {
278
+ "auth.check": commonFlags,
279
+ "orgs.list": commonFlags,
280
+ context: [...commonFlags, "template"],
281
+ doctor: [...commonFlags, "template"],
282
+ "model-providers.list": commonFlags,
283
+ "model-providers.get": commonFlags,
284
+ "model-providers.models": commonFlags,
285
+ "templates.list": commonFlags,
286
+ "templates.get": commonFlags,
287
+ "templates.validate": commonFlags,
288
+ "templates.create": [...commonFlags, "name", "model-provider", "model", "llm-budget-usd", "web-access"],
289
+ run: [
290
+ ...commonFlags, "task", "input", "template", "external-run-id", "retention-ttl-seconds",
291
+ "dry-run", "wait", "watch", "jsonl", "view", "after-event-id", "cancel-on-interrupt",
292
+ "poll-interval-ms", "event-page-size", "timeout-seconds", "verbose"
293
+ ],
294
+ batch: [
295
+ ...commonFlags, "tasks", "template", "input", "max-parallel", "wait", "batch-id",
296
+ "retention-ttl-seconds", "poll-interval-ms", "timeout-seconds"
297
+ ],
298
+ "runs.list": [...commonFlags, "limit"],
299
+ "runs.get": commonFlags,
300
+ "runs.events": [...commonFlags, "after-event-id"],
301
+ "runs.messages": commonFlags,
302
+ "runs.artifacts": commonFlags,
303
+ "runs.download": [...commonFlags, "output", "artifact", "overwrite"],
304
+ "runs.watch": [
305
+ ...commonFlags, "after-event-id", "view", "jsonl", "cancel-on-interrupt",
306
+ "poll-interval-ms", "event-page-size", "timeout-seconds"
307
+ ],
308
+ "runs.cancel": commonFlags,
309
+ "runs.message": [
310
+ ...commonFlags, "message", "wait", "watch", "jsonl", "view",
311
+ "poll-interval-ms", "event-page-size", "timeout-seconds"
312
+ ],
313
+ init: [...commonFlags, "force", "template"],
314
+ "init.agent": [...commonFlags, "write"],
315
+ version: ["json", "help"]
316
+ };
317
+ const commandKey = (command) => {
318
+ if (command.length === 0)
319
+ return "help";
320
+ if (command[0] === "version")
321
+ return "version";
322
+ if (command[0] === "auth")
323
+ return `auth.${command[1] || ""}`;
324
+ if (command[0] === "orgs")
325
+ return `orgs.${command[1] || ""}`;
326
+ if (command[0] === "model-providers" || command[0] === "templates" || command[0] === "runs") {
327
+ return `${command[0]}.${command[1] || ""}`;
328
+ }
329
+ if (command[0] === "init" && command[1] === "agent")
330
+ return "init.agent";
331
+ return command[0];
332
+ };
333
+ const requiredValueFlags = new Set([
334
+ "api-url", "org", "template", "task", "input", "include", "external-run-id",
335
+ "retention-ttl-seconds", "tasks", "max-parallel", "batch-id", "poll-interval-ms",
336
+ "event-page-size", "timeout-seconds", "view", "after-event-id", "limit", "name",
337
+ "model-provider", "model", "llm-budget-usd", "message", "output", "artifact"
338
+ ]);
339
+ const validateFlagValues = (flags) => {
340
+ for (const flag of requiredValueFlags) {
341
+ const value = flags[flag];
342
+ const missing = value === true
343
+ || (typeof value === "string" && value.trim() === "")
344
+ || (Array.isArray(value) && value.some((item) => item.trim() === ""));
345
+ if (missing)
346
+ throw new CliError("flag_value_required", `--${flag} requires a value.`);
347
+ }
348
+ for (const flag of booleanFlags) {
349
+ if (flags[flag] !== undefined && flags[flag] !== true) {
350
+ throw new CliError("unexpected_flag_value", `--${flag} does not accept a value.`);
351
+ }
352
+ }
353
+ };
354
+ const normalizeInputAlias = (command, flags) => {
355
+ if (command[0] !== "run" && command[0] !== "batch")
356
+ return;
357
+ const legacyInputs = flagList(flags, "include");
358
+ if (legacyInputs.length === 0)
359
+ return;
360
+ flags.input = [...flagList(flags, "input"), ...legacyInputs];
361
+ delete flags.include;
362
+ if (!hasFlag(flags, "json") && !hasFlag(flags, "jsonl")) {
363
+ process.stderr.write("Warning: --include is deprecated; use --input.\n");
364
+ }
365
+ };
366
+ const validateFlags = (command, flags) => {
367
+ const key = commandKey(command);
368
+ if (key === "help") {
369
+ const unknown = Object.keys(flags).filter((flag) => !["help", "version", "json"].includes(flag));
370
+ if (unknown.length > 0)
371
+ throw new CliError("unknown_flag", `Unknown flag: --${unknown[0]}`);
372
+ return;
373
+ }
374
+ const knownRoots = new Set([
375
+ "auth", "orgs", "context", "doctor", "model-providers", "templates",
376
+ "run", "batch", "runs", "init", "version"
377
+ ]);
378
+ const allowed = flagSets[key] ?? (knownRoots.has(command[0] || "") ? commonFlags : null);
379
+ if (!allowed)
380
+ return;
381
+ const unknown = Object.keys(flags).filter((flag) => !allowed.includes(flag));
382
+ if (unknown.length > 0) {
383
+ throw new CliError("unknown_flag", `Unknown flag for ${command.join(" ")}: --${unknown[0]}`);
384
+ }
385
+ };
386
+ const validatePositionals = (command, flags) => {
387
+ if (hasFlag(flags, "help"))
388
+ return;
389
+ const maximums = {
390
+ "auth.check": 2,
391
+ "orgs.list": 2,
392
+ context: 1,
393
+ doctor: 1,
394
+ "model-providers.list": 2,
395
+ "model-providers.get": 3,
396
+ "model-providers.models": 3,
397
+ "templates.list": 2,
398
+ "templates.get": 3,
399
+ "templates.validate": 3,
400
+ "templates.create": 2,
401
+ batch: 1,
402
+ "runs.list": 2,
403
+ "runs.get": 3,
404
+ "runs.events": 3,
405
+ "runs.messages": 3,
406
+ "runs.artifacts": 3,
407
+ "runs.download": 3,
408
+ "runs.watch": 3,
409
+ "runs.cancel": 3,
410
+ init: 1,
411
+ "init.agent": 2,
412
+ version: 1
413
+ };
414
+ const maximum = maximums[commandKey(command)];
415
+ if (maximum !== undefined && command.length > maximum) {
416
+ throw new CliError("unexpected_argument", `Unexpected positional argument: ${command[maximum]}`);
417
+ }
418
+ };
243
419
  const wantsWatch = (flags) => hasFlag(flags, "watch") || hasFlag(flags, "jsonl");
244
420
  const integerFlag = (flags, key, fallback, minimum, maximum) => {
245
421
  if (flags[key] === undefined)
@@ -328,23 +504,20 @@ const writeIfNeeded = async (filePath, content, force) => {
328
504
  }
329
505
  };
330
506
  const commandRun = async (command, flags) => {
507
+ if (flagString(flags, "task") && positionalText(command, 1)) {
508
+ throw new CliError("conflicting_arguments", "Use either --task or a positional task, not both.");
509
+ }
331
510
  const task = runTask(command, flags);
332
- const dossierPath = flagString(flags, "dossier");
333
- if (!task && !dossierPath)
334
- throw new Error("--task, positional task, or --dossier is required.");
511
+ if (!task)
512
+ throw new Error("--task or a positional task is required.");
513
+ if (hasFlag(flags, "wait") && wantsWatch(flags)) {
514
+ throw new Error("--wait cannot be combined with --watch or --jsonl.");
515
+ }
335
516
  validateWatchFlags(flags);
336
517
  if (hasFlag(flags, "dry-run")) {
337
518
  if (wantsWatch(flags))
338
519
  throw new Error("--dry-run cannot be combined with --watch or --jsonl.");
339
- if (dossierPath) {
340
- const inspection = await inspectDossierFile(path.resolve(cwd(), dossierPath));
341
- if (hasFlag(flags, "json"))
342
- printSuccess("run.preview", inspection, localJsonContext(flags));
343
- else
344
- process.stdout.write(`Bundle has ${inspection.entries.length} entries\n`);
345
- return;
346
- }
347
- const preview = await previewTaskDossier({ cwd: cwd(), include: flagList(flags, "include") });
520
+ const preview = await previewInputs({ cwd: cwd(), inputs: flagList(flags, "input") });
348
521
  if (hasFlag(flags, "json"))
349
522
  printSuccess("run.preview", preview, localJsonContext(flags));
350
523
  else
@@ -357,13 +530,11 @@ const commandRun = async (command, flags) => {
357
530
  try {
358
531
  payload = await createRun(client, {
359
532
  cwd: cwd(),
360
- cliVersion: version,
361
- task,
362
- dossierPath,
363
- include: flagList(flags, "include"),
533
+ instruction: task,
534
+ inputs: flagList(flags, "input"),
364
535
  externalRunId: flagString(flags, "external-run-id") || undefined,
365
536
  templateId: template.id,
366
- retentionTtlSeconds: flagNumber(flags, "retention-ttl-seconds", 86400)
537
+ retentionTtlSeconds: integerFlag(flags, "retention-ttl-seconds", 86400, 0, Number.MAX_SAFE_INTEGER)
367
538
  });
368
539
  }
369
540
  catch (error) {
@@ -382,8 +553,8 @@ const commandRun = async (command, flags) => {
382
553
  }
383
554
  if (hasFlag(flags, "wait")) {
384
555
  payload = await waitForRun(client, payload.run.id, {
385
- pollIntervalMs: flagNumber(flags, "poll-interval-ms", 2000),
386
- timeoutSeconds: flagNumber(flags, "timeout-seconds", 1800)
556
+ pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
557
+ timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800)
387
558
  });
388
559
  }
389
560
  if (hasFlag(flags, "json")) {
@@ -405,20 +576,19 @@ const commandBatch = async (flags) => {
405
576
  const template = readTemplateSelection(flags);
406
577
  const tasks = await readTasks(tasksPath);
407
578
  const batchId = flagString(flags, "batch-id") || await stableBatchId(tasksPath);
408
- const include = flagList(flags, "include");
409
- const maxParallel = flagNumber(flags, "max-parallel", 5);
579
+ const inputs = flagList(flags, "input");
580
+ const maxParallel = integerFlag(flags, "max-parallel", 5, 1, 100);
410
581
  const wait = hasFlag(flags, "wait");
411
582
  const results = await runPool(tasks, maxParallel, async (task, index) => {
412
583
  let payload;
413
584
  try {
414
585
  payload = await createRun(client, {
415
586
  cwd: cwd(),
416
- cliVersion: version,
417
- task: task.task,
418
- include: task.include || include,
587
+ instruction: task.task,
588
+ inputs: task.input || inputs,
419
589
  externalRunId: task.external_run_id || `sanbox-batch-${batchId}-${index + 1}`,
420
590
  templateId: template.id,
421
- retentionTtlSeconds: flagNumber(flags, "retention-ttl-seconds", 86400)
591
+ retentionTtlSeconds: integerFlag(flags, "retention-ttl-seconds", 86400, 0, Number.MAX_SAFE_INTEGER)
422
592
  });
423
593
  }
424
594
  catch (error) {
@@ -426,8 +596,8 @@ const commandBatch = async (flags) => {
426
596
  }
427
597
  if (wait) {
428
598
  payload = await waitForRun(client, payload.run.id, {
429
- pollIntervalMs: flagNumber(flags, "poll-interval-ms", 2000),
430
- timeoutSeconds: flagNumber(flags, "timeout-seconds", 1800)
599
+ pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
600
+ timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800)
431
601
  });
432
602
  }
433
603
  return payload;
@@ -461,6 +631,19 @@ const commandAuthCheck = async (flags) => {
461
631
  if (!selected)
462
632
  process.exitCode = 2;
463
633
  };
634
+ const commandOrganizations = async (command, flags) => {
635
+ if (command[1] !== "list")
636
+ throw new CliError("orgs_action_required", "orgs requires the list action.");
637
+ const client = makeAuthClient(flags);
638
+ const payload = await client.listOrganizations();
639
+ if (hasFlag(flags, "json")) {
640
+ printSuccess("orgs.list", payload, { api_url: client.config.apiUrl });
641
+ return;
642
+ }
643
+ for (const organization of payload.organizations) {
644
+ process.stdout.write(`${organization.slug}${organization.name ? `\t${organization.name}` : ""}${organization.membership_role ? `\t${organization.membership_role}` : ""}\n`);
645
+ }
646
+ };
464
647
  const commandContext = async (flags) => {
465
648
  const client = makeClient(flags);
466
649
  const selection = readTemplateSelection(flags, { required: false });
@@ -652,13 +835,23 @@ const commandTemplates = async (command, flags) => {
652
835
  const name = requiredPositional(flagString(flags, "name"), "template_name_required", "templates create requires --name.");
653
836
  const modelProvider = requiredPositional(flagString(flags, "model-provider"), "model_provider_required", "templates create requires --model-provider.");
654
837
  const model = requiredPositional(flagString(flags, "model"), "model_required", "templates create requires --model.");
838
+ const budgetRaw = flagString(flags, "llm-budget-usd");
839
+ const parsedBudgetUsd = budgetRaw ? Number(budgetRaw) : undefined;
840
+ const llmBudgetUsd = parsedBudgetUsd === undefined
841
+ ? undefined
842
+ : Math.round(parsedBudgetUsd * 1_000_000) / 1_000_000;
843
+ if (budgetRaw &&
844
+ (!Number.isFinite(parsedBudgetUsd) || llmBudgetUsd <= 0 || llmBudgetUsd > 100_000)) {
845
+ throw new CliError("invalid_llm_budget", "templates create --llm-budget-usd must be at least 0.000001 and no more than 100000.");
846
+ }
655
847
  let payload;
656
848
  try {
657
849
  payload = await client.createTemplate({
658
850
  name,
659
851
  provider_id: modelProvider,
660
852
  model_id: model,
661
- web_access: hasFlag(flags, "web-access")
853
+ web_access: hasFlag(flags, "web-access"),
854
+ ...(llmBudgetUsd === undefined ? {} : { llm_budget_usd: llmBudgetUsd })
662
855
  });
663
856
  }
664
857
  catch (error) {
@@ -671,7 +864,8 @@ const commandTemplates = async (command, flags) => {
671
864
  ]);
672
865
  return;
673
866
  }
674
- process.stdout.write(`created ${payload.template.id} provider=${payload.template.provider_id || modelProvider} model=${payload.template.model_id || model}\n`);
867
+ process.stdout.write(`created ${payload.template.id} provider=${payload.template.provider_id || modelProvider} model=${payload.template.model_id || model}` +
868
+ `${payload.template.llm_budget_usd ? ` budget=$${payload.template.llm_budget_usd}/run` : ""}\n`);
675
869
  return;
676
870
  }
677
871
  throw new CliError("templates_action_required", "templates requires an action: list, get, validate, or create.");
@@ -679,9 +873,21 @@ const commandTemplates = async (command, flags) => {
679
873
  const commandRuns = async (command, flags) => {
680
874
  const client = makeClient(flags);
681
875
  const action = command[1];
682
- const runId = command[2];
683
- if (!action || !runId)
684
- throw new Error("runs command requires an action and run id.");
876
+ if (!action)
877
+ throw new Error("runs command requires an action.");
878
+ if (action === "list") {
879
+ const limit = integerFlag(flags, "limit", 50, 1, 200);
880
+ const payload = await client.listRuns(limit);
881
+ if (hasFlag(flags, "json")) {
882
+ printSuccess("runs.list", { ...payload, runs: payload.runs.map(publicRun) }, jsonContext(client));
883
+ return;
884
+ }
885
+ for (const run of payload.runs) {
886
+ process.stdout.write(`${run.id}\t${run.status}\t${run.created_at}\t${run.instruction.replace(/\s+/g, " ").slice(0, 100)}\n`);
887
+ }
888
+ return;
889
+ }
890
+ const runId = requiredPositional(command[2], "run_id_required", `runs ${action} requires a run id.`);
685
891
  if (action === "get") {
686
892
  const payload = await client.getRun(runId);
687
893
  if (hasFlag(flags, "json"))
@@ -691,13 +897,82 @@ const commandRuns = async (command, flags) => {
691
897
  return;
692
898
  }
693
899
  if (action === "events") {
694
- const payload = await client.listEvents(runId, flagNumber(flags, "after-event-id", 0));
900
+ const payload = await client.listEvents(runId, integerFlag(flags, "after-event-id", 0, 0, Number.MAX_SAFE_INTEGER));
695
901
  if (hasFlag(flags, "json"))
696
902
  printSuccess("runs.events", payload, jsonContext(client));
697
903
  else
698
904
  payload.events.forEach((event) => process.stdout.write(`${event.id} ${event.level} ${event.kind} ${event.message}\n`));
699
905
  return;
700
906
  }
907
+ if (action === "messages") {
908
+ const payload = await client.listMessages(runId);
909
+ if (hasFlag(flags, "json")) {
910
+ printSuccess("runs.messages", payload, jsonContext(client));
911
+ return;
912
+ }
913
+ for (const message of payload.messages) {
914
+ process.stdout.write(`${message.role}\t${message.created_at}\t${message.message}\n`);
915
+ }
916
+ return;
917
+ }
918
+ if (action === "artifacts") {
919
+ const payload = await client.listArtifacts(runId);
920
+ if (hasFlag(flags, "json")) {
921
+ printSuccess("runs.artifacts", payload, jsonContext(client));
922
+ return;
923
+ }
924
+ for (const artifact of payload.artifacts) {
925
+ process.stdout.write(`${artifact.path}\t${artifact.size_bytes}\n`);
926
+ }
927
+ return;
928
+ }
929
+ if (action === "download") {
930
+ const outputDirectory = requiredPositional(flagString(flags, "output"), "output_directory_required", "runs download requires --output.");
931
+ const payload = await client.listArtifacts(runId);
932
+ const requested = flagList(flags, "artifact");
933
+ const selected = requested.length === 0
934
+ ? payload.artifacts
935
+ : requested.map((artifactPath) => {
936
+ const artifact = payload.artifacts.find((item) => item.path === artifactPath);
937
+ if (!artifact) {
938
+ throw new CliError("artifact_not_found", `Run ${runId} has no artifact named ${artifactPath}.`, {
939
+ details: { run_id: runId, artifact_path: artifactPath },
940
+ nextActions: [commandAction(["sanbox", "runs", "artifacts", runId, "--json"], "List exact artifact paths for this run.")]
941
+ });
942
+ }
943
+ return artifact;
944
+ });
945
+ let downloads;
946
+ try {
947
+ downloads = await downloadArtifacts(client, runId, selected, outputDirectory, hasFlag(flags, "overwrite"));
948
+ }
949
+ catch (error) {
950
+ if (error instanceof ArtifactExistsError) {
951
+ throw new CliError("artifact_exists", error.message, {
952
+ details: {
953
+ run_id: runId,
954
+ artifact_path: error.artifactPath,
955
+ local_path: error.destination
956
+ },
957
+ nextActions: [commandAction(["sanbox", "runs", "download", runId, "--output", "<new-output-directory>", "--json"], "Download into a new directory without replacing an existing file.")]
958
+ });
959
+ }
960
+ throw error;
961
+ }
962
+ const result = {
963
+ run_id: runId,
964
+ output_directory: path.resolve(outputDirectory),
965
+ downloads
966
+ };
967
+ if (hasFlag(flags, "json")) {
968
+ printSuccess("runs.download", result, jsonContext(client));
969
+ return;
970
+ }
971
+ for (const download of downloads) {
972
+ process.stdout.write(`${download.path}\t${download.local_path}\t${download.size_bytes}\t${download.sha256}\n`);
973
+ }
974
+ return;
975
+ }
701
976
  if (action === "watch") {
702
977
  validateWatchFlags({ ...flags, watch: true });
703
978
  const payload = await watchRunWithOutput(client, runId, { ...flags, watch: true });
@@ -718,14 +993,102 @@ const commandRuns = async (command, flags) => {
718
993
  return;
719
994
  }
720
995
  if (action === "message") {
721
- const message = flagString(flags, "message");
996
+ const flaggedMessage = flagString(flags, "message");
997
+ const positionalMessage = positionalText(command, 3);
998
+ if (flaggedMessage && positionalMessage) {
999
+ throw new CliError("conflicting_arguments", "Use either --message or a positional message, not both.");
1000
+ }
1001
+ const message = flaggedMessage || positionalMessage;
722
1002
  if (!message)
723
- throw new Error("--message is required.");
724
- const payload = await client.sendMessage(runId, message);
725
- if (hasFlag(flags, "json"))
726
- printSuccess("runs.message", publicRunPayload(payload), jsonContext(client));
727
- else
728
- printRun(payload);
1003
+ throw new Error("--message or a positional message is required.");
1004
+ if (wantsWatch(flags) && hasFlag(flags, "json")) {
1005
+ throw new Error("--json cannot be combined with --watch or --jsonl.");
1006
+ }
1007
+ if (hasFlag(flags, "wait") && wantsWatch(flags)) {
1008
+ throw new Error("--wait cannot be combined with --watch or --jsonl.");
1009
+ }
1010
+ const submitted = await client.sendMessage(runId, message);
1011
+ if (!hasFlag(flags, "wait") && !wantsWatch(flags)) {
1012
+ if (hasFlag(flags, "json")) {
1013
+ printSuccess("runs.message", {
1014
+ ...publicRunPayload(submitted),
1015
+ message: submitted.message,
1016
+ chat_job: submitted.chat_job
1017
+ }, jsonContext(client));
1018
+ }
1019
+ else
1020
+ process.stdout.write(`queued follow-up for run ${runId}\n`);
1021
+ return;
1022
+ }
1023
+ const chatJobId = submitted.chat_job.id;
1024
+ const belongsToFollowup = (event) => event.payload.chat_job_id === chatJobId;
1025
+ const queuedEventId = submitted.events.reduce((latest, event) => event.kind === "chat.queued" && belongsToFollowup(event) ? Math.max(latest, event.id) : latest, 0);
1026
+ const receivedEventId = [...submitted.events]
1027
+ .reverse()
1028
+ .find((event) => event.kind === "message.received" && belongsToFollowup(event))?.id ?? queuedEventId;
1029
+ const cursor = submitted.events.reduce((latest, event) => Math.max(latest, event.id), 0);
1030
+ const completedInResponse = [...submitted.events]
1031
+ .reverse()
1032
+ .find((event) => (event.kind === "chat.completed" || event.kind === "chat.failed") && belongsToFollowup(event));
1033
+ const view = parseActivityView(flagString(flags, "view", "activity"));
1034
+ const jsonl = hasFlag(flags, "jsonl");
1035
+ const controller = new AbortController();
1036
+ const onInterrupt = () => controller.abort();
1037
+ process.once("SIGINT", onInterrupt);
1038
+ if (wantsWatch(flags) && !jsonl)
1039
+ process.stdout.write(`Watching follow-up for run ${runId}. Ctrl-C detaches.\n`);
1040
+ try {
1041
+ const renderFollowupEvent = (event) => {
1042
+ if (!belongsToFollowup(event))
1043
+ return;
1044
+ if (!wantsWatch(flags) || !shouldRenderEvent(event, view))
1045
+ return;
1046
+ process.stdout.write(`${jsonl ? formatActivityJsonl(event) : formatActivityLine(event, submitted.run.created_at)}\n`);
1047
+ };
1048
+ for (const event of submitted.events) {
1049
+ if (event.id >= receivedEventId)
1050
+ renderFollowupEvent(event);
1051
+ }
1052
+ const terminalEvent = completedInResponse ?? await watchEventsUntil(client, runId, {
1053
+ afterEventId: cursor,
1054
+ pageSize: integerFlag(flags, "event-page-size", 200, 1, 500),
1055
+ pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
1056
+ timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800),
1057
+ signal: controller.signal,
1058
+ stopWhen: (event) => (event.kind === "chat.completed" || event.kind === "chat.failed") && belongsToFollowup(event),
1059
+ onRetry: ({ error, attempt, delayMs }) => {
1060
+ const detail = error instanceof Error ? error.message : String(error);
1061
+ process.stderr.write(`Follow-up connection lost (${detail}); retry ${attempt} in ${delayMs}ms.\n`);
1062
+ },
1063
+ onEvent: renderFollowupEvent
1064
+ });
1065
+ const messages = await client.listMessages(runId);
1066
+ if (hasFlag(flags, "json")) {
1067
+ printSuccess("runs.message", {
1068
+ ...publicRunPayload(submitted),
1069
+ message: submitted.message,
1070
+ chat_job: submitted.chat_job,
1071
+ messages: messages.messages,
1072
+ followup_event: terminalEvent
1073
+ }, jsonContext(client));
1074
+ }
1075
+ else if (!jsonl) {
1076
+ const assistant = [...messages.messages].reverse().find((item) => item.role === "assistant" && item.payload.chat_job_id === chatJobId);
1077
+ if (assistant)
1078
+ process.stdout.write(`${assistant.message}\n`);
1079
+ }
1080
+ if (terminalEvent.kind === "chat.failed")
1081
+ process.exitCode = 2;
1082
+ }
1083
+ catch (error) {
1084
+ if (!(error instanceof WatchInterruptedError))
1085
+ throw error;
1086
+ process.stderr.write(`Detached from follow-up for run ${runId}; processing continues.\n`);
1087
+ process.exitCode = 130;
1088
+ }
1089
+ finally {
1090
+ process.off("SIGINT", onInterrupt);
1091
+ }
729
1092
  return;
730
1093
  }
731
1094
  throw new Error(`Unknown runs action: ${action}`);
@@ -808,11 +1171,11 @@ const commandDoctor = async (flags) => {
808
1171
  }
809
1172
  }
810
1173
  try {
811
- const preview = await previewTaskDossier({ cwd: cwd(), include: ["README.md"] });
812
- add("bundle_preview", true, `${preview.files.length} file(s), ${formatBytes(preview.totalBytes)}`);
1174
+ const preview = await previewInputs({ cwd: cwd(), inputs: ["README.md"] });
1175
+ add("input_preview", true, `${preview.files.length} file(s), ${formatBytes(preview.totalBytes)}`);
813
1176
  }
814
1177
  catch (error) {
815
- add("bundle_preview", false, error instanceof Error ? error.message : String(error));
1178
+ add("input_preview", false, error instanceof Error ? error.message : String(error));
816
1179
  }
817
1180
  const output = {
818
1181
  ok: checks.every((check) => check.ok),
@@ -830,55 +1193,6 @@ const commandDoctor = async (flags) => {
830
1193
  if (!output.ok)
831
1194
  process.exitCode = 2;
832
1195
  };
833
- const commandBundle = async (command, flags) => {
834
- const action = command[1];
835
- if (action === "preview") {
836
- const preview = await previewTaskDossier({ cwd: cwd(), include: flagList(flags, "include") });
837
- if (hasFlag(flags, "json"))
838
- printSuccess("bundle.preview", preview, localJsonContext(flags));
839
- else
840
- printPreview(preview, hasFlag(flags, "verbose"));
841
- return;
842
- }
843
- if (action === "create") {
844
- const task = bundleTask(command, flags);
845
- const outPath = flagString(flags, "out");
846
- if (!task)
847
- throw new Error("bundle create requires a positional task or --task.");
848
- if (!outPath)
849
- throw new Error("bundle create requires --out.");
850
- const dossier = await writeTaskDossier({
851
- cwd: cwd(),
852
- task,
853
- include: flagList(flags, "include"),
854
- cliVersion: version,
855
- outPath: path.resolve(cwd(), outPath)
856
- });
857
- const output = { path: outPath, sha256: dossier.sha256, files: dossier.files, bytes: dossier.buffer.byteLength };
858
- if (hasFlag(flags, "json"))
859
- printSuccess("bundle.create", output, localJsonContext(flags));
860
- else
861
- process.stdout.write(`Wrote ${outPath} with ${dossier.files.length} files (${formatBytes(dossier.buffer.byteLength)})\n`);
862
- return;
863
- }
864
- if (action === "inspect") {
865
- const bundlePath = command[2] || flagString(flags, "dossier") || flagString(flags, "bundle");
866
- if (!bundlePath)
867
- throw new Error("bundle inspect requires a ZIP path.");
868
- const inspection = await inspectDossierFile(path.resolve(cwd(), bundlePath));
869
- if (hasFlag(flags, "json")) {
870
- printSuccess("bundle.inspect", inspection, localJsonContext(flags));
871
- return;
872
- }
873
- process.stdout.write(`Entries: ${inspection.entries.length}\n`);
874
- if (inspection.manifest)
875
- process.stdout.write("manifest.json: present\n");
876
- if (inspection.runbook)
877
- process.stdout.write(`RUNBOOK.md: ${inspection.runbook.split(/\r?\n/)[0] || "present"}\n`);
878
- return;
879
- }
880
- throw new Error("bundle requires action: preview, create, or inspect.");
881
- };
882
1196
  const commandInit = async (command, flags) => {
883
1197
  const dir = path.join(cwd(), ".sanbox");
884
1198
  if (command[1] === "agent") {
@@ -941,8 +1255,6 @@ const commandInit = async (command, flags) => {
941
1255
  const helpFor = (command) => {
942
1256
  if (command[0] === "run")
943
1257
  return runHelp;
944
- if (command[0] === "bundle")
945
- return bundleHelp;
946
1258
  if (command[0] === "doctor")
947
1259
  return doctorHelp;
948
1260
  if (command[0] === "model-providers")
@@ -952,8 +1264,12 @@ const helpFor = (command) => {
952
1264
  return help;
953
1265
  };
954
1266
  const commandId = (command) => {
1267
+ if (command[0] === "version" || command.length === 0)
1268
+ return "version";
955
1269
  if (command[0] === "auth")
956
1270
  return "auth.check";
1271
+ if (command[0] === "orgs")
1272
+ return `orgs.${command[1] || "unknown"}`;
957
1273
  if (command[0] === "context")
958
1274
  return "context.get";
959
1275
  if (command[0] === "model-providers")
@@ -964,13 +1280,22 @@ const commandId = (command) => {
964
1280
  return "run.create";
965
1281
  if (command[0] === "batch")
966
1282
  return "batch.create";
967
- if (command[0] === "bundle")
968
- return `bundle.${command[1] || "unknown"}`;
969
1283
  if (command[0] === "runs")
970
1284
  return `runs.${command[1] || "unknown"}`;
971
1285
  return command[0] || "help";
972
1286
  };
973
1287
  const main = async (command, flags) => {
1288
+ validateFlagValues(flags);
1289
+ normalizeInputAlias(command, flags);
1290
+ validateFlags(command, flags);
1291
+ validatePositionals(command, flags);
1292
+ if (command[0] === "version" || (command.length === 0 && hasFlag(flags, "version"))) {
1293
+ if (hasFlag(flags, "json"))
1294
+ printSuccess("version", { version });
1295
+ else
1296
+ process.stdout.write(`${version}\n`);
1297
+ return;
1298
+ }
974
1299
  if (command.length === 0) {
975
1300
  process.stdout.write(help);
976
1301
  return;
@@ -981,6 +1306,8 @@ const main = async (command, flags) => {
981
1306
  }
982
1307
  if (command[0] === "auth" && command[1] === "check")
983
1308
  return commandAuthCheck(flags);
1309
+ if (command[0] === "orgs")
1310
+ return commandOrganizations(command, flags);
984
1311
  if (command[0] === "context")
985
1312
  return commandContext(flags);
986
1313
  if (command[0] === "doctor")
@@ -993,8 +1320,6 @@ const main = async (command, flags) => {
993
1320
  return commandRun(command, flags);
994
1321
  if (command[0] === "batch")
995
1322
  return commandBatch(flags);
996
- if (command[0] === "bundle")
997
- return commandBundle(command, flags);
998
1323
  if (command[0] === "runs")
999
1324
  return commandRuns(command, flags);
1000
1325
  if (command[0] === "init")