@sanlabs/sanbox-cli 0.0.1 → 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
@@ -1,77 +1,568 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { flagList, flagNumber, flagString, hasFlag, parseArgs } from "./args.js";
5
- import { SanboxClient } from "./api.js";
6
- import { defaultWorkloadId, readConfig } from "./config.js";
7
- import { printJson, printRun } from "./output.js";
4
+ import { formatActivityJsonl, formatActivityLine, parseActivityView, shouldRenderEvent } from "./activity.js";
5
+ import { ArtifactExistsError, downloadArtifacts } from "./artifacts.js";
6
+ import { booleanFlags, flagList, flagString, hasFlag, parseArgs } from "./args.js";
7
+ import { SanboxApiError, SanboxClient } from "./api.js";
8
+ import { defaultApiUrl, readConfig, readLocalConfig, readTemplateSelection } from "./config.js";
9
+ import { CliError, commandAction, consoleAction } from "./errors.js";
10
+ import { previewInputs } from "./inputs.js";
11
+ import { printError, printJsonlError, printRun, printSuccess, publicRun, publicRunPayload } from "./output.js";
8
12
  import { createRun, isTerminalRun, readTasks, runPool, stableBatchId, waitForRun } from "./runs.js";
9
13
  import { version } from "./version.js";
14
+ import { WatchInterruptedError, watchEventsUntil, watchRun } from "./watch.js";
10
15
  const help = `Sanbox CLI
11
16
 
12
17
  Environment:
13
18
  SANBOX_API_URL Sanbox API base URL
14
19
  SANBOX_ORG Organization slug
15
20
  SANBOX_API_KEY Org API key
21
+ SANBOX_TEMPLATE Explicit template id or slug for run and batch
16
22
 
17
23
  Commands:
24
+ sanbox --version
18
25
  sanbox auth check [--json]
19
- sanbox run --task "..." [--include "app/**"] [--wait] [--json]
20
- sanbox run --dossier ./task.zip [--wait] [--json]
21
- sanbox batch --tasks tasks.json [--include "app/**"] [--max-parallel 5] [--wait] [--json]
26
+ sanbox orgs list [--json]
27
+ sanbox context [--json]
28
+ sanbox doctor [--json]
29
+ sanbox model-providers list [--json]
30
+ sanbox model-providers get <provider-id> [--json]
31
+ sanbox model-providers models <provider-id> [--json]
32
+ sanbox templates list [--json]
33
+ sanbox templates get <template-id> [--json]
34
+ sanbox templates validate <template-id> [--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]
22
40
  sanbox runs get <run-id> [--json]
23
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]
45
+ sanbox runs watch <run-id> [--after-event-id 0] [--view activity|logs|compact] [--jsonl]
24
46
  sanbox runs cancel <run-id> [--json]
25
- sanbox runs message <run-id> --message "..." [--json]
47
+ sanbox runs message <run-id> "..." [--wait | --watch] [--json | --jsonl]
48
+ sanbox init [--force]
26
49
  sanbox init agent [--write]
27
50
  `;
28
- const agentInstructions = `# Sanbox Agent Integration
51
+ const runHelp = `Sanbox run
29
52
 
30
- Use Sanbox when a task is independent, long-running, risky to run locally, or can be split into parallel subtasks.
53
+ Usage:
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
31
56
 
32
- Environment expected by the CLI:
33
- - SANBOX_API_URL
34
- - SANBOX_ORG
35
- - SANBOX_API_KEY
57
+ Options:
58
+ --input <path> File, directory, or glob to upload. Repeatable.
59
+ --template <id> Template id or slug. Required unless SANBOX_TEMPLATE or project config sets it.
60
+ --external-run-id <id> Idempotency key for retries.
61
+ --retention-ttl-seconds <n> Workspace retention TTL. Default: 86400.
62
+ --dry-run Preview included files without creating a run.
63
+ --wait Poll until terminal status.
64
+ --watch Stream activity until terminal status.
65
+ --jsonl Stream one versioned activity event per line. Implies --watch.
66
+ --view <name> activity, logs, or compact. Default: activity.
67
+ --after-event-id <id> Resume after an event cursor. Default: 0.
68
+ --cancel-on-interrupt Request run cancellation when Ctrl-C is pressed.
69
+ --json Print JSON.
70
+ `;
71
+ const doctorHelp = `Sanbox doctor
72
+
73
+ Usage:
74
+ sanbox doctor [--json]
75
+
76
+ Checks local configuration, API health, auth, org visibility, and template access.
77
+ SANBOX_API_KEY is still read only from the environment.
78
+ `;
79
+ const modelProvidersHelp = `Sanbox model providers
80
+
81
+ Usage:
82
+ sanbox model-providers list [--json]
83
+ sanbox model-providers get <provider-id> [--json]
84
+ sanbox model-providers models <provider-id> [--json]
85
+
86
+ Provider credentials are configured by organization admins in the Sanbox console.
87
+ Model ids are scoped to their provider and are never deduplicated across providers.
88
+ `;
89
+ const templatesHelp = `Sanbox templates
90
+
91
+ Usage:
92
+ sanbox templates list [--json]
93
+ sanbox templates get <template-id> [--json]
94
+ sanbox templates validate <template-id> [--json]
95
+ sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--llm-budget-usd <amount>] [--web-access] [--json]
36
96
 
37
- Useful commands:
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.
99
+ `;
100
+ const agentInstructions = `# Operate Sanbox Autonomously
101
+
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.
104
+
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.
109
+
110
+ Deterministic startup:
38
111
  \`\`\`bash
39
- sanbox auth check
40
- sanbox run --task "Investigate one focused task" --include "app/**" --wait --json
41
- sanbox batch --tasks .sanbox/tasks.json --include "app/**" --max-parallel 5 --wait --json
112
+ sanbox --version
113
+ sanbox orgs list --json
114
+ export SANBOX_ORG=<returned-org-slug>
115
+ sanbox auth check --json
116
+ sanbox context --json
117
+ sanbox templates list --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
42
147
  sanbox runs get <run-id> --json
43
- sanbox runs events <run-id> --json
44
- 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
45
161
  \`\`\`
46
162
 
47
- Do not put secrets in dossiers. The CLI excludes common env and secret-like filenames by default.
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.
48
170
  `;
49
171
  const cwd = () => process.cwd();
50
172
  const makeClient = (flags) => new SanboxClient(readConfig(flags));
51
- const commandRun = async (flags) => {
52
- const client = makeClient(flags);
53
- const task = flagString(flags, "task");
54
- const dossierPath = flagString(flags, "dossier");
55
- if (!task && !dossierPath)
56
- throw new Error("--task or --dossier is required.");
57
- let payload = await createRun(client, {
58
- cwd: cwd(),
59
- cliVersion: version,
60
- task,
61
- dossierPath,
62
- include: flagList(flags, "include"),
63
- externalRunId: flagString(flags, "external-run-id") || undefined,
64
- workloadId: flagString(flags, "workload-id", defaultWorkloadId),
65
- retentionTtlSeconds: flagNumber(flags, "retention-ttl-seconds", 86400)
173
+ const makeAuthClient = (flags) => new SanboxClient(readConfig(flags, { requireOrg: false }));
174
+ const jsonContext = (client) => ({
175
+ api_url: client.config.apiUrl,
176
+ org: client.config.org
177
+ });
178
+ const localJsonContext = (flags) => {
179
+ try {
180
+ const local = readLocalConfig();
181
+ const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || local.api_url || defaultApiUrl).replace(/\/+$/, "");
182
+ const org = String(flags.org || process.env.SANBOX_ORG || local.org || "").trim();
183
+ return { api_url: apiUrl, ...(org ? { org } : {}) };
184
+ }
185
+ catch {
186
+ return {};
187
+ }
188
+ };
189
+ const consolePathUrl = (client, pathname) => new URL(pathname, `${client.config.apiUrl}/`).toString();
190
+ const consoleUrl = (client) => consolePathUrl(client, "/model-providers");
191
+ const providerConsoleAction = (client) => consoleAction(consoleUrl(client), "Ask an organization admin to configure or repair the model provider in the Sanbox console.");
192
+ const templateAdminConsoleAction = (client) => consoleAction(consolePathUrl(client, "/templates"), "Ask an organization admin to create a template in the Sanbox console.");
193
+ const templateActions = () => [
194
+ commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization."),
195
+ commandAction(["sanbox", "templates", "validate", "<template-id>", "--json"], "Check whether a template is runnable.")
196
+ ];
197
+ const runReadinessCodes = new Set([
198
+ "provider_not_configured",
199
+ "provider_invalid",
200
+ "model_not_found_for_provider",
201
+ "template_provider_missing",
202
+ "template_not_runnable"
203
+ ]);
204
+ const providerReadinessCodes = new Set([
205
+ "provider_not_configured",
206
+ "provider_invalid",
207
+ "model_not_found_for_provider",
208
+ "template_provider_missing"
209
+ ]);
210
+ const runCreationError = (error, client, template) => {
211
+ if (!(error instanceof SanboxApiError) || !runReadinessCodes.has(error.code))
212
+ return error;
213
+ const nextActions = [
214
+ commandAction(["sanbox", "templates", "validate", template.id, "--json"], "Inspect the selected template's current provider/model blockers."),
215
+ commandAction(["sanbox", "model-providers", "list", "--json"], "Inspect organization-scoped model-provider status.")
216
+ ];
217
+ if (providerReadinessCodes.has(error.code))
218
+ nextActions.push(providerConsoleAction(client));
219
+ return new CliError(error.code, error.message, {
220
+ status: error.status,
221
+ details: { template_id: template.id },
222
+ nextActions
66
223
  });
224
+ };
225
+ const templateCreationError = (error, client, providerId, modelId) => {
226
+ if (!(error instanceof SanboxApiError))
227
+ return error;
228
+ if (error.code === "provider_not_configured" || error.code === "provider_invalid") {
229
+ return new CliError(error.code, error.message, {
230
+ status: error.status,
231
+ details: { provider_id: providerId, model_id: modelId },
232
+ nextActions: [
233
+ commandAction(["sanbox", "model-providers", "get", providerId, "--json"], "Inspect the selected provider's organization-scoped status."),
234
+ providerConsoleAction(client)
235
+ ]
236
+ });
237
+ }
238
+ if (error.code === "model_not_found_for_provider") {
239
+ return new CliError(error.code, error.message, {
240
+ status: error.status,
241
+ details: { provider_id: providerId, model_id: modelId },
242
+ nextActions: [commandAction(["sanbox", "model-providers", "models", providerId, "--json"], "List exact selectable and non-selectable model ids exposed by the selected provider.")]
243
+ });
244
+ }
245
+ if (error.code === "template_name_conflict") {
246
+ return new CliError(error.code, error.message, {
247
+ status: error.status,
248
+ nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "Inspect existing template names and identifiers.")]
249
+ });
250
+ }
251
+ return error;
252
+ };
253
+ const requiredPositional = (value, code, message) => {
254
+ const normalized = value?.trim();
255
+ if (!normalized)
256
+ throw new CliError(code, message);
257
+ return normalized;
258
+ };
259
+ const providerId = (provider) => String(provider.provider_id || provider.id || "");
260
+ const templateName = (template) => String(template.display_name || template.template_slug || template.id || "");
261
+ const validationRunnable = (validation) => {
262
+ if (typeof validation.runnable === "boolean")
263
+ return validation.runnable;
264
+ const template = validation.template;
265
+ return Boolean(template && typeof template === "object" && !Array.isArray(template) && template.runnable);
266
+ };
267
+ const formatBytes = (bytes) => {
268
+ if (bytes < 1024)
269
+ return `${bytes} B`;
270
+ if (bytes < 1024 * 1024)
271
+ return `${(bytes / 1024).toFixed(1)} KB`;
272
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
273
+ };
274
+ const positionalText = (command, startIndex) => command.slice(startIndex).join(" ").trim();
275
+ const runTask = (command, flags) => flagString(flags, "task") || positionalText(command, 1);
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
+ };
419
+ const wantsWatch = (flags) => hasFlag(flags, "watch") || hasFlag(flags, "jsonl");
420
+ const integerFlag = (flags, key, fallback, minimum, maximum) => {
421
+ if (flags[key] === undefined)
422
+ return fallback;
423
+ const value = Number(flagString(flags, key));
424
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
425
+ throw new Error(`--${key} must be an integer between ${minimum} and ${maximum}.`);
426
+ }
427
+ return value;
428
+ };
429
+ const validateWatchFlags = (flags) => {
430
+ if (!wantsWatch(flags))
431
+ return;
432
+ if (hasFlag(flags, "json"))
433
+ throw new Error("--json cannot be combined with --watch or --jsonl.");
434
+ parseActivityView(flagString(flags, "view", "activity"));
435
+ integerFlag(flags, "after-event-id", 0, 0, Number.MAX_SAFE_INTEGER);
436
+ integerFlag(flags, "event-page-size", 200, 1, 500);
437
+ integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000);
438
+ integerFlag(flags, "timeout-seconds", 1800, 1, 604_800);
439
+ };
440
+ const watchRunWithOutput = async (client, runId, flags, initialPayload) => {
441
+ validateWatchFlags(flags);
442
+ const initial = initialPayload ?? await client.getRun(runId);
443
+ const view = parseActivityView(flagString(flags, "view", "activity"));
444
+ const jsonl = hasFlag(flags, "jsonl");
445
+ const controller = new AbortController();
446
+ const onInterrupt = () => controller.abort();
447
+ process.once("SIGINT", onInterrupt);
448
+ if (!jsonl)
449
+ process.stdout.write(`Watching run ${runId}. Ctrl-C detaches without canceling.\n`);
450
+ try {
451
+ return await watchRun(client, runId, {
452
+ afterEventId: integerFlag(flags, "after-event-id", 0, 0, Number.MAX_SAFE_INTEGER),
453
+ pageSize: integerFlag(flags, "event-page-size", 200, 1, 500),
454
+ pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
455
+ timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800),
456
+ signal: controller.signal,
457
+ onRetry: ({ error, attempt, delayMs }) => {
458
+ const message = error instanceof Error ? error.message : String(error);
459
+ process.stderr.write(`Watch connection lost (${message}); retry ${attempt} in ${delayMs}ms.\n`);
460
+ },
461
+ onEvent: (event) => {
462
+ if (!shouldRenderEvent(event, view))
463
+ return;
464
+ process.stdout.write(`${jsonl ? formatActivityJsonl(event) : formatActivityLine(event, initial.run.created_at)}\n`);
465
+ }
466
+ });
467
+ }
468
+ catch (error) {
469
+ if (!(error instanceof WatchInterruptedError))
470
+ throw error;
471
+ if (hasFlag(flags, "cancel-on-interrupt")) {
472
+ await client.cancelRun(runId);
473
+ process.stderr.write(`Cancellation requested for run ${runId}.\n`);
474
+ }
475
+ else {
476
+ process.stderr.write(`Detached from run ${runId}; the run is still active.\n`);
477
+ }
478
+ process.exitCode = 130;
479
+ return null;
480
+ }
481
+ finally {
482
+ process.off("SIGINT", onInterrupt);
483
+ }
484
+ };
485
+ const printPreview = (preview, verbose) => {
486
+ process.stdout.write(`Matched ${preview.files.length} files (${formatBytes(preview.totalBytes)})\n`);
487
+ process.stdout.write(`Patterns: ${preview.patterns.join(", ")}\n`);
488
+ const files = verbose ? preview.files : preview.files.slice(0, 30);
489
+ for (const file of files)
490
+ process.stdout.write(` ${file.path} ${formatBytes(file.size)}\n`);
491
+ if (!verbose && preview.files.length > files.length) {
492
+ process.stdout.write(` ... ${preview.files.length - files.length} more files. Use --verbose to list all.\n`);
493
+ }
494
+ };
495
+ const writeIfNeeded = async (filePath, content, force) => {
496
+ try {
497
+ await fs.writeFile(filePath, content, { encoding: "utf8", flag: force ? "w" : "wx" });
498
+ return "written";
499
+ }
500
+ catch (error) {
501
+ if (error.code === "EEXIST")
502
+ return "exists";
503
+ throw error;
504
+ }
505
+ };
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
+ }
510
+ const task = runTask(command, flags);
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
+ }
516
+ validateWatchFlags(flags);
517
+ if (hasFlag(flags, "dry-run")) {
518
+ if (wantsWatch(flags))
519
+ throw new Error("--dry-run cannot be combined with --watch or --jsonl.");
520
+ const preview = await previewInputs({ cwd: cwd(), inputs: flagList(flags, "input") });
521
+ if (hasFlag(flags, "json"))
522
+ printSuccess("run.preview", preview, localJsonContext(flags));
523
+ else
524
+ printPreview(preview, hasFlag(flags, "verbose"));
525
+ return;
526
+ }
527
+ const client = makeClient(flags);
528
+ const template = readTemplateSelection(flags);
529
+ let payload;
530
+ try {
531
+ payload = await createRun(client, {
532
+ cwd: cwd(),
533
+ instruction: task,
534
+ inputs: flagList(flags, "input"),
535
+ externalRunId: flagString(flags, "external-run-id") || undefined,
536
+ templateId: template.id,
537
+ retentionTtlSeconds: integerFlag(flags, "retention-ttl-seconds", 86400, 0, Number.MAX_SAFE_INTEGER)
538
+ });
539
+ }
540
+ catch (error) {
541
+ throw runCreationError(error, client, template);
542
+ }
543
+ if (wantsWatch(flags)) {
544
+ const watched = await watchRunWithOutput(client, payload.run.id, flags, payload);
545
+ if (!watched)
546
+ return;
547
+ payload = watched;
548
+ if (!hasFlag(flags, "jsonl"))
549
+ printRun(payload);
550
+ if (isTerminalRun(payload.run) && payload.run.status !== "completed")
551
+ process.exitCode = 2;
552
+ return;
553
+ }
67
554
  if (hasFlag(flags, "wait")) {
68
555
  payload = await waitForRun(client, payload.run.id, {
69
- pollIntervalMs: flagNumber(flags, "poll-interval-ms", 2000),
70
- 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)
71
558
  });
72
559
  }
73
- if (hasFlag(flags, "json"))
74
- printJson(payload);
560
+ if (hasFlag(flags, "json")) {
561
+ printSuccess("run.create", {
562
+ ...publicRunPayload(payload),
563
+ selection: { template_id: template.id, source: template.source }
564
+ }, jsonContext(client));
565
+ }
75
566
  else
76
567
  printRun(payload);
77
568
  if (hasFlag(flags, "wait") && isTerminalRun(payload.run) && payload.run.status !== "completed")
@@ -82,32 +573,43 @@ const commandBatch = async (flags) => {
82
573
  if (!tasksPath)
83
574
  throw new Error("--tasks is required.");
84
575
  const client = makeClient(flags);
576
+ const template = readTemplateSelection(flags);
85
577
  const tasks = await readTasks(tasksPath);
86
578
  const batchId = flagString(flags, "batch-id") || await stableBatchId(tasksPath);
87
- const include = flagList(flags, "include");
88
- const maxParallel = flagNumber(flags, "max-parallel", 5);
579
+ const inputs = flagList(flags, "input");
580
+ const maxParallel = integerFlag(flags, "max-parallel", 5, 1, 100);
89
581
  const wait = hasFlag(flags, "wait");
90
582
  const results = await runPool(tasks, maxParallel, async (task, index) => {
91
- let payload = await createRun(client, {
92
- cwd: cwd(),
93
- cliVersion: version,
94
- task: task.task,
95
- include: task.include || include,
96
- externalRunId: task.external_run_id || `sanbox-batch-${batchId}-${index + 1}`,
97
- workloadId: flagString(flags, "workload-id", defaultWorkloadId),
98
- retentionTtlSeconds: flagNumber(flags, "retention-ttl-seconds", 86400)
99
- });
583
+ let payload;
584
+ try {
585
+ payload = await createRun(client, {
586
+ cwd: cwd(),
587
+ instruction: task.task,
588
+ inputs: task.input || inputs,
589
+ externalRunId: task.external_run_id || `sanbox-batch-${batchId}-${index + 1}`,
590
+ templateId: template.id,
591
+ retentionTtlSeconds: integerFlag(flags, "retention-ttl-seconds", 86400, 0, Number.MAX_SAFE_INTEGER)
592
+ });
593
+ }
594
+ catch (error) {
595
+ throw runCreationError(error, client, template);
596
+ }
100
597
  if (wait) {
101
598
  payload = await waitForRun(client, payload.run.id, {
102
- pollIntervalMs: flagNumber(flags, "poll-interval-ms", 2000),
103
- 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)
104
601
  });
105
602
  }
106
603
  return payload;
107
604
  });
108
- const output = { batch_id: batchId, runs: results.map((result) => result.run), results };
605
+ const output = {
606
+ batch_id: batchId,
607
+ selection: { template_id: template.id, source: template.source },
608
+ runs: results.map((result) => publicRun(result.run)),
609
+ results: results.map(publicRunPayload)
610
+ };
109
611
  if (hasFlag(flags, "json"))
110
- printJson(output);
612
+ printSuccess("batch.create", output, jsonContext(client));
111
613
  else
112
614
  results.forEach(printRun);
113
615
  if (wait && results.some((result) => result.run.status !== "completed"))
@@ -123,86 +625,717 @@ const commandAuthCheck = async (flags) => {
123
625
  const selected = organizations.find((item) => item.slug === org) || null;
124
626
  const output = { ok: Boolean(selected), org, selected, me };
125
627
  if (hasFlag(flags, "json"))
126
- printJson(output);
628
+ printSuccess("auth.check", output, jsonContext(client));
127
629
  else
128
630
  process.stdout.write(selected ? `ok org=${org}\n` : `authenticated, but org ${org} is not visible\n`);
129
631
  if (!selected)
130
632
  process.exitCode = 2;
131
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
+ };
647
+ const commandContext = async (flags) => {
648
+ const client = makeClient(flags);
649
+ const selection = readTemplateSelection(flags, { required: false });
650
+ const [me, providerPayload, templatePayload] = await Promise.all([
651
+ client.me(),
652
+ client.listModelProviders(),
653
+ client.listTemplates()
654
+ ]);
655
+ const organizations = Array.isArray(me.organizations)
656
+ ? me.organizations
657
+ : [];
658
+ const selectedOrganization = organizations.find((item) => item.slug === client.config.org) || null;
659
+ const selectedTemplate = selection
660
+ ? templatePayload.templates.find((item) => item.id === selection.id || item.template_slug === selection.id) || null
661
+ : null;
662
+ const output = {
663
+ organization: selectedOrganization,
664
+ authentication: me.auth || null,
665
+ template_selection: selection
666
+ ? { id: selection.id, source: selection.source, found: Boolean(selectedTemplate), template: selectedTemplate }
667
+ : null,
668
+ model_providers: providerPayload.providers,
669
+ templates: templatePayload.templates
670
+ };
671
+ const nextActions = [];
672
+ if (!selectedOrganization) {
673
+ nextActions.push(commandAction(["sanbox", "auth", "check", "--json"], "Verify that the API key can access the selected organization."));
674
+ }
675
+ if (!selection) {
676
+ 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>" }));
677
+ }
678
+ else if (!selectedTemplate) {
679
+ nextActions.push(commandAction(["sanbox", "templates", "list", "--json"], "Choose a template that exists in this organization."));
680
+ }
681
+ if (!selectedOrganization)
682
+ process.exitCode = 2;
683
+ if (hasFlag(flags, "json")) {
684
+ printSuccess("context.get", output, jsonContext(client), nextActions);
685
+ return;
686
+ }
687
+ process.stdout.write(`org=${client.config.org}\n`);
688
+ process.stdout.write(`template=${selection ? `${selection.id} source=${selection.source}${selectedTemplate ? "" : " missing"}` : "not selected"}\n`);
689
+ process.stdout.write(`model_providers=${providerPayload.providers.map((item) => providerId(item)).filter(Boolean).join(",") || "none"}\n`);
690
+ process.stdout.write(`templates=${templatePayload.templates.map((item) => item.template_slug || item.id).join(",") || "none"}\n`);
691
+ };
692
+ const commandModelProviders = async (command, flags) => {
693
+ const client = makeClient(flags);
694
+ const action = command[1];
695
+ if (action === "list") {
696
+ const payload = await client.listModelProviders();
697
+ if (hasFlag(flags, "json")) {
698
+ printSuccess("model_providers.list", payload, jsonContext(client));
699
+ return;
700
+ }
701
+ for (const provider of payload.providers) {
702
+ const id = providerId(provider);
703
+ process.stdout.write(`${id}${provider.status ? ` ${provider.status}` : ""}${provider.configured === undefined ? "" : ` configured=${provider.configured}`}\n`);
704
+ }
705
+ return;
706
+ }
707
+ if (action === "get") {
708
+ const id = requiredPositional(command[2], "model_provider_required", "model-providers get requires a provider id.");
709
+ let payload;
710
+ try {
711
+ payload = await client.getModelProvider(id);
712
+ }
713
+ catch (error) {
714
+ if (error instanceof SanboxApiError) {
715
+ throw new CliError("model_provider_not_found", error.message, {
716
+ status: error.status,
717
+ details: { provider_id: id },
718
+ nextActions: [commandAction(["sanbox", "model-providers", "list", "--json"], "List supported providers and their organization-scoped status.")]
719
+ });
720
+ }
721
+ throw error;
722
+ }
723
+ const nextActions = payload.provider.configured === false ? [providerConsoleAction(client)] : [];
724
+ if (hasFlag(flags, "json")) {
725
+ printSuccess("model_providers.get", payload, jsonContext(client), nextActions);
726
+ return;
727
+ }
728
+ process.stdout.write(`${providerId(payload.provider)}${payload.provider.status ? ` ${payload.provider.status}` : ""}\n`);
729
+ return;
730
+ }
731
+ if (action === "models") {
732
+ const id = requiredPositional(command[2], "model_provider_required", "model-providers models requires a provider id.");
733
+ let payload;
734
+ try {
735
+ payload = await client.listProviderModels(id);
736
+ }
737
+ catch (error) {
738
+ if (error instanceof SanboxApiError) {
739
+ throw new CliError("model_provider_unavailable", error.message, {
740
+ status: error.status,
741
+ details: { provider_id: id },
742
+ nextActions: [
743
+ commandAction(["sanbox", "model-providers", "get", id, "--json"], "Inspect the provider's organization-scoped status."),
744
+ providerConsoleAction(client)
745
+ ]
746
+ });
747
+ }
748
+ throw error;
749
+ }
750
+ if (hasFlag(flags, "json")) {
751
+ printSuccess("model_providers.models", payload, jsonContext(client));
752
+ return;
753
+ }
754
+ for (const model of payload.models) {
755
+ const modelId = String(model.model_id || model.id || "");
756
+ const displayName = String(model.display_name || model.name || "");
757
+ process.stdout.write(`${modelId}${displayName && displayName !== modelId ? `\t${displayName}` : ""}${model.selectable === undefined ? "" : `\tselectable=${model.selectable}`}\n`);
758
+ }
759
+ return;
760
+ }
761
+ throw new CliError("model_providers_action_required", "model-providers requires an action: list, get, or models.");
762
+ };
763
+ const commandTemplates = async (command, flags) => {
764
+ const client = makeClient(flags);
765
+ const action = command[1];
766
+ if (action === "list") {
767
+ const payload = await client.listTemplates();
768
+ const nextActions = payload.templates.length === 0
769
+ ? [
770
+ commandAction(["sanbox", "context", "--json"], "Inspect the selected organization and current authentication role."),
771
+ templateAdminConsoleAction(client)
772
+ ]
773
+ : [];
774
+ if (hasFlag(flags, "json")) {
775
+ printSuccess("templates.list", payload, jsonContext(client), nextActions);
776
+ return;
777
+ }
778
+ for (const template of payload.templates) {
779
+ process.stdout.write(`${template.id}\t${templateName(template)}\t${template.provider_id || ""}\t${template.model_id || ""}${template.runnable === undefined ? "" : `\trunnable=${template.runnable}`}\n`);
780
+ }
781
+ return;
782
+ }
783
+ if (action === "get") {
784
+ const id = requiredPositional(command[2], "template_id_required", "templates get requires a template id or slug.");
785
+ let payload;
786
+ try {
787
+ payload = await client.getTemplate(id);
788
+ }
789
+ catch (error) {
790
+ if (error instanceof SanboxApiError) {
791
+ throw new CliError("template_not_found", error.message, {
792
+ status: error.status,
793
+ details: { template_id: id },
794
+ nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization.")]
795
+ });
796
+ }
797
+ throw error;
798
+ }
799
+ if (hasFlag(flags, "json")) {
800
+ printSuccess("templates.get", payload, jsonContext(client), templateActions());
801
+ return;
802
+ }
803
+ process.stdout.write(`${payload.template.id} ${templateName(payload.template)} provider=${payload.template.provider_id || ""} model=${payload.template.model_id || ""}${payload.template.runnable === undefined ? "" : ` runnable=${payload.template.runnable}`}\n`);
804
+ return;
805
+ }
806
+ if (action === "validate") {
807
+ const id = requiredPositional(command[2], "template_id_required", "templates validate requires a template id or slug.");
808
+ let payload;
809
+ try {
810
+ payload = await client.validateTemplate(id);
811
+ }
812
+ catch (error) {
813
+ if (error instanceof SanboxApiError) {
814
+ throw new CliError("template_validation_failed", error.message, {
815
+ status: error.status,
816
+ details: { template_id: id },
817
+ nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization.")]
818
+ });
819
+ }
820
+ throw error;
821
+ }
822
+ const runnable = validationRunnable(payload);
823
+ const nextActions = runnable ? [] : [
824
+ commandAction(["sanbox", "model-providers", "list", "--json"], "Inspect model-provider configuration for this organization."),
825
+ providerConsoleAction(client)
826
+ ];
827
+ if (hasFlag(flags, "json")) {
828
+ printSuccess("templates.validate", payload, jsonContext(client), nextActions);
829
+ return;
830
+ }
831
+ process.stdout.write(`${runnable ? "ready" : "blocked"} template=${id}\n`);
832
+ return;
833
+ }
834
+ if (action === "create") {
835
+ const name = requiredPositional(flagString(flags, "name"), "template_name_required", "templates create requires --name.");
836
+ const modelProvider = requiredPositional(flagString(flags, "model-provider"), "model_provider_required", "templates create requires --model-provider.");
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
+ }
847
+ let payload;
848
+ try {
849
+ payload = await client.createTemplate({
850
+ name,
851
+ provider_id: modelProvider,
852
+ model_id: model,
853
+ web_access: hasFlag(flags, "web-access"),
854
+ ...(llmBudgetUsd === undefined ? {} : { llm_budget_usd: llmBudgetUsd })
855
+ });
856
+ }
857
+ catch (error) {
858
+ throw templateCreationError(error, client, modelProvider, model);
859
+ }
860
+ if (hasFlag(flags, "json")) {
861
+ printSuccess("templates.create", payload, jsonContext(client), [
862
+ commandAction(["sanbox", "templates", "validate", payload.template.id, "--json"], "Validate the new template before running it."),
863
+ commandAction(["sanbox", "run", "<task>", "--template", payload.template.id], "Create a run with the new template.")
864
+ ]);
865
+ return;
866
+ }
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`);
869
+ return;
870
+ }
871
+ throw new CliError("templates_action_required", "templates requires an action: list, get, validate, or create.");
872
+ };
132
873
  const commandRuns = async (command, flags) => {
133
874
  const client = makeClient(flags);
134
875
  const action = command[1];
135
- const runId = command[2];
136
- if (!action || !runId)
137
- 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.`);
138
891
  if (action === "get") {
139
892
  const payload = await client.getRun(runId);
140
893
  if (hasFlag(flags, "json"))
141
- printJson(payload);
894
+ printSuccess("runs.get", publicRunPayload(payload), jsonContext(client));
142
895
  else
143
896
  printRun(payload);
144
897
  return;
145
898
  }
146
899
  if (action === "events") {
147
- 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));
148
901
  if (hasFlag(flags, "json"))
149
- printJson(payload);
902
+ printSuccess("runs.events", payload, jsonContext(client));
150
903
  else
151
904
  payload.events.forEach((event) => process.stdout.write(`${event.id} ${event.level} ${event.kind} ${event.message}\n`));
152
905
  return;
153
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
+ }
976
+ if (action === "watch") {
977
+ validateWatchFlags({ ...flags, watch: true });
978
+ const payload = await watchRunWithOutput(client, runId, { ...flags, watch: true });
979
+ if (!payload)
980
+ return;
981
+ if (!hasFlag(flags, "jsonl"))
982
+ printRun(payload);
983
+ if (isTerminalRun(payload.run) && payload.run.status !== "completed")
984
+ process.exitCode = 2;
985
+ return;
986
+ }
154
987
  if (action === "cancel") {
155
988
  const payload = await client.cancelRun(runId);
156
989
  if (hasFlag(flags, "json"))
157
- printJson(payload);
990
+ printSuccess("runs.cancel", publicRunPayload(payload), jsonContext(client));
158
991
  else
159
992
  printRun(payload);
160
993
  return;
161
994
  }
162
995
  if (action === "message") {
163
- 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;
164
1002
  if (!message)
165
- throw new Error("--message is required.");
166
- const payload = await client.sendMessage(runId, message);
167
- if (hasFlag(flags, "json"))
168
- printJson(payload);
169
- else
170
- 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
+ }
171
1092
  return;
172
1093
  }
173
1094
  throw new Error(`Unknown runs action: ${action}`);
174
1095
  };
1096
+ const commandDoctor = async (flags) => {
1097
+ const localConfig = readLocalConfig();
1098
+ const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
1099
+ const org = String(flags.org || process.env.SANBOX_ORG || localConfig.org || "").trim();
1100
+ const apiKey = process.env.SANBOX_API_KEY || "";
1101
+ const selection = readTemplateSelection(flags, { required: false });
1102
+ const checks = [];
1103
+ const nextActions = [];
1104
+ const add = (name, ok, detail, data) => checks.push({
1105
+ name,
1106
+ ok,
1107
+ detail,
1108
+ ...(data === undefined ? {} : { data })
1109
+ });
1110
+ add("node", Number(process.versions.node.split(".")[0]) >= 20, process.version);
1111
+ add("api_url", Boolean(apiUrl), apiUrl);
1112
+ add("org", Boolean(org), org || "missing SANBOX_ORG, --org, or .sanbox/config.json org");
1113
+ add("api_key", Boolean(apiKey), apiKey ? "present in environment" : "missing SANBOX_API_KEY");
1114
+ if (!org) {
1115
+ nextActions.push(commandAction(["sanbox", "doctor", "--json"], "Select an organization and run the checks again.", { SANBOX_ORG: "<org-slug>" }));
1116
+ }
1117
+ if (!apiKey) {
1118
+ nextActions.push(commandAction(["sanbox", "doctor", "--json"], "Set a Sanbox control-plane API key and run the checks again.", { SANBOX_API_KEY: "<sanbox-api-key>" }));
1119
+ }
1120
+ try {
1121
+ const res = await fetch(`${apiUrl}/health`);
1122
+ add("api_health", res.ok, `${res.status} ${res.statusText}`);
1123
+ }
1124
+ catch (error) {
1125
+ add("api_health", false, error instanceof Error ? error.message : String(error));
1126
+ }
1127
+ if (org && apiKey) {
1128
+ const client = new SanboxClient({ apiUrl, org, apiKey });
1129
+ try {
1130
+ const me = await client.me();
1131
+ const organizations = Array.isArray(me.organizations)
1132
+ ? me.organizations
1133
+ : [];
1134
+ add("auth", true, "authenticated");
1135
+ add("org_visible", organizations.some((item) => item.slug === org), org);
1136
+ }
1137
+ catch (error) {
1138
+ add("auth", false, error instanceof Error ? error.message : String(error));
1139
+ }
1140
+ try {
1141
+ const providers = await client.listModelProviders();
1142
+ add("model_providers", true, providers.providers.map((item) => `${providerId(item)}:${item.status || (item.configured ? "configured" : "not_configured")}`).join(", ") || "none", providers.providers);
1143
+ }
1144
+ catch (error) {
1145
+ add("model_providers", false, error instanceof Error ? error.message : String(error));
1146
+ }
1147
+ if (!selection) {
1148
+ add("template", false, "not selected; use --template, SANBOX_TEMPLATE, or .sanbox/config.json default_template");
1149
+ nextActions.push(...templateActions());
1150
+ }
1151
+ else {
1152
+ try {
1153
+ const template = await client.getTemplate(selection.id);
1154
+ add("template", true, `${template.template.id}; source=${selection.source}`, template.template);
1155
+ try {
1156
+ const validation = await client.validateTemplate(selection.id);
1157
+ const runnable = validationRunnable(validation);
1158
+ add("template_readiness", runnable, runnable ? "ready" : "blocked", validation);
1159
+ if (!runnable) {
1160
+ nextActions.push(commandAction(["sanbox", "model-providers", "list", "--json"], "Inspect model-provider status."), providerConsoleAction(client));
1161
+ }
1162
+ }
1163
+ catch (error) {
1164
+ add("template_readiness", false, error instanceof Error ? error.message : String(error));
1165
+ }
1166
+ }
1167
+ catch (error) {
1168
+ add("template", false, error instanceof Error ? error.message : String(error));
1169
+ nextActions.push(...templateActions());
1170
+ }
1171
+ }
1172
+ }
1173
+ try {
1174
+ const preview = await previewInputs({ cwd: cwd(), inputs: ["README.md"] });
1175
+ add("input_preview", true, `${preview.files.length} file(s), ${formatBytes(preview.totalBytes)}`);
1176
+ }
1177
+ catch (error) {
1178
+ add("input_preview", false, error instanceof Error ? error.message : String(error));
1179
+ }
1180
+ const output = {
1181
+ ok: checks.every((check) => check.ok),
1182
+ template_selection: selection,
1183
+ checks
1184
+ };
1185
+ if (hasFlag(flags, "json")) {
1186
+ printSuccess("doctor", output, { api_url: apiUrl, ...(org ? { org } : {}) }, nextActions);
1187
+ }
1188
+ else {
1189
+ for (const check of checks) {
1190
+ process.stdout.write(`${check.ok ? "ok" : "fail"} ${check.name}: ${check.detail}\n`);
1191
+ }
1192
+ }
1193
+ if (!output.ok)
1194
+ process.exitCode = 2;
1195
+ };
175
1196
  const commandInit = async (command, flags) => {
176
- if (command[1] !== "agent")
177
- throw new Error("Only `sanbox init agent` is supported.");
178
- if (hasFlag(flags, "write")) {
179
- const dir = path.join(cwd(), ".sanbox");
180
- await fs.mkdir(dir, { recursive: true });
181
- await fs.writeFile(path.join(dir, "agent.md"), agentInstructions, "utf8");
182
- process.stdout.write(".sanbox/agent.md written\n");
1197
+ const dir = path.join(cwd(), ".sanbox");
1198
+ if (command[1] === "agent") {
1199
+ if (hasFlag(flags, "write")) {
1200
+ await fs.mkdir(dir, { recursive: true });
1201
+ await fs.writeFile(path.join(dir, "agent.md"), agentInstructions, "utf8");
1202
+ if (hasFlag(flags, "json")) {
1203
+ printSuccess("init.agent", { path: ".sanbox/agent.md", status: "written" }, localJsonContext(flags));
1204
+ }
1205
+ else {
1206
+ process.stdout.write(".sanbox/agent.md written\n");
1207
+ }
1208
+ return;
1209
+ }
1210
+ if (hasFlag(flags, "json")) {
1211
+ printSuccess("init.agent", { instructions: agentInstructions }, localJsonContext(flags));
1212
+ }
1213
+ else {
1214
+ process.stdout.write(agentInstructions);
1215
+ }
183
1216
  return;
184
1217
  }
185
- process.stdout.write(agentInstructions);
1218
+ if (command[1])
1219
+ throw new Error("init supports no subcommand or `agent`.");
1220
+ const force = hasFlag(flags, "force");
1221
+ const localConfig = readLocalConfig();
1222
+ const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
1223
+ const org = String(flags.org || process.env.SANBOX_ORG || localConfig.org || "").trim();
1224
+ const template = readTemplateSelection(flags, { required: false });
1225
+ const config = {
1226
+ api_url: apiUrl,
1227
+ org,
1228
+ ...(template ? { default_template: template.id } : {})
1229
+ };
1230
+ const sanboxIgnore = [
1231
+ "# Project-specific Sanbox excludes. Defaults already exclude .git, node_modules, build output, env files, and secret-like names.",
1232
+ "",
1233
+ "# examples:",
1234
+ "# data/private/**",
1235
+ "# tmp/**",
1236
+ ""
1237
+ ].join("\n");
1238
+ await fs.mkdir(dir, { recursive: true });
1239
+ const results = [
1240
+ [".sanbox/config.json", await writeIfNeeded(path.join(dir, "config.json"), `${JSON.stringify(config, null, 2)}\n`, force)],
1241
+ [".sanbox/agent.md", await writeIfNeeded(path.join(dir, "agent.md"), agentInstructions, force)],
1242
+ [".sanboxignore", await writeIfNeeded(path.join(cwd(), ".sanboxignore"), sanboxIgnore, force)]
1243
+ ];
1244
+ if (hasFlag(flags, "json")) {
1245
+ printSuccess("init", {
1246
+ files: results.map(([file, status]) => ({ file, status })),
1247
+ template_selection: template
1248
+ }, { api_url: apiUrl, ...(org ? { org } : {}) }, template ? [] : templateActions());
1249
+ }
1250
+ else {
1251
+ for (const [file, status] of results)
1252
+ process.stdout.write(`${status} ${file}\n`);
1253
+ }
1254
+ };
1255
+ const helpFor = (command) => {
1256
+ if (command[0] === "run")
1257
+ return runHelp;
1258
+ if (command[0] === "doctor")
1259
+ return doctorHelp;
1260
+ if (command[0] === "model-providers")
1261
+ return modelProvidersHelp;
1262
+ if (command[0] === "templates")
1263
+ return templatesHelp;
1264
+ return help;
186
1265
  };
187
- const main = async () => {
188
- const { command, flags } = parseArgs(process.argv.slice(2));
189
- if (command.length === 0 || hasFlag(flags, "help")) {
1266
+ const commandId = (command) => {
1267
+ if (command[0] === "version" || command.length === 0)
1268
+ return "version";
1269
+ if (command[0] === "auth")
1270
+ return "auth.check";
1271
+ if (command[0] === "orgs")
1272
+ return `orgs.${command[1] || "unknown"}`;
1273
+ if (command[0] === "context")
1274
+ return "context.get";
1275
+ if (command[0] === "model-providers")
1276
+ return `model_providers.${command[1] || "unknown"}`;
1277
+ if (command[0] === "templates")
1278
+ return `templates.${command[1] || "unknown"}`;
1279
+ if (command[0] === "run")
1280
+ return "run.create";
1281
+ if (command[0] === "batch")
1282
+ return "batch.create";
1283
+ if (command[0] === "runs")
1284
+ return `runs.${command[1] || "unknown"}`;
1285
+ return command[0] || "help";
1286
+ };
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
+ }
1299
+ if (command.length === 0) {
190
1300
  process.stdout.write(help);
191
1301
  return;
192
1302
  }
1303
+ if (hasFlag(flags, "help")) {
1304
+ process.stdout.write(helpFor(command));
1305
+ return;
1306
+ }
193
1307
  if (command[0] === "auth" && command[1] === "check")
194
1308
  return commandAuthCheck(flags);
1309
+ if (command[0] === "orgs")
1310
+ return commandOrganizations(command, flags);
1311
+ if (command[0] === "context")
1312
+ return commandContext(flags);
1313
+ if (command[0] === "doctor")
1314
+ return commandDoctor(flags);
1315
+ if (command[0] === "model-providers")
1316
+ return commandModelProviders(command, flags);
1317
+ if (command[0] === "templates")
1318
+ return commandTemplates(command, flags);
195
1319
  if (command[0] === "run")
196
- return commandRun(flags);
1320
+ return commandRun(command, flags);
197
1321
  if (command[0] === "batch")
198
1322
  return commandBatch(flags);
199
1323
  if (command[0] === "runs")
200
1324
  return commandRuns(command, flags);
201
1325
  if (command[0] === "init")
202
1326
  return commandInit(command, flags);
203
- throw new Error(`Unknown command: ${command.join(" ")}`);
1327
+ throw new CliError("unknown_command", `Unknown command: ${command.join(" ")}`);
204
1328
  };
205
- main().catch((error) => {
206
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
207
- process.exit(1);
1329
+ const parsed = parseArgs(process.argv.slice(2));
1330
+ main(parsed.command, parsed.flags).catch((error) => {
1331
+ if (hasFlag(parsed.flags, "jsonl")) {
1332
+ printJsonlError(commandId(parsed.command), error, localJsonContext(parsed.flags));
1333
+ }
1334
+ else if (hasFlag(parsed.flags, "json")) {
1335
+ printError(commandId(parsed.command), error, localJsonContext(parsed.flags));
1336
+ }
1337
+ else {
1338
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
1339
+ }
1340
+ process.exitCode = error instanceof CliError ? error.exitCode : 1;
208
1341
  });