@sanlabs/sanbox-cli 0.0.3 → 0.0.5

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,24 +2,25 @@
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:
17
18
  SANBOX_API_URL Sanbox API base URL
18
- SANBOX_ORG Organization slug
19
19
  SANBOX_API_KEY Org API key
20
20
  SANBOX_TEMPLATE Explicit template id or slug for run and batch
21
21
 
22
22
  Commands:
23
+ sanbox --version
23
24
  sanbox auth check [--json]
24
25
  sanbox context [--json]
25
26
  sanbox doctor [--json]
@@ -29,35 +30,32 @@ Commands:
29
30
  sanbox templates list [--json]
30
31
  sanbox templates get <template-id> [--json]
31
32
  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]
33
+ sanbox templates create --name "..." --model-provider <provider-id> --model <model-id> [--harness opencode|hermes] [--llm-budget-usd <amount>] [--json]
34
+ sanbox run "task" --template <template-id> [--input <path>] [--wait | --watch] [--json | --jsonl]
35
+ sanbox run --task "..." --template <template-id> [--input <path>] [--wait | --watch] [--json | --jsonl]
36
+ sanbox batch --tasks tasks.json --template <template-id> [--input <path>] [--max-parallel 5] [--wait] [--json]
37
+ sanbox runs list [--limit 50] [--json]
40
38
  sanbox runs get <run-id> [--json]
41
39
  sanbox runs events <run-id> [--after-event-id 0] [--json]
40
+ sanbox runs messages <run-id> [--json]
41
+ sanbox runs artifacts <run-id> [--json]
42
+ sanbox runs download <run-id> --output <directory> [--artifact <path>] [--overwrite] [--json]
42
43
  sanbox runs watch <run-id> [--after-event-id 0] [--view activity|logs|compact] [--jsonl]
43
44
  sanbox runs cancel <run-id> [--json]
44
- sanbox runs message <run-id> --message "..." [--json]
45
+ sanbox runs message <run-id> "..." [--wait | --watch] [--json | --jsonl]
45
46
  sanbox init [--force]
46
47
  sanbox init agent [--write]
47
48
  `;
48
49
  const runHelp = `Sanbox run
49
50
 
50
51
  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
52
+ sanbox run "Review these files" --template <template-id> --input report.pdf --input data/ --wait
53
+ sanbox run --task "Review these files" --template <template-id> --input report.pdf --wait --json
54
54
 
55
55
  Options:
56
- --include <glob> File glob or directory to include. Repeatable.
57
- --dossier <path> Submit an existing dossier/run-bundle ZIP.
56
+ --input <path> File, directory, or glob to upload. Repeatable.
58
57
  --template <id> Template id or slug. Required unless SANBOX_TEMPLATE or project config sets it.
59
58
  --external-run-id <id> Idempotency key for retries.
60
- --retention-ttl-seconds <n> Workspace retention TTL. Default: 86400.
61
59
  --dry-run Preview included files without creating a run.
62
60
  --wait Poll until terminal status.
63
61
  --watch Stream activity until terminal status.
@@ -67,22 +65,12 @@ Options:
67
65
  --cancel-on-interrupt Request run cancellation when Ctrl-C is pressed.
68
66
  --json Print JSON.
69
67
  `;
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
68
  const doctorHelp = `Sanbox doctor
81
69
 
82
70
  Usage:
83
71
  sanbox doctor [--json]
84
72
 
85
- Checks local configuration, API health, auth, org visibility, and template access.
73
+ Checks local configuration, API health, auth, organization discovery, and template access.
86
74
  SANBOX_API_KEY is still read only from the environment.
87
75
  `;
88
76
  const modelProvidersHelp = `Sanbox model providers
@@ -101,52 +89,111 @@ Usage:
101
89
  sanbox templates list [--json]
102
90
  sanbox templates get <template-id> [--json]
103
91
  sanbox templates validate <template-id> [--json]
104
- sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--web-access] [--json]
92
+ sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--harness opencode|hermes] [--llm-budget-usd <amount>] [--json]
105
93
 
106
94
  Template creation requires an exact provider id and that provider's exact model id.
95
+ LiteLLM budgets are optional USD amounts and apply separately to each run.
96
+ Hermes templates are always-on Telegram gateways. Set SANBOX_TELEGRAM_BOT_TOKEN
97
+ and repeat --telegram-allowed-user <numeric-id> for every permitted user.
107
98
  `;
108
- const agentInstructions = `# Sanbox Agent Integration
99
+ const agentInstructions = `# Operate Sanbox Autonomously
100
+
101
+ Use the \`sanbox\` CLI for focused, isolated, long-running, risky, or parallel work. The canonical
102
+ protocol is https://console.sanbox.cloud/agent.md.
109
103
 
110
- Use Sanbox when a task is independent, long-running, risky to run locally, or can be split into parallel subtasks.
104
+ Always use the latest published CLI. Install and verify it at the start of every operating session:
105
+ \`\`\`bash
106
+ npm install -g @sanlabs/sanbox-cli@latest
107
+ latest_cli_version="$(npm view @sanlabs/sanbox-cli version)"
108
+ installed_cli_version="$(sanbox --version)"
109
+ test "$installed_cli_version" = "$latest_cli_version"
110
+ \`\`\`
111
111
 
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
112
+ Human bootstrap:
113
+ - A human/admin supplies an organization-scoped SANBOX_API_KEY and configures provider credentials plus a runnable template.
114
+ - Never print, persist, prompt with, or upload API keys or provider credentials.
115
+ - If the key or a runnable template is unavailable, stop with the exact human action required.
117
116
 
118
- Useful commands:
117
+ Deterministic startup:
119
118
  \`\`\`bash
120
- sanbox auth check
121
- sanbox doctor
119
+ sanbox --version
120
+ sanbox auth check --json
122
121
  sanbox context --json
123
- sanbox model-providers list --json
124
- sanbox model-providers models <provider-id> --json
125
122
  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
123
+ export SANBOX_TEMPLATE=<returned-template-id-or-slug>
124
+ sanbox templates validate "$SANBOX_TEMPLATE" --json
125
+ sanbox doctor --json
126
+ \`\`\`
127
+
128
+ Never guess opaque IDs. The CLI derives the organization from the API key and requires the key to
129
+ resolve to exactly one organization. For task execution, select only a template with runnable: true,
130
+ template_type: "runner", and runner_config.harness: "opencode". Never select a Hermes service
131
+ template for a waited task because it is always-on. Select automatically only when exactly one task
132
+ template qualifies; otherwise ask the user. Provider credentials and template administration are
133
+ console-only.
134
+
135
+ Use --json for request/response commands and --jsonl for streams. Parse the versioned envelope:
136
+ schema_version, ok, command, context, data or error, and next_actions. Execute command actions as
137
+ argv arrays, never shell strings. Exit 0 means command success, 1 local/API failure, 2 readiness or
138
+ waited remote failure, and 130 detached while remote work continues.
139
+
140
+ Preview and submit each logical task with a stable idempotency key:
141
+ \`\`\`bash
142
+ sanbox run "Investigate one focused task and write output/report.md" --input app/ --dry-run --json
143
+ sanbox run "Investigate one focused task and write output/report.md" \\
144
+ --template "$SANBOX_TEMPLATE" --external-run-id "<stable-project-task-id>" \\
145
+ --input app/ --wait --json
146
+ \`\`\`
147
+
148
+ Reuse the same --external-run-id after ambiguous failures. Retry network errors, HTTP 429, HTTP 5xx,
149
+ and workspace_busy with bounded backoff. Do not retry other 4xx errors unless next_actions directs
150
+ recovery. Terminal statuses are completed, failed, and canceled.
151
+
152
+ Recover and retrieve results:
153
+ \`\`\`bash
154
+ sanbox runs list --limit 50 --json
155
+ sanbox runs get <run-id> --json
156
+ sanbox runs events <run-id> --after-event-id <cursor> --json
157
+ sanbox runs watch <run-id> --after-event-id <cursor> --jsonl
158
+ sanbox runs artifacts <run-id> --json
159
+ sanbox runs download <run-id> --output .sanbox/output/<run-id> --json
160
+ \`\`\`
161
+
162
+ Tasks must put durable files under /workspace/output. Downloads preserve relative paths and report
163
+ byte counts plus SHA-256 digests; existing files require explicit --overwrite.
164
+
165
+ Continue through the same paused writable sandbox:
166
+ \`\`\`bash
167
+ sanbox runs messages <run-id> --json
129
168
  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
169
+ sanbox runs message <run-id> "Summarize the retained output" --wait --json
133
170
  \`\`\`
134
171
 
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.
172
+ Before a follow-up, require sandbox_state: "paused" and a positive snapshot_generation. The message
173
+ resumes the same sandbox and OpenCode session, then pauses it as a new generation. Follow-up waits
174
+ are correlated to the returned chat_job.id. Never submit concurrent follow-ups to one run. Retry
175
+ sandbox_not_paused only after the run returns to paused; treat sandbox_not_resumable,
176
+ sandbox_snapshot_missing, sandbox_worker_missing, and hermes_uses_channel as blockers.
177
+
178
+ For independent fan-out, use \`sanbox batch\` with a stable external_run_id per task and keep the
179
+ client alive until submission completes.
180
+
181
+ Do not claim completion until the run is completed, required artifacts are downloaded and verified,
182
+ and required follow-ups succeeded. Report run/external/template IDs, status, sandbox state, snapshot
183
+ generation, artifact paths/digests, and blockers. The CLI excludes common secrets by default; add
184
+ .sanboxignore for project rules.
137
185
  `;
138
186
  const cwd = () => process.cwd();
139
187
  const makeClient = (flags) => new SanboxClient(readConfig(flags));
140
188
  const jsonContext = (client) => ({
141
189
  api_url: client.config.apiUrl,
142
- org: client.config.org
190
+ ...(client.resolvedOrganizationSlug() ? { org: client.resolvedOrganizationSlug() } : {})
143
191
  });
144
192
  const localJsonContext = (flags) => {
145
193
  try {
146
194
  const local = readLocalConfig();
147
195
  const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || local.api_url || defaultApiUrl).replace(/\/+$/, "");
148
- const org = String(flags.org || process.env.SANBOX_ORG || local.org || "").trim();
149
- return { api_url: apiUrl, ...(org ? { org } : {}) };
196
+ return { api_url: apiUrl };
150
197
  }
151
198
  catch {
152
199
  return {};
@@ -157,7 +204,7 @@ const consoleUrl = (client) => consolePathUrl(client, "/model-providers");
157
204
  const providerConsoleAction = (client) => consoleAction(consoleUrl(client), "Ask an organization admin to configure or repair the model provider in the Sanbox console.");
158
205
  const templateAdminConsoleAction = (client) => consoleAction(consolePathUrl(client, "/templates"), "Ask an organization admin to create a template in the Sanbox console.");
159
206
  const templateActions = () => [
160
- commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization."),
207
+ commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the API key's organization."),
161
208
  commandAction(["sanbox", "templates", "validate", "<template-id>", "--json"], "Check whether a template is runnable.")
162
209
  ];
163
210
  const runReadinessCodes = new Set([
@@ -239,7 +286,155 @@ const formatBytes = (bytes) => {
239
286
  };
240
287
  const positionalText = (command, startIndex) => command.slice(startIndex).join(" ").trim();
241
288
  const runTask = (command, flags) => flagString(flags, "task") || positionalText(command, 1);
242
- const bundleTask = (command, flags) => flagString(flags, "task") || positionalText(command, 2);
289
+ const commonFlags = ["api-url", "json", "help"];
290
+ const flagSets = {
291
+ "auth.check": commonFlags,
292
+ context: [...commonFlags, "template"],
293
+ doctor: [...commonFlags, "template"],
294
+ "model-providers.list": commonFlags,
295
+ "model-providers.get": commonFlags,
296
+ "model-providers.models": commonFlags,
297
+ "templates.list": commonFlags,
298
+ "templates.get": commonFlags,
299
+ "templates.validate": commonFlags,
300
+ "templates.create": [
301
+ ...commonFlags,
302
+ "name",
303
+ "model-provider",
304
+ "model",
305
+ "harness",
306
+ "llm-budget-usd",
307
+ "web-access",
308
+ "telegram-allowed-user"
309
+ ],
310
+ run: [
311
+ ...commonFlags, "task", "input", "template", "external-run-id",
312
+ "dry-run", "wait", "watch", "jsonl", "view", "after-event-id", "cancel-on-interrupt",
313
+ "poll-interval-ms", "event-page-size", "timeout-seconds", "verbose"
314
+ ],
315
+ batch: [
316
+ ...commonFlags, "tasks", "template", "input", "max-parallel", "wait", "batch-id",
317
+ "poll-interval-ms", "timeout-seconds"
318
+ ],
319
+ "runs.list": [...commonFlags, "limit"],
320
+ "runs.get": commonFlags,
321
+ "runs.events": [...commonFlags, "after-event-id"],
322
+ "runs.messages": commonFlags,
323
+ "runs.artifacts": commonFlags,
324
+ "runs.download": [...commonFlags, "output", "artifact", "overwrite"],
325
+ "runs.watch": [
326
+ ...commonFlags, "after-event-id", "view", "jsonl", "cancel-on-interrupt",
327
+ "poll-interval-ms", "event-page-size", "timeout-seconds"
328
+ ],
329
+ "runs.cancel": commonFlags,
330
+ "runs.message": [
331
+ ...commonFlags, "message", "wait", "watch", "jsonl", "view",
332
+ "poll-interval-ms", "event-page-size", "timeout-seconds"
333
+ ],
334
+ init: [...commonFlags, "force", "template"],
335
+ "init.agent": [...commonFlags, "write"],
336
+ version: ["json", "help"]
337
+ };
338
+ const commandKey = (command) => {
339
+ if (command.length === 0)
340
+ return "help";
341
+ if (command[0] === "version")
342
+ return "version";
343
+ if (command[0] === "auth")
344
+ return `auth.${command[1] || ""}`;
345
+ if (command[0] === "model-providers" || command[0] === "templates" || command[0] === "runs") {
346
+ return `${command[0]}.${command[1] || ""}`;
347
+ }
348
+ if (command[0] === "init" && command[1] === "agent")
349
+ return "init.agent";
350
+ return command[0];
351
+ };
352
+ const requiredValueFlags = new Set([
353
+ "api-url", "template", "task", "input", "include", "external-run-id",
354
+ "tasks", "max-parallel", "batch-id", "poll-interval-ms",
355
+ "event-page-size", "timeout-seconds", "view", "after-event-id", "limit", "name",
356
+ "model-provider", "model", "harness", "llm-budget-usd", "telegram-allowed-user",
357
+ "message", "output", "artifact"
358
+ ]);
359
+ const validateFlagValues = (flags) => {
360
+ for (const flag of requiredValueFlags) {
361
+ const value = flags[flag];
362
+ const missing = value === true
363
+ || (typeof value === "string" && value.trim() === "")
364
+ || (Array.isArray(value) && value.some((item) => item.trim() === ""));
365
+ if (missing)
366
+ throw new CliError("flag_value_required", `--${flag} requires a value.`);
367
+ }
368
+ for (const flag of booleanFlags) {
369
+ if (flags[flag] !== undefined && flags[flag] !== true) {
370
+ throw new CliError("unexpected_flag_value", `--${flag} does not accept a value.`);
371
+ }
372
+ }
373
+ };
374
+ const normalizeInputAlias = (command, flags) => {
375
+ if (command[0] !== "run" && command[0] !== "batch")
376
+ return;
377
+ const legacyInputs = flagList(flags, "include");
378
+ if (legacyInputs.length === 0)
379
+ return;
380
+ flags.input = [...flagList(flags, "input"), ...legacyInputs];
381
+ delete flags.include;
382
+ if (!hasFlag(flags, "json") && !hasFlag(flags, "jsonl")) {
383
+ process.stderr.write("Warning: --include is deprecated; use --input.\n");
384
+ }
385
+ };
386
+ const validateFlags = (command, flags) => {
387
+ const key = commandKey(command);
388
+ if (key === "help") {
389
+ const unknown = Object.keys(flags).filter((flag) => !["help", "version", "json"].includes(flag));
390
+ if (unknown.length > 0)
391
+ throw new CliError("unknown_flag", `Unknown flag: --${unknown[0]}`);
392
+ return;
393
+ }
394
+ const knownRoots = new Set([
395
+ "auth", "context", "doctor", "model-providers", "templates",
396
+ "run", "batch", "runs", "init", "version"
397
+ ]);
398
+ const allowed = flagSets[key] ?? (knownRoots.has(command[0] || "") ? commonFlags : null);
399
+ if (!allowed)
400
+ return;
401
+ const unknown = Object.keys(flags).filter((flag) => !allowed.includes(flag));
402
+ if (unknown.length > 0) {
403
+ throw new CliError("unknown_flag", `Unknown flag for ${command.join(" ")}: --${unknown[0]}`);
404
+ }
405
+ };
406
+ const validatePositionals = (command, flags) => {
407
+ if (hasFlag(flags, "help"))
408
+ return;
409
+ const maximums = {
410
+ "auth.check": 2,
411
+ context: 1,
412
+ doctor: 1,
413
+ "model-providers.list": 2,
414
+ "model-providers.get": 3,
415
+ "model-providers.models": 3,
416
+ "templates.list": 2,
417
+ "templates.get": 3,
418
+ "templates.validate": 3,
419
+ "templates.create": 2,
420
+ batch: 1,
421
+ "runs.list": 2,
422
+ "runs.get": 3,
423
+ "runs.events": 3,
424
+ "runs.messages": 3,
425
+ "runs.artifacts": 3,
426
+ "runs.download": 3,
427
+ "runs.watch": 3,
428
+ "runs.cancel": 3,
429
+ init: 1,
430
+ "init.agent": 2,
431
+ version: 1
432
+ };
433
+ const maximum = maximums[commandKey(command)];
434
+ if (maximum !== undefined && command.length > maximum) {
435
+ throw new CliError("unexpected_argument", `Unexpected positional argument: ${command[maximum]}`);
436
+ }
437
+ };
243
438
  const wantsWatch = (flags) => hasFlag(flags, "watch") || hasFlag(flags, "jsonl");
244
439
  const integerFlag = (flags, key, fallback, minimum, maximum) => {
245
440
  if (flags[key] === undefined)
@@ -328,23 +523,20 @@ const writeIfNeeded = async (filePath, content, force) => {
328
523
  }
329
524
  };
330
525
  const commandRun = async (command, flags) => {
526
+ if (flagString(flags, "task") && positionalText(command, 1)) {
527
+ throw new CliError("conflicting_arguments", "Use either --task or a positional task, not both.");
528
+ }
331
529
  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.");
530
+ if (!task)
531
+ throw new Error("--task or a positional task is required.");
532
+ if (hasFlag(flags, "wait") && wantsWatch(flags)) {
533
+ throw new Error("--wait cannot be combined with --watch or --jsonl.");
534
+ }
335
535
  validateWatchFlags(flags);
336
536
  if (hasFlag(flags, "dry-run")) {
337
537
  if (wantsWatch(flags))
338
538
  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") });
539
+ const preview = await previewInputs({ cwd: cwd(), inputs: flagList(flags, "input") });
348
540
  if (hasFlag(flags, "json"))
349
541
  printSuccess("run.preview", preview, localJsonContext(flags));
350
542
  else
@@ -357,13 +549,10 @@ const commandRun = async (command, flags) => {
357
549
  try {
358
550
  payload = await createRun(client, {
359
551
  cwd: cwd(),
360
- cliVersion: version,
361
- task,
362
- dossierPath,
363
- include: flagList(flags, "include"),
552
+ instruction: task,
553
+ inputs: flagList(flags, "input"),
364
554
  externalRunId: flagString(flags, "external-run-id") || undefined,
365
- templateId: template.id,
366
- retentionTtlSeconds: flagNumber(flags, "retention-ttl-seconds", 86400)
555
+ templateId: template.id
367
556
  });
368
557
  }
369
558
  catch (error) {
@@ -382,8 +571,8 @@ const commandRun = async (command, flags) => {
382
571
  }
383
572
  if (hasFlag(flags, "wait")) {
384
573
  payload = await waitForRun(client, payload.run.id, {
385
- pollIntervalMs: flagNumber(flags, "poll-interval-ms", 2000),
386
- timeoutSeconds: flagNumber(flags, "timeout-seconds", 1800)
574
+ pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
575
+ timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800)
387
576
  });
388
577
  }
389
578
  if (hasFlag(flags, "json")) {
@@ -405,20 +594,18 @@ const commandBatch = async (flags) => {
405
594
  const template = readTemplateSelection(flags);
406
595
  const tasks = await readTasks(tasksPath);
407
596
  const batchId = flagString(flags, "batch-id") || await stableBatchId(tasksPath);
408
- const include = flagList(flags, "include");
409
- const maxParallel = flagNumber(flags, "max-parallel", 5);
597
+ const inputs = flagList(flags, "input");
598
+ const maxParallel = integerFlag(flags, "max-parallel", 5, 1, 100);
410
599
  const wait = hasFlag(flags, "wait");
411
600
  const results = await runPool(tasks, maxParallel, async (task, index) => {
412
601
  let payload;
413
602
  try {
414
603
  payload = await createRun(client, {
415
604
  cwd: cwd(),
416
- cliVersion: version,
417
- task: task.task,
418
- include: task.include || include,
605
+ instruction: task.task,
606
+ inputs: task.input || inputs,
419
607
  externalRunId: task.external_run_id || `sanbox-batch-${batchId}-${index + 1}`,
420
- templateId: template.id,
421
- retentionTtlSeconds: flagNumber(flags, "retention-ttl-seconds", 86400)
608
+ templateId: template.id
422
609
  });
423
610
  }
424
611
  catch (error) {
@@ -426,8 +613,8 @@ const commandBatch = async (flags) => {
426
613
  }
427
614
  if (wait) {
428
615
  payload = await waitForRun(client, payload.run.id, {
429
- pollIntervalMs: flagNumber(flags, "poll-interval-ms", 2000),
430
- timeoutSeconds: flagNumber(flags, "timeout-seconds", 1800)
616
+ pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
617
+ timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800)
431
618
  });
432
619
  }
433
620
  return payload;
@@ -447,37 +634,28 @@ const commandBatch = async (flags) => {
447
634
  };
448
635
  const commandAuthCheck = async (flags) => {
449
636
  const client = makeClient(flags);
450
- const me = await client.me();
451
- const org = client.config.org;
452
- const organizations = Array.isArray(me.organizations)
453
- ? me.organizations
454
- : [];
455
- const selected = organizations.find((item) => item.slug === org) || null;
456
- const output = { ok: Boolean(selected), org, selected, me };
637
+ const [me, selected] = await Promise.all([client.me(), client.organization()]);
638
+ const org = selected.slug;
639
+ const output = { ok: true, org, selected, me };
457
640
  if (hasFlag(flags, "json"))
458
641
  printSuccess("auth.check", output, jsonContext(client));
459
642
  else
460
- process.stdout.write(selected ? `ok org=${org}\n` : `authenticated, but org ${org} is not visible\n`);
461
- if (!selected)
462
- process.exitCode = 2;
643
+ process.stdout.write(`ok org=${org}\n`);
463
644
  };
464
645
  const commandContext = async (flags) => {
465
646
  const client = makeClient(flags);
466
647
  const selection = readTemplateSelection(flags, { required: false });
467
- const [me, providerPayload, templatePayload] = await Promise.all([
648
+ const [organization, me, providerPayload, templatePayload] = await Promise.all([
649
+ client.organization(),
468
650
  client.me(),
469
651
  client.listModelProviders(),
470
652
  client.listTemplates()
471
653
  ]);
472
- const organizations = Array.isArray(me.organizations)
473
- ? me.organizations
474
- : [];
475
- const selectedOrganization = organizations.find((item) => item.slug === client.config.org) || null;
476
654
  const selectedTemplate = selection
477
655
  ? templatePayload.templates.find((item) => item.id === selection.id || item.template_slug === selection.id) || null
478
656
  : null;
479
657
  const output = {
480
- organization: selectedOrganization,
658
+ organization,
481
659
  authentication: me.auth || null,
482
660
  template_selection: selection
483
661
  ? { id: selection.id, source: selection.source, found: Boolean(selectedTemplate), template: selectedTemplate }
@@ -486,22 +664,17 @@ const commandContext = async (flags) => {
486
664
  templates: templatePayload.templates
487
665
  };
488
666
  const nextActions = [];
489
- if (!selectedOrganization) {
490
- nextActions.push(commandAction(["sanbox", "auth", "check", "--json"], "Verify that the API key can access the selected organization."));
491
- }
492
667
  if (!selection) {
493
668
  nextActions.push(commandAction(["sanbox", "templates", "list", "--json"], "List available templates."), commandAction(["sanbox", "context", "--json"], "Select a template for this shell and inspect the resolved context.", { SANBOX_TEMPLATE: "<template-id>" }));
494
669
  }
495
670
  else if (!selectedTemplate) {
496
671
  nextActions.push(commandAction(["sanbox", "templates", "list", "--json"], "Choose a template that exists in this organization."));
497
672
  }
498
- if (!selectedOrganization)
499
- process.exitCode = 2;
500
673
  if (hasFlag(flags, "json")) {
501
674
  printSuccess("context.get", output, jsonContext(client), nextActions);
502
675
  return;
503
676
  }
504
- process.stdout.write(`org=${client.config.org}\n`);
677
+ process.stdout.write(`org=${organization.slug}\n`);
505
678
  process.stdout.write(`template=${selection ? `${selection.id} source=${selection.source}${selectedTemplate ? "" : " missing"}` : "not selected"}\n`);
506
679
  process.stdout.write(`model_providers=${providerPayload.providers.map((item) => providerId(item)).filter(Boolean).join(",") || "none"}\n`);
507
680
  process.stdout.write(`templates=${templatePayload.templates.map((item) => item.template_slug || item.id).join(",") || "none"}\n`);
@@ -584,7 +757,7 @@ const commandTemplates = async (command, flags) => {
584
757
  const payload = await client.listTemplates();
585
758
  const nextActions = payload.templates.length === 0
586
759
  ? [
587
- commandAction(["sanbox", "context", "--json"], "Inspect the selected organization and current authentication role."),
760
+ commandAction(["sanbox", "context", "--json"], "Inspect the API key's organization and current authentication role."),
588
761
  templateAdminConsoleAction(client)
589
762
  ]
590
763
  : [];
@@ -608,7 +781,7 @@ const commandTemplates = async (command, flags) => {
608
781
  throw new CliError("template_not_found", error.message, {
609
782
  status: error.status,
610
783
  details: { template_id: id },
611
- nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization.")]
784
+ nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the API key's organization.")]
612
785
  });
613
786
  }
614
787
  throw error;
@@ -631,7 +804,7 @@ const commandTemplates = async (command, flags) => {
631
804
  throw new CliError("template_validation_failed", error.message, {
632
805
  status: error.status,
633
806
  details: { template_id: id },
634
- nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization.")]
807
+ nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the API key's organization.")]
635
808
  });
636
809
  }
637
810
  throw error;
@@ -652,13 +825,42 @@ const commandTemplates = async (command, flags) => {
652
825
  const name = requiredPositional(flagString(flags, "name"), "template_name_required", "templates create requires --name.");
653
826
  const modelProvider = requiredPositional(flagString(flags, "model-provider"), "model_provider_required", "templates create requires --model-provider.");
654
827
  const model = requiredPositional(flagString(flags, "model"), "model_required", "templates create requires --model.");
828
+ const harness = flagString(flags, "harness", "opencode");
829
+ if (harness !== "opencode" && harness !== "hermes") {
830
+ throw new CliError("invalid_harness", "templates create --harness must be opencode or hermes.");
831
+ }
832
+ const budgetRaw = flagString(flags, "llm-budget-usd");
833
+ const parsedBudgetUsd = budgetRaw ? Number(budgetRaw) : undefined;
834
+ const llmBudgetUsd = parsedBudgetUsd === undefined
835
+ ? undefined
836
+ : Math.round(parsedBudgetUsd * 1_000_000) / 1_000_000;
837
+ if (budgetRaw &&
838
+ (!Number.isFinite(parsedBudgetUsd) || llmBudgetUsd <= 0 || llmBudgetUsd > 100_000)) {
839
+ throw new CliError("invalid_llm_budget", "templates create --llm-budget-usd must be at least 0.000001 and no more than 100000.");
840
+ }
841
+ if (harness === "hermes" && llmBudgetUsd !== undefined) {
842
+ throw new CliError("hermes_budget_unsupported", "Always-on Hermes templates do not support --llm-budget-usd yet.");
843
+ }
844
+ const telegramBotToken = process.env.SANBOX_TELEGRAM_BOT_TOKEN?.trim() || "";
845
+ const telegramAllowedUsers = flagList(flags, "telegram-allowed-user").map((value) => value.trim()).filter(Boolean);
846
+ if (harness === "hermes" && !telegramBotToken) {
847
+ throw new CliError("telegram_bot_token_required", "Hermes template creation requires SANBOX_TELEGRAM_BOT_TOKEN.");
848
+ }
849
+ if (harness === "hermes" && telegramAllowedUsers.length === 0) {
850
+ throw new CliError("telegram_allowed_user_required", "Hermes template creation requires at least one --telegram-allowed-user.");
851
+ }
655
852
  let payload;
656
853
  try {
657
854
  payload = await client.createTemplate({
658
855
  name,
659
856
  provider_id: modelProvider,
660
857
  model_id: model,
661
- web_access: hasFlag(flags, "web-access")
858
+ ...(llmBudgetUsd === undefined ? {} : { llm_budget_usd: llmBudgetUsd }),
859
+ ...(harness === "hermes" ? {
860
+ harness,
861
+ telegram_bot_token: telegramBotToken,
862
+ telegram_allowed_users: telegramAllowedUsers
863
+ } : {})
662
864
  });
663
865
  }
664
866
  catch (error) {
@@ -667,11 +869,14 @@ const commandTemplates = async (command, flags) => {
667
869
  if (hasFlag(flags, "json")) {
668
870
  printSuccess("templates.create", payload, jsonContext(client), [
669
871
  commandAction(["sanbox", "templates", "validate", payload.template.id, "--json"], "Validate the new template before running it."),
670
- commandAction(["sanbox", "run", "<task>", "--template", payload.template.id], "Create a run with the new template.")
872
+ commandAction(["sanbox", "run", harness === "hermes" ? "<agent role and instructions>" : "<task>", "--template", payload.template.id], harness === "hermes"
873
+ ? "Start the always-on gateway; interact with it through the configured Telegram bot."
874
+ : "Create a run with the new template.")
671
875
  ]);
672
876
  return;
673
877
  }
674
- process.stdout.write(`created ${payload.template.id} provider=${payload.template.provider_id || modelProvider} model=${payload.template.model_id || model}\n`);
878
+ process.stdout.write(`created ${payload.template.id} provider=${payload.template.provider_id || modelProvider} model=${payload.template.model_id || model}` +
879
+ `${payload.template.llm_budget_usd ? ` budget=$${payload.template.llm_budget_usd}/run` : ""}\n`);
675
880
  return;
676
881
  }
677
882
  throw new CliError("templates_action_required", "templates requires an action: list, get, validate, or create.");
@@ -679,9 +884,21 @@ const commandTemplates = async (command, flags) => {
679
884
  const commandRuns = async (command, flags) => {
680
885
  const client = makeClient(flags);
681
886
  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.");
887
+ if (!action)
888
+ throw new Error("runs command requires an action.");
889
+ if (action === "list") {
890
+ const limit = integerFlag(flags, "limit", 50, 1, 200);
891
+ const payload = await client.listRuns(limit);
892
+ if (hasFlag(flags, "json")) {
893
+ printSuccess("runs.list", { ...payload, runs: payload.runs.map(publicRun) }, jsonContext(client));
894
+ return;
895
+ }
896
+ for (const run of payload.runs) {
897
+ process.stdout.write(`${run.id}\t${run.status}\t${run.created_at}\t${run.instruction.replace(/\s+/g, " ").slice(0, 100)}\n`);
898
+ }
899
+ return;
900
+ }
901
+ const runId = requiredPositional(command[2], "run_id_required", `runs ${action} requires a run id.`);
685
902
  if (action === "get") {
686
903
  const payload = await client.getRun(runId);
687
904
  if (hasFlag(flags, "json"))
@@ -691,13 +908,82 @@ const commandRuns = async (command, flags) => {
691
908
  return;
692
909
  }
693
910
  if (action === "events") {
694
- const payload = await client.listEvents(runId, flagNumber(flags, "after-event-id", 0));
911
+ const payload = await client.listEvents(runId, integerFlag(flags, "after-event-id", 0, 0, Number.MAX_SAFE_INTEGER));
695
912
  if (hasFlag(flags, "json"))
696
913
  printSuccess("runs.events", payload, jsonContext(client));
697
914
  else
698
915
  payload.events.forEach((event) => process.stdout.write(`${event.id} ${event.level} ${event.kind} ${event.message}\n`));
699
916
  return;
700
917
  }
918
+ if (action === "messages") {
919
+ const payload = await client.listMessages(runId);
920
+ if (hasFlag(flags, "json")) {
921
+ printSuccess("runs.messages", payload, jsonContext(client));
922
+ return;
923
+ }
924
+ for (const message of payload.messages) {
925
+ process.stdout.write(`${message.role}\t${message.created_at}\t${message.message}\n`);
926
+ }
927
+ return;
928
+ }
929
+ if (action === "artifacts") {
930
+ const payload = await client.listArtifacts(runId);
931
+ if (hasFlag(flags, "json")) {
932
+ printSuccess("runs.artifacts", payload, jsonContext(client));
933
+ return;
934
+ }
935
+ for (const artifact of payload.artifacts) {
936
+ process.stdout.write(`${artifact.path}\t${artifact.size_bytes}\n`);
937
+ }
938
+ return;
939
+ }
940
+ if (action === "download") {
941
+ const outputDirectory = requiredPositional(flagString(flags, "output"), "output_directory_required", "runs download requires --output.");
942
+ const payload = await client.listArtifacts(runId);
943
+ const requested = flagList(flags, "artifact");
944
+ const selected = requested.length === 0
945
+ ? payload.artifacts
946
+ : requested.map((artifactPath) => {
947
+ const artifact = payload.artifacts.find((item) => item.path === artifactPath);
948
+ if (!artifact) {
949
+ throw new CliError("artifact_not_found", `Run ${runId} has no artifact named ${artifactPath}.`, {
950
+ details: { run_id: runId, artifact_path: artifactPath },
951
+ nextActions: [commandAction(["sanbox", "runs", "artifacts", runId, "--json"], "List exact artifact paths for this run.")]
952
+ });
953
+ }
954
+ return artifact;
955
+ });
956
+ let downloads;
957
+ try {
958
+ downloads = await downloadArtifacts(client, runId, selected, outputDirectory, hasFlag(flags, "overwrite"));
959
+ }
960
+ catch (error) {
961
+ if (error instanceof ArtifactExistsError) {
962
+ throw new CliError("artifact_exists", error.message, {
963
+ details: {
964
+ run_id: runId,
965
+ artifact_path: error.artifactPath,
966
+ local_path: error.destination
967
+ },
968
+ nextActions: [commandAction(["sanbox", "runs", "download", runId, "--output", "<new-output-directory>", "--json"], "Download into a new directory without replacing an existing file.")]
969
+ });
970
+ }
971
+ throw error;
972
+ }
973
+ const result = {
974
+ run_id: runId,
975
+ output_directory: path.resolve(outputDirectory),
976
+ downloads
977
+ };
978
+ if (hasFlag(flags, "json")) {
979
+ printSuccess("runs.download", result, jsonContext(client));
980
+ return;
981
+ }
982
+ for (const download of downloads) {
983
+ process.stdout.write(`${download.path}\t${download.local_path}\t${download.size_bytes}\t${download.sha256}\n`);
984
+ }
985
+ return;
986
+ }
701
987
  if (action === "watch") {
702
988
  validateWatchFlags({ ...flags, watch: true });
703
989
  const payload = await watchRunWithOutput(client, runId, { ...flags, watch: true });
@@ -718,14 +1004,102 @@ const commandRuns = async (command, flags) => {
718
1004
  return;
719
1005
  }
720
1006
  if (action === "message") {
721
- const message = flagString(flags, "message");
1007
+ const flaggedMessage = flagString(flags, "message");
1008
+ const positionalMessage = positionalText(command, 3);
1009
+ if (flaggedMessage && positionalMessage) {
1010
+ throw new CliError("conflicting_arguments", "Use either --message or a positional message, not both.");
1011
+ }
1012
+ const message = flaggedMessage || positionalMessage;
722
1013
  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);
1014
+ throw new Error("--message or a positional message is required.");
1015
+ if (wantsWatch(flags) && hasFlag(flags, "json")) {
1016
+ throw new Error("--json cannot be combined with --watch or --jsonl.");
1017
+ }
1018
+ if (hasFlag(flags, "wait") && wantsWatch(flags)) {
1019
+ throw new Error("--wait cannot be combined with --watch or --jsonl.");
1020
+ }
1021
+ const submitted = await client.sendMessage(runId, message);
1022
+ if (!hasFlag(flags, "wait") && !wantsWatch(flags)) {
1023
+ if (hasFlag(flags, "json")) {
1024
+ printSuccess("runs.message", {
1025
+ ...publicRunPayload(submitted),
1026
+ message: submitted.message,
1027
+ chat_job: submitted.chat_job
1028
+ }, jsonContext(client));
1029
+ }
1030
+ else
1031
+ process.stdout.write(`queued follow-up for run ${runId}\n`);
1032
+ return;
1033
+ }
1034
+ const chatJobId = submitted.chat_job.id;
1035
+ const belongsToFollowup = (event) => event.payload.chat_job_id === chatJobId;
1036
+ const queuedEventId = submitted.events.reduce((latest, event) => event.kind === "chat.queued" && belongsToFollowup(event) ? Math.max(latest, event.id) : latest, 0);
1037
+ const receivedEventId = [...submitted.events]
1038
+ .reverse()
1039
+ .find((event) => event.kind === "message.received" && belongsToFollowup(event))?.id ?? queuedEventId;
1040
+ const cursor = submitted.events.reduce((latest, event) => Math.max(latest, event.id), 0);
1041
+ const completedInResponse = [...submitted.events]
1042
+ .reverse()
1043
+ .find((event) => (event.kind === "chat.completed" || event.kind === "chat.failed") && belongsToFollowup(event));
1044
+ const view = parseActivityView(flagString(flags, "view", "activity"));
1045
+ const jsonl = hasFlag(flags, "jsonl");
1046
+ const controller = new AbortController();
1047
+ const onInterrupt = () => controller.abort();
1048
+ process.once("SIGINT", onInterrupt);
1049
+ if (wantsWatch(flags) && !jsonl)
1050
+ process.stdout.write(`Watching follow-up for run ${runId}. Ctrl-C detaches.\n`);
1051
+ try {
1052
+ const renderFollowupEvent = (event) => {
1053
+ if (!belongsToFollowup(event))
1054
+ return;
1055
+ if (!wantsWatch(flags) || !shouldRenderEvent(event, view))
1056
+ return;
1057
+ process.stdout.write(`${jsonl ? formatActivityJsonl(event) : formatActivityLine(event, submitted.run.created_at)}\n`);
1058
+ };
1059
+ for (const event of submitted.events) {
1060
+ if (event.id >= receivedEventId)
1061
+ renderFollowupEvent(event);
1062
+ }
1063
+ const terminalEvent = completedInResponse ?? await watchEventsUntil(client, runId, {
1064
+ afterEventId: cursor,
1065
+ pageSize: integerFlag(flags, "event-page-size", 200, 1, 500),
1066
+ pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
1067
+ timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800),
1068
+ signal: controller.signal,
1069
+ stopWhen: (event) => (event.kind === "chat.completed" || event.kind === "chat.failed") && belongsToFollowup(event),
1070
+ onRetry: ({ error, attempt, delayMs }) => {
1071
+ const detail = error instanceof Error ? error.message : String(error);
1072
+ process.stderr.write(`Follow-up connection lost (${detail}); retry ${attempt} in ${delayMs}ms.\n`);
1073
+ },
1074
+ onEvent: renderFollowupEvent
1075
+ });
1076
+ const messages = await client.listMessages(runId);
1077
+ if (hasFlag(flags, "json")) {
1078
+ printSuccess("runs.message", {
1079
+ ...publicRunPayload(submitted),
1080
+ message: submitted.message,
1081
+ chat_job: submitted.chat_job,
1082
+ messages: messages.messages,
1083
+ followup_event: terminalEvent
1084
+ }, jsonContext(client));
1085
+ }
1086
+ else if (!jsonl) {
1087
+ const assistant = [...messages.messages].reverse().find((item) => item.role === "assistant" && item.payload.chat_job_id === chatJobId);
1088
+ if (assistant)
1089
+ process.stdout.write(`${assistant.message}\n`);
1090
+ }
1091
+ if (terminalEvent.kind === "chat.failed")
1092
+ process.exitCode = 2;
1093
+ }
1094
+ catch (error) {
1095
+ if (!(error instanceof WatchInterruptedError))
1096
+ throw error;
1097
+ process.stderr.write(`Detached from follow-up for run ${runId}; processing continues.\n`);
1098
+ process.exitCode = 130;
1099
+ }
1100
+ finally {
1101
+ process.off("SIGINT", onInterrupt);
1102
+ }
729
1103
  return;
730
1104
  }
731
1105
  throw new Error(`Unknown runs action: ${action}`);
@@ -733,9 +1107,9 @@ const commandRuns = async (command, flags) => {
733
1107
  const commandDoctor = async (flags) => {
734
1108
  const localConfig = readLocalConfig();
735
1109
  const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
736
- const org = String(flags.org || process.env.SANBOX_ORG || localConfig.org || "").trim();
737
1110
  const apiKey = process.env.SANBOX_API_KEY || "";
738
1111
  const selection = readTemplateSelection(flags, { required: false });
1112
+ let resolvedOrg = "";
739
1113
  const checks = [];
740
1114
  const nextActions = [];
741
1115
  const add = (name, ok, detail, data) => checks.push({
@@ -746,11 +1120,7 @@ const commandDoctor = async (flags) => {
746
1120
  });
747
1121
  add("node", Number(process.versions.node.split(".")[0]) >= 20, process.version);
748
1122
  add("api_url", Boolean(apiUrl), apiUrl);
749
- add("org", Boolean(org), org || "missing SANBOX_ORG, --org, or .sanbox/config.json org");
750
1123
  add("api_key", Boolean(apiKey), apiKey ? "present in environment" : "missing SANBOX_API_KEY");
751
- if (!org) {
752
- nextActions.push(commandAction(["sanbox", "doctor", "--json"], "Select an organization and run the checks again.", { SANBOX_ORG: "<org-slug>" }));
753
- }
754
1124
  if (!apiKey) {
755
1125
  nextActions.push(commandAction(["sanbox", "doctor", "--json"], "Set a Sanbox control-plane API key and run the checks again.", { SANBOX_API_KEY: "<sanbox-api-key>" }));
756
1126
  }
@@ -761,58 +1131,58 @@ const commandDoctor = async (flags) => {
761
1131
  catch (error) {
762
1132
  add("api_health", false, error instanceof Error ? error.message : String(error));
763
1133
  }
764
- if (org && apiKey) {
765
- const client = new SanboxClient({ apiUrl, org, apiKey });
1134
+ if (apiKey) {
1135
+ const client = new SanboxClient({ apiUrl, apiKey });
766
1136
  try {
767
- const me = await client.me();
768
- const organizations = Array.isArray(me.organizations)
769
- ? me.organizations
770
- : [];
1137
+ const organization = await client.organization();
1138
+ resolvedOrg = organization.slug;
771
1139
  add("auth", true, "authenticated");
772
- add("org_visible", organizations.some((item) => item.slug === org), org);
1140
+ add("organization", true, organization.slug, organization);
773
1141
  }
774
1142
  catch (error) {
775
1143
  add("auth", false, error instanceof Error ? error.message : String(error));
776
1144
  }
777
- try {
778
- const providers = await client.listModelProviders();
779
- add("model_providers", true, providers.providers.map((item) => `${providerId(item)}:${item.status || (item.configured ? "configured" : "not_configured")}`).join(", ") || "none", providers.providers);
780
- }
781
- catch (error) {
782
- add("model_providers", false, error instanceof Error ? error.message : String(error));
783
- }
784
- if (!selection) {
785
- add("template", false, "not selected; use --template, SANBOX_TEMPLATE, or .sanbox/config.json default_template");
786
- nextActions.push(...templateActions());
787
- }
788
- else {
1145
+ if (resolvedOrg) {
789
1146
  try {
790
- const template = await client.getTemplate(selection.id);
791
- add("template", true, `${template.template.id}; source=${selection.source}`, template.template);
1147
+ const providers = await client.listModelProviders();
1148
+ add("model_providers", true, providers.providers.map((item) => `${providerId(item)}:${item.status || (item.configured ? "configured" : "not_configured")}`).join(", ") || "none", providers.providers);
1149
+ }
1150
+ catch (error) {
1151
+ add("model_providers", false, error instanceof Error ? error.message : String(error));
1152
+ }
1153
+ if (!selection) {
1154
+ add("template", false, "not selected; use --template, SANBOX_TEMPLATE, or .sanbox/config.json default_template");
1155
+ nextActions.push(...templateActions());
1156
+ }
1157
+ else {
792
1158
  try {
793
- const validation = await client.validateTemplate(selection.id);
794
- const runnable = validationRunnable(validation);
795
- add("template_readiness", runnable, runnable ? "ready" : "blocked", validation);
796
- if (!runnable) {
797
- nextActions.push(commandAction(["sanbox", "model-providers", "list", "--json"], "Inspect model-provider status."), providerConsoleAction(client));
1159
+ const template = await client.getTemplate(selection.id);
1160
+ add("template", true, `${template.template.id}; source=${selection.source}`, template.template);
1161
+ try {
1162
+ const validation = await client.validateTemplate(selection.id);
1163
+ const runnable = validationRunnable(validation);
1164
+ add("template_readiness", runnable, runnable ? "ready" : "blocked", validation);
1165
+ if (!runnable) {
1166
+ nextActions.push(commandAction(["sanbox", "model-providers", "list", "--json"], "Inspect model-provider status."), providerConsoleAction(client));
1167
+ }
1168
+ }
1169
+ catch (error) {
1170
+ add("template_readiness", false, error instanceof Error ? error.message : String(error));
798
1171
  }
799
1172
  }
800
1173
  catch (error) {
801
- add("template_readiness", false, error instanceof Error ? error.message : String(error));
1174
+ add("template", false, error instanceof Error ? error.message : String(error));
1175
+ nextActions.push(...templateActions());
802
1176
  }
803
1177
  }
804
- catch (error) {
805
- add("template", false, error instanceof Error ? error.message : String(error));
806
- nextActions.push(...templateActions());
807
- }
808
1178
  }
809
1179
  }
810
1180
  try {
811
- const preview = await previewTaskDossier({ cwd: cwd(), include: ["README.md"] });
812
- add("bundle_preview", true, `${preview.files.length} file(s), ${formatBytes(preview.totalBytes)}`);
1181
+ const preview = await previewInputs({ cwd: cwd(), inputs: ["README.md"] });
1182
+ add("input_preview", true, `${preview.files.length} file(s), ${formatBytes(preview.totalBytes)}`);
813
1183
  }
814
1184
  catch (error) {
815
- add("bundle_preview", false, error instanceof Error ? error.message : String(error));
1185
+ add("input_preview", false, error instanceof Error ? error.message : String(error));
816
1186
  }
817
1187
  const output = {
818
1188
  ok: checks.every((check) => check.ok),
@@ -820,7 +1190,7 @@ const commandDoctor = async (flags) => {
820
1190
  checks
821
1191
  };
822
1192
  if (hasFlag(flags, "json")) {
823
- printSuccess("doctor", output, { api_url: apiUrl, ...(org ? { org } : {}) }, nextActions);
1193
+ printSuccess("doctor", output, { api_url: apiUrl, ...(resolvedOrg ? { org: resolvedOrg } : {}) }, nextActions);
824
1194
  }
825
1195
  else {
826
1196
  for (const check of checks) {
@@ -830,55 +1200,6 @@ const commandDoctor = async (flags) => {
830
1200
  if (!output.ok)
831
1201
  process.exitCode = 2;
832
1202
  };
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
1203
  const commandInit = async (command, flags) => {
883
1204
  const dir = path.join(cwd(), ".sanbox");
884
1205
  if (command[1] === "agent") {
@@ -906,11 +1227,9 @@ const commandInit = async (command, flags) => {
906
1227
  const force = hasFlag(flags, "force");
907
1228
  const localConfig = readLocalConfig();
908
1229
  const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
909
- const org = String(flags.org || process.env.SANBOX_ORG || localConfig.org || "").trim();
910
1230
  const template = readTemplateSelection(flags, { required: false });
911
1231
  const config = {
912
1232
  api_url: apiUrl,
913
- org,
914
1233
  ...(template ? { default_template: template.id } : {})
915
1234
  };
916
1235
  const sanboxIgnore = [
@@ -931,7 +1250,7 @@ const commandInit = async (command, flags) => {
931
1250
  printSuccess("init", {
932
1251
  files: results.map(([file, status]) => ({ file, status })),
933
1252
  template_selection: template
934
- }, { api_url: apiUrl, ...(org ? { org } : {}) }, template ? [] : templateActions());
1253
+ }, { api_url: apiUrl }, template ? [] : templateActions());
935
1254
  }
936
1255
  else {
937
1256
  for (const [file, status] of results)
@@ -941,8 +1260,6 @@ const commandInit = async (command, flags) => {
941
1260
  const helpFor = (command) => {
942
1261
  if (command[0] === "run")
943
1262
  return runHelp;
944
- if (command[0] === "bundle")
945
- return bundleHelp;
946
1263
  if (command[0] === "doctor")
947
1264
  return doctorHelp;
948
1265
  if (command[0] === "model-providers")
@@ -952,6 +1269,8 @@ const helpFor = (command) => {
952
1269
  return help;
953
1270
  };
954
1271
  const commandId = (command) => {
1272
+ if (command[0] === "version" || command.length === 0)
1273
+ return "version";
955
1274
  if (command[0] === "auth")
956
1275
  return "auth.check";
957
1276
  if (command[0] === "context")
@@ -964,13 +1283,22 @@ const commandId = (command) => {
964
1283
  return "run.create";
965
1284
  if (command[0] === "batch")
966
1285
  return "batch.create";
967
- if (command[0] === "bundle")
968
- return `bundle.${command[1] || "unknown"}`;
969
1286
  if (command[0] === "runs")
970
1287
  return `runs.${command[1] || "unknown"}`;
971
1288
  return command[0] || "help";
972
1289
  };
973
1290
  const main = async (command, flags) => {
1291
+ validateFlagValues(flags);
1292
+ normalizeInputAlias(command, flags);
1293
+ validateFlags(command, flags);
1294
+ validatePositionals(command, flags);
1295
+ if (command[0] === "version" || (command.length === 0 && hasFlag(flags, "version"))) {
1296
+ if (hasFlag(flags, "json"))
1297
+ printSuccess("version", { version });
1298
+ else
1299
+ process.stdout.write(`${version}\n`);
1300
+ return;
1301
+ }
974
1302
  if (command.length === 0) {
975
1303
  process.stdout.write(help);
976
1304
  return;
@@ -993,8 +1321,6 @@ const main = async (command, flags) => {
993
1321
  return commandRun(command, flags);
994
1322
  if (command[0] === "batch")
995
1323
  return commandBatch(flags);
996
- if (command[0] === "bundle")
997
- return commandBundle(command, flags);
998
1324
  if (command[0] === "runs")
999
1325
  return commandRuns(command, flags);
1000
1326
  if (command[0] === "init")