@sanlabs/sanbox-cli 0.0.1 → 0.0.3

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,30 +1,110 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { formatActivityJsonl, formatActivityLine, parseActivityView, shouldRenderEvent } from "./activity.js";
4
5
  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";
6
+ import { SanboxApiError, SanboxClient } from "./api.js";
7
+ import { defaultApiUrl, readConfig, readLocalConfig, readTemplateSelection } from "./config.js";
8
+ import { inspectDossierFile, previewTaskDossier, writeTaskDossier } from "./dossier.js";
9
+ import { CliError, commandAction, consoleAction } from "./errors.js";
10
+ import { printError, printJsonlError, printRun, printSuccess, publicRun, publicRunPayload } from "./output.js";
8
11
  import { createRun, isTerminalRun, readTasks, runPool, stableBatchId, waitForRun } from "./runs.js";
9
12
  import { version } from "./version.js";
13
+ import { WatchInterruptedError, watchRun } from "./watch.js";
10
14
  const help = `Sanbox CLI
11
15
 
12
16
  Environment:
13
17
  SANBOX_API_URL Sanbox API base URL
14
18
  SANBOX_ORG Organization slug
15
19
  SANBOX_API_KEY Org API key
20
+ SANBOX_TEMPLATE Explicit template id or slug for run and batch
16
21
 
17
22
  Commands:
18
23
  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]
24
+ sanbox context [--json]
25
+ sanbox doctor [--json]
26
+ sanbox model-providers list [--json]
27
+ sanbox model-providers get <provider-id> [--json]
28
+ sanbox model-providers models <provider-id> [--json]
29
+ sanbox templates list [--json]
30
+ sanbox templates get <template-id> [--json]
31
+ 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]
22
40
  sanbox runs get <run-id> [--json]
23
41
  sanbox runs events <run-id> [--after-event-id 0] [--json]
42
+ sanbox runs watch <run-id> [--after-event-id 0] [--view activity|logs|compact] [--jsonl]
24
43
  sanbox runs cancel <run-id> [--json]
25
44
  sanbox runs message <run-id> --message "..." [--json]
45
+ sanbox init [--force]
26
46
  sanbox init agent [--write]
27
47
  `;
48
+ const runHelp = `Sanbox run
49
+
50
+ Usage:
51
+ sanbox run "Review this code" --template <template-id> --include "app/**" --wait
52
+ sanbox run --task "Review this code" --template <template-id> --include "app/**" --wait --json
53
+ sanbox run --dossier ./run.zip --template <template-id> --wait
54
+
55
+ Options:
56
+ --include <glob> File glob or directory to include. Repeatable.
57
+ --dossier <path> Submit an existing dossier/run-bundle ZIP.
58
+ --template <id> Template id or slug. Required unless SANBOX_TEMPLATE or project config sets it.
59
+ --external-run-id <id> Idempotency key for retries.
60
+ --retention-ttl-seconds <n> Workspace retention TTL. Default: 86400.
61
+ --dry-run Preview included files without creating a run.
62
+ --wait Poll until terminal status.
63
+ --watch Stream activity until terminal status.
64
+ --jsonl Stream one versioned activity event per line. Implies --watch.
65
+ --view <name> activity, logs, or compact. Default: activity.
66
+ --after-event-id <id> Resume after an event cursor. Default: 0.
67
+ --cancel-on-interrupt Request run cancellation when Ctrl-C is pressed.
68
+ --json Print JSON.
69
+ `;
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
+ const doctorHelp = `Sanbox doctor
81
+
82
+ Usage:
83
+ sanbox doctor [--json]
84
+
85
+ Checks local configuration, API health, auth, org visibility, and template access.
86
+ SANBOX_API_KEY is still read only from the environment.
87
+ `;
88
+ const modelProvidersHelp = `Sanbox model providers
89
+
90
+ Usage:
91
+ sanbox model-providers list [--json]
92
+ sanbox model-providers get <provider-id> [--json]
93
+ sanbox model-providers models <provider-id> [--json]
94
+
95
+ Provider credentials are configured by organization admins in the Sanbox console.
96
+ Model ids are scoped to their provider and are never deduplicated across providers.
97
+ `;
98
+ const templatesHelp = `Sanbox templates
99
+
100
+ Usage:
101
+ sanbox templates list [--json]
102
+ sanbox templates get <template-id> [--json]
103
+ sanbox templates validate <template-id> [--json]
104
+ sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--web-access] [--json]
105
+
106
+ Template creation requires an exact provider id and that provider's exact model id.
107
+ `;
28
108
  const agentInstructions = `# Sanbox Agent Integration
29
109
 
30
110
  Use Sanbox when a task is independent, long-running, risky to run locally, or can be split into parallel subtasks.
@@ -33,45 +113,285 @@ Environment expected by the CLI:
33
113
  - SANBOX_API_URL
34
114
  - SANBOX_ORG
35
115
  - SANBOX_API_KEY
116
+ - SANBOX_TEMPLATE, or an explicit --template flag/project default
36
117
 
37
118
  Useful commands:
38
119
  \`\`\`bash
39
120
  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
121
+ sanbox doctor
122
+ sanbox context --json
123
+ sanbox model-providers list --json
124
+ sanbox model-providers models <provider-id> --json
125
+ 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
42
129
  sanbox runs get <run-id> --json
130
+ sanbox runs watch <run-id>
43
131
  sanbox runs events <run-id> --json
44
132
  sanbox runs message <run-id> --message "Summarize the retained output" --json
45
133
  \`\`\`
46
134
 
47
- Do not put secrets in dossiers. The CLI excludes common env and secret-like filenames by default.
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.
48
137
  `;
49
138
  const cwd = () => process.cwd();
50
139
  const makeClient = (flags) => new SanboxClient(readConfig(flags));
51
- const commandRun = async (flags) => {
52
- const client = makeClient(flags);
53
- const task = flagString(flags, "task");
140
+ const jsonContext = (client) => ({
141
+ api_url: client.config.apiUrl,
142
+ org: client.config.org
143
+ });
144
+ const localJsonContext = (flags) => {
145
+ try {
146
+ const local = readLocalConfig();
147
+ 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 } : {}) };
150
+ }
151
+ catch {
152
+ return {};
153
+ }
154
+ };
155
+ const consolePathUrl = (client, pathname) => new URL(pathname, `${client.config.apiUrl}/`).toString();
156
+ const consoleUrl = (client) => consolePathUrl(client, "/model-providers");
157
+ const providerConsoleAction = (client) => consoleAction(consoleUrl(client), "Ask an organization admin to configure or repair the model provider in the Sanbox console.");
158
+ const templateAdminConsoleAction = (client) => consoleAction(consolePathUrl(client, "/templates"), "Ask an organization admin to create a template in the Sanbox console.");
159
+ const templateActions = () => [
160
+ commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization."),
161
+ commandAction(["sanbox", "templates", "validate", "<template-id>", "--json"], "Check whether a template is runnable.")
162
+ ];
163
+ const runReadinessCodes = new Set([
164
+ "provider_not_configured",
165
+ "provider_invalid",
166
+ "model_not_found_for_provider",
167
+ "template_provider_missing",
168
+ "template_not_runnable"
169
+ ]);
170
+ const providerReadinessCodes = new Set([
171
+ "provider_not_configured",
172
+ "provider_invalid",
173
+ "model_not_found_for_provider",
174
+ "template_provider_missing"
175
+ ]);
176
+ const runCreationError = (error, client, template) => {
177
+ if (!(error instanceof SanboxApiError) || !runReadinessCodes.has(error.code))
178
+ return error;
179
+ const nextActions = [
180
+ commandAction(["sanbox", "templates", "validate", template.id, "--json"], "Inspect the selected template's current provider/model blockers."),
181
+ commandAction(["sanbox", "model-providers", "list", "--json"], "Inspect organization-scoped model-provider status.")
182
+ ];
183
+ if (providerReadinessCodes.has(error.code))
184
+ nextActions.push(providerConsoleAction(client));
185
+ return new CliError(error.code, error.message, {
186
+ status: error.status,
187
+ details: { template_id: template.id },
188
+ nextActions
189
+ });
190
+ };
191
+ const templateCreationError = (error, client, providerId, modelId) => {
192
+ if (!(error instanceof SanboxApiError))
193
+ return error;
194
+ if (error.code === "provider_not_configured" || error.code === "provider_invalid") {
195
+ return new CliError(error.code, error.message, {
196
+ status: error.status,
197
+ details: { provider_id: providerId, model_id: modelId },
198
+ nextActions: [
199
+ commandAction(["sanbox", "model-providers", "get", providerId, "--json"], "Inspect the selected provider's organization-scoped status."),
200
+ providerConsoleAction(client)
201
+ ]
202
+ });
203
+ }
204
+ if (error.code === "model_not_found_for_provider") {
205
+ return new CliError(error.code, error.message, {
206
+ status: error.status,
207
+ details: { provider_id: providerId, model_id: modelId },
208
+ nextActions: [commandAction(["sanbox", "model-providers", "models", providerId, "--json"], "List exact selectable and non-selectable model ids exposed by the selected provider.")]
209
+ });
210
+ }
211
+ if (error.code === "template_name_conflict") {
212
+ return new CliError(error.code, error.message, {
213
+ status: error.status,
214
+ nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "Inspect existing template names and identifiers.")]
215
+ });
216
+ }
217
+ return error;
218
+ };
219
+ const requiredPositional = (value, code, message) => {
220
+ const normalized = value?.trim();
221
+ if (!normalized)
222
+ throw new CliError(code, message);
223
+ return normalized;
224
+ };
225
+ const providerId = (provider) => String(provider.provider_id || provider.id || "");
226
+ const templateName = (template) => String(template.display_name || template.template_slug || template.id || "");
227
+ const validationRunnable = (validation) => {
228
+ if (typeof validation.runnable === "boolean")
229
+ return validation.runnable;
230
+ const template = validation.template;
231
+ return Boolean(template && typeof template === "object" && !Array.isArray(template) && template.runnable);
232
+ };
233
+ const formatBytes = (bytes) => {
234
+ if (bytes < 1024)
235
+ return `${bytes} B`;
236
+ if (bytes < 1024 * 1024)
237
+ return `${(bytes / 1024).toFixed(1)} KB`;
238
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
239
+ };
240
+ const positionalText = (command, startIndex) => command.slice(startIndex).join(" ").trim();
241
+ const runTask = (command, flags) => flagString(flags, "task") || positionalText(command, 1);
242
+ const bundleTask = (command, flags) => flagString(flags, "task") || positionalText(command, 2);
243
+ const wantsWatch = (flags) => hasFlag(flags, "watch") || hasFlag(flags, "jsonl");
244
+ const integerFlag = (flags, key, fallback, minimum, maximum) => {
245
+ if (flags[key] === undefined)
246
+ return fallback;
247
+ const value = Number(flagString(flags, key));
248
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
249
+ throw new Error(`--${key} must be an integer between ${minimum} and ${maximum}.`);
250
+ }
251
+ return value;
252
+ };
253
+ const validateWatchFlags = (flags) => {
254
+ if (!wantsWatch(flags))
255
+ return;
256
+ if (hasFlag(flags, "json"))
257
+ throw new Error("--json cannot be combined with --watch or --jsonl.");
258
+ parseActivityView(flagString(flags, "view", "activity"));
259
+ integerFlag(flags, "after-event-id", 0, 0, Number.MAX_SAFE_INTEGER);
260
+ integerFlag(flags, "event-page-size", 200, 1, 500);
261
+ integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000);
262
+ integerFlag(flags, "timeout-seconds", 1800, 1, 604_800);
263
+ };
264
+ const watchRunWithOutput = async (client, runId, flags, initialPayload) => {
265
+ validateWatchFlags(flags);
266
+ const initial = initialPayload ?? await client.getRun(runId);
267
+ const view = parseActivityView(flagString(flags, "view", "activity"));
268
+ const jsonl = hasFlag(flags, "jsonl");
269
+ const controller = new AbortController();
270
+ const onInterrupt = () => controller.abort();
271
+ process.once("SIGINT", onInterrupt);
272
+ if (!jsonl)
273
+ process.stdout.write(`Watching run ${runId}. Ctrl-C detaches without canceling.\n`);
274
+ try {
275
+ return await watchRun(client, runId, {
276
+ afterEventId: integerFlag(flags, "after-event-id", 0, 0, Number.MAX_SAFE_INTEGER),
277
+ pageSize: integerFlag(flags, "event-page-size", 200, 1, 500),
278
+ pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
279
+ timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800),
280
+ signal: controller.signal,
281
+ onRetry: ({ error, attempt, delayMs }) => {
282
+ const message = error instanceof Error ? error.message : String(error);
283
+ process.stderr.write(`Watch connection lost (${message}); retry ${attempt} in ${delayMs}ms.\n`);
284
+ },
285
+ onEvent: (event) => {
286
+ if (!shouldRenderEvent(event, view))
287
+ return;
288
+ process.stdout.write(`${jsonl ? formatActivityJsonl(event) : formatActivityLine(event, initial.run.created_at)}\n`);
289
+ }
290
+ });
291
+ }
292
+ catch (error) {
293
+ if (!(error instanceof WatchInterruptedError))
294
+ throw error;
295
+ if (hasFlag(flags, "cancel-on-interrupt")) {
296
+ await client.cancelRun(runId);
297
+ process.stderr.write(`Cancellation requested for run ${runId}.\n`);
298
+ }
299
+ else {
300
+ process.stderr.write(`Detached from run ${runId}; the run is still active.\n`);
301
+ }
302
+ process.exitCode = 130;
303
+ return null;
304
+ }
305
+ finally {
306
+ process.off("SIGINT", onInterrupt);
307
+ }
308
+ };
309
+ const printPreview = (preview, verbose) => {
310
+ process.stdout.write(`Matched ${preview.files.length} files (${formatBytes(preview.totalBytes)})\n`);
311
+ process.stdout.write(`Patterns: ${preview.patterns.join(", ")}\n`);
312
+ const files = verbose ? preview.files : preview.files.slice(0, 30);
313
+ for (const file of files)
314
+ process.stdout.write(` ${file.path} ${formatBytes(file.size)}\n`);
315
+ if (!verbose && preview.files.length > files.length) {
316
+ process.stdout.write(` ... ${preview.files.length - files.length} more files. Use --verbose to list all.\n`);
317
+ }
318
+ };
319
+ const writeIfNeeded = async (filePath, content, force) => {
320
+ try {
321
+ await fs.writeFile(filePath, content, { encoding: "utf8", flag: force ? "w" : "wx" });
322
+ return "written";
323
+ }
324
+ catch (error) {
325
+ if (error.code === "EEXIST")
326
+ return "exists";
327
+ throw error;
328
+ }
329
+ };
330
+ const commandRun = async (command, flags) => {
331
+ const task = runTask(command, flags);
54
332
  const dossierPath = flagString(flags, "dossier");
55
333
  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)
66
- });
334
+ throw new Error("--task, positional task, or --dossier is required.");
335
+ validateWatchFlags(flags);
336
+ if (hasFlag(flags, "dry-run")) {
337
+ if (wantsWatch(flags))
338
+ 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") });
348
+ if (hasFlag(flags, "json"))
349
+ printSuccess("run.preview", preview, localJsonContext(flags));
350
+ else
351
+ printPreview(preview, hasFlag(flags, "verbose"));
352
+ return;
353
+ }
354
+ const client = makeClient(flags);
355
+ const template = readTemplateSelection(flags);
356
+ let payload;
357
+ try {
358
+ payload = await createRun(client, {
359
+ cwd: cwd(),
360
+ cliVersion: version,
361
+ task,
362
+ dossierPath,
363
+ include: flagList(flags, "include"),
364
+ externalRunId: flagString(flags, "external-run-id") || undefined,
365
+ templateId: template.id,
366
+ retentionTtlSeconds: flagNumber(flags, "retention-ttl-seconds", 86400)
367
+ });
368
+ }
369
+ catch (error) {
370
+ throw runCreationError(error, client, template);
371
+ }
372
+ if (wantsWatch(flags)) {
373
+ const watched = await watchRunWithOutput(client, payload.run.id, flags, payload);
374
+ if (!watched)
375
+ return;
376
+ payload = watched;
377
+ if (!hasFlag(flags, "jsonl"))
378
+ printRun(payload);
379
+ if (isTerminalRun(payload.run) && payload.run.status !== "completed")
380
+ process.exitCode = 2;
381
+ return;
382
+ }
67
383
  if (hasFlag(flags, "wait")) {
68
384
  payload = await waitForRun(client, payload.run.id, {
69
385
  pollIntervalMs: flagNumber(flags, "poll-interval-ms", 2000),
70
386
  timeoutSeconds: flagNumber(flags, "timeout-seconds", 1800)
71
387
  });
72
388
  }
73
- if (hasFlag(flags, "json"))
74
- printJson(payload);
389
+ if (hasFlag(flags, "json")) {
390
+ printSuccess("run.create", {
391
+ ...publicRunPayload(payload),
392
+ selection: { template_id: template.id, source: template.source }
393
+ }, jsonContext(client));
394
+ }
75
395
  else
76
396
  printRun(payload);
77
397
  if (hasFlag(flags, "wait") && isTerminalRun(payload.run) && payload.run.status !== "completed")
@@ -82,21 +402,28 @@ const commandBatch = async (flags) => {
82
402
  if (!tasksPath)
83
403
  throw new Error("--tasks is required.");
84
404
  const client = makeClient(flags);
405
+ const template = readTemplateSelection(flags);
85
406
  const tasks = await readTasks(tasksPath);
86
407
  const batchId = flagString(flags, "batch-id") || await stableBatchId(tasksPath);
87
408
  const include = flagList(flags, "include");
88
409
  const maxParallel = flagNumber(flags, "max-parallel", 5);
89
410
  const wait = hasFlag(flags, "wait");
90
411
  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
- });
412
+ let payload;
413
+ try {
414
+ payload = await createRun(client, {
415
+ cwd: cwd(),
416
+ cliVersion: version,
417
+ task: task.task,
418
+ include: task.include || include,
419
+ externalRunId: task.external_run_id || `sanbox-batch-${batchId}-${index + 1}`,
420
+ templateId: template.id,
421
+ retentionTtlSeconds: flagNumber(flags, "retention-ttl-seconds", 86400)
422
+ });
423
+ }
424
+ catch (error) {
425
+ throw runCreationError(error, client, template);
426
+ }
100
427
  if (wait) {
101
428
  payload = await waitForRun(client, payload.run.id, {
102
429
  pollIntervalMs: flagNumber(flags, "poll-interval-ms", 2000),
@@ -105,9 +432,14 @@ const commandBatch = async (flags) => {
105
432
  }
106
433
  return payload;
107
434
  });
108
- const output = { batch_id: batchId, runs: results.map((result) => result.run), results };
435
+ const output = {
436
+ batch_id: batchId,
437
+ selection: { template_id: template.id, source: template.source },
438
+ runs: results.map((result) => publicRun(result.run)),
439
+ results: results.map(publicRunPayload)
440
+ };
109
441
  if (hasFlag(flags, "json"))
110
- printJson(output);
442
+ printSuccess("batch.create", output, jsonContext(client));
111
443
  else
112
444
  results.forEach(printRun);
113
445
  if (wait && results.some((result) => result.run.status !== "completed"))
@@ -123,12 +455,227 @@ const commandAuthCheck = async (flags) => {
123
455
  const selected = organizations.find((item) => item.slug === org) || null;
124
456
  const output = { ok: Boolean(selected), org, selected, me };
125
457
  if (hasFlag(flags, "json"))
126
- printJson(output);
458
+ printSuccess("auth.check", output, jsonContext(client));
127
459
  else
128
460
  process.stdout.write(selected ? `ok org=${org}\n` : `authenticated, but org ${org} is not visible\n`);
129
461
  if (!selected)
130
462
  process.exitCode = 2;
131
463
  };
464
+ const commandContext = async (flags) => {
465
+ const client = makeClient(flags);
466
+ const selection = readTemplateSelection(flags, { required: false });
467
+ const [me, providerPayload, templatePayload] = await Promise.all([
468
+ client.me(),
469
+ client.listModelProviders(),
470
+ client.listTemplates()
471
+ ]);
472
+ const organizations = Array.isArray(me.organizations)
473
+ ? me.organizations
474
+ : [];
475
+ const selectedOrganization = organizations.find((item) => item.slug === client.config.org) || null;
476
+ const selectedTemplate = selection
477
+ ? templatePayload.templates.find((item) => item.id === selection.id || item.template_slug === selection.id) || null
478
+ : null;
479
+ const output = {
480
+ organization: selectedOrganization,
481
+ authentication: me.auth || null,
482
+ template_selection: selection
483
+ ? { id: selection.id, source: selection.source, found: Boolean(selectedTemplate), template: selectedTemplate }
484
+ : null,
485
+ model_providers: providerPayload.providers,
486
+ templates: templatePayload.templates
487
+ };
488
+ 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
+ if (!selection) {
493
+ 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
+ }
495
+ else if (!selectedTemplate) {
496
+ nextActions.push(commandAction(["sanbox", "templates", "list", "--json"], "Choose a template that exists in this organization."));
497
+ }
498
+ if (!selectedOrganization)
499
+ process.exitCode = 2;
500
+ if (hasFlag(flags, "json")) {
501
+ printSuccess("context.get", output, jsonContext(client), nextActions);
502
+ return;
503
+ }
504
+ process.stdout.write(`org=${client.config.org}\n`);
505
+ process.stdout.write(`template=${selection ? `${selection.id} source=${selection.source}${selectedTemplate ? "" : " missing"}` : "not selected"}\n`);
506
+ process.stdout.write(`model_providers=${providerPayload.providers.map((item) => providerId(item)).filter(Boolean).join(",") || "none"}\n`);
507
+ process.stdout.write(`templates=${templatePayload.templates.map((item) => item.template_slug || item.id).join(",") || "none"}\n`);
508
+ };
509
+ const commandModelProviders = async (command, flags) => {
510
+ const client = makeClient(flags);
511
+ const action = command[1];
512
+ if (action === "list") {
513
+ const payload = await client.listModelProviders();
514
+ if (hasFlag(flags, "json")) {
515
+ printSuccess("model_providers.list", payload, jsonContext(client));
516
+ return;
517
+ }
518
+ for (const provider of payload.providers) {
519
+ const id = providerId(provider);
520
+ process.stdout.write(`${id}${provider.status ? ` ${provider.status}` : ""}${provider.configured === undefined ? "" : ` configured=${provider.configured}`}\n`);
521
+ }
522
+ return;
523
+ }
524
+ if (action === "get") {
525
+ const id = requiredPositional(command[2], "model_provider_required", "model-providers get requires a provider id.");
526
+ let payload;
527
+ try {
528
+ payload = await client.getModelProvider(id);
529
+ }
530
+ catch (error) {
531
+ if (error instanceof SanboxApiError) {
532
+ throw new CliError("model_provider_not_found", error.message, {
533
+ status: error.status,
534
+ details: { provider_id: id },
535
+ nextActions: [commandAction(["sanbox", "model-providers", "list", "--json"], "List supported providers and their organization-scoped status.")]
536
+ });
537
+ }
538
+ throw error;
539
+ }
540
+ const nextActions = payload.provider.configured === false ? [providerConsoleAction(client)] : [];
541
+ if (hasFlag(flags, "json")) {
542
+ printSuccess("model_providers.get", payload, jsonContext(client), nextActions);
543
+ return;
544
+ }
545
+ process.stdout.write(`${providerId(payload.provider)}${payload.provider.status ? ` ${payload.provider.status}` : ""}\n`);
546
+ return;
547
+ }
548
+ if (action === "models") {
549
+ const id = requiredPositional(command[2], "model_provider_required", "model-providers models requires a provider id.");
550
+ let payload;
551
+ try {
552
+ payload = await client.listProviderModels(id);
553
+ }
554
+ catch (error) {
555
+ if (error instanceof SanboxApiError) {
556
+ throw new CliError("model_provider_unavailable", error.message, {
557
+ status: error.status,
558
+ details: { provider_id: id },
559
+ nextActions: [
560
+ commandAction(["sanbox", "model-providers", "get", id, "--json"], "Inspect the provider's organization-scoped status."),
561
+ providerConsoleAction(client)
562
+ ]
563
+ });
564
+ }
565
+ throw error;
566
+ }
567
+ if (hasFlag(flags, "json")) {
568
+ printSuccess("model_providers.models", payload, jsonContext(client));
569
+ return;
570
+ }
571
+ for (const model of payload.models) {
572
+ const modelId = String(model.model_id || model.id || "");
573
+ const displayName = String(model.display_name || model.name || "");
574
+ process.stdout.write(`${modelId}${displayName && displayName !== modelId ? `\t${displayName}` : ""}${model.selectable === undefined ? "" : `\tselectable=${model.selectable}`}\n`);
575
+ }
576
+ return;
577
+ }
578
+ throw new CliError("model_providers_action_required", "model-providers requires an action: list, get, or models.");
579
+ };
580
+ const commandTemplates = async (command, flags) => {
581
+ const client = makeClient(flags);
582
+ const action = command[1];
583
+ if (action === "list") {
584
+ const payload = await client.listTemplates();
585
+ const nextActions = payload.templates.length === 0
586
+ ? [
587
+ commandAction(["sanbox", "context", "--json"], "Inspect the selected organization and current authentication role."),
588
+ templateAdminConsoleAction(client)
589
+ ]
590
+ : [];
591
+ if (hasFlag(flags, "json")) {
592
+ printSuccess("templates.list", payload, jsonContext(client), nextActions);
593
+ return;
594
+ }
595
+ for (const template of payload.templates) {
596
+ process.stdout.write(`${template.id}\t${templateName(template)}\t${template.provider_id || ""}\t${template.model_id || ""}${template.runnable === undefined ? "" : `\trunnable=${template.runnable}`}\n`);
597
+ }
598
+ return;
599
+ }
600
+ if (action === "get") {
601
+ const id = requiredPositional(command[2], "template_id_required", "templates get requires a template id or slug.");
602
+ let payload;
603
+ try {
604
+ payload = await client.getTemplate(id);
605
+ }
606
+ catch (error) {
607
+ if (error instanceof SanboxApiError) {
608
+ throw new CliError("template_not_found", error.message, {
609
+ status: error.status,
610
+ details: { template_id: id },
611
+ nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization.")]
612
+ });
613
+ }
614
+ throw error;
615
+ }
616
+ if (hasFlag(flags, "json")) {
617
+ printSuccess("templates.get", payload, jsonContext(client), templateActions());
618
+ return;
619
+ }
620
+ 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`);
621
+ return;
622
+ }
623
+ if (action === "validate") {
624
+ const id = requiredPositional(command[2], "template_id_required", "templates validate requires a template id or slug.");
625
+ let payload;
626
+ try {
627
+ payload = await client.validateTemplate(id);
628
+ }
629
+ catch (error) {
630
+ if (error instanceof SanboxApiError) {
631
+ throw new CliError("template_validation_failed", error.message, {
632
+ status: error.status,
633
+ details: { template_id: id },
634
+ nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization.")]
635
+ });
636
+ }
637
+ throw error;
638
+ }
639
+ const runnable = validationRunnable(payload);
640
+ const nextActions = runnable ? [] : [
641
+ commandAction(["sanbox", "model-providers", "list", "--json"], "Inspect model-provider configuration for this organization."),
642
+ providerConsoleAction(client)
643
+ ];
644
+ if (hasFlag(flags, "json")) {
645
+ printSuccess("templates.validate", payload, jsonContext(client), nextActions);
646
+ return;
647
+ }
648
+ process.stdout.write(`${runnable ? "ready" : "blocked"} template=${id}\n`);
649
+ return;
650
+ }
651
+ if (action === "create") {
652
+ const name = requiredPositional(flagString(flags, "name"), "template_name_required", "templates create requires --name.");
653
+ const modelProvider = requiredPositional(flagString(flags, "model-provider"), "model_provider_required", "templates create requires --model-provider.");
654
+ const model = requiredPositional(flagString(flags, "model"), "model_required", "templates create requires --model.");
655
+ let payload;
656
+ try {
657
+ payload = await client.createTemplate({
658
+ name,
659
+ provider_id: modelProvider,
660
+ model_id: model,
661
+ web_access: hasFlag(flags, "web-access")
662
+ });
663
+ }
664
+ catch (error) {
665
+ throw templateCreationError(error, client, modelProvider, model);
666
+ }
667
+ if (hasFlag(flags, "json")) {
668
+ printSuccess("templates.create", payload, jsonContext(client), [
669
+ 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.")
671
+ ]);
672
+ return;
673
+ }
674
+ process.stdout.write(`created ${payload.template.id} provider=${payload.template.provider_id || modelProvider} model=${payload.template.model_id || model}\n`);
675
+ return;
676
+ }
677
+ throw new CliError("templates_action_required", "templates requires an action: list, get, validate, or create.");
678
+ };
132
679
  const commandRuns = async (command, flags) => {
133
680
  const client = makeClient(flags);
134
681
  const action = command[1];
@@ -138,7 +685,7 @@ const commandRuns = async (command, flags) => {
138
685
  if (action === "get") {
139
686
  const payload = await client.getRun(runId);
140
687
  if (hasFlag(flags, "json"))
141
- printJson(payload);
688
+ printSuccess("runs.get", publicRunPayload(payload), jsonContext(client));
142
689
  else
143
690
  printRun(payload);
144
691
  return;
@@ -146,15 +693,26 @@ const commandRuns = async (command, flags) => {
146
693
  if (action === "events") {
147
694
  const payload = await client.listEvents(runId, flagNumber(flags, "after-event-id", 0));
148
695
  if (hasFlag(flags, "json"))
149
- printJson(payload);
696
+ printSuccess("runs.events", payload, jsonContext(client));
150
697
  else
151
698
  payload.events.forEach((event) => process.stdout.write(`${event.id} ${event.level} ${event.kind} ${event.message}\n`));
152
699
  return;
153
700
  }
701
+ if (action === "watch") {
702
+ validateWatchFlags({ ...flags, watch: true });
703
+ const payload = await watchRunWithOutput(client, runId, { ...flags, watch: true });
704
+ if (!payload)
705
+ return;
706
+ if (!hasFlag(flags, "jsonl"))
707
+ printRun(payload);
708
+ if (isTerminalRun(payload.run) && payload.run.status !== "completed")
709
+ process.exitCode = 2;
710
+ return;
711
+ }
154
712
  if (action === "cancel") {
155
713
  const payload = await client.cancelRun(runId);
156
714
  if (hasFlag(flags, "json"))
157
- printJson(payload);
715
+ printSuccess("runs.cancel", publicRunPayload(payload), jsonContext(client));
158
716
  else
159
717
  printRun(payload);
160
718
  return;
@@ -165,44 +723,294 @@ const commandRuns = async (command, flags) => {
165
723
  throw new Error("--message is required.");
166
724
  const payload = await client.sendMessage(runId, message);
167
725
  if (hasFlag(flags, "json"))
168
- printJson(payload);
726
+ printSuccess("runs.message", publicRunPayload(payload), jsonContext(client));
169
727
  else
170
728
  printRun(payload);
171
729
  return;
172
730
  }
173
731
  throw new Error(`Unknown runs action: ${action}`);
174
732
  };
733
+ const commandDoctor = async (flags) => {
734
+ const localConfig = readLocalConfig();
735
+ 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
+ const apiKey = process.env.SANBOX_API_KEY || "";
738
+ const selection = readTemplateSelection(flags, { required: false });
739
+ const checks = [];
740
+ const nextActions = [];
741
+ const add = (name, ok, detail, data) => checks.push({
742
+ name,
743
+ ok,
744
+ detail,
745
+ ...(data === undefined ? {} : { data })
746
+ });
747
+ add("node", Number(process.versions.node.split(".")[0]) >= 20, process.version);
748
+ add("api_url", Boolean(apiUrl), apiUrl);
749
+ add("org", Boolean(org), org || "missing SANBOX_ORG, --org, or .sanbox/config.json org");
750
+ 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
+ if (!apiKey) {
755
+ 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
+ }
757
+ try {
758
+ const res = await fetch(`${apiUrl}/health`);
759
+ add("api_health", res.ok, `${res.status} ${res.statusText}`);
760
+ }
761
+ catch (error) {
762
+ add("api_health", false, error instanceof Error ? error.message : String(error));
763
+ }
764
+ if (org && apiKey) {
765
+ const client = new SanboxClient({ apiUrl, org, apiKey });
766
+ try {
767
+ const me = await client.me();
768
+ const organizations = Array.isArray(me.organizations)
769
+ ? me.organizations
770
+ : [];
771
+ add("auth", true, "authenticated");
772
+ add("org_visible", organizations.some((item) => item.slug === org), org);
773
+ }
774
+ catch (error) {
775
+ add("auth", false, error instanceof Error ? error.message : String(error));
776
+ }
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 {
789
+ try {
790
+ const template = await client.getTemplate(selection.id);
791
+ add("template", true, `${template.template.id}; source=${selection.source}`, template.template);
792
+ 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));
798
+ }
799
+ }
800
+ catch (error) {
801
+ add("template_readiness", false, error instanceof Error ? error.message : String(error));
802
+ }
803
+ }
804
+ catch (error) {
805
+ add("template", false, error instanceof Error ? error.message : String(error));
806
+ nextActions.push(...templateActions());
807
+ }
808
+ }
809
+ }
810
+ try {
811
+ const preview = await previewTaskDossier({ cwd: cwd(), include: ["README.md"] });
812
+ add("bundle_preview", true, `${preview.files.length} file(s), ${formatBytes(preview.totalBytes)}`);
813
+ }
814
+ catch (error) {
815
+ add("bundle_preview", false, error instanceof Error ? error.message : String(error));
816
+ }
817
+ const output = {
818
+ ok: checks.every((check) => check.ok),
819
+ template_selection: selection,
820
+ checks
821
+ };
822
+ if (hasFlag(flags, "json")) {
823
+ printSuccess("doctor", output, { api_url: apiUrl, ...(org ? { org } : {}) }, nextActions);
824
+ }
825
+ else {
826
+ for (const check of checks) {
827
+ process.stdout.write(`${check.ok ? "ok" : "fail"} ${check.name}: ${check.detail}\n`);
828
+ }
829
+ }
830
+ if (!output.ok)
831
+ process.exitCode = 2;
832
+ };
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
+ };
175
882
  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");
883
+ const dir = path.join(cwd(), ".sanbox");
884
+ if (command[1] === "agent") {
885
+ if (hasFlag(flags, "write")) {
886
+ await fs.mkdir(dir, { recursive: true });
887
+ await fs.writeFile(path.join(dir, "agent.md"), agentInstructions, "utf8");
888
+ if (hasFlag(flags, "json")) {
889
+ printSuccess("init.agent", { path: ".sanbox/agent.md", status: "written" }, localJsonContext(flags));
890
+ }
891
+ else {
892
+ process.stdout.write(".sanbox/agent.md written\n");
893
+ }
894
+ return;
895
+ }
896
+ if (hasFlag(flags, "json")) {
897
+ printSuccess("init.agent", { instructions: agentInstructions }, localJsonContext(flags));
898
+ }
899
+ else {
900
+ process.stdout.write(agentInstructions);
901
+ }
183
902
  return;
184
903
  }
185
- process.stdout.write(agentInstructions);
904
+ if (command[1])
905
+ throw new Error("init supports no subcommand or `agent`.");
906
+ const force = hasFlag(flags, "force");
907
+ const localConfig = readLocalConfig();
908
+ 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
+ const template = readTemplateSelection(flags, { required: false });
911
+ const config = {
912
+ api_url: apiUrl,
913
+ org,
914
+ ...(template ? { default_template: template.id } : {})
915
+ };
916
+ const sanboxIgnore = [
917
+ "# Project-specific Sanbox excludes. Defaults already exclude .git, node_modules, build output, env files, and secret-like names.",
918
+ "",
919
+ "# examples:",
920
+ "# data/private/**",
921
+ "# tmp/**",
922
+ ""
923
+ ].join("\n");
924
+ await fs.mkdir(dir, { recursive: true });
925
+ const results = [
926
+ [".sanbox/config.json", await writeIfNeeded(path.join(dir, "config.json"), `${JSON.stringify(config, null, 2)}\n`, force)],
927
+ [".sanbox/agent.md", await writeIfNeeded(path.join(dir, "agent.md"), agentInstructions, force)],
928
+ [".sanboxignore", await writeIfNeeded(path.join(cwd(), ".sanboxignore"), sanboxIgnore, force)]
929
+ ];
930
+ if (hasFlag(flags, "json")) {
931
+ printSuccess("init", {
932
+ files: results.map(([file, status]) => ({ file, status })),
933
+ template_selection: template
934
+ }, { api_url: apiUrl, ...(org ? { org } : {}) }, template ? [] : templateActions());
935
+ }
936
+ else {
937
+ for (const [file, status] of results)
938
+ process.stdout.write(`${status} ${file}\n`);
939
+ }
186
940
  };
187
- const main = async () => {
188
- const { command, flags } = parseArgs(process.argv.slice(2));
189
- if (command.length === 0 || hasFlag(flags, "help")) {
941
+ const helpFor = (command) => {
942
+ if (command[0] === "run")
943
+ return runHelp;
944
+ if (command[0] === "bundle")
945
+ return bundleHelp;
946
+ if (command[0] === "doctor")
947
+ return doctorHelp;
948
+ if (command[0] === "model-providers")
949
+ return modelProvidersHelp;
950
+ if (command[0] === "templates")
951
+ return templatesHelp;
952
+ return help;
953
+ };
954
+ const commandId = (command) => {
955
+ if (command[0] === "auth")
956
+ return "auth.check";
957
+ if (command[0] === "context")
958
+ return "context.get";
959
+ if (command[0] === "model-providers")
960
+ return `model_providers.${command[1] || "unknown"}`;
961
+ if (command[0] === "templates")
962
+ return `templates.${command[1] || "unknown"}`;
963
+ if (command[0] === "run")
964
+ return "run.create";
965
+ if (command[0] === "batch")
966
+ return "batch.create";
967
+ if (command[0] === "bundle")
968
+ return `bundle.${command[1] || "unknown"}`;
969
+ if (command[0] === "runs")
970
+ return `runs.${command[1] || "unknown"}`;
971
+ return command[0] || "help";
972
+ };
973
+ const main = async (command, flags) => {
974
+ if (command.length === 0) {
190
975
  process.stdout.write(help);
191
976
  return;
192
977
  }
978
+ if (hasFlag(flags, "help")) {
979
+ process.stdout.write(helpFor(command));
980
+ return;
981
+ }
193
982
  if (command[0] === "auth" && command[1] === "check")
194
983
  return commandAuthCheck(flags);
984
+ if (command[0] === "context")
985
+ return commandContext(flags);
986
+ if (command[0] === "doctor")
987
+ return commandDoctor(flags);
988
+ if (command[0] === "model-providers")
989
+ return commandModelProviders(command, flags);
990
+ if (command[0] === "templates")
991
+ return commandTemplates(command, flags);
195
992
  if (command[0] === "run")
196
- return commandRun(flags);
993
+ return commandRun(command, flags);
197
994
  if (command[0] === "batch")
198
995
  return commandBatch(flags);
996
+ if (command[0] === "bundle")
997
+ return commandBundle(command, flags);
199
998
  if (command[0] === "runs")
200
999
  return commandRuns(command, flags);
201
1000
  if (command[0] === "init")
202
1001
  return commandInit(command, flags);
203
- throw new Error(`Unknown command: ${command.join(" ")}`);
1002
+ throw new CliError("unknown_command", `Unknown command: ${command.join(" ")}`);
204
1003
  };
205
- main().catch((error) => {
206
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
207
- process.exit(1);
1004
+ const parsed = parseArgs(process.argv.slice(2));
1005
+ main(parsed.command, parsed.flags).catch((error) => {
1006
+ if (hasFlag(parsed.flags, "jsonl")) {
1007
+ printJsonlError(commandId(parsed.command), error, localJsonContext(parsed.flags));
1008
+ }
1009
+ else if (hasFlag(parsed.flags, "json")) {
1010
+ printError(commandId(parsed.command), error, localJsonContext(parsed.flags));
1011
+ }
1012
+ else {
1013
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
1014
+ }
1015
+ process.exitCode = error instanceof CliError ? error.exitCode : 1;
208
1016
  });