@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/README.md CHANGED
@@ -1,15 +1,136 @@
1
1
  # Sanbox CLI
2
2
 
3
- Agent-facing CLI and local MCP server for creating Sanbox runs.
3
+ Run Sanbox agent tasks from a terminal, CI job, or coding agent.
4
+
5
+ Sanbox packages a task and selected inputs, starts an isolated runner, streams events, and keeps the outputs in a retained workspace.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @sanlabs/sanbox-cli
11
+ ```
12
+
13
+ ## Configure
14
+
15
+ Create an API key in the Sanbox console, then set:
4
16
 
5
17
  ```bash
6
- export SANBOX_API_URL=http://167.233.236.51
7
- export SANBOX_ORG=rheinfall-bank
18
+ export SANBOX_API_URL=https://console.sanbox.cloud
19
+ export SANBOX_ORG=<org-slug>
8
20
  export SANBOX_API_KEY=sbx_live_...
21
+ export SANBOX_TEMPLATE=<template-id>
22
+ ```
9
23
 
24
+ `SANBOX_API_URL` defaults to `https://console.sanbox.cloud`. Set it only for self-hosted or local deployments.
25
+ `SANBOX_TEMPLATE` is optional when every run uses `--template` or `.sanbox/config.json` contains an explicit `default_template`.
26
+
27
+ `SANBOX_API_KEY` authenticates to Sanbox. Model-provider credentials are separate, organization-scoped, and configured by an admin in the console. Do not pass provider keys to the CLI.
28
+
29
+ ## Check Access
30
+
31
+ ```bash
32
+ sanbox doctor
10
33
  sanbox auth check
11
- sanbox run --task "Inspect the auth flow" --include "app/**" --wait --json
12
- sanbox batch --tasks tasks.json --include "app/**" --max-parallel 5 --wait --json
34
+ sanbox context --json
35
+ ```
36
+
37
+ ## Choose A Model Provider And Template
38
+
39
+ List model providers configured for the selected organization, then list the exact models exposed by one provider:
40
+
41
+ ```bash
42
+ sanbox model-providers list --json
43
+ sanbox model-providers get <provider-id> --json
44
+ sanbox model-providers models <provider-id> --json
45
+ ```
46
+
47
+ Model ids are scoped to their provider. If two providers expose the same model id, they remain separate choices.
48
+
49
+ List and inspect templates:
50
+
51
+ ```bash
52
+ sanbox templates list --json
53
+ sanbox templates get <template-id> --json
54
+ sanbox templates validate <template-id> --json
55
+ ```
56
+
57
+ Organization admins can create a template with an exact provider/model pair:
58
+
59
+ ```bash
60
+ sanbox templates create \
61
+ --name "Code review" \
62
+ --model-provider <provider-id> \
63
+ --model <exact-model-id> \
64
+ --json
65
+ ```
66
+
67
+ The CLI does not recommend or automatically select a provider or model.
68
+
69
+ ## Run a Task
70
+
71
+ ```bash
72
+ sanbox run "Review this repo and write output/report.md" --template <template-id> --include "src/**" --watch
73
+ ```
74
+
75
+ Use repeatable `--include` flags to choose what is sent to the runner:
76
+
77
+ ```bash
78
+ sanbox run "Fill the report template from these notes" \
79
+ --template <template-id> \
80
+ --include "templates/report.docx" \
81
+ --include "docs/source/**" \
82
+ --watch
83
+ ```
84
+
85
+ Sanbox excludes `.git`, `node_modules`, build output, `.env` files, private keys, and common secret-like filenames by default. Add `.sanboxignore` for project-specific excludes.
86
+
87
+ ## Watch Agent Activity
88
+
89
+ Create a run and stream its activity until completion:
90
+
91
+ ```bash
92
+ sanbox run "Fix the authentication tests" --template <template-id> --include "src/**" --watch
93
+ ```
94
+
95
+ Reattach to an existing run:
96
+
97
+ ```bash
98
+ sanbox runs watch <run-id>
99
+ ```
100
+
101
+ Use JSONL for scripts and agents:
102
+
103
+ ```bash
104
+ sanbox runs watch <run-id> --jsonl
105
+ ```
106
+
107
+ `--view logs` shows only stdout/stderr events, while `--view compact` hides them. Ctrl-C detaches without canceling the run. Use `--cancel-on-interrupt` only when the watcher and run should share a lifetime, such as a CI job.
108
+
109
+ `--wait` remains available when only the final run result is needed.
110
+
111
+ OpenCode runner images normalize session, turn, tool completion/failure, file-change, assistant-message, usage, and error activity. The unmodified provider stream is retained separately at `/workspace/logs/opencode.jsonl`; provider stderr is retained at `/workspace/logs/opencode.stderr.log`.
112
+
113
+ ## Batch Work
114
+
115
+ ```bash
116
+ sanbox batch --tasks tasks.json --template <template-id> --include "src/**" --max-parallel 5 --wait --json
13
117
  ```
14
118
 
15
- The package also exposes `sanbox-mcp`, a local stdio MCP server for tools such as Codex, Claude Code, Cursor, and Copilot.
119
+ ## Inspect a Run Bundle
120
+
121
+ ```bash
122
+ sanbox bundle preview --include "app/**"
123
+ sanbox bundle create "Review this repo" --include "app/**" --out run.zip
124
+ sanbox bundle inspect run.zip
125
+ ```
126
+
127
+ ## Use From Agents
128
+
129
+ Use Sanbox for independent, long-running, risky, or parallelizable work. Tell the runner to write durable results under `/workspace/output`.
130
+
131
+ Use the CLI directly from coding agents that can run shell commands, including Codex and Claude Code.
132
+
133
+ ## Links
134
+
135
+ - Console: https://console.sanbox.cloud
136
+ - Agent instructions: https://console.sanbox.cloud/agent.md
@@ -0,0 +1,72 @@
1
+ export const parseActivityView = (value) => {
2
+ const normalized = value.trim() || "activity";
3
+ if (normalized === "activity" || normalized === "logs" || normalized === "compact")
4
+ return normalized;
5
+ throw new Error("--view must be one of: activity, logs, compact.");
6
+ };
7
+ export const shouldRenderEvent = (event, view) => {
8
+ const isLog = event.kind === "log.stdout" || event.kind === "log.stderr";
9
+ if (view === "logs")
10
+ return isLog;
11
+ if (view === "compact")
12
+ return !isLog;
13
+ return true;
14
+ };
15
+ const elapsed = (createdAt, occurredAt) => {
16
+ const start = Date.parse(createdAt);
17
+ const end = Date.parse(occurredAt);
18
+ if (!Number.isFinite(start) || !Number.isFinite(end))
19
+ return "--:--";
20
+ const totalSeconds = Math.max(0, Math.floor((end - start) / 1000));
21
+ const hours = Math.floor(totalSeconds / 3600);
22
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
23
+ const seconds = totalSeconds % 60;
24
+ return hours > 0
25
+ ? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
26
+ : `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
27
+ };
28
+ const oneLine = (value) => value
29
+ .replace(/[\r\n\t]+/g, " ")
30
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
31
+ .trim();
32
+ export const formatActivityLine = (event, runCreatedAt) => {
33
+ const summary = oneLine(event.message);
34
+ const bounded = summary.length > 500 ? `${summary.slice(0, 497)}...` : summary;
35
+ return `${elapsed(runCreatedAt, event.created_at).padStart(8)} ${event.kind.padEnd(22)} ${bounded}`.trimEnd();
36
+ };
37
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
38
+ const safeEventData = (value) => {
39
+ if (Array.isArray(value))
40
+ return value.map(safeEventData);
41
+ if (!isRecord(value))
42
+ return value;
43
+ const output = {};
44
+ for (const [key, item] of Object.entries(value)) {
45
+ if (key === "workspace_id" || key === "workspace_path" || key === "host_path")
46
+ continue;
47
+ output[key] = safeEventData(item);
48
+ }
49
+ return output;
50
+ };
51
+ export const activityEnvelope = (event) => {
52
+ const normalized = event.payload.schema_version === 1 && isRecord(event.payload.source);
53
+ const occurredAt = normalized && typeof event.payload.occurred_at === "string"
54
+ ? event.payload.occurred_at
55
+ : event.created_at;
56
+ return {
57
+ schema_version: 1,
58
+ scope: "run",
59
+ run_id: event.run_id,
60
+ cursor: String(event.id),
61
+ kind: event.kind,
62
+ level: event.level,
63
+ summary: event.message,
64
+ source: normalized ? event.payload.source : { plane: "control_plane", trust: "control_plane" },
65
+ sequence: normalized && typeof event.payload.sequence === "number" ? event.payload.sequence : null,
66
+ correlation: normalized && isRecord(event.payload.correlation) ? event.payload.correlation : {},
67
+ data: safeEventData(normalized ? event.payload.data : event.payload),
68
+ occurred_at: occurredAt,
69
+ observed_at: event.created_at
70
+ };
71
+ };
72
+ export const formatActivityJsonl = (event) => JSON.stringify(activityEnvelope(event));
package/dist/api.js CHANGED
@@ -1,10 +1,39 @@
1
+ const errorRecord = (body) => {
2
+ if (!body || typeof body !== "object" || Array.isArray(body))
3
+ return null;
4
+ return body;
5
+ };
6
+ const apiErrorCode = (body) => {
7
+ const record = errorRecord(body);
8
+ if (!record)
9
+ return "api_error";
10
+ if (typeof record.code === "string" && record.code)
11
+ return record.code;
12
+ const nested = errorRecord(record.error);
13
+ return typeof nested?.code === "string" && nested.code ? nested.code : "api_error";
14
+ };
15
+ const apiErrorMessage = (body, fallback) => {
16
+ const record = errorRecord(body);
17
+ if (!record)
18
+ return fallback;
19
+ if (typeof record.error === "string" && record.error)
20
+ return record.error;
21
+ const nested = errorRecord(record.error);
22
+ if (typeof nested?.message === "string" && nested.message)
23
+ return nested.message;
24
+ if (typeof record.message === "string" && record.message)
25
+ return record.message;
26
+ return fallback;
27
+ };
1
28
  export class SanboxApiError extends Error {
2
29
  status;
3
30
  body;
31
+ code;
4
32
  constructor(message, status, body) {
5
33
  super(message);
6
34
  this.status = status;
7
35
  this.body = body;
36
+ this.code = apiErrorCode(body);
8
37
  }
9
38
  }
10
39
  export class SanboxClient {
@@ -24,7 +53,7 @@ export class SanboxClient {
24
53
  const text = await res.text();
25
54
  const body = text ? JSON.parse(text) : {};
26
55
  if (!res.ok) {
27
- const message = body && typeof body === "object" && "error" in body ? String(body.error) : res.statusText;
56
+ const message = apiErrorMessage(body, res.statusText);
28
57
  throw new SanboxApiError(message, res.status, body);
29
58
  }
30
59
  return body;
@@ -38,11 +67,39 @@ export class SanboxClient {
38
67
  body: JSON.stringify(body)
39
68
  });
40
69
  }
41
- getRun(runId) {
42
- return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}`);
70
+ listModelProviders() {
71
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/model-providers`);
43
72
  }
44
- listEvents(runId, afterEventId = 0) {
45
- return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}/events?after_event_id=${afterEventId}`);
73
+ async getModelProvider(providerId) {
74
+ const payload = await this.listModelProviders();
75
+ const provider = payload.providers.find((item) => (item.provider_id || item.id) === providerId);
76
+ if (!provider)
77
+ throw new SanboxApiError(`Model provider ${providerId} was not found.`, 404, { code: "model_provider_not_found" });
78
+ return { provider };
79
+ }
80
+ listProviderModels(providerId) {
81
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/model-providers/${encodeURIComponent(providerId)}/models`);
82
+ }
83
+ listTemplates() {
84
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/templates`);
85
+ }
86
+ getTemplate(templateId) {
87
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/templates/${encodeURIComponent(templateId)}`);
88
+ }
89
+ validateTemplate(templateId) {
90
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/templates/${encodeURIComponent(templateId)}/validate`);
91
+ }
92
+ createTemplate(body) {
93
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/templates`, {
94
+ method: "POST",
95
+ body: JSON.stringify(body)
96
+ });
97
+ }
98
+ getRun(runId, signal) {
99
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}`, { signal });
100
+ }
101
+ listEvents(runId, afterEventId = 0, limit = 200, signal) {
102
+ return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}/events?after_event_id=${afterEventId}&limit=${limit}`, { signal });
46
103
  }
47
104
  cancelRun(runId) {
48
105
  return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/runs/${encodeURIComponent(runId)}/cancel`, {