@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 +127 -6
- package/dist/activity.js +72 -0
- package/dist/api.js +62 -5
- package/dist/cli.js +865 -57
- package/dist/config.js +56 -8
- package/dist/dossier.js +84 -5
- package/dist/errors.js +24 -0
- package/dist/output.js +77 -1
- package/dist/runs.js +1 -2
- package/dist/version.js +1 -1
- package/dist/watch.js +111 -0
- package/package.json +9 -6
- package/dist/mcp.js +0 -131
package/README.md
CHANGED
|
@@ -1,15 +1,136 @@
|
|
|
1
1
|
# Sanbox CLI
|
|
2
2
|
|
|
3
|
-
|
|
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=
|
|
7
|
-
export SANBOX_ORG
|
|
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
|
|
12
|
-
|
|
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
|
-
|
|
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
|
package/dist/activity.js
ADDED
|
@@ -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 =
|
|
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
|
-
|
|
42
|
-
return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/
|
|
70
|
+
listModelProviders() {
|
|
71
|
+
return this.request(`/v1/orgs/${encodeURIComponent(this.config.org)}/model-providers`);
|
|
43
72
|
}
|
|
44
|
-
|
|
45
|
-
|
|
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`, {
|