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