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