@sanlabs/sanbox-cli 0.0.1 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -7
- package/dist/activity.js +72 -0
- package/dist/api.js +109 -5
- package/dist/args.js +23 -7
- package/dist/artifacts.js +124 -0
- package/dist/cli.js +1219 -86
- package/dist/config.js +57 -9
- package/dist/errors.js +24 -0
- package/dist/inputs.js +150 -0
- package/dist/output.js +77 -1
- package/dist/runs.js +13 -25
- package/dist/version.js +1 -1
- package/dist/watch.js +170 -0
- package/package.json +9 -6
- package/dist/dossier.js +0 -108
- package/dist/mcp.js +0 -131
package/dist/cli.js
CHANGED
|
@@ -1,77 +1,568 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
4
|
+
import { formatActivityJsonl, formatActivityLine, parseActivityView, shouldRenderEvent } from "./activity.js";
|
|
5
|
+
import { ArtifactExistsError, downloadArtifacts } from "./artifacts.js";
|
|
6
|
+
import { booleanFlags, flagList, flagString, hasFlag, parseArgs } from "./args.js";
|
|
7
|
+
import { SanboxApiError, SanboxClient } from "./api.js";
|
|
8
|
+
import { defaultApiUrl, readConfig, readLocalConfig, readTemplateSelection } from "./config.js";
|
|
9
|
+
import { CliError, commandAction, consoleAction } from "./errors.js";
|
|
10
|
+
import { previewInputs } from "./inputs.js";
|
|
11
|
+
import { printError, printJsonlError, printRun, printSuccess, publicRun, publicRunPayload } from "./output.js";
|
|
8
12
|
import { createRun, isTerminalRun, readTasks, runPool, stableBatchId, waitForRun } from "./runs.js";
|
|
9
13
|
import { version } from "./version.js";
|
|
14
|
+
import { WatchInterruptedError, watchEventsUntil, watchRun } from "./watch.js";
|
|
10
15
|
const help = `Sanbox CLI
|
|
11
16
|
|
|
12
17
|
Environment:
|
|
13
18
|
SANBOX_API_URL Sanbox API base URL
|
|
14
19
|
SANBOX_ORG Organization slug
|
|
15
20
|
SANBOX_API_KEY Org API key
|
|
21
|
+
SANBOX_TEMPLATE Explicit template id or slug for run and batch
|
|
16
22
|
|
|
17
23
|
Commands:
|
|
24
|
+
sanbox --version
|
|
18
25
|
sanbox auth check [--json]
|
|
19
|
-
sanbox
|
|
20
|
-
sanbox
|
|
21
|
-
sanbox
|
|
26
|
+
sanbox orgs list [--json]
|
|
27
|
+
sanbox context [--json]
|
|
28
|
+
sanbox doctor [--json]
|
|
29
|
+
sanbox model-providers list [--json]
|
|
30
|
+
sanbox model-providers get <provider-id> [--json]
|
|
31
|
+
sanbox model-providers models <provider-id> [--json]
|
|
32
|
+
sanbox templates list [--json]
|
|
33
|
+
sanbox templates get <template-id> [--json]
|
|
34
|
+
sanbox templates validate <template-id> [--json]
|
|
35
|
+
sanbox templates create --name "..." --model-provider <provider-id> --model <model-id> [--llm-budget-usd <amount>] [--web-access] [--json]
|
|
36
|
+
sanbox run "task" --template <template-id> [--input <path>] [--wait | --watch] [--json | --jsonl]
|
|
37
|
+
sanbox run --task "..." --template <template-id> [--input <path>] [--wait | --watch] [--json | --jsonl]
|
|
38
|
+
sanbox batch --tasks tasks.json --template <template-id> [--input <path>] [--max-parallel 5] [--wait] [--json]
|
|
39
|
+
sanbox runs list [--limit 50] [--json]
|
|
22
40
|
sanbox runs get <run-id> [--json]
|
|
23
41
|
sanbox runs events <run-id> [--after-event-id 0] [--json]
|
|
42
|
+
sanbox runs messages <run-id> [--json]
|
|
43
|
+
sanbox runs artifacts <run-id> [--json]
|
|
44
|
+
sanbox runs download <run-id> --output <directory> [--artifact <path>] [--overwrite] [--json]
|
|
45
|
+
sanbox runs watch <run-id> [--after-event-id 0] [--view activity|logs|compact] [--jsonl]
|
|
24
46
|
sanbox runs cancel <run-id> [--json]
|
|
25
|
-
sanbox runs message <run-id>
|
|
47
|
+
sanbox runs message <run-id> "..." [--wait | --watch] [--json | --jsonl]
|
|
48
|
+
sanbox init [--force]
|
|
26
49
|
sanbox init agent [--write]
|
|
27
50
|
`;
|
|
28
|
-
const
|
|
51
|
+
const runHelp = `Sanbox run
|
|
29
52
|
|
|
30
|
-
|
|
53
|
+
Usage:
|
|
54
|
+
sanbox run "Review these files" --template <template-id> --input report.pdf --input data/ --wait
|
|
55
|
+
sanbox run --task "Review these files" --template <template-id> --input report.pdf --wait --json
|
|
31
56
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
-
|
|
57
|
+
Options:
|
|
58
|
+
--input <path> File, directory, or glob to upload. Repeatable.
|
|
59
|
+
--template <id> Template id or slug. Required unless SANBOX_TEMPLATE or project config sets it.
|
|
60
|
+
--external-run-id <id> Idempotency key for retries.
|
|
61
|
+
--retention-ttl-seconds <n> Workspace retention TTL. Default: 86400.
|
|
62
|
+
--dry-run Preview included files without creating a run.
|
|
63
|
+
--wait Poll until terminal status.
|
|
64
|
+
--watch Stream activity until terminal status.
|
|
65
|
+
--jsonl Stream one versioned activity event per line. Implies --watch.
|
|
66
|
+
--view <name> activity, logs, or compact. Default: activity.
|
|
67
|
+
--after-event-id <id> Resume after an event cursor. Default: 0.
|
|
68
|
+
--cancel-on-interrupt Request run cancellation when Ctrl-C is pressed.
|
|
69
|
+
--json Print JSON.
|
|
70
|
+
`;
|
|
71
|
+
const doctorHelp = `Sanbox doctor
|
|
72
|
+
|
|
73
|
+
Usage:
|
|
74
|
+
sanbox doctor [--json]
|
|
75
|
+
|
|
76
|
+
Checks local configuration, API health, auth, org visibility, and template access.
|
|
77
|
+
SANBOX_API_KEY is still read only from the environment.
|
|
78
|
+
`;
|
|
79
|
+
const modelProvidersHelp = `Sanbox model providers
|
|
80
|
+
|
|
81
|
+
Usage:
|
|
82
|
+
sanbox model-providers list [--json]
|
|
83
|
+
sanbox model-providers get <provider-id> [--json]
|
|
84
|
+
sanbox model-providers models <provider-id> [--json]
|
|
85
|
+
|
|
86
|
+
Provider credentials are configured by organization admins in the Sanbox console.
|
|
87
|
+
Model ids are scoped to their provider and are never deduplicated across providers.
|
|
88
|
+
`;
|
|
89
|
+
const templatesHelp = `Sanbox templates
|
|
90
|
+
|
|
91
|
+
Usage:
|
|
92
|
+
sanbox templates list [--json]
|
|
93
|
+
sanbox templates get <template-id> [--json]
|
|
94
|
+
sanbox templates validate <template-id> [--json]
|
|
95
|
+
sanbox templates create --name "Code review" --model-provider <provider-id> --model <model-id> [--llm-budget-usd <amount>] [--web-access] [--json]
|
|
36
96
|
|
|
37
|
-
|
|
97
|
+
Template creation requires an exact provider id and that provider's exact model id.
|
|
98
|
+
LiteLLM budgets are optional USD amounts and apply separately to each run.
|
|
99
|
+
`;
|
|
100
|
+
const agentInstructions = `# Operate Sanbox Autonomously
|
|
101
|
+
|
|
102
|
+
Use the \`sanbox\` CLI for focused, isolated, long-running, risky, or parallel work. The canonical
|
|
103
|
+
protocol is https://console.sanbox.cloud/agent.md. This workflow requires CLI 0.0.4 or newer.
|
|
104
|
+
|
|
105
|
+
Human bootstrap:
|
|
106
|
+
- A human/admin supplies SANBOX_API_KEY and configures provider credentials plus a runnable template.
|
|
107
|
+
- Never print, persist, prompt with, or upload API keys or provider credentials.
|
|
108
|
+
- If the key or a runnable template is unavailable, stop with the exact human action required.
|
|
109
|
+
|
|
110
|
+
Deterministic startup:
|
|
38
111
|
\`\`\`bash
|
|
39
|
-
sanbox
|
|
40
|
-
sanbox
|
|
41
|
-
|
|
112
|
+
sanbox --version
|
|
113
|
+
sanbox orgs list --json
|
|
114
|
+
export SANBOX_ORG=<returned-org-slug>
|
|
115
|
+
sanbox auth check --json
|
|
116
|
+
sanbox context --json
|
|
117
|
+
sanbox templates list --json
|
|
118
|
+
export SANBOX_TEMPLATE=<returned-template-id-or-slug>
|
|
119
|
+
sanbox templates validate "$SANBOX_TEMPLATE" --json
|
|
120
|
+
sanbox doctor --json
|
|
121
|
+
\`\`\`
|
|
122
|
+
|
|
123
|
+
Never guess opaque IDs. Select a discovered org/template automatically only when exactly one valid
|
|
124
|
+
choice exists; otherwise ask the user. Provider credentials and template administration are
|
|
125
|
+
console-only.
|
|
126
|
+
|
|
127
|
+
Use --json for request/response commands and --jsonl for streams. Parse the versioned envelope:
|
|
128
|
+
schema_version, ok, command, context, data or error, and next_actions. Execute command actions as
|
|
129
|
+
argv arrays, never shell strings. Exit 0 means command success, 1 local/API failure, 2 readiness or
|
|
130
|
+
waited remote failure, and 130 detached while remote work continues.
|
|
131
|
+
|
|
132
|
+
Preview and submit each logical task with a stable idempotency key:
|
|
133
|
+
\`\`\`bash
|
|
134
|
+
sanbox run "Investigate one focused task and write output/report.md" --input app/ --dry-run --json
|
|
135
|
+
sanbox run "Investigate one focused task and write output/report.md" \\
|
|
136
|
+
--template "$SANBOX_TEMPLATE" --external-run-id "<stable-project-task-id>" \\
|
|
137
|
+
--input app/ --wait --json
|
|
138
|
+
\`\`\`
|
|
139
|
+
|
|
140
|
+
Reuse the same --external-run-id after ambiguous failures. Retry network errors, HTTP 429, HTTP 5xx,
|
|
141
|
+
and workspace_busy with bounded backoff. Do not retry other 4xx errors unless next_actions directs
|
|
142
|
+
recovery.
|
|
143
|
+
|
|
144
|
+
Recover and retrieve results:
|
|
145
|
+
\`\`\`bash
|
|
146
|
+
sanbox runs list --limit 50 --json
|
|
42
147
|
sanbox runs get <run-id> --json
|
|
43
|
-
sanbox runs events <run-id> --json
|
|
44
|
-
sanbox runs
|
|
148
|
+
sanbox runs events <run-id> --after-event-id <cursor> --json
|
|
149
|
+
sanbox runs watch <run-id> --after-event-id <cursor> --jsonl
|
|
150
|
+
sanbox runs artifacts <run-id> --json
|
|
151
|
+
sanbox runs download <run-id> --output .sanbox/output/<run-id> --json
|
|
152
|
+
\`\`\`
|
|
153
|
+
|
|
154
|
+
Tasks must put durable files under /workspace/output. Downloads preserve relative paths and report
|
|
155
|
+
byte counts plus SHA-256 digests; existing files require explicit --overwrite.
|
|
156
|
+
|
|
157
|
+
Continue through the retained workspace:
|
|
158
|
+
\`\`\`bash
|
|
159
|
+
sanbox runs messages <run-id> --json
|
|
160
|
+
sanbox runs message <run-id> "Summarize the retained output" --wait --json
|
|
45
161
|
\`\`\`
|
|
46
162
|
|
|
47
|
-
|
|
163
|
+
Follow-up waits are correlated to the returned chat_job.id. Keep dependent follow-ups sequential.
|
|
164
|
+
For independent fan-out, use \`sanbox batch\` with a stable external_run_id per task and keep the
|
|
165
|
+
client alive until submission completes.
|
|
166
|
+
|
|
167
|
+
Do not claim completion until the run is completed, required artifacts are downloaded and verified,
|
|
168
|
+
and required follow-ups succeeded. Report run/external/template IDs, status, artifact paths/digests,
|
|
169
|
+
and blockers. The CLI excludes common secrets by default; add .sanboxignore for project rules.
|
|
48
170
|
`;
|
|
49
171
|
const cwd = () => process.cwd();
|
|
50
172
|
const makeClient = (flags) => new SanboxClient(readConfig(flags));
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
173
|
+
const makeAuthClient = (flags) => new SanboxClient(readConfig(flags, { requireOrg: false }));
|
|
174
|
+
const jsonContext = (client) => ({
|
|
175
|
+
api_url: client.config.apiUrl,
|
|
176
|
+
org: client.config.org
|
|
177
|
+
});
|
|
178
|
+
const localJsonContext = (flags) => {
|
|
179
|
+
try {
|
|
180
|
+
const local = readLocalConfig();
|
|
181
|
+
const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || local.api_url || defaultApiUrl).replace(/\/+$/, "");
|
|
182
|
+
const org = String(flags.org || process.env.SANBOX_ORG || local.org || "").trim();
|
|
183
|
+
return { api_url: apiUrl, ...(org ? { org } : {}) };
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return {};
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const consolePathUrl = (client, pathname) => new URL(pathname, `${client.config.apiUrl}/`).toString();
|
|
190
|
+
const consoleUrl = (client) => consolePathUrl(client, "/model-providers");
|
|
191
|
+
const providerConsoleAction = (client) => consoleAction(consoleUrl(client), "Ask an organization admin to configure or repair the model provider in the Sanbox console.");
|
|
192
|
+
const templateAdminConsoleAction = (client) => consoleAction(consolePathUrl(client, "/templates"), "Ask an organization admin to create a template in the Sanbox console.");
|
|
193
|
+
const templateActions = () => [
|
|
194
|
+
commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization."),
|
|
195
|
+
commandAction(["sanbox", "templates", "validate", "<template-id>", "--json"], "Check whether a template is runnable.")
|
|
196
|
+
];
|
|
197
|
+
const runReadinessCodes = new Set([
|
|
198
|
+
"provider_not_configured",
|
|
199
|
+
"provider_invalid",
|
|
200
|
+
"model_not_found_for_provider",
|
|
201
|
+
"template_provider_missing",
|
|
202
|
+
"template_not_runnable"
|
|
203
|
+
]);
|
|
204
|
+
const providerReadinessCodes = new Set([
|
|
205
|
+
"provider_not_configured",
|
|
206
|
+
"provider_invalid",
|
|
207
|
+
"model_not_found_for_provider",
|
|
208
|
+
"template_provider_missing"
|
|
209
|
+
]);
|
|
210
|
+
const runCreationError = (error, client, template) => {
|
|
211
|
+
if (!(error instanceof SanboxApiError) || !runReadinessCodes.has(error.code))
|
|
212
|
+
return error;
|
|
213
|
+
const nextActions = [
|
|
214
|
+
commandAction(["sanbox", "templates", "validate", template.id, "--json"], "Inspect the selected template's current provider/model blockers."),
|
|
215
|
+
commandAction(["sanbox", "model-providers", "list", "--json"], "Inspect organization-scoped model-provider status.")
|
|
216
|
+
];
|
|
217
|
+
if (providerReadinessCodes.has(error.code))
|
|
218
|
+
nextActions.push(providerConsoleAction(client));
|
|
219
|
+
return new CliError(error.code, error.message, {
|
|
220
|
+
status: error.status,
|
|
221
|
+
details: { template_id: template.id },
|
|
222
|
+
nextActions
|
|
66
223
|
});
|
|
224
|
+
};
|
|
225
|
+
const templateCreationError = (error, client, providerId, modelId) => {
|
|
226
|
+
if (!(error instanceof SanboxApiError))
|
|
227
|
+
return error;
|
|
228
|
+
if (error.code === "provider_not_configured" || error.code === "provider_invalid") {
|
|
229
|
+
return new CliError(error.code, error.message, {
|
|
230
|
+
status: error.status,
|
|
231
|
+
details: { provider_id: providerId, model_id: modelId },
|
|
232
|
+
nextActions: [
|
|
233
|
+
commandAction(["sanbox", "model-providers", "get", providerId, "--json"], "Inspect the selected provider's organization-scoped status."),
|
|
234
|
+
providerConsoleAction(client)
|
|
235
|
+
]
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (error.code === "model_not_found_for_provider") {
|
|
239
|
+
return new CliError(error.code, error.message, {
|
|
240
|
+
status: error.status,
|
|
241
|
+
details: { provider_id: providerId, model_id: modelId },
|
|
242
|
+
nextActions: [commandAction(["sanbox", "model-providers", "models", providerId, "--json"], "List exact selectable and non-selectable model ids exposed by the selected provider.")]
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (error.code === "template_name_conflict") {
|
|
246
|
+
return new CliError(error.code, error.message, {
|
|
247
|
+
status: error.status,
|
|
248
|
+
nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "Inspect existing template names and identifiers.")]
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return error;
|
|
252
|
+
};
|
|
253
|
+
const requiredPositional = (value, code, message) => {
|
|
254
|
+
const normalized = value?.trim();
|
|
255
|
+
if (!normalized)
|
|
256
|
+
throw new CliError(code, message);
|
|
257
|
+
return normalized;
|
|
258
|
+
};
|
|
259
|
+
const providerId = (provider) => String(provider.provider_id || provider.id || "");
|
|
260
|
+
const templateName = (template) => String(template.display_name || template.template_slug || template.id || "");
|
|
261
|
+
const validationRunnable = (validation) => {
|
|
262
|
+
if (typeof validation.runnable === "boolean")
|
|
263
|
+
return validation.runnable;
|
|
264
|
+
const template = validation.template;
|
|
265
|
+
return Boolean(template && typeof template === "object" && !Array.isArray(template) && template.runnable);
|
|
266
|
+
};
|
|
267
|
+
const formatBytes = (bytes) => {
|
|
268
|
+
if (bytes < 1024)
|
|
269
|
+
return `${bytes} B`;
|
|
270
|
+
if (bytes < 1024 * 1024)
|
|
271
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
272
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
273
|
+
};
|
|
274
|
+
const positionalText = (command, startIndex) => command.slice(startIndex).join(" ").trim();
|
|
275
|
+
const runTask = (command, flags) => flagString(flags, "task") || positionalText(command, 1);
|
|
276
|
+
const commonFlags = ["api-url", "org", "json", "help"];
|
|
277
|
+
const flagSets = {
|
|
278
|
+
"auth.check": commonFlags,
|
|
279
|
+
"orgs.list": commonFlags,
|
|
280
|
+
context: [...commonFlags, "template"],
|
|
281
|
+
doctor: [...commonFlags, "template"],
|
|
282
|
+
"model-providers.list": commonFlags,
|
|
283
|
+
"model-providers.get": commonFlags,
|
|
284
|
+
"model-providers.models": commonFlags,
|
|
285
|
+
"templates.list": commonFlags,
|
|
286
|
+
"templates.get": commonFlags,
|
|
287
|
+
"templates.validate": commonFlags,
|
|
288
|
+
"templates.create": [...commonFlags, "name", "model-provider", "model", "llm-budget-usd", "web-access"],
|
|
289
|
+
run: [
|
|
290
|
+
...commonFlags, "task", "input", "template", "external-run-id", "retention-ttl-seconds",
|
|
291
|
+
"dry-run", "wait", "watch", "jsonl", "view", "after-event-id", "cancel-on-interrupt",
|
|
292
|
+
"poll-interval-ms", "event-page-size", "timeout-seconds", "verbose"
|
|
293
|
+
],
|
|
294
|
+
batch: [
|
|
295
|
+
...commonFlags, "tasks", "template", "input", "max-parallel", "wait", "batch-id",
|
|
296
|
+
"retention-ttl-seconds", "poll-interval-ms", "timeout-seconds"
|
|
297
|
+
],
|
|
298
|
+
"runs.list": [...commonFlags, "limit"],
|
|
299
|
+
"runs.get": commonFlags,
|
|
300
|
+
"runs.events": [...commonFlags, "after-event-id"],
|
|
301
|
+
"runs.messages": commonFlags,
|
|
302
|
+
"runs.artifacts": commonFlags,
|
|
303
|
+
"runs.download": [...commonFlags, "output", "artifact", "overwrite"],
|
|
304
|
+
"runs.watch": [
|
|
305
|
+
...commonFlags, "after-event-id", "view", "jsonl", "cancel-on-interrupt",
|
|
306
|
+
"poll-interval-ms", "event-page-size", "timeout-seconds"
|
|
307
|
+
],
|
|
308
|
+
"runs.cancel": commonFlags,
|
|
309
|
+
"runs.message": [
|
|
310
|
+
...commonFlags, "message", "wait", "watch", "jsonl", "view",
|
|
311
|
+
"poll-interval-ms", "event-page-size", "timeout-seconds"
|
|
312
|
+
],
|
|
313
|
+
init: [...commonFlags, "force", "template"],
|
|
314
|
+
"init.agent": [...commonFlags, "write"],
|
|
315
|
+
version: ["json", "help"]
|
|
316
|
+
};
|
|
317
|
+
const commandKey = (command) => {
|
|
318
|
+
if (command.length === 0)
|
|
319
|
+
return "help";
|
|
320
|
+
if (command[0] === "version")
|
|
321
|
+
return "version";
|
|
322
|
+
if (command[0] === "auth")
|
|
323
|
+
return `auth.${command[1] || ""}`;
|
|
324
|
+
if (command[0] === "orgs")
|
|
325
|
+
return `orgs.${command[1] || ""}`;
|
|
326
|
+
if (command[0] === "model-providers" || command[0] === "templates" || command[0] === "runs") {
|
|
327
|
+
return `${command[0]}.${command[1] || ""}`;
|
|
328
|
+
}
|
|
329
|
+
if (command[0] === "init" && command[1] === "agent")
|
|
330
|
+
return "init.agent";
|
|
331
|
+
return command[0];
|
|
332
|
+
};
|
|
333
|
+
const requiredValueFlags = new Set([
|
|
334
|
+
"api-url", "org", "template", "task", "input", "include", "external-run-id",
|
|
335
|
+
"retention-ttl-seconds", "tasks", "max-parallel", "batch-id", "poll-interval-ms",
|
|
336
|
+
"event-page-size", "timeout-seconds", "view", "after-event-id", "limit", "name",
|
|
337
|
+
"model-provider", "model", "llm-budget-usd", "message", "output", "artifact"
|
|
338
|
+
]);
|
|
339
|
+
const validateFlagValues = (flags) => {
|
|
340
|
+
for (const flag of requiredValueFlags) {
|
|
341
|
+
const value = flags[flag];
|
|
342
|
+
const missing = value === true
|
|
343
|
+
|| (typeof value === "string" && value.trim() === "")
|
|
344
|
+
|| (Array.isArray(value) && value.some((item) => item.trim() === ""));
|
|
345
|
+
if (missing)
|
|
346
|
+
throw new CliError("flag_value_required", `--${flag} requires a value.`);
|
|
347
|
+
}
|
|
348
|
+
for (const flag of booleanFlags) {
|
|
349
|
+
if (flags[flag] !== undefined && flags[flag] !== true) {
|
|
350
|
+
throw new CliError("unexpected_flag_value", `--${flag} does not accept a value.`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
const normalizeInputAlias = (command, flags) => {
|
|
355
|
+
if (command[0] !== "run" && command[0] !== "batch")
|
|
356
|
+
return;
|
|
357
|
+
const legacyInputs = flagList(flags, "include");
|
|
358
|
+
if (legacyInputs.length === 0)
|
|
359
|
+
return;
|
|
360
|
+
flags.input = [...flagList(flags, "input"), ...legacyInputs];
|
|
361
|
+
delete flags.include;
|
|
362
|
+
if (!hasFlag(flags, "json") && !hasFlag(flags, "jsonl")) {
|
|
363
|
+
process.stderr.write("Warning: --include is deprecated; use --input.\n");
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
const validateFlags = (command, flags) => {
|
|
367
|
+
const key = commandKey(command);
|
|
368
|
+
if (key === "help") {
|
|
369
|
+
const unknown = Object.keys(flags).filter((flag) => !["help", "version", "json"].includes(flag));
|
|
370
|
+
if (unknown.length > 0)
|
|
371
|
+
throw new CliError("unknown_flag", `Unknown flag: --${unknown[0]}`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const knownRoots = new Set([
|
|
375
|
+
"auth", "orgs", "context", "doctor", "model-providers", "templates",
|
|
376
|
+
"run", "batch", "runs", "init", "version"
|
|
377
|
+
]);
|
|
378
|
+
const allowed = flagSets[key] ?? (knownRoots.has(command[0] || "") ? commonFlags : null);
|
|
379
|
+
if (!allowed)
|
|
380
|
+
return;
|
|
381
|
+
const unknown = Object.keys(flags).filter((flag) => !allowed.includes(flag));
|
|
382
|
+
if (unknown.length > 0) {
|
|
383
|
+
throw new CliError("unknown_flag", `Unknown flag for ${command.join(" ")}: --${unknown[0]}`);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
const validatePositionals = (command, flags) => {
|
|
387
|
+
if (hasFlag(flags, "help"))
|
|
388
|
+
return;
|
|
389
|
+
const maximums = {
|
|
390
|
+
"auth.check": 2,
|
|
391
|
+
"orgs.list": 2,
|
|
392
|
+
context: 1,
|
|
393
|
+
doctor: 1,
|
|
394
|
+
"model-providers.list": 2,
|
|
395
|
+
"model-providers.get": 3,
|
|
396
|
+
"model-providers.models": 3,
|
|
397
|
+
"templates.list": 2,
|
|
398
|
+
"templates.get": 3,
|
|
399
|
+
"templates.validate": 3,
|
|
400
|
+
"templates.create": 2,
|
|
401
|
+
batch: 1,
|
|
402
|
+
"runs.list": 2,
|
|
403
|
+
"runs.get": 3,
|
|
404
|
+
"runs.events": 3,
|
|
405
|
+
"runs.messages": 3,
|
|
406
|
+
"runs.artifacts": 3,
|
|
407
|
+
"runs.download": 3,
|
|
408
|
+
"runs.watch": 3,
|
|
409
|
+
"runs.cancel": 3,
|
|
410
|
+
init: 1,
|
|
411
|
+
"init.agent": 2,
|
|
412
|
+
version: 1
|
|
413
|
+
};
|
|
414
|
+
const maximum = maximums[commandKey(command)];
|
|
415
|
+
if (maximum !== undefined && command.length > maximum) {
|
|
416
|
+
throw new CliError("unexpected_argument", `Unexpected positional argument: ${command[maximum]}`);
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
const wantsWatch = (flags) => hasFlag(flags, "watch") || hasFlag(flags, "jsonl");
|
|
420
|
+
const integerFlag = (flags, key, fallback, minimum, maximum) => {
|
|
421
|
+
if (flags[key] === undefined)
|
|
422
|
+
return fallback;
|
|
423
|
+
const value = Number(flagString(flags, key));
|
|
424
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
425
|
+
throw new Error(`--${key} must be an integer between ${minimum} and ${maximum}.`);
|
|
426
|
+
}
|
|
427
|
+
return value;
|
|
428
|
+
};
|
|
429
|
+
const validateWatchFlags = (flags) => {
|
|
430
|
+
if (!wantsWatch(flags))
|
|
431
|
+
return;
|
|
432
|
+
if (hasFlag(flags, "json"))
|
|
433
|
+
throw new Error("--json cannot be combined with --watch or --jsonl.");
|
|
434
|
+
parseActivityView(flagString(flags, "view", "activity"));
|
|
435
|
+
integerFlag(flags, "after-event-id", 0, 0, Number.MAX_SAFE_INTEGER);
|
|
436
|
+
integerFlag(flags, "event-page-size", 200, 1, 500);
|
|
437
|
+
integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000);
|
|
438
|
+
integerFlag(flags, "timeout-seconds", 1800, 1, 604_800);
|
|
439
|
+
};
|
|
440
|
+
const watchRunWithOutput = async (client, runId, flags, initialPayload) => {
|
|
441
|
+
validateWatchFlags(flags);
|
|
442
|
+
const initial = initialPayload ?? await client.getRun(runId);
|
|
443
|
+
const view = parseActivityView(flagString(flags, "view", "activity"));
|
|
444
|
+
const jsonl = hasFlag(flags, "jsonl");
|
|
445
|
+
const controller = new AbortController();
|
|
446
|
+
const onInterrupt = () => controller.abort();
|
|
447
|
+
process.once("SIGINT", onInterrupt);
|
|
448
|
+
if (!jsonl)
|
|
449
|
+
process.stdout.write(`Watching run ${runId}. Ctrl-C detaches without canceling.\n`);
|
|
450
|
+
try {
|
|
451
|
+
return await watchRun(client, runId, {
|
|
452
|
+
afterEventId: integerFlag(flags, "after-event-id", 0, 0, Number.MAX_SAFE_INTEGER),
|
|
453
|
+
pageSize: integerFlag(flags, "event-page-size", 200, 1, 500),
|
|
454
|
+
pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
|
|
455
|
+
timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800),
|
|
456
|
+
signal: controller.signal,
|
|
457
|
+
onRetry: ({ error, attempt, delayMs }) => {
|
|
458
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
459
|
+
process.stderr.write(`Watch connection lost (${message}); retry ${attempt} in ${delayMs}ms.\n`);
|
|
460
|
+
},
|
|
461
|
+
onEvent: (event) => {
|
|
462
|
+
if (!shouldRenderEvent(event, view))
|
|
463
|
+
return;
|
|
464
|
+
process.stdout.write(`${jsonl ? formatActivityJsonl(event) : formatActivityLine(event, initial.run.created_at)}\n`);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
if (!(error instanceof WatchInterruptedError))
|
|
470
|
+
throw error;
|
|
471
|
+
if (hasFlag(flags, "cancel-on-interrupt")) {
|
|
472
|
+
await client.cancelRun(runId);
|
|
473
|
+
process.stderr.write(`Cancellation requested for run ${runId}.\n`);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
process.stderr.write(`Detached from run ${runId}; the run is still active.\n`);
|
|
477
|
+
}
|
|
478
|
+
process.exitCode = 130;
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
process.off("SIGINT", onInterrupt);
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
const printPreview = (preview, verbose) => {
|
|
486
|
+
process.stdout.write(`Matched ${preview.files.length} files (${formatBytes(preview.totalBytes)})\n`);
|
|
487
|
+
process.stdout.write(`Patterns: ${preview.patterns.join(", ")}\n`);
|
|
488
|
+
const files = verbose ? preview.files : preview.files.slice(0, 30);
|
|
489
|
+
for (const file of files)
|
|
490
|
+
process.stdout.write(` ${file.path} ${formatBytes(file.size)}\n`);
|
|
491
|
+
if (!verbose && preview.files.length > files.length) {
|
|
492
|
+
process.stdout.write(` ... ${preview.files.length - files.length} more files. Use --verbose to list all.\n`);
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
const writeIfNeeded = async (filePath, content, force) => {
|
|
496
|
+
try {
|
|
497
|
+
await fs.writeFile(filePath, content, { encoding: "utf8", flag: force ? "w" : "wx" });
|
|
498
|
+
return "written";
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
if (error.code === "EEXIST")
|
|
502
|
+
return "exists";
|
|
503
|
+
throw error;
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
const commandRun = async (command, flags) => {
|
|
507
|
+
if (flagString(flags, "task") && positionalText(command, 1)) {
|
|
508
|
+
throw new CliError("conflicting_arguments", "Use either --task or a positional task, not both.");
|
|
509
|
+
}
|
|
510
|
+
const task = runTask(command, flags);
|
|
511
|
+
if (!task)
|
|
512
|
+
throw new Error("--task or a positional task is required.");
|
|
513
|
+
if (hasFlag(flags, "wait") && wantsWatch(flags)) {
|
|
514
|
+
throw new Error("--wait cannot be combined with --watch or --jsonl.");
|
|
515
|
+
}
|
|
516
|
+
validateWatchFlags(flags);
|
|
517
|
+
if (hasFlag(flags, "dry-run")) {
|
|
518
|
+
if (wantsWatch(flags))
|
|
519
|
+
throw new Error("--dry-run cannot be combined with --watch or --jsonl.");
|
|
520
|
+
const preview = await previewInputs({ cwd: cwd(), inputs: flagList(flags, "input") });
|
|
521
|
+
if (hasFlag(flags, "json"))
|
|
522
|
+
printSuccess("run.preview", preview, localJsonContext(flags));
|
|
523
|
+
else
|
|
524
|
+
printPreview(preview, hasFlag(flags, "verbose"));
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const client = makeClient(flags);
|
|
528
|
+
const template = readTemplateSelection(flags);
|
|
529
|
+
let payload;
|
|
530
|
+
try {
|
|
531
|
+
payload = await createRun(client, {
|
|
532
|
+
cwd: cwd(),
|
|
533
|
+
instruction: task,
|
|
534
|
+
inputs: flagList(flags, "input"),
|
|
535
|
+
externalRunId: flagString(flags, "external-run-id") || undefined,
|
|
536
|
+
templateId: template.id,
|
|
537
|
+
retentionTtlSeconds: integerFlag(flags, "retention-ttl-seconds", 86400, 0, Number.MAX_SAFE_INTEGER)
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
throw runCreationError(error, client, template);
|
|
542
|
+
}
|
|
543
|
+
if (wantsWatch(flags)) {
|
|
544
|
+
const watched = await watchRunWithOutput(client, payload.run.id, flags, payload);
|
|
545
|
+
if (!watched)
|
|
546
|
+
return;
|
|
547
|
+
payload = watched;
|
|
548
|
+
if (!hasFlag(flags, "jsonl"))
|
|
549
|
+
printRun(payload);
|
|
550
|
+
if (isTerminalRun(payload.run) && payload.run.status !== "completed")
|
|
551
|
+
process.exitCode = 2;
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
67
554
|
if (hasFlag(flags, "wait")) {
|
|
68
555
|
payload = await waitForRun(client, payload.run.id, {
|
|
69
|
-
pollIntervalMs:
|
|
70
|
-
timeoutSeconds:
|
|
556
|
+
pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
|
|
557
|
+
timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800)
|
|
71
558
|
});
|
|
72
559
|
}
|
|
73
|
-
if (hasFlag(flags, "json"))
|
|
74
|
-
|
|
560
|
+
if (hasFlag(flags, "json")) {
|
|
561
|
+
printSuccess("run.create", {
|
|
562
|
+
...publicRunPayload(payload),
|
|
563
|
+
selection: { template_id: template.id, source: template.source }
|
|
564
|
+
}, jsonContext(client));
|
|
565
|
+
}
|
|
75
566
|
else
|
|
76
567
|
printRun(payload);
|
|
77
568
|
if (hasFlag(flags, "wait") && isTerminalRun(payload.run) && payload.run.status !== "completed")
|
|
@@ -82,32 +573,43 @@ const commandBatch = async (flags) => {
|
|
|
82
573
|
if (!tasksPath)
|
|
83
574
|
throw new Error("--tasks is required.");
|
|
84
575
|
const client = makeClient(flags);
|
|
576
|
+
const template = readTemplateSelection(flags);
|
|
85
577
|
const tasks = await readTasks(tasksPath);
|
|
86
578
|
const batchId = flagString(flags, "batch-id") || await stableBatchId(tasksPath);
|
|
87
|
-
const
|
|
88
|
-
const maxParallel =
|
|
579
|
+
const inputs = flagList(flags, "input");
|
|
580
|
+
const maxParallel = integerFlag(flags, "max-parallel", 5, 1, 100);
|
|
89
581
|
const wait = hasFlag(flags, "wait");
|
|
90
582
|
const results = await runPool(tasks, maxParallel, async (task, index) => {
|
|
91
|
-
let payload
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
583
|
+
let payload;
|
|
584
|
+
try {
|
|
585
|
+
payload = await createRun(client, {
|
|
586
|
+
cwd: cwd(),
|
|
587
|
+
instruction: task.task,
|
|
588
|
+
inputs: task.input || inputs,
|
|
589
|
+
externalRunId: task.external_run_id || `sanbox-batch-${batchId}-${index + 1}`,
|
|
590
|
+
templateId: template.id,
|
|
591
|
+
retentionTtlSeconds: integerFlag(flags, "retention-ttl-seconds", 86400, 0, Number.MAX_SAFE_INTEGER)
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
throw runCreationError(error, client, template);
|
|
596
|
+
}
|
|
100
597
|
if (wait) {
|
|
101
598
|
payload = await waitForRun(client, payload.run.id, {
|
|
102
|
-
pollIntervalMs:
|
|
103
|
-
timeoutSeconds:
|
|
599
|
+
pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
|
|
600
|
+
timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800)
|
|
104
601
|
});
|
|
105
602
|
}
|
|
106
603
|
return payload;
|
|
107
604
|
});
|
|
108
|
-
const output = {
|
|
605
|
+
const output = {
|
|
606
|
+
batch_id: batchId,
|
|
607
|
+
selection: { template_id: template.id, source: template.source },
|
|
608
|
+
runs: results.map((result) => publicRun(result.run)),
|
|
609
|
+
results: results.map(publicRunPayload)
|
|
610
|
+
};
|
|
109
611
|
if (hasFlag(flags, "json"))
|
|
110
|
-
|
|
612
|
+
printSuccess("batch.create", output, jsonContext(client));
|
|
111
613
|
else
|
|
112
614
|
results.forEach(printRun);
|
|
113
615
|
if (wait && results.some((result) => result.run.status !== "completed"))
|
|
@@ -123,86 +625,717 @@ const commandAuthCheck = async (flags) => {
|
|
|
123
625
|
const selected = organizations.find((item) => item.slug === org) || null;
|
|
124
626
|
const output = { ok: Boolean(selected), org, selected, me };
|
|
125
627
|
if (hasFlag(flags, "json"))
|
|
126
|
-
|
|
628
|
+
printSuccess("auth.check", output, jsonContext(client));
|
|
127
629
|
else
|
|
128
630
|
process.stdout.write(selected ? `ok org=${org}\n` : `authenticated, but org ${org} is not visible\n`);
|
|
129
631
|
if (!selected)
|
|
130
632
|
process.exitCode = 2;
|
|
131
633
|
};
|
|
634
|
+
const commandOrganizations = async (command, flags) => {
|
|
635
|
+
if (command[1] !== "list")
|
|
636
|
+
throw new CliError("orgs_action_required", "orgs requires the list action.");
|
|
637
|
+
const client = makeAuthClient(flags);
|
|
638
|
+
const payload = await client.listOrganizations();
|
|
639
|
+
if (hasFlag(flags, "json")) {
|
|
640
|
+
printSuccess("orgs.list", payload, { api_url: client.config.apiUrl });
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
for (const organization of payload.organizations) {
|
|
644
|
+
process.stdout.write(`${organization.slug}${organization.name ? `\t${organization.name}` : ""}${organization.membership_role ? `\t${organization.membership_role}` : ""}\n`);
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
const commandContext = async (flags) => {
|
|
648
|
+
const client = makeClient(flags);
|
|
649
|
+
const selection = readTemplateSelection(flags, { required: false });
|
|
650
|
+
const [me, providerPayload, templatePayload] = await Promise.all([
|
|
651
|
+
client.me(),
|
|
652
|
+
client.listModelProviders(),
|
|
653
|
+
client.listTemplates()
|
|
654
|
+
]);
|
|
655
|
+
const organizations = Array.isArray(me.organizations)
|
|
656
|
+
? me.organizations
|
|
657
|
+
: [];
|
|
658
|
+
const selectedOrganization = organizations.find((item) => item.slug === client.config.org) || null;
|
|
659
|
+
const selectedTemplate = selection
|
|
660
|
+
? templatePayload.templates.find((item) => item.id === selection.id || item.template_slug === selection.id) || null
|
|
661
|
+
: null;
|
|
662
|
+
const output = {
|
|
663
|
+
organization: selectedOrganization,
|
|
664
|
+
authentication: me.auth || null,
|
|
665
|
+
template_selection: selection
|
|
666
|
+
? { id: selection.id, source: selection.source, found: Boolean(selectedTemplate), template: selectedTemplate }
|
|
667
|
+
: null,
|
|
668
|
+
model_providers: providerPayload.providers,
|
|
669
|
+
templates: templatePayload.templates
|
|
670
|
+
};
|
|
671
|
+
const nextActions = [];
|
|
672
|
+
if (!selectedOrganization) {
|
|
673
|
+
nextActions.push(commandAction(["sanbox", "auth", "check", "--json"], "Verify that the API key can access the selected organization."));
|
|
674
|
+
}
|
|
675
|
+
if (!selection) {
|
|
676
|
+
nextActions.push(commandAction(["sanbox", "templates", "list", "--json"], "List available templates."), commandAction(["sanbox", "context", "--json"], "Select a template for this shell and inspect the resolved context.", { SANBOX_TEMPLATE: "<template-id>" }));
|
|
677
|
+
}
|
|
678
|
+
else if (!selectedTemplate) {
|
|
679
|
+
nextActions.push(commandAction(["sanbox", "templates", "list", "--json"], "Choose a template that exists in this organization."));
|
|
680
|
+
}
|
|
681
|
+
if (!selectedOrganization)
|
|
682
|
+
process.exitCode = 2;
|
|
683
|
+
if (hasFlag(flags, "json")) {
|
|
684
|
+
printSuccess("context.get", output, jsonContext(client), nextActions);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
process.stdout.write(`org=${client.config.org}\n`);
|
|
688
|
+
process.stdout.write(`template=${selection ? `${selection.id} source=${selection.source}${selectedTemplate ? "" : " missing"}` : "not selected"}\n`);
|
|
689
|
+
process.stdout.write(`model_providers=${providerPayload.providers.map((item) => providerId(item)).filter(Boolean).join(",") || "none"}\n`);
|
|
690
|
+
process.stdout.write(`templates=${templatePayload.templates.map((item) => item.template_slug || item.id).join(",") || "none"}\n`);
|
|
691
|
+
};
|
|
692
|
+
const commandModelProviders = async (command, flags) => {
|
|
693
|
+
const client = makeClient(flags);
|
|
694
|
+
const action = command[1];
|
|
695
|
+
if (action === "list") {
|
|
696
|
+
const payload = await client.listModelProviders();
|
|
697
|
+
if (hasFlag(flags, "json")) {
|
|
698
|
+
printSuccess("model_providers.list", payload, jsonContext(client));
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
for (const provider of payload.providers) {
|
|
702
|
+
const id = providerId(provider);
|
|
703
|
+
process.stdout.write(`${id}${provider.status ? ` ${provider.status}` : ""}${provider.configured === undefined ? "" : ` configured=${provider.configured}`}\n`);
|
|
704
|
+
}
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (action === "get") {
|
|
708
|
+
const id = requiredPositional(command[2], "model_provider_required", "model-providers get requires a provider id.");
|
|
709
|
+
let payload;
|
|
710
|
+
try {
|
|
711
|
+
payload = await client.getModelProvider(id);
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
if (error instanceof SanboxApiError) {
|
|
715
|
+
throw new CliError("model_provider_not_found", error.message, {
|
|
716
|
+
status: error.status,
|
|
717
|
+
details: { provider_id: id },
|
|
718
|
+
nextActions: [commandAction(["sanbox", "model-providers", "list", "--json"], "List supported providers and their organization-scoped status.")]
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
throw error;
|
|
722
|
+
}
|
|
723
|
+
const nextActions = payload.provider.configured === false ? [providerConsoleAction(client)] : [];
|
|
724
|
+
if (hasFlag(flags, "json")) {
|
|
725
|
+
printSuccess("model_providers.get", payload, jsonContext(client), nextActions);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
process.stdout.write(`${providerId(payload.provider)}${payload.provider.status ? ` ${payload.provider.status}` : ""}\n`);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (action === "models") {
|
|
732
|
+
const id = requiredPositional(command[2], "model_provider_required", "model-providers models requires a provider id.");
|
|
733
|
+
let payload;
|
|
734
|
+
try {
|
|
735
|
+
payload = await client.listProviderModels(id);
|
|
736
|
+
}
|
|
737
|
+
catch (error) {
|
|
738
|
+
if (error instanceof SanboxApiError) {
|
|
739
|
+
throw new CliError("model_provider_unavailable", error.message, {
|
|
740
|
+
status: error.status,
|
|
741
|
+
details: { provider_id: id },
|
|
742
|
+
nextActions: [
|
|
743
|
+
commandAction(["sanbox", "model-providers", "get", id, "--json"], "Inspect the provider's organization-scoped status."),
|
|
744
|
+
providerConsoleAction(client)
|
|
745
|
+
]
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
750
|
+
if (hasFlag(flags, "json")) {
|
|
751
|
+
printSuccess("model_providers.models", payload, jsonContext(client));
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
for (const model of payload.models) {
|
|
755
|
+
const modelId = String(model.model_id || model.id || "");
|
|
756
|
+
const displayName = String(model.display_name || model.name || "");
|
|
757
|
+
process.stdout.write(`${modelId}${displayName && displayName !== modelId ? `\t${displayName}` : ""}${model.selectable === undefined ? "" : `\tselectable=${model.selectable}`}\n`);
|
|
758
|
+
}
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
throw new CliError("model_providers_action_required", "model-providers requires an action: list, get, or models.");
|
|
762
|
+
};
|
|
763
|
+
const commandTemplates = async (command, flags) => {
|
|
764
|
+
const client = makeClient(flags);
|
|
765
|
+
const action = command[1];
|
|
766
|
+
if (action === "list") {
|
|
767
|
+
const payload = await client.listTemplates();
|
|
768
|
+
const nextActions = payload.templates.length === 0
|
|
769
|
+
? [
|
|
770
|
+
commandAction(["sanbox", "context", "--json"], "Inspect the selected organization and current authentication role."),
|
|
771
|
+
templateAdminConsoleAction(client)
|
|
772
|
+
]
|
|
773
|
+
: [];
|
|
774
|
+
if (hasFlag(flags, "json")) {
|
|
775
|
+
printSuccess("templates.list", payload, jsonContext(client), nextActions);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
for (const template of payload.templates) {
|
|
779
|
+
process.stdout.write(`${template.id}\t${templateName(template)}\t${template.provider_id || ""}\t${template.model_id || ""}${template.runnable === undefined ? "" : `\trunnable=${template.runnable}`}\n`);
|
|
780
|
+
}
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (action === "get") {
|
|
784
|
+
const id = requiredPositional(command[2], "template_id_required", "templates get requires a template id or slug.");
|
|
785
|
+
let payload;
|
|
786
|
+
try {
|
|
787
|
+
payload = await client.getTemplate(id);
|
|
788
|
+
}
|
|
789
|
+
catch (error) {
|
|
790
|
+
if (error instanceof SanboxApiError) {
|
|
791
|
+
throw new CliError("template_not_found", error.message, {
|
|
792
|
+
status: error.status,
|
|
793
|
+
details: { template_id: id },
|
|
794
|
+
nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization.")]
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
throw error;
|
|
798
|
+
}
|
|
799
|
+
if (hasFlag(flags, "json")) {
|
|
800
|
+
printSuccess("templates.get", payload, jsonContext(client), templateActions());
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
process.stdout.write(`${payload.template.id} ${templateName(payload.template)} provider=${payload.template.provider_id || ""} model=${payload.template.model_id || ""}${payload.template.runnable === undefined ? "" : ` runnable=${payload.template.runnable}`}\n`);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (action === "validate") {
|
|
807
|
+
const id = requiredPositional(command[2], "template_id_required", "templates validate requires a template id or slug.");
|
|
808
|
+
let payload;
|
|
809
|
+
try {
|
|
810
|
+
payload = await client.validateTemplate(id);
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
if (error instanceof SanboxApiError) {
|
|
814
|
+
throw new CliError("template_validation_failed", error.message, {
|
|
815
|
+
status: error.status,
|
|
816
|
+
details: { template_id: id },
|
|
817
|
+
nextActions: [commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization.")]
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
throw error;
|
|
821
|
+
}
|
|
822
|
+
const runnable = validationRunnable(payload);
|
|
823
|
+
const nextActions = runnable ? [] : [
|
|
824
|
+
commandAction(["sanbox", "model-providers", "list", "--json"], "Inspect model-provider configuration for this organization."),
|
|
825
|
+
providerConsoleAction(client)
|
|
826
|
+
];
|
|
827
|
+
if (hasFlag(flags, "json")) {
|
|
828
|
+
printSuccess("templates.validate", payload, jsonContext(client), nextActions);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
process.stdout.write(`${runnable ? "ready" : "blocked"} template=${id}\n`);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (action === "create") {
|
|
835
|
+
const name = requiredPositional(flagString(flags, "name"), "template_name_required", "templates create requires --name.");
|
|
836
|
+
const modelProvider = requiredPositional(flagString(flags, "model-provider"), "model_provider_required", "templates create requires --model-provider.");
|
|
837
|
+
const model = requiredPositional(flagString(flags, "model"), "model_required", "templates create requires --model.");
|
|
838
|
+
const budgetRaw = flagString(flags, "llm-budget-usd");
|
|
839
|
+
const parsedBudgetUsd = budgetRaw ? Number(budgetRaw) : undefined;
|
|
840
|
+
const llmBudgetUsd = parsedBudgetUsd === undefined
|
|
841
|
+
? undefined
|
|
842
|
+
: Math.round(parsedBudgetUsd * 1_000_000) / 1_000_000;
|
|
843
|
+
if (budgetRaw &&
|
|
844
|
+
(!Number.isFinite(parsedBudgetUsd) || llmBudgetUsd <= 0 || llmBudgetUsd > 100_000)) {
|
|
845
|
+
throw new CliError("invalid_llm_budget", "templates create --llm-budget-usd must be at least 0.000001 and no more than 100000.");
|
|
846
|
+
}
|
|
847
|
+
let payload;
|
|
848
|
+
try {
|
|
849
|
+
payload = await client.createTemplate({
|
|
850
|
+
name,
|
|
851
|
+
provider_id: modelProvider,
|
|
852
|
+
model_id: model,
|
|
853
|
+
web_access: hasFlag(flags, "web-access"),
|
|
854
|
+
...(llmBudgetUsd === undefined ? {} : { llm_budget_usd: llmBudgetUsd })
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
catch (error) {
|
|
858
|
+
throw templateCreationError(error, client, modelProvider, model);
|
|
859
|
+
}
|
|
860
|
+
if (hasFlag(flags, "json")) {
|
|
861
|
+
printSuccess("templates.create", payload, jsonContext(client), [
|
|
862
|
+
commandAction(["sanbox", "templates", "validate", payload.template.id, "--json"], "Validate the new template before running it."),
|
|
863
|
+
commandAction(["sanbox", "run", "<task>", "--template", payload.template.id], "Create a run with the new template.")
|
|
864
|
+
]);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
process.stdout.write(`created ${payload.template.id} provider=${payload.template.provider_id || modelProvider} model=${payload.template.model_id || model}` +
|
|
868
|
+
`${payload.template.llm_budget_usd ? ` budget=$${payload.template.llm_budget_usd}/run` : ""}\n`);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
throw new CliError("templates_action_required", "templates requires an action: list, get, validate, or create.");
|
|
872
|
+
};
|
|
132
873
|
const commandRuns = async (command, flags) => {
|
|
133
874
|
const client = makeClient(flags);
|
|
134
875
|
const action = command[1];
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
876
|
+
if (!action)
|
|
877
|
+
throw new Error("runs command requires an action.");
|
|
878
|
+
if (action === "list") {
|
|
879
|
+
const limit = integerFlag(flags, "limit", 50, 1, 200);
|
|
880
|
+
const payload = await client.listRuns(limit);
|
|
881
|
+
if (hasFlag(flags, "json")) {
|
|
882
|
+
printSuccess("runs.list", { ...payload, runs: payload.runs.map(publicRun) }, jsonContext(client));
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
for (const run of payload.runs) {
|
|
886
|
+
process.stdout.write(`${run.id}\t${run.status}\t${run.created_at}\t${run.instruction.replace(/\s+/g, " ").slice(0, 100)}\n`);
|
|
887
|
+
}
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const runId = requiredPositional(command[2], "run_id_required", `runs ${action} requires a run id.`);
|
|
138
891
|
if (action === "get") {
|
|
139
892
|
const payload = await client.getRun(runId);
|
|
140
893
|
if (hasFlag(flags, "json"))
|
|
141
|
-
|
|
894
|
+
printSuccess("runs.get", publicRunPayload(payload), jsonContext(client));
|
|
142
895
|
else
|
|
143
896
|
printRun(payload);
|
|
144
897
|
return;
|
|
145
898
|
}
|
|
146
899
|
if (action === "events") {
|
|
147
|
-
const payload = await client.listEvents(runId,
|
|
900
|
+
const payload = await client.listEvents(runId, integerFlag(flags, "after-event-id", 0, 0, Number.MAX_SAFE_INTEGER));
|
|
148
901
|
if (hasFlag(flags, "json"))
|
|
149
|
-
|
|
902
|
+
printSuccess("runs.events", payload, jsonContext(client));
|
|
150
903
|
else
|
|
151
904
|
payload.events.forEach((event) => process.stdout.write(`${event.id} ${event.level} ${event.kind} ${event.message}\n`));
|
|
152
905
|
return;
|
|
153
906
|
}
|
|
907
|
+
if (action === "messages") {
|
|
908
|
+
const payload = await client.listMessages(runId);
|
|
909
|
+
if (hasFlag(flags, "json")) {
|
|
910
|
+
printSuccess("runs.messages", payload, jsonContext(client));
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
for (const message of payload.messages) {
|
|
914
|
+
process.stdout.write(`${message.role}\t${message.created_at}\t${message.message}\n`);
|
|
915
|
+
}
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (action === "artifacts") {
|
|
919
|
+
const payload = await client.listArtifacts(runId);
|
|
920
|
+
if (hasFlag(flags, "json")) {
|
|
921
|
+
printSuccess("runs.artifacts", payload, jsonContext(client));
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
for (const artifact of payload.artifacts) {
|
|
925
|
+
process.stdout.write(`${artifact.path}\t${artifact.size_bytes}\n`);
|
|
926
|
+
}
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (action === "download") {
|
|
930
|
+
const outputDirectory = requiredPositional(flagString(flags, "output"), "output_directory_required", "runs download requires --output.");
|
|
931
|
+
const payload = await client.listArtifacts(runId);
|
|
932
|
+
const requested = flagList(flags, "artifact");
|
|
933
|
+
const selected = requested.length === 0
|
|
934
|
+
? payload.artifacts
|
|
935
|
+
: requested.map((artifactPath) => {
|
|
936
|
+
const artifact = payload.artifacts.find((item) => item.path === artifactPath);
|
|
937
|
+
if (!artifact) {
|
|
938
|
+
throw new CliError("artifact_not_found", `Run ${runId} has no artifact named ${artifactPath}.`, {
|
|
939
|
+
details: { run_id: runId, artifact_path: artifactPath },
|
|
940
|
+
nextActions: [commandAction(["sanbox", "runs", "artifacts", runId, "--json"], "List exact artifact paths for this run.")]
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
return artifact;
|
|
944
|
+
});
|
|
945
|
+
let downloads;
|
|
946
|
+
try {
|
|
947
|
+
downloads = await downloadArtifacts(client, runId, selected, outputDirectory, hasFlag(flags, "overwrite"));
|
|
948
|
+
}
|
|
949
|
+
catch (error) {
|
|
950
|
+
if (error instanceof ArtifactExistsError) {
|
|
951
|
+
throw new CliError("artifact_exists", error.message, {
|
|
952
|
+
details: {
|
|
953
|
+
run_id: runId,
|
|
954
|
+
artifact_path: error.artifactPath,
|
|
955
|
+
local_path: error.destination
|
|
956
|
+
},
|
|
957
|
+
nextActions: [commandAction(["sanbox", "runs", "download", runId, "--output", "<new-output-directory>", "--json"], "Download into a new directory without replacing an existing file.")]
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
throw error;
|
|
961
|
+
}
|
|
962
|
+
const result = {
|
|
963
|
+
run_id: runId,
|
|
964
|
+
output_directory: path.resolve(outputDirectory),
|
|
965
|
+
downloads
|
|
966
|
+
};
|
|
967
|
+
if (hasFlag(flags, "json")) {
|
|
968
|
+
printSuccess("runs.download", result, jsonContext(client));
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
for (const download of downloads) {
|
|
972
|
+
process.stdout.write(`${download.path}\t${download.local_path}\t${download.size_bytes}\t${download.sha256}\n`);
|
|
973
|
+
}
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (action === "watch") {
|
|
977
|
+
validateWatchFlags({ ...flags, watch: true });
|
|
978
|
+
const payload = await watchRunWithOutput(client, runId, { ...flags, watch: true });
|
|
979
|
+
if (!payload)
|
|
980
|
+
return;
|
|
981
|
+
if (!hasFlag(flags, "jsonl"))
|
|
982
|
+
printRun(payload);
|
|
983
|
+
if (isTerminalRun(payload.run) && payload.run.status !== "completed")
|
|
984
|
+
process.exitCode = 2;
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
154
987
|
if (action === "cancel") {
|
|
155
988
|
const payload = await client.cancelRun(runId);
|
|
156
989
|
if (hasFlag(flags, "json"))
|
|
157
|
-
|
|
990
|
+
printSuccess("runs.cancel", publicRunPayload(payload), jsonContext(client));
|
|
158
991
|
else
|
|
159
992
|
printRun(payload);
|
|
160
993
|
return;
|
|
161
994
|
}
|
|
162
995
|
if (action === "message") {
|
|
163
|
-
const
|
|
996
|
+
const flaggedMessage = flagString(flags, "message");
|
|
997
|
+
const positionalMessage = positionalText(command, 3);
|
|
998
|
+
if (flaggedMessage && positionalMessage) {
|
|
999
|
+
throw new CliError("conflicting_arguments", "Use either --message or a positional message, not both.");
|
|
1000
|
+
}
|
|
1001
|
+
const message = flaggedMessage || positionalMessage;
|
|
164
1002
|
if (!message)
|
|
165
|
-
throw new Error("--message is required.");
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
1003
|
+
throw new Error("--message or a positional message is required.");
|
|
1004
|
+
if (wantsWatch(flags) && hasFlag(flags, "json")) {
|
|
1005
|
+
throw new Error("--json cannot be combined with --watch or --jsonl.");
|
|
1006
|
+
}
|
|
1007
|
+
if (hasFlag(flags, "wait") && wantsWatch(flags)) {
|
|
1008
|
+
throw new Error("--wait cannot be combined with --watch or --jsonl.");
|
|
1009
|
+
}
|
|
1010
|
+
const submitted = await client.sendMessage(runId, message);
|
|
1011
|
+
if (!hasFlag(flags, "wait") && !wantsWatch(flags)) {
|
|
1012
|
+
if (hasFlag(flags, "json")) {
|
|
1013
|
+
printSuccess("runs.message", {
|
|
1014
|
+
...publicRunPayload(submitted),
|
|
1015
|
+
message: submitted.message,
|
|
1016
|
+
chat_job: submitted.chat_job
|
|
1017
|
+
}, jsonContext(client));
|
|
1018
|
+
}
|
|
1019
|
+
else
|
|
1020
|
+
process.stdout.write(`queued follow-up for run ${runId}\n`);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
const chatJobId = submitted.chat_job.id;
|
|
1024
|
+
const belongsToFollowup = (event) => event.payload.chat_job_id === chatJobId;
|
|
1025
|
+
const queuedEventId = submitted.events.reduce((latest, event) => event.kind === "chat.queued" && belongsToFollowup(event) ? Math.max(latest, event.id) : latest, 0);
|
|
1026
|
+
const receivedEventId = [...submitted.events]
|
|
1027
|
+
.reverse()
|
|
1028
|
+
.find((event) => event.kind === "message.received" && belongsToFollowup(event))?.id ?? queuedEventId;
|
|
1029
|
+
const cursor = submitted.events.reduce((latest, event) => Math.max(latest, event.id), 0);
|
|
1030
|
+
const completedInResponse = [...submitted.events]
|
|
1031
|
+
.reverse()
|
|
1032
|
+
.find((event) => (event.kind === "chat.completed" || event.kind === "chat.failed") && belongsToFollowup(event));
|
|
1033
|
+
const view = parseActivityView(flagString(flags, "view", "activity"));
|
|
1034
|
+
const jsonl = hasFlag(flags, "jsonl");
|
|
1035
|
+
const controller = new AbortController();
|
|
1036
|
+
const onInterrupt = () => controller.abort();
|
|
1037
|
+
process.once("SIGINT", onInterrupt);
|
|
1038
|
+
if (wantsWatch(flags) && !jsonl)
|
|
1039
|
+
process.stdout.write(`Watching follow-up for run ${runId}. Ctrl-C detaches.\n`);
|
|
1040
|
+
try {
|
|
1041
|
+
const renderFollowupEvent = (event) => {
|
|
1042
|
+
if (!belongsToFollowup(event))
|
|
1043
|
+
return;
|
|
1044
|
+
if (!wantsWatch(flags) || !shouldRenderEvent(event, view))
|
|
1045
|
+
return;
|
|
1046
|
+
process.stdout.write(`${jsonl ? formatActivityJsonl(event) : formatActivityLine(event, submitted.run.created_at)}\n`);
|
|
1047
|
+
};
|
|
1048
|
+
for (const event of submitted.events) {
|
|
1049
|
+
if (event.id >= receivedEventId)
|
|
1050
|
+
renderFollowupEvent(event);
|
|
1051
|
+
}
|
|
1052
|
+
const terminalEvent = completedInResponse ?? await watchEventsUntil(client, runId, {
|
|
1053
|
+
afterEventId: cursor,
|
|
1054
|
+
pageSize: integerFlag(flags, "event-page-size", 200, 1, 500),
|
|
1055
|
+
pollIntervalMs: integerFlag(flags, "poll-interval-ms", 2000, 250, 60_000),
|
|
1056
|
+
timeoutSeconds: integerFlag(flags, "timeout-seconds", 1800, 1, 604_800),
|
|
1057
|
+
signal: controller.signal,
|
|
1058
|
+
stopWhen: (event) => (event.kind === "chat.completed" || event.kind === "chat.failed") && belongsToFollowup(event),
|
|
1059
|
+
onRetry: ({ error, attempt, delayMs }) => {
|
|
1060
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1061
|
+
process.stderr.write(`Follow-up connection lost (${detail}); retry ${attempt} in ${delayMs}ms.\n`);
|
|
1062
|
+
},
|
|
1063
|
+
onEvent: renderFollowupEvent
|
|
1064
|
+
});
|
|
1065
|
+
const messages = await client.listMessages(runId);
|
|
1066
|
+
if (hasFlag(flags, "json")) {
|
|
1067
|
+
printSuccess("runs.message", {
|
|
1068
|
+
...publicRunPayload(submitted),
|
|
1069
|
+
message: submitted.message,
|
|
1070
|
+
chat_job: submitted.chat_job,
|
|
1071
|
+
messages: messages.messages,
|
|
1072
|
+
followup_event: terminalEvent
|
|
1073
|
+
}, jsonContext(client));
|
|
1074
|
+
}
|
|
1075
|
+
else if (!jsonl) {
|
|
1076
|
+
const assistant = [...messages.messages].reverse().find((item) => item.role === "assistant" && item.payload.chat_job_id === chatJobId);
|
|
1077
|
+
if (assistant)
|
|
1078
|
+
process.stdout.write(`${assistant.message}\n`);
|
|
1079
|
+
}
|
|
1080
|
+
if (terminalEvent.kind === "chat.failed")
|
|
1081
|
+
process.exitCode = 2;
|
|
1082
|
+
}
|
|
1083
|
+
catch (error) {
|
|
1084
|
+
if (!(error instanceof WatchInterruptedError))
|
|
1085
|
+
throw error;
|
|
1086
|
+
process.stderr.write(`Detached from follow-up for run ${runId}; processing continues.\n`);
|
|
1087
|
+
process.exitCode = 130;
|
|
1088
|
+
}
|
|
1089
|
+
finally {
|
|
1090
|
+
process.off("SIGINT", onInterrupt);
|
|
1091
|
+
}
|
|
171
1092
|
return;
|
|
172
1093
|
}
|
|
173
1094
|
throw new Error(`Unknown runs action: ${action}`);
|
|
174
1095
|
};
|
|
1096
|
+
const commandDoctor = async (flags) => {
|
|
1097
|
+
const localConfig = readLocalConfig();
|
|
1098
|
+
const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
|
|
1099
|
+
const org = String(flags.org || process.env.SANBOX_ORG || localConfig.org || "").trim();
|
|
1100
|
+
const apiKey = process.env.SANBOX_API_KEY || "";
|
|
1101
|
+
const selection = readTemplateSelection(flags, { required: false });
|
|
1102
|
+
const checks = [];
|
|
1103
|
+
const nextActions = [];
|
|
1104
|
+
const add = (name, ok, detail, data) => checks.push({
|
|
1105
|
+
name,
|
|
1106
|
+
ok,
|
|
1107
|
+
detail,
|
|
1108
|
+
...(data === undefined ? {} : { data })
|
|
1109
|
+
});
|
|
1110
|
+
add("node", Number(process.versions.node.split(".")[0]) >= 20, process.version);
|
|
1111
|
+
add("api_url", Boolean(apiUrl), apiUrl);
|
|
1112
|
+
add("org", Boolean(org), org || "missing SANBOX_ORG, --org, or .sanbox/config.json org");
|
|
1113
|
+
add("api_key", Boolean(apiKey), apiKey ? "present in environment" : "missing SANBOX_API_KEY");
|
|
1114
|
+
if (!org) {
|
|
1115
|
+
nextActions.push(commandAction(["sanbox", "doctor", "--json"], "Select an organization and run the checks again.", { SANBOX_ORG: "<org-slug>" }));
|
|
1116
|
+
}
|
|
1117
|
+
if (!apiKey) {
|
|
1118
|
+
nextActions.push(commandAction(["sanbox", "doctor", "--json"], "Set a Sanbox control-plane API key and run the checks again.", { SANBOX_API_KEY: "<sanbox-api-key>" }));
|
|
1119
|
+
}
|
|
1120
|
+
try {
|
|
1121
|
+
const res = await fetch(`${apiUrl}/health`);
|
|
1122
|
+
add("api_health", res.ok, `${res.status} ${res.statusText}`);
|
|
1123
|
+
}
|
|
1124
|
+
catch (error) {
|
|
1125
|
+
add("api_health", false, error instanceof Error ? error.message : String(error));
|
|
1126
|
+
}
|
|
1127
|
+
if (org && apiKey) {
|
|
1128
|
+
const client = new SanboxClient({ apiUrl, org, apiKey });
|
|
1129
|
+
try {
|
|
1130
|
+
const me = await client.me();
|
|
1131
|
+
const organizations = Array.isArray(me.organizations)
|
|
1132
|
+
? me.organizations
|
|
1133
|
+
: [];
|
|
1134
|
+
add("auth", true, "authenticated");
|
|
1135
|
+
add("org_visible", organizations.some((item) => item.slug === org), org);
|
|
1136
|
+
}
|
|
1137
|
+
catch (error) {
|
|
1138
|
+
add("auth", false, error instanceof Error ? error.message : String(error));
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
const providers = await client.listModelProviders();
|
|
1142
|
+
add("model_providers", true, providers.providers.map((item) => `${providerId(item)}:${item.status || (item.configured ? "configured" : "not_configured")}`).join(", ") || "none", providers.providers);
|
|
1143
|
+
}
|
|
1144
|
+
catch (error) {
|
|
1145
|
+
add("model_providers", false, error instanceof Error ? error.message : String(error));
|
|
1146
|
+
}
|
|
1147
|
+
if (!selection) {
|
|
1148
|
+
add("template", false, "not selected; use --template, SANBOX_TEMPLATE, or .sanbox/config.json default_template");
|
|
1149
|
+
nextActions.push(...templateActions());
|
|
1150
|
+
}
|
|
1151
|
+
else {
|
|
1152
|
+
try {
|
|
1153
|
+
const template = await client.getTemplate(selection.id);
|
|
1154
|
+
add("template", true, `${template.template.id}; source=${selection.source}`, template.template);
|
|
1155
|
+
try {
|
|
1156
|
+
const validation = await client.validateTemplate(selection.id);
|
|
1157
|
+
const runnable = validationRunnable(validation);
|
|
1158
|
+
add("template_readiness", runnable, runnable ? "ready" : "blocked", validation);
|
|
1159
|
+
if (!runnable) {
|
|
1160
|
+
nextActions.push(commandAction(["sanbox", "model-providers", "list", "--json"], "Inspect model-provider status."), providerConsoleAction(client));
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
catch (error) {
|
|
1164
|
+
add("template_readiness", false, error instanceof Error ? error.message : String(error));
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
catch (error) {
|
|
1168
|
+
add("template", false, error instanceof Error ? error.message : String(error));
|
|
1169
|
+
nextActions.push(...templateActions());
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
try {
|
|
1174
|
+
const preview = await previewInputs({ cwd: cwd(), inputs: ["README.md"] });
|
|
1175
|
+
add("input_preview", true, `${preview.files.length} file(s), ${formatBytes(preview.totalBytes)}`);
|
|
1176
|
+
}
|
|
1177
|
+
catch (error) {
|
|
1178
|
+
add("input_preview", false, error instanceof Error ? error.message : String(error));
|
|
1179
|
+
}
|
|
1180
|
+
const output = {
|
|
1181
|
+
ok: checks.every((check) => check.ok),
|
|
1182
|
+
template_selection: selection,
|
|
1183
|
+
checks
|
|
1184
|
+
};
|
|
1185
|
+
if (hasFlag(flags, "json")) {
|
|
1186
|
+
printSuccess("doctor", output, { api_url: apiUrl, ...(org ? { org } : {}) }, nextActions);
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
for (const check of checks) {
|
|
1190
|
+
process.stdout.write(`${check.ok ? "ok" : "fail"} ${check.name}: ${check.detail}\n`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
if (!output.ok)
|
|
1194
|
+
process.exitCode = 2;
|
|
1195
|
+
};
|
|
175
1196
|
const commandInit = async (command, flags) => {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
1197
|
+
const dir = path.join(cwd(), ".sanbox");
|
|
1198
|
+
if (command[1] === "agent") {
|
|
1199
|
+
if (hasFlag(flags, "write")) {
|
|
1200
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1201
|
+
await fs.writeFile(path.join(dir, "agent.md"), agentInstructions, "utf8");
|
|
1202
|
+
if (hasFlag(flags, "json")) {
|
|
1203
|
+
printSuccess("init.agent", { path: ".sanbox/agent.md", status: "written" }, localJsonContext(flags));
|
|
1204
|
+
}
|
|
1205
|
+
else {
|
|
1206
|
+
process.stdout.write(".sanbox/agent.md written\n");
|
|
1207
|
+
}
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
if (hasFlag(flags, "json")) {
|
|
1211
|
+
printSuccess("init.agent", { instructions: agentInstructions }, localJsonContext(flags));
|
|
1212
|
+
}
|
|
1213
|
+
else {
|
|
1214
|
+
process.stdout.write(agentInstructions);
|
|
1215
|
+
}
|
|
183
1216
|
return;
|
|
184
1217
|
}
|
|
185
|
-
|
|
1218
|
+
if (command[1])
|
|
1219
|
+
throw new Error("init supports no subcommand or `agent`.");
|
|
1220
|
+
const force = hasFlag(flags, "force");
|
|
1221
|
+
const localConfig = readLocalConfig();
|
|
1222
|
+
const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
|
|
1223
|
+
const org = String(flags.org || process.env.SANBOX_ORG || localConfig.org || "").trim();
|
|
1224
|
+
const template = readTemplateSelection(flags, { required: false });
|
|
1225
|
+
const config = {
|
|
1226
|
+
api_url: apiUrl,
|
|
1227
|
+
org,
|
|
1228
|
+
...(template ? { default_template: template.id } : {})
|
|
1229
|
+
};
|
|
1230
|
+
const sanboxIgnore = [
|
|
1231
|
+
"# Project-specific Sanbox excludes. Defaults already exclude .git, node_modules, build output, env files, and secret-like names.",
|
|
1232
|
+
"",
|
|
1233
|
+
"# examples:",
|
|
1234
|
+
"# data/private/**",
|
|
1235
|
+
"# tmp/**",
|
|
1236
|
+
""
|
|
1237
|
+
].join("\n");
|
|
1238
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1239
|
+
const results = [
|
|
1240
|
+
[".sanbox/config.json", await writeIfNeeded(path.join(dir, "config.json"), `${JSON.stringify(config, null, 2)}\n`, force)],
|
|
1241
|
+
[".sanbox/agent.md", await writeIfNeeded(path.join(dir, "agent.md"), agentInstructions, force)],
|
|
1242
|
+
[".sanboxignore", await writeIfNeeded(path.join(cwd(), ".sanboxignore"), sanboxIgnore, force)]
|
|
1243
|
+
];
|
|
1244
|
+
if (hasFlag(flags, "json")) {
|
|
1245
|
+
printSuccess("init", {
|
|
1246
|
+
files: results.map(([file, status]) => ({ file, status })),
|
|
1247
|
+
template_selection: template
|
|
1248
|
+
}, { api_url: apiUrl, ...(org ? { org } : {}) }, template ? [] : templateActions());
|
|
1249
|
+
}
|
|
1250
|
+
else {
|
|
1251
|
+
for (const [file, status] of results)
|
|
1252
|
+
process.stdout.write(`${status} ${file}\n`);
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
const helpFor = (command) => {
|
|
1256
|
+
if (command[0] === "run")
|
|
1257
|
+
return runHelp;
|
|
1258
|
+
if (command[0] === "doctor")
|
|
1259
|
+
return doctorHelp;
|
|
1260
|
+
if (command[0] === "model-providers")
|
|
1261
|
+
return modelProvidersHelp;
|
|
1262
|
+
if (command[0] === "templates")
|
|
1263
|
+
return templatesHelp;
|
|
1264
|
+
return help;
|
|
186
1265
|
};
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
1266
|
+
const commandId = (command) => {
|
|
1267
|
+
if (command[0] === "version" || command.length === 0)
|
|
1268
|
+
return "version";
|
|
1269
|
+
if (command[0] === "auth")
|
|
1270
|
+
return "auth.check";
|
|
1271
|
+
if (command[0] === "orgs")
|
|
1272
|
+
return `orgs.${command[1] || "unknown"}`;
|
|
1273
|
+
if (command[0] === "context")
|
|
1274
|
+
return "context.get";
|
|
1275
|
+
if (command[0] === "model-providers")
|
|
1276
|
+
return `model_providers.${command[1] || "unknown"}`;
|
|
1277
|
+
if (command[0] === "templates")
|
|
1278
|
+
return `templates.${command[1] || "unknown"}`;
|
|
1279
|
+
if (command[0] === "run")
|
|
1280
|
+
return "run.create";
|
|
1281
|
+
if (command[0] === "batch")
|
|
1282
|
+
return "batch.create";
|
|
1283
|
+
if (command[0] === "runs")
|
|
1284
|
+
return `runs.${command[1] || "unknown"}`;
|
|
1285
|
+
return command[0] || "help";
|
|
1286
|
+
};
|
|
1287
|
+
const main = async (command, flags) => {
|
|
1288
|
+
validateFlagValues(flags);
|
|
1289
|
+
normalizeInputAlias(command, flags);
|
|
1290
|
+
validateFlags(command, flags);
|
|
1291
|
+
validatePositionals(command, flags);
|
|
1292
|
+
if (command[0] === "version" || (command.length === 0 && hasFlag(flags, "version"))) {
|
|
1293
|
+
if (hasFlag(flags, "json"))
|
|
1294
|
+
printSuccess("version", { version });
|
|
1295
|
+
else
|
|
1296
|
+
process.stdout.write(`${version}\n`);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
if (command.length === 0) {
|
|
190
1300
|
process.stdout.write(help);
|
|
191
1301
|
return;
|
|
192
1302
|
}
|
|
1303
|
+
if (hasFlag(flags, "help")) {
|
|
1304
|
+
process.stdout.write(helpFor(command));
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
193
1307
|
if (command[0] === "auth" && command[1] === "check")
|
|
194
1308
|
return commandAuthCheck(flags);
|
|
1309
|
+
if (command[0] === "orgs")
|
|
1310
|
+
return commandOrganizations(command, flags);
|
|
1311
|
+
if (command[0] === "context")
|
|
1312
|
+
return commandContext(flags);
|
|
1313
|
+
if (command[0] === "doctor")
|
|
1314
|
+
return commandDoctor(flags);
|
|
1315
|
+
if (command[0] === "model-providers")
|
|
1316
|
+
return commandModelProviders(command, flags);
|
|
1317
|
+
if (command[0] === "templates")
|
|
1318
|
+
return commandTemplates(command, flags);
|
|
195
1319
|
if (command[0] === "run")
|
|
196
|
-
return commandRun(flags);
|
|
1320
|
+
return commandRun(command, flags);
|
|
197
1321
|
if (command[0] === "batch")
|
|
198
1322
|
return commandBatch(flags);
|
|
199
1323
|
if (command[0] === "runs")
|
|
200
1324
|
return commandRuns(command, flags);
|
|
201
1325
|
if (command[0] === "init")
|
|
202
1326
|
return commandInit(command, flags);
|
|
203
|
-
throw new
|
|
1327
|
+
throw new CliError("unknown_command", `Unknown command: ${command.join(" ")}`);
|
|
204
1328
|
};
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
1329
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
1330
|
+
main(parsed.command, parsed.flags).catch((error) => {
|
|
1331
|
+
if (hasFlag(parsed.flags, "jsonl")) {
|
|
1332
|
+
printJsonlError(commandId(parsed.command), error, localJsonContext(parsed.flags));
|
|
1333
|
+
}
|
|
1334
|
+
else if (hasFlag(parsed.flags, "json")) {
|
|
1335
|
+
printError(commandId(parsed.command), error, localJsonContext(parsed.flags));
|
|
1336
|
+
}
|
|
1337
|
+
else {
|
|
1338
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
1339
|
+
}
|
|
1340
|
+
process.exitCode = error instanceof CliError ? error.exitCode : 1;
|
|
208
1341
|
});
|