@kheleshikhi/studio-agent 0.6.1
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 +225 -0
- package/dist/attempt.d.ts +25 -0
- package/dist/attempt.js +197 -0
- package/dist/attempt.js.map +1 -0
- package/dist/browser.d.ts +102 -0
- package/dist/browser.js +837 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +1198 -0
- package/dist/cli.js.map +1 -0
- package/dist/daemon.d.ts +17 -0
- package/dist/daemon.js +208 -0
- package/dist/daemon.js.map +1 -0
- package/dist/library.d.ts +25 -0
- package/dist/library.js +120 -0
- package/dist/library.js.map +1 -0
- package/dist/observation.d.ts +31 -0
- package/dist/observation.js +66 -0
- package/dist/observation.js.map +1 -0
- package/dist/protocol.d.ts +20 -0
- package/dist/protocol.js +99 -0
- package/dist/protocol.js.map +1 -0
- package/dist/scenario.d.ts +19 -0
- package/dist/scenario.js +90 -0
- package/dist/scenario.js.map +1 -0
- package/dist/state.d.ts +13 -0
- package/dist/state.js +111 -0
- package/dist/state.js.map +1 -0
- package/dist/types.d.ts +64 -0
- package/dist/types.js +20 -0
- package/dist/types.js.map +1 -0
- package/dist/workspace.d.ts +15 -0
- package/dist/workspace.js +66 -0
- package/dist/workspace.js.map +1 -0
- package/dist/zip.d.ts +7 -0
- package/dist/zip.js +76 -0
- package/dist/zip.js.map +1 -0
- package/package.json +36 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { access, appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { chromium } from "playwright";
|
|
11
|
+
import { browserProbeLaunchOptions, VIEW_QUERY } from "./browser.js";
|
|
12
|
+
import { attemptDirectory, createAttemptPointer, finalizeClient, readAttemptPointer, saveCommandEvidence } from "./attempt.js";
|
|
13
|
+
import { runDaemon } from "./daemon.js";
|
|
14
|
+
import { audioUploadExtension, isLibraryKind, LIBRARY_STUDIO_WORKSPACE, libraryLockAfterSave, LibraryWorkspace, } from "./library.js";
|
|
15
|
+
import { daemonRpc, setCliTrace } from "./protocol.js";
|
|
16
|
+
import { cleanStaleDaemonMetadata, readConfig, readDaemonMetadata, stateDirectory, validateOrigin, writeConfig, } from "./state.js";
|
|
17
|
+
import { CliError } from "./types.js";
|
|
18
|
+
import { sourceHash, StudioWorkspace } from "./workspace.js";
|
|
19
|
+
import { parseScenario } from "./scenario.js";
|
|
20
|
+
const VERSION = "0.6.1";
|
|
21
|
+
const AGENT_PROTOCOL = 1;
|
|
22
|
+
const INSTRUCTIONS = `# NerdBro Interactive Studio agent\n\nUse the provider-neutral \`nerdbro\` CLI. Before substantial authoring: read \`nerdbro context\`, fetch the live \`nerdbro guide\`, then search only relevant docs, exact contracts, Components, Behaviors and examples. Do not read the entire catalogue blindly.\n\nFor complex work, prove risky capabilities in a small spike first. Checkout once, edit only supported source, validate, compile, drive the learner scenario where supported, inspect visuals, save, certify, then stop for human approval. Never force a conflict, approve, publish, administer or deploy. Disconnect when work is over.\n\nIf this is a Git-backed workspace, inspect recent history first. Work in coherent batches; after each, run focused checks, inspect the diff, and commit the green checkpoint with intent and verification. Never force-push or rewrite another Agent's work.\n\nExperience is final learner content: it contains one or more Scenes, and each Scene owns local interaction and Bands. Read \`nerdbro schema experience\`, \`scene.stateMachines\`, and \`stateMachine.actions\` for current contracts. Library definitions (experience, audio, component, behavior) use \`create\` or \`checkout\`, edit \`source.json\`, then \`status\`, \`save\`, \`validate\` and \`certify\`. Audio must be AAC in MP4/M4A.\n\nCurrent live playbook: /docs/studio-language/agent-authoring-playbook\n`;
|
|
23
|
+
// Presence-only options never consume the following positional argument.
|
|
24
|
+
const BOOLEAN_FLAGS = new Set(["force", "full-page", "headless", "no-browser", "declarative", "assessment", "no-assessment", "scratch"]);
|
|
25
|
+
export function parse(argv) {
|
|
26
|
+
const args = [];
|
|
27
|
+
const flags = new Map();
|
|
28
|
+
let json = false;
|
|
29
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
30
|
+
const value = argv[index];
|
|
31
|
+
if (value === "--json") {
|
|
32
|
+
json = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (value === "--version") {
|
|
36
|
+
args.push(value);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (value.startsWith("--")) {
|
|
40
|
+
const name = value.slice(2);
|
|
41
|
+
if (BOOLEAN_FLAGS.has(name)) {
|
|
42
|
+
flags.set(name, true);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const next = argv[index + 1];
|
|
46
|
+
if (next && !next.startsWith("--")) {
|
|
47
|
+
flags.set(name, next);
|
|
48
|
+
index += 1;
|
|
49
|
+
}
|
|
50
|
+
else
|
|
51
|
+
flags.set(name, true);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
args.push(value);
|
|
55
|
+
}
|
|
56
|
+
return { args, json, flags };
|
|
57
|
+
}
|
|
58
|
+
function flag(parsed, name, fallback) {
|
|
59
|
+
const value = parsed.flags.get(name);
|
|
60
|
+
return typeof value === "string" ? value : fallback;
|
|
61
|
+
}
|
|
62
|
+
const BRIEF_FLAGS = ["concept", "teaching-purpose", "learner-action", "expected-visual-behavior", "graph-purpose", "graph-x", "graph-y", "graph-sync", "language", "additional-instruction", "components", "behavior", "important-values", "assessment", "no-assessment"];
|
|
63
|
+
const BROWSER_FLAGS = ["view", "viewport", "timeout"];
|
|
64
|
+
export function assertSupportedFlags(parsed) {
|
|
65
|
+
const [command, action, detail] = parsed.args;
|
|
66
|
+
let allowed = [];
|
|
67
|
+
if (command === "_daemon")
|
|
68
|
+
allowed = ["origin", "headless", "no-browser", "version-tag"];
|
|
69
|
+
else if (command === "attempt" && action === "start")
|
|
70
|
+
allowed = ["label", "mode"];
|
|
71
|
+
else if (command === "attempt" && action === "finish")
|
|
72
|
+
allowed = ["outcome"];
|
|
73
|
+
else if (command === "agent" && action === "init")
|
|
74
|
+
allowed = ["provider"];
|
|
75
|
+
else if (["component", "behavior", "example"].includes(command) && action === "search")
|
|
76
|
+
allowed = ["limit", "offset"];
|
|
77
|
+
else if (isLibraryKind(command)) {
|
|
78
|
+
if (action === "create")
|
|
79
|
+
allowed = ["workspace", "name", "category", "visual", "palette", "implementation", "media", "language", "description", "kind", "master-audio", "declarative", "duration-ms", "scene-asset", "scene-package", "scene", "document"];
|
|
80
|
+
else if (action === "checkout")
|
|
81
|
+
allowed = ["workspace", "force"];
|
|
82
|
+
else if (action === "export")
|
|
83
|
+
allowed = ["workspace", "revision", "out"];
|
|
84
|
+
else if (action === "inspect-browser")
|
|
85
|
+
allowed = ["workspace", "viewport", "timeout"];
|
|
86
|
+
else if (["status", "save", "validate", "certify", "preview"].includes(action))
|
|
87
|
+
allowed = ["workspace"];
|
|
88
|
+
else if (action === "search")
|
|
89
|
+
allowed = ["limit"];
|
|
90
|
+
}
|
|
91
|
+
else if (command === "studio") {
|
|
92
|
+
if (action === "create")
|
|
93
|
+
allowed = ["title", "target", ...BRIEF_FLAGS.filter((name) => name !== "no-assessment"), "command-id", "scratch"];
|
|
94
|
+
else if (action === "brief" && detail === "update")
|
|
95
|
+
allowed = BRIEF_FLAGS;
|
|
96
|
+
else if (action === "checkout")
|
|
97
|
+
allowed = ["workspace", "force"];
|
|
98
|
+
else if (["screenshot", "inspect", "observe"].includes(action))
|
|
99
|
+
allowed = ["workspace", ...BROWSER_FLAGS, "full-page", "out"];
|
|
100
|
+
else if (action === "open")
|
|
101
|
+
allowed = ["workspace", "view"];
|
|
102
|
+
else if (action === "drive")
|
|
103
|
+
allowed = ["workspace", "scenario", "viewport", "timeout", "out", "view"];
|
|
104
|
+
else if (action === "capture-preview")
|
|
105
|
+
allowed = ["workspace", "out", "viewport", "mode", "duration", "fps", "timeout"];
|
|
106
|
+
else if (action === "certify")
|
|
107
|
+
allowed = ["workspace", ...BROWSER_FLAGS];
|
|
108
|
+
else if (action === "export")
|
|
109
|
+
allowed = ["workspace", "revision", "out"];
|
|
110
|
+
else
|
|
111
|
+
allowed = ["workspace"];
|
|
112
|
+
}
|
|
113
|
+
const unknown = [...parsed.flags.keys()].filter((name) => !allowed.includes(name));
|
|
114
|
+
if (unknown.length) {
|
|
115
|
+
throw new CliError(`Unknown flag --${unknown[0]} for ${command || "this command"}${action ? ` ${action}` : ""}. Valid flags: ${allowed.length ? allowed.map((name) => `--${name}`).join(", ") : "--json"}.`, "CLI_UNKNOWN_FLAG", 2);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function required(value, message) {
|
|
119
|
+
if (!value)
|
|
120
|
+
throw new CliError(message, "AGENT_ARGUMENT_REQUIRED", 2);
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
function output(value, asJson) {
|
|
124
|
+
if (asJson)
|
|
125
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
126
|
+
else if (typeof value === "string")
|
|
127
|
+
process.stdout.write(`${value}\n`);
|
|
128
|
+
else
|
|
129
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
130
|
+
}
|
|
131
|
+
function versionParts(value) {
|
|
132
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value);
|
|
133
|
+
if (!match)
|
|
134
|
+
throw new CliError(`Server returned an invalid CLI version: ${value}`, "AGENT_VERSION_INVALID", 5);
|
|
135
|
+
return match.slice(1).map(Number);
|
|
136
|
+
}
|
|
137
|
+
function compareVersions(left, right) {
|
|
138
|
+
const a = versionParts(left);
|
|
139
|
+
const b = versionParts(right);
|
|
140
|
+
for (let index = 0; index < 3; index += 1) {
|
|
141
|
+
if (a[index] !== b[index])
|
|
142
|
+
return a[index] - b[index];
|
|
143
|
+
}
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
export function assertCompatibility(context) {
|
|
147
|
+
if (Number(context.agent_protocol) !== AGENT_PROTOCOL) {
|
|
148
|
+
throw new CliError(`Agent protocol ${context.agent_protocol} is incompatible with CLI protocol ${AGENT_PROTOCOL}. Upgrade the NerdBro Studio Agent.`, "AGENT_PROTOCOL_INCOMPATIBLE", 5, context);
|
|
149
|
+
}
|
|
150
|
+
const minimum = String(context.minimum_cli_version || "");
|
|
151
|
+
if (compareVersions(VERSION, minimum) < 0) {
|
|
152
|
+
throw new CliError(`CLI ${VERSION} is too old; NerdBro requires ${minimum} or newer.`, "AGENT_CLI_UPGRADE_REQUIRED", 5, context);
|
|
153
|
+
}
|
|
154
|
+
const recommended = String(context.recommended_cli_version || minimum);
|
|
155
|
+
if (compareVersions(VERSION, recommended) < 0) {
|
|
156
|
+
process.stderr.write(`nerdbro: CLI ${recommended} is recommended (current ${VERSION}).\n`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
export function certificationRuntimeObservation(observation) {
|
|
160
|
+
const allowed = [
|
|
161
|
+
"mounted", "runtime_ready", "geometry_instance_ids", "source_hash",
|
|
162
|
+
"runtime_fingerprint", "runtime_part_id", "active_states",
|
|
163
|
+
"state_machine_error", "math_graph", "environment", "error_summary",
|
|
164
|
+
];
|
|
165
|
+
return Object.fromEntries(allowed.filter((key) => observation[key] !== undefined).map((key) => [key, observation[key]]));
|
|
166
|
+
}
|
|
167
|
+
export function structuredErrorDetails(value) {
|
|
168
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
return value == null ? {} : { server_detail: value };
|
|
172
|
+
}
|
|
173
|
+
export function studioLockAfterSave(lock, saved, hash, checkoutTime = new Date().toISOString()) {
|
|
174
|
+
return {
|
|
175
|
+
...lock,
|
|
176
|
+
base_snapshot_public_id: String(saved.public_id || lock.base_snapshot_public_id || "") || null,
|
|
177
|
+
checkout_source_hash: hash,
|
|
178
|
+
source_hash: hash,
|
|
179
|
+
checkout_time: checkoutTime,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
export function reconciledStudioLock(lock, remote, hash) {
|
|
183
|
+
const head = remote.head_snapshot;
|
|
184
|
+
if (remote.source_hash !== hash || !head?.public_id)
|
|
185
|
+
return null;
|
|
186
|
+
return studioLockAfterSave(lock, head, hash);
|
|
187
|
+
}
|
|
188
|
+
/** A session paired before a scope existed must re-pair; say so plainly. */
|
|
189
|
+
export function scopeAwareError(error) {
|
|
190
|
+
if (!(error instanceof CliError) || error.code !== "AGENT_SCOPE_REQUIRED")
|
|
191
|
+
return error;
|
|
192
|
+
const scope = /AGENT_SCOPE_REQUIRED:([A-Z0-9_]+)/.exec(error.message)?.[1] || "required";
|
|
193
|
+
return new CliError(`This Agent session was not granted the ${scope} scope (it was probably paired by an older CLI). Run \`nerdbro disconnect\` and then \`nerdbro connect\` to pair again.`, "AGENT_SCOPE_REQUIRED", 4, { required_scope: scope, reconnect_required: true });
|
|
194
|
+
}
|
|
195
|
+
async function rawApi(method, route, body) {
|
|
196
|
+
try {
|
|
197
|
+
await ensureDaemon();
|
|
198
|
+
const response = await daemonRpc("/api", { method: "POST", body: JSON.stringify({ method, path: route, body }) });
|
|
199
|
+
return response.result;
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
throw scopeAwareError(error);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function rawBinaryApi(method, route, code = "STUDIO_EXPORT_INVALID") {
|
|
206
|
+
await ensureDaemon();
|
|
207
|
+
let response;
|
|
208
|
+
try {
|
|
209
|
+
response = await daemonRpc("/api", {
|
|
210
|
+
method: "POST",
|
|
211
|
+
body: JSON.stringify({ method, path: route }),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
throw scopeAwareError(error);
|
|
216
|
+
}
|
|
217
|
+
if (!response.binaryBase64)
|
|
218
|
+
throw new CliError("Server did not return package bytes.", code);
|
|
219
|
+
return new Uint8Array(Buffer.from(response.binaryBase64, "base64"));
|
|
220
|
+
}
|
|
221
|
+
async function uploadApi(route, extension, contentType, bytes) {
|
|
222
|
+
await ensureDaemon();
|
|
223
|
+
if (!compatibilityChecked)
|
|
224
|
+
await ensureCompatibility();
|
|
225
|
+
try {
|
|
226
|
+
// Only the bytes and a bare extension cross the loopback RPC; the daemon
|
|
227
|
+
// names the multipart part `upload.<extension>`.
|
|
228
|
+
const response = await daemonRpc("/api-upload", {
|
|
229
|
+
method: "POST",
|
|
230
|
+
body: JSON.stringify({ path: route, field: "file", extension, contentType, dataBase64: Buffer.from(bytes).toString("base64") }),
|
|
231
|
+
});
|
|
232
|
+
return response.result;
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
throw scopeAwareError(error);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
let compatibilityChecked = false;
|
|
239
|
+
async function ensureCompatibility() {
|
|
240
|
+
const context = await rawApi("GET", "/v1/agent/context");
|
|
241
|
+
assertCompatibility(context);
|
|
242
|
+
compatibilityChecked = true;
|
|
243
|
+
return context;
|
|
244
|
+
}
|
|
245
|
+
async function api(method, route, body) {
|
|
246
|
+
if (!compatibilityChecked && route !== "/v1/agent/context")
|
|
247
|
+
await ensureCompatibility();
|
|
248
|
+
const result = await rawApi(method, route, body);
|
|
249
|
+
if (route === "/v1/agent/context") {
|
|
250
|
+
assertCompatibility(result);
|
|
251
|
+
compatibilityChecked = true;
|
|
252
|
+
}
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
async function waitForDaemon() {
|
|
256
|
+
const deadline = Date.now() + 15_000;
|
|
257
|
+
while (Date.now() < deadline) {
|
|
258
|
+
if (await readDaemonMetadata()) {
|
|
259
|
+
try {
|
|
260
|
+
await daemonRpc("/status");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
catch { /* keep waiting */ }
|
|
264
|
+
}
|
|
265
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
266
|
+
}
|
|
267
|
+
throw new CliError("Agent daemon did not start.", "AGENT_DAEMON_START_FAILED");
|
|
268
|
+
}
|
|
269
|
+
async function startDaemon(headless, noBrowser = false) {
|
|
270
|
+
await cleanStaleDaemonMetadata();
|
|
271
|
+
if (await readDaemonMetadata()) {
|
|
272
|
+
const running = await daemonRpc("/status");
|
|
273
|
+
if (running.daemon_version === VERSION && running.protocol_version === AGENT_PROTOCOL)
|
|
274
|
+
return running;
|
|
275
|
+
await daemonRpc("/stop", { method: "POST", body: "{}" });
|
|
276
|
+
for (let attempt = 0; attempt < 50 && await readDaemonMetadata(); attempt += 1)
|
|
277
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
278
|
+
}
|
|
279
|
+
const config = await readConfig();
|
|
280
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "_daemon", "--origin", config.apiOrigin, "--version-tag", VERSION, ...(headless ? ["--headless"] : []), ...(noBrowser ? ["--no-browser"] : [])], {
|
|
281
|
+
detached: true,
|
|
282
|
+
stdio: "ignore",
|
|
283
|
+
windowsHide: false,
|
|
284
|
+
});
|
|
285
|
+
child.unref();
|
|
286
|
+
await waitForDaemon();
|
|
287
|
+
return daemonRpc("/status");
|
|
288
|
+
}
|
|
289
|
+
async function ensureDaemon() { await startDaemon(true); }
|
|
290
|
+
function openExternal(url) {
|
|
291
|
+
const command = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
292
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
293
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
294
|
+
child.unref();
|
|
295
|
+
}
|
|
296
|
+
async function setup() {
|
|
297
|
+
if (Number(process.versions.node.split(".")[0]) < 20)
|
|
298
|
+
throw new CliError("Node 20 or newer is required.", "AGENT_NODE_UNSUPPORTED", 5);
|
|
299
|
+
if (!["win32", "darwin", "linux"].includes(process.platform))
|
|
300
|
+
throw new CliError(`Unsupported platform: ${process.platform}`, "AGENT_OS_UNSUPPORTED", 5);
|
|
301
|
+
let browser = "";
|
|
302
|
+
for (const channel of ["chrome", "msedge"]) {
|
|
303
|
+
try {
|
|
304
|
+
const instance = await chromium.launch(browserProbeLaunchOptions(channel));
|
|
305
|
+
browser = channel === "chrome" ? "Google Chrome" : "Microsoft Edge";
|
|
306
|
+
await instance.close();
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
catch { /* try next browser */ }
|
|
310
|
+
}
|
|
311
|
+
if (!browser) {
|
|
312
|
+
try {
|
|
313
|
+
const instance = await chromium.launch(browserProbeLaunchOptions());
|
|
314
|
+
browser = "Playwright Chromium";
|
|
315
|
+
await instance.close();
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
const require = createRequire(import.meta.url);
|
|
319
|
+
const cli = require.resolve("playwright/cli");
|
|
320
|
+
const installed = spawnSync(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" });
|
|
321
|
+
if (installed.status !== 0)
|
|
322
|
+
throw new CliError("Playwright Chromium installation failed.", "AGENT_BROWSER_INSTALL_FAILED", 5);
|
|
323
|
+
const instance = await chromium.launch(browserProbeLaunchOptions());
|
|
324
|
+
browser = "Playwright Chromium";
|
|
325
|
+
await instance.close();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const config = await readConfig();
|
|
329
|
+
const health = await fetch(new URL("/healthcheck", `${config.apiOrigin}/`)).then((response) => response.ok).catch(() => false);
|
|
330
|
+
return { ok: true, command: "setup", node: process.versions.node, platform: `${process.platform} ${process.arch}`, browser, production_api: config.apiOrigin, production_api_reachable: health, state_directory: stateDirectory(), ready: true };
|
|
331
|
+
}
|
|
332
|
+
async function connect() {
|
|
333
|
+
await startDaemon(true);
|
|
334
|
+
const created = await daemonRpc("/connect", { method: "POST", body: JSON.stringify({ clientVersion: VERSION, clientLabel: `${os.hostname()} (${process.platform} ${process.arch})` }) });
|
|
335
|
+
const pairing = created.pairing;
|
|
336
|
+
const verification = `${String(pairing.verification_url)}?code=${encodeURIComponent(String(pairing.user_code))}`;
|
|
337
|
+
if (!process.env.NERDBRO_NO_OPEN)
|
|
338
|
+
openExternal(verification);
|
|
339
|
+
if (!process.argv.includes("--json"))
|
|
340
|
+
process.stdout.write(`Connect NerdBro Studio Agent\n\nOpen:\n${verification}\n\nCode:\n${pairing.user_code}\n\nWaiting for approval...\n`);
|
|
341
|
+
const expires = new Date(String(pairing.expires_at)).getTime();
|
|
342
|
+
while (Date.now() < expires) {
|
|
343
|
+
await new Promise((resolve) => setTimeout(resolve, Number(pairing.polling_interval_seconds || 2) * 1000));
|
|
344
|
+
const status = await daemonRpc("/pairing-poll", { method: "POST", body: JSON.stringify({ pairingId: pairing.pairing_id, pairingSecret: pairing.pairing_secret }) });
|
|
345
|
+
if (status.status === "approved") {
|
|
346
|
+
const context = await ensureCompatibility();
|
|
347
|
+
return { ok: true, command: "connect", session_id: status.session_id, expires_at: status.session_expires_at, scopes: status.scopes, compatibility: { agent_protocol: context.agent_protocol, minimum_cli_version: context.minimum_cli_version, recommended_cli_version: context.recommended_cli_version } };
|
|
348
|
+
}
|
|
349
|
+
if (["expired", "denied", "redeemed"].includes(String(status.status)))
|
|
350
|
+
throw new CliError(`Pairing ended with status ${status.status}.`, "PAIRING_NOT_APPROVED", 4);
|
|
351
|
+
}
|
|
352
|
+
throw new CliError("Pairing expired before approval.", "PAIRING_EXPIRED", 4);
|
|
353
|
+
}
|
|
354
|
+
function workspace(parsed, asset) {
|
|
355
|
+
return new StudioWorkspace(path.resolve(flag(parsed, "workspace", path.join(process.cwd(), "nerdbro-studio"))), asset);
|
|
356
|
+
}
|
|
357
|
+
async function checkout(parsed, asset) {
|
|
358
|
+
const remote = await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/checkout`);
|
|
359
|
+
const source = remote.source || remote.starter_source;
|
|
360
|
+
if (!source || typeof source !== "object")
|
|
361
|
+
throw new CliError("Asset has no semantic source or canonical starter.", "STUDIO_SOURCE_UNAVAILABLE");
|
|
362
|
+
const target = workspace(parsed, asset);
|
|
363
|
+
if (!parsed.flags.has("force")) {
|
|
364
|
+
try {
|
|
365
|
+
await access(target.sourcePath);
|
|
366
|
+
throw new CliError(`${target.sourcePath} already exists; use --force.`, "STUDIO_CHECKOUT_EXISTS", 2);
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
if (error instanceof CliError)
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const hash = sourceHash(source);
|
|
374
|
+
const lock = {
|
|
375
|
+
schema: "kheleshikhi.interactive-studio-lock.v1",
|
|
376
|
+
asset_id: asset,
|
|
377
|
+
server_origin: (await readConfig()).apiOrigin,
|
|
378
|
+
base_snapshot_public_id: remote.head_snapshot?.public_id || null,
|
|
379
|
+
base_revision_public_id: remote.base_revision_public_id || null,
|
|
380
|
+
source_hash: String(remote.source_hash || hash),
|
|
381
|
+
checkout_source_hash: hash,
|
|
382
|
+
compiler_version: String(remote.compiler_version),
|
|
383
|
+
scene_schema: remote.scene_schema,
|
|
384
|
+
component_catalogue_checksum: String(remote.component_catalogue_checksum),
|
|
385
|
+
behavior_catalogue_checksum: String(remote.behavior_catalogue_checksum),
|
|
386
|
+
checkout_time: new Date().toISOString(),
|
|
387
|
+
};
|
|
388
|
+
await target.write(source, lock);
|
|
389
|
+
return { ok: true, command: "studio.checkout", asset, source: target.sourcePath, lock: target.lockPath, source_hash: hash, base_snapshot_public_id: lock.base_snapshot_public_id, authoring_brief: remote.authoring_brief || {}, authoring_warnings: remote.authoring_warnings || [] };
|
|
390
|
+
}
|
|
391
|
+
async function studioStatus(parsed, asset) {
|
|
392
|
+
const target = workspace(parsed, asset);
|
|
393
|
+
const [source, lock, remote] = await Promise.all([target.source(), target.lock(), api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/status`)]);
|
|
394
|
+
const localHash = sourceHash(source);
|
|
395
|
+
const remoteHead = remote.head_snapshot?.public_id || null;
|
|
396
|
+
return { ok: true, command: "studio.status", asset, local_source_hash: localHash, local_changed: localHash !== lock.checkout_source_hash, base_snapshot_public_id: lock.base_snapshot_public_id, remote_snapshot_public_id: remoteHead, remote_source_hash: remote.source_hash, remote_changed: remoteHead !== lock.base_snapshot_public_id, conflict: remoteHead !== lock.base_snapshot_public_id && localHash !== lock.checkout_source_hash, authoring_warnings: remote.authoring_warnings || [] };
|
|
397
|
+
}
|
|
398
|
+
async function assertBrowserSource(parsed, asset) {
|
|
399
|
+
const target = workspace(parsed, asset);
|
|
400
|
+
const source = await target.source();
|
|
401
|
+
const hash = sourceHash(source);
|
|
402
|
+
const status = await studioStatus(parsed, asset);
|
|
403
|
+
if (status.local_changed || status.remote_source_hash !== hash)
|
|
404
|
+
throw new CliError("Save the local source before browser inspection; local, server, and runtime hashes must match.", "STUDIO_SOURCE_MISMATCH", 3, status);
|
|
405
|
+
return { target, source, hash };
|
|
406
|
+
}
|
|
407
|
+
async function reportBrowserActivity(asset, operation, metadata) {
|
|
408
|
+
await api("POST", `/v1/agent/studio/${encodeURIComponent(asset)}/activity`, { operation, ...metadata });
|
|
409
|
+
}
|
|
410
|
+
async function capture(parsed, asset, inspect, activityOperation = inspect ? "browser.inspect" : "browser.capture") {
|
|
411
|
+
const { source, hash } = await assertBrowserSource(parsed, asset);
|
|
412
|
+
const view = flag(parsed, "view", "canvas");
|
|
413
|
+
const root = path.resolve(flag(parsed, "out", inspect ? `capture-${new Date().toISOString().replace(/[:.]/g, "-")}` : `studio-${asset}-${view}.png`));
|
|
414
|
+
if (!inspect) {
|
|
415
|
+
try {
|
|
416
|
+
if ((await stat(root)).isDirectory())
|
|
417
|
+
throw new CliError("Screenshot --out must name a PNG file.", "STUDIO_SCREENSHOT_OUTPUT_INVALID", 2);
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
if (error instanceof CliError)
|
|
421
|
+
throw error;
|
|
422
|
+
if (error.code !== "ENOENT")
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
if (path.extname(root).toLowerCase() !== ".png")
|
|
426
|
+
throw new CliError("Screenshot --out must name a .png file.", "STUDIO_SCREENSHOT_OUTPUT_INVALID", 2);
|
|
427
|
+
}
|
|
428
|
+
const screenshot = inspect ? path.join(root, "screenshot.png") : root;
|
|
429
|
+
const preflight = inspect ? {
|
|
430
|
+
validation: await api("POST", `/v1/agent/studio/${encodeURIComponent(asset)}/validate`, { source }),
|
|
431
|
+
compilation: await api("POST", `/v1/agent/studio/${encodeURIComponent(asset)}/compile`, { source }),
|
|
432
|
+
} : undefined;
|
|
433
|
+
const response = await daemonRpc("/browser/capture", { method: "POST", body: JSON.stringify({ asset, sourceHash: hash, view, viewport: flag(parsed, "viewport", "desktop"), fullPage: parsed.flags.has("full-page"), out: screenshot, timeoutMs: Number(flag(parsed, "timeout", "30000")) }) });
|
|
434
|
+
const browserFailures = {
|
|
435
|
+
console_errors: response.result.browser?.console_errors || [],
|
|
436
|
+
page_errors: response.result.browser?.page_errors || [],
|
|
437
|
+
request_failures: response.result.browser?.request_failures || [],
|
|
438
|
+
http_errors: response.result.browser?.http_errors || [],
|
|
439
|
+
};
|
|
440
|
+
const browserFailed = Object.values(browserFailures).some((values) => values.length > 0);
|
|
441
|
+
const screenshotSha256 = createHash("sha256").update(await readFile(screenshot)).digest("hex");
|
|
442
|
+
await reportBrowserActivity(asset, activityOperation, {
|
|
443
|
+
source_hash: hash,
|
|
444
|
+
view,
|
|
445
|
+
viewport: flag(parsed, "viewport", "desktop"),
|
|
446
|
+
runtime_fingerprint: response.result.runtime?.runtime_fingerprint,
|
|
447
|
+
runtime_ready: response.result.runtime?.runtime_ready,
|
|
448
|
+
console_error_count: browserFailures.console_errors.length + browserFailures.page_errors.length,
|
|
449
|
+
network_error_count: browserFailures.request_failures.length + browserFailures.http_errors.length,
|
|
450
|
+
screenshot_sha256: screenshotSha256,
|
|
451
|
+
status: browserFailed ? "failed" : "completed",
|
|
452
|
+
});
|
|
453
|
+
if (inspect) {
|
|
454
|
+
await mkdir(root, { recursive: true });
|
|
455
|
+
await writeFile(path.join(root, "runtime.json"), `${JSON.stringify(response.result.runtime, null, 2)}\n`);
|
|
456
|
+
await writeFile(path.join(root, "browser.json"), `${JSON.stringify(response.result.browser, null, 2)}\n`);
|
|
457
|
+
await writeFile(path.join(root, "preflight.json"), `${JSON.stringify(preflight, null, 2)}\n`);
|
|
458
|
+
await writeFile(path.join(root, "source-hash.txt"), `${hash}\n`);
|
|
459
|
+
if (browserFailed)
|
|
460
|
+
throw new CliError("Studio inspection captured browser or network errors.", "STUDIO_BROWSER_DIAGNOSTICS_FAILED", 1, { ...browserFailures, output: root });
|
|
461
|
+
return { ok: true, command: "studio.inspect", asset, view, output: root, screenshot, runtime: path.join(root, "runtime.json"), browser: path.join(root, "browser.json"), preflight: path.join(root, "preflight.json"), source_hash: hash };
|
|
462
|
+
}
|
|
463
|
+
if (browserFailed)
|
|
464
|
+
throw new CliError("Studio capture reported browser or network errors.", "STUDIO_BROWSER_DIAGNOSTICS_FAILED", 1, browserFailures);
|
|
465
|
+
return { ok: true, command: "studio.screenshot", asset, view, path: screenshot, source_hash: hash, browser: response.result.browser, runtime: response.result.runtime };
|
|
466
|
+
}
|
|
467
|
+
async function initInstructions(provider) {
|
|
468
|
+
const name = provider === "claude" ? "CLAUDE.md" : provider === "codex" ? "AGENTS.md" : "NERDBRO_AGENT.md";
|
|
469
|
+
const target = path.resolve(name);
|
|
470
|
+
let existing = "";
|
|
471
|
+
try {
|
|
472
|
+
existing = await readFile(target, "utf8");
|
|
473
|
+
}
|
|
474
|
+
catch { /* new file */ }
|
|
475
|
+
if (!existing.includes("# NerdBro Interactive Studio agent")) {
|
|
476
|
+
if (existing)
|
|
477
|
+
await appendFile(target, `\n${INSTRUCTIONS}`);
|
|
478
|
+
else
|
|
479
|
+
await writeFile(target, INSTRUCTIONS);
|
|
480
|
+
}
|
|
481
|
+
const active = await readAttemptPointer();
|
|
482
|
+
if (active?.pointer.mode === "benchmark" && !existing.includes("## NerdBro benchmark completion")) {
|
|
483
|
+
await appendFile(target, `\n## NerdBro benchmark completion\n\nThis workspace is Attempt ${active.pointer.attempt_id}. Preserve observable command outputs and decisions. Before stopping, write REPORT.md with what was built, completed and incomplete work, asset and revision IDs, certification and approval status, and reviewer next steps. Write EVALUATION.md with educational-runtime, language, tooling, documentation and browser ratings; what worked, exact failures, workarounds, your own mistakes, platform limitations and prioritized product fixes. Do not include private chain-of-thought or credentials. Run \`nerdbro attempt finish --outcome completed|blocked|incomplete|abandoned\` with the accurate outcome and report the Attempt ID, client bundle path and SHA-256.\n`);
|
|
484
|
+
}
|
|
485
|
+
return { ok: true, command: "agent.init", provider, path: target, credentials_written: false };
|
|
486
|
+
}
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
// Library authoring (experience, audio, component, behavior). A thin client of
|
|
489
|
+
// /v1/agent/authoring: every semantic rule, template and certification lives on
|
|
490
|
+
// the server. There is deliberately no approve or publish command.
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
const LIBRARY_ACTIONS = ["create", "checkout", "status", "save", "validate", "certify", "preview", "export", "inspect-browser"];
|
|
493
|
+
const PREVIEW_KINDS = ["experience", "behavior"];
|
|
494
|
+
const AUTHORING_SEARCH_KINDS = ["experience", "audio"];
|
|
495
|
+
function authoringRoute(kind, stableId, suffix = "") {
|
|
496
|
+
return `/v1/agent/authoring/${kind}${stableId === undefined ? "" : `/${encodeURIComponent(stableId)}`}${suffix}`;
|
|
497
|
+
}
|
|
498
|
+
function libraryWorkspace(parsed, kind, stableId) {
|
|
499
|
+
return new LibraryWorkspace(path.resolve(flag(parsed, "workspace", path.join(process.cwd(), "nerdbro-studio"))), kind, stableId);
|
|
500
|
+
}
|
|
501
|
+
async function readJsonObject(file, code) {
|
|
502
|
+
try {
|
|
503
|
+
const value = JSON.parse(await readFile(path.resolve(file), "utf8"));
|
|
504
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
505
|
+
throw new Error("not a JSON object");
|
|
506
|
+
return value;
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
throw new CliError(`Cannot read ${file}: ${String(error)}`, code, 2);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
export async function libraryCreateBody(parsed, stableId) {
|
|
513
|
+
const body = { stable_id: stableId, name: required(flag(parsed, "name"), "--name is required.") };
|
|
514
|
+
const mapping = [
|
|
515
|
+
["category", "category"],
|
|
516
|
+
["visual", "visual"],
|
|
517
|
+
["palette", "palette"],
|
|
518
|
+
["implementation", "implementation"],
|
|
519
|
+
["media", "audio_media_sha256"],
|
|
520
|
+
["language", "language"],
|
|
521
|
+
["description", "description"],
|
|
522
|
+
["kind", "experience_kind"],
|
|
523
|
+
["master-audio", "master_audio"],
|
|
524
|
+
];
|
|
525
|
+
for (const [option, field] of mapping) {
|
|
526
|
+
const value = flag(parsed, option);
|
|
527
|
+
if (value !== undefined)
|
|
528
|
+
body[field] = value;
|
|
529
|
+
}
|
|
530
|
+
if (parsed.flags.has("declarative"))
|
|
531
|
+
body.implementation = "declarative";
|
|
532
|
+
const duration = flag(parsed, "duration-ms");
|
|
533
|
+
if (duration !== undefined) {
|
|
534
|
+
if (!/^\d+$/.test(duration) || Number(duration) <= 0)
|
|
535
|
+
throw new CliError("--duration-ms must be a positive integer.", "AGENT_ARGUMENT_INVALID", 2);
|
|
536
|
+
body.duration_ms = Number(duration);
|
|
537
|
+
}
|
|
538
|
+
const sceneAsset = flag(parsed, "scene-asset");
|
|
539
|
+
const scenePackage = flag(parsed, "scene-package");
|
|
540
|
+
if (sceneAsset && scenePackage)
|
|
541
|
+
throw new CliError("Use either --scene-asset or --scene-package, not both.", "AGENT_ARGUMENT_INVALID", 2);
|
|
542
|
+
if (sceneAsset)
|
|
543
|
+
body.scene = { asset: sceneAsset };
|
|
544
|
+
if (scenePackage)
|
|
545
|
+
body.scene = { package: scenePackage, scene: required(flag(parsed, "scene"), "--scene <scene-id> is required with --scene-package.") };
|
|
546
|
+
const documentFile = flag(parsed, "document");
|
|
547
|
+
if (documentFile)
|
|
548
|
+
body.document = await readJsonObject(documentFile, "LIBRARY_SOURCE_INVALID");
|
|
549
|
+
return body;
|
|
550
|
+
}
|
|
551
|
+
async function libraryCheckout(parsed, kind, stableId) {
|
|
552
|
+
const target = libraryWorkspace(parsed, kind, stableId);
|
|
553
|
+
if (!parsed.flags.has("force"))
|
|
554
|
+
await target.assertNoLocalChanges();
|
|
555
|
+
const remote = await api("GET", `${authoringRoute(kind, stableId)}?purpose=checkout`);
|
|
556
|
+
const document = remote.document;
|
|
557
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) {
|
|
558
|
+
throw new CliError(`${kind} ${stableId} has no document to check out.`, "LIBRARY_SOURCE_UNAVAILABLE");
|
|
559
|
+
}
|
|
560
|
+
const hash = sourceHash(document);
|
|
561
|
+
const now = new Date().toISOString();
|
|
562
|
+
const lock = {
|
|
563
|
+
schema: "kheleshikhi.library-lock.v1",
|
|
564
|
+
kind,
|
|
565
|
+
stable_id: stableId,
|
|
566
|
+
server_origin: (await readConfig()).apiOrigin,
|
|
567
|
+
head_revision_public_id: remote.head_revision?.public_id || null,
|
|
568
|
+
source_hash: hash,
|
|
569
|
+
schema_version: remote.schema_version ?? null,
|
|
570
|
+
checked_out_at: now,
|
|
571
|
+
};
|
|
572
|
+
await target.write(document, lock);
|
|
573
|
+
return { ok: true, command: `${kind}.checkout`, kind, stable_id: stableId, source: target.sourcePath, lock: target.lockPath, source_hash: hash, head_revision: remote.head_revision || null, approved_revision: remote.approved_revision || null, schema_version: lock.schema_version };
|
|
574
|
+
}
|
|
575
|
+
async function libraryStatus(parsed, kind, stableId) {
|
|
576
|
+
const target = libraryWorkspace(parsed, kind, stableId);
|
|
577
|
+
const [source, lock] = await Promise.all([target.source(), target.lock()]);
|
|
578
|
+
const remote = await api("GET", `${authoringRoute(kind, stableId)}?purpose=status`);
|
|
579
|
+
const localHash = sourceHash(source);
|
|
580
|
+
const remoteHead = remote.head_revision?.public_id || null;
|
|
581
|
+
const localChanged = localHash !== lock.source_hash;
|
|
582
|
+
const remoteChanged = remoteHead !== lock.head_revision_public_id;
|
|
583
|
+
return {
|
|
584
|
+
ok: true,
|
|
585
|
+
command: `${kind}.status`,
|
|
586
|
+
kind,
|
|
587
|
+
stable_id: stableId,
|
|
588
|
+
local_source_hash: localHash,
|
|
589
|
+
local_changed: localChanged,
|
|
590
|
+
base_revision_public_id: lock.head_revision_public_id,
|
|
591
|
+
remote_revision_public_id: remoteHead,
|
|
592
|
+
remote_changed: remoteChanged,
|
|
593
|
+
conflict: localChanged && remoteChanged,
|
|
594
|
+
head_revision: remote.head_revision || null,
|
|
595
|
+
approved_revision: remote.approved_revision || null,
|
|
596
|
+
schema_version: remote.schema_version ?? null,
|
|
597
|
+
schema_changed: (remote.schema_version ?? null) !== lock.schema_version,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
async function librarySave(parsed, kind, stableId) {
|
|
601
|
+
const target = libraryWorkspace(parsed, kind, stableId);
|
|
602
|
+
const [source, lock] = await Promise.all([target.source(), target.lock()]);
|
|
603
|
+
const base = required(lock.head_revision_public_id || undefined, "The lock has no base revision; run checkout again.");
|
|
604
|
+
const hash = sourceHash(source);
|
|
605
|
+
let saved;
|
|
606
|
+
try {
|
|
607
|
+
saved = await api("POST", authoringRoute(kind, stableId, "/drafts"), { document: source, base_revision_public_id: base });
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
if (error instanceof CliError && error.exitCode === 3) {
|
|
611
|
+
throw new CliError(error.message, "VOCABULARY_DRAFT_CONFLICT", 3, { ...structuredErrorDetails(error.details), local_source_preserved: true, source: target.sourcePath, base_revision_public_id: base });
|
|
612
|
+
}
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
const updated = libraryLockAfterSave(lock, saved, hash);
|
|
616
|
+
await target.writeLock(updated);
|
|
617
|
+
return { ok: true, command: `${kind}.save`, kind, stable_id: stableId, source_hash: hash, draft_created: saved.draft_created !== false, reason: saved.reason || null, head_revision: saved.head_revision || null, approval: "not_requested" };
|
|
618
|
+
}
|
|
619
|
+
async function libraryRevision(parsed, kind, stableId) {
|
|
620
|
+
const explicit = flag(parsed, "revision");
|
|
621
|
+
if (explicit)
|
|
622
|
+
return explicit;
|
|
623
|
+
const lock = await libraryWorkspace(parsed, kind, stableId).lock();
|
|
624
|
+
return required(lock.head_revision_public_id || undefined, "--revision is required (the lock has no head revision).");
|
|
625
|
+
}
|
|
626
|
+
async function libraryCheck(parsed, kind, stableId, action) {
|
|
627
|
+
const revision = await libraryRevision(parsed, kind, stableId);
|
|
628
|
+
const result = await api("POST", authoringRoute(kind, stableId, `/revisions/${encodeURIComponent(revision)}/${action}`));
|
|
629
|
+
if (action === "certify" && result.certified === false) {
|
|
630
|
+
throw new CliError("Certification did not pass; see details for outstanding profiles.", "LIBRARY_CERTIFICATION_FAILED", 1, result);
|
|
631
|
+
}
|
|
632
|
+
return { ok: true, command: `${kind}.${action}`, kind, stable_id: stableId, revision, result, approval: action === "certify" ? "requires_human" : "not_requested" };
|
|
633
|
+
}
|
|
634
|
+
async function libraryPreview(parsed, kind, stableId) {
|
|
635
|
+
const target = libraryWorkspace(parsed, kind, stableId);
|
|
636
|
+
const source = await target.source();
|
|
637
|
+
const preview = await api("POST", `/v1/agent/authoring/${kind}/preview`, { document: source });
|
|
638
|
+
await writeFile(target.previewPath, `${JSON.stringify(preview, null, 2)}\n`, "utf8");
|
|
639
|
+
const count = (value) => Array.isArray(value) ? value.length : value !== null && typeof value === "object" ? Object.keys(value).length : null;
|
|
640
|
+
const summary = kind === "experience"
|
|
641
|
+
? { mode: preview.mode ?? null, source_hash: preview.sourceHash ?? null, scenes: count(preview.scenes), audio: count(preview.audio) }
|
|
642
|
+
: { id: preview.id ?? null, implementation: preview.implementation ?? null, outputs: count(preview.outputs) };
|
|
643
|
+
return { ok: true, command: `${kind}.preview`, kind, stable_id: stableId, local_source_hash: sourceHash(source), preview: target.previewPath, summary };
|
|
644
|
+
}
|
|
645
|
+
async function libraryExport(parsed, kind, stableId) {
|
|
646
|
+
const revision = required(flag(parsed, "revision"), "--revision is required.");
|
|
647
|
+
if (!/^[A-Za-z0-9_-]+$/.test(revision))
|
|
648
|
+
throw new CliError("Revision contains unsafe characters.", "AGENT_ARGUMENT_INVALID", 2);
|
|
649
|
+
const out = path.resolve(flag(parsed, "out", `${stableId}-${revision}.kspkg`));
|
|
650
|
+
if (!out.endsWith(".kspkg"))
|
|
651
|
+
throw new CliError("Export output must end in .kspkg.", "LIBRARY_EXPORT_EXTENSION_INVALID", 2);
|
|
652
|
+
await ensureCompatibility();
|
|
653
|
+
const bytes = await rawBinaryApi("GET", authoringRoute(kind, stableId, `/revisions/${encodeURIComponent(revision)}/export.kspkg`), "LIBRARY_EXPORT_INVALID");
|
|
654
|
+
await mkdir(path.dirname(out), { recursive: true });
|
|
655
|
+
await writeFile(out, bytes);
|
|
656
|
+
return { ok: true, command: `${kind}.export`, kind, stable_id: stableId, revision, path: out, bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"), approved_revision_only: true };
|
|
657
|
+
}
|
|
658
|
+
async function audioUpload(file) {
|
|
659
|
+
let byteSize;
|
|
660
|
+
try {
|
|
661
|
+
const info = await stat(file);
|
|
662
|
+
if (!info.isFile())
|
|
663
|
+
throw new Error("not a file");
|
|
664
|
+
byteSize = info.size;
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
throw new CliError("Cannot read the audio file.", "AUDIO_FILE_UNREADABLE", 2);
|
|
668
|
+
}
|
|
669
|
+
const extension = audioUploadExtension(file, byteSize);
|
|
670
|
+
const bytes = await readFile(file);
|
|
671
|
+
audioUploadExtension(file, bytes.length); // the file may have grown since stat
|
|
672
|
+
const media = await uploadApi("/v1/agent/audio/media", extension, "audio/mp4", new Uint8Array(bytes));
|
|
673
|
+
// Deliberately no local path, filename, or bytes in the result.
|
|
674
|
+
return {
|
|
675
|
+
ok: true,
|
|
676
|
+
command: "audio.upload",
|
|
677
|
+
sha256: media.sha256,
|
|
678
|
+
duration_ms: media.durationMs ?? null,
|
|
679
|
+
byte_size: media.byteSize ?? bytes.length,
|
|
680
|
+
media_type: media.mediaType ?? null,
|
|
681
|
+
codec: media.codec ?? null,
|
|
682
|
+
sample_rate: media.sampleRate ?? null,
|
|
683
|
+
channels: media.channels ?? null,
|
|
684
|
+
public_id: media.publicId ?? null,
|
|
685
|
+
next: `nerdbro audio create <stable-id> --name <text> --media ${media.sha256}`,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
async function libraryInspectBrowser(parsed, kind, stableId) {
|
|
689
|
+
const target = libraryWorkspace(parsed, kind, stableId);
|
|
690
|
+
const viewport = flag(parsed, "viewport", "desktop");
|
|
691
|
+
const studioWorkspace = LIBRARY_STUDIO_WORKSPACE[kind];
|
|
692
|
+
let warning = null;
|
|
693
|
+
let expectedRevision;
|
|
694
|
+
let expectedHash;
|
|
695
|
+
try {
|
|
696
|
+
const [source, lock] = await Promise.all([target.source(), target.lock()]);
|
|
697
|
+
if (sourceHash(source) !== lock.source_hash)
|
|
698
|
+
warning = "Local source.json has unsaved changes; the Studio shows the saved server head.";
|
|
699
|
+
expectedRevision = lock.head_revision_public_id || undefined;
|
|
700
|
+
if (!warning)
|
|
701
|
+
expectedHash = lock.source_hash;
|
|
702
|
+
}
|
|
703
|
+
catch { /* no local checkout: the capture shows the server head */ }
|
|
704
|
+
await ensureCompatibility();
|
|
705
|
+
await mkdir(target.directory, { recursive: true });
|
|
706
|
+
const response = await daemonRpc("/browser/capture", {
|
|
707
|
+
method: "POST",
|
|
708
|
+
body: JSON.stringify({ asset: stableId, sourceHash: "", viewport, out: target.screenshotPath, timeoutMs: Number(flag(parsed, "timeout", "30000")), library: { workspace: studioWorkspace, selection: stableId, revision: expectedRevision, definitionHash: expectedHash } }),
|
|
709
|
+
});
|
|
710
|
+
const browser = response.result.browser || {};
|
|
711
|
+
const consoleErrors = (browser.console_errors?.length || 0) + (browser.page_errors?.length || 0);
|
|
712
|
+
const networkErrors = (browser.request_failures?.length || 0) + (browser.http_errors?.length || 0);
|
|
713
|
+
const failed = consoleErrors + networkErrors > 0;
|
|
714
|
+
const browserPath = path.join(target.directory, "browser.json");
|
|
715
|
+
await writeFile(browserPath, `${JSON.stringify(browser, null, 2)}\n`);
|
|
716
|
+
const activity = {
|
|
717
|
+
operation: "browser.capture",
|
|
718
|
+
subject: `${kind}:${stableId}`,
|
|
719
|
+
workspace: studioWorkspace,
|
|
720
|
+
viewport,
|
|
721
|
+
screenshot_sha256: createHash("sha256").update(await readFile(target.screenshotPath)).digest("hex"),
|
|
722
|
+
console_error_count: consoleErrors,
|
|
723
|
+
network_error_count: networkErrors,
|
|
724
|
+
status: failed ? "failed" : "completed",
|
|
725
|
+
};
|
|
726
|
+
if (failed)
|
|
727
|
+
throw new CliError("Studio Library capture reported browser or network errors.", "STUDIO_BROWSER_DIAGNOSTICS_FAILED", 1, { activity, browser: browserPath });
|
|
728
|
+
return { ok: true, command: `${kind}.inspect-browser`, kind, stable_id: stableId, screenshot: target.screenshotPath, browser: browserPath, activity, activity_scope: "local", warning };
|
|
729
|
+
}
|
|
730
|
+
async function library(parsed, kind, action, argument) {
|
|
731
|
+
if (kind === "audio" && action === "upload")
|
|
732
|
+
return audioUpload(required(argument, "Audio file is required."));
|
|
733
|
+
if (AUTHORING_SEARCH_KINDS.includes(kind) && action === "search") {
|
|
734
|
+
const parameters = new URLSearchParams({ query: parsed.args.slice(2).join(" ") });
|
|
735
|
+
const limit = flag(parsed, "limit");
|
|
736
|
+
if (limit)
|
|
737
|
+
parameters.set("limit", limit);
|
|
738
|
+
return { ok: true, command: `${kind}.search`, result: await api("GET", `${authoringRoute(kind)}?${parameters}`) };
|
|
739
|
+
}
|
|
740
|
+
if (AUTHORING_SEARCH_KINDS.includes(kind) && action === "inspect") {
|
|
741
|
+
const stableId = required(argument, "Stable id is required.");
|
|
742
|
+
return { ok: true, command: `${kind}.inspect`, result: await api("GET", `${authoringRoute(kind, stableId)}?purpose=inspect`) };
|
|
743
|
+
}
|
|
744
|
+
if (!action || !LIBRARY_ACTIONS.includes(action))
|
|
745
|
+
return undefined;
|
|
746
|
+
if ((action === "preview" || action === "export") && !PREVIEW_KINDS.includes(kind))
|
|
747
|
+
return undefined;
|
|
748
|
+
const stableId = required(argument, "Stable id is required.");
|
|
749
|
+
libraryWorkspace(parsed, kind, stableId);
|
|
750
|
+
if (action === "create") {
|
|
751
|
+
const created = await api("POST", authoringRoute(kind), await libraryCreateBody(parsed, stableId));
|
|
752
|
+
return { ok: true, command: `${kind}.create`, kind, stable_id: created.stable_id || stableId, head_revision: created.head_revision || null, approval: created.approval || "not_requested", next: `nerdbro ${kind} checkout ${created.stable_id || stableId}` };
|
|
753
|
+
}
|
|
754
|
+
if (action === "checkout")
|
|
755
|
+
return libraryCheckout(parsed, kind, stableId);
|
|
756
|
+
if (action === "status")
|
|
757
|
+
return libraryStatus(parsed, kind, stableId);
|
|
758
|
+
if (action === "save")
|
|
759
|
+
return librarySave(parsed, kind, stableId);
|
|
760
|
+
if (action === "validate" || action === "certify")
|
|
761
|
+
return libraryCheck(parsed, kind, stableId, action);
|
|
762
|
+
if (action === "preview")
|
|
763
|
+
return libraryPreview(parsed, kind, stableId);
|
|
764
|
+
if (action === "export")
|
|
765
|
+
return libraryExport(parsed, kind, stableId);
|
|
766
|
+
return libraryInspectBrowser(parsed, kind, stableId);
|
|
767
|
+
}
|
|
768
|
+
async function run(parsed) {
|
|
769
|
+
const [first, second, third] = parsed.args;
|
|
770
|
+
assertSupportedFlags(parsed);
|
|
771
|
+
if (!first || first === "help" || first === "--help")
|
|
772
|
+
return help();
|
|
773
|
+
if (first === "--version" || first === "version")
|
|
774
|
+
return { ok: true, command: "version", version: VERSION };
|
|
775
|
+
if (first === "_daemon") {
|
|
776
|
+
await runDaemon({ apiOrigin: validateOrigin(required(flag(parsed, "origin"), "Daemon origin is required.")), headless: parsed.flags.has("headless"), noBrowser: parsed.flags.has("no-browser"), version: flag(parsed, "version-tag", VERSION) });
|
|
777
|
+
}
|
|
778
|
+
if (first === "setup")
|
|
779
|
+
return setup();
|
|
780
|
+
if (first === "attempt") {
|
|
781
|
+
if (second === "start") {
|
|
782
|
+
const label = required(flag(parsed, "label"), "--label is required.");
|
|
783
|
+
const mode = flag(parsed, "mode", "authoring");
|
|
784
|
+
if (!["authoring", "benchmark"].includes(mode))
|
|
785
|
+
throw new CliError("--mode must be authoring or benchmark.", "ATTEMPT_MODE_INVALID", 2);
|
|
786
|
+
if (await readAttemptPointer())
|
|
787
|
+
throw new CliError("This workspace already has an Attempt.", "AGENT_ATTEMPT_ALREADY_ACTIVE", 3);
|
|
788
|
+
const origin = (await readConfig()).apiOrigin;
|
|
789
|
+
const server = await api("POST", "/v1/agent/attempts", { label, mode, server_origin: origin });
|
|
790
|
+
const pointer = await createAttemptPointer(server, origin);
|
|
791
|
+
return { ok: true, command: "attempt.start", attempt_id: pointer.attempt_id, label, mode, folder: pointer.attempt_root, pointer: path.join(process.cwd(), ".nerdbro-attempt.json") };
|
|
792
|
+
}
|
|
793
|
+
if (second === "current") {
|
|
794
|
+
const active = await readAttemptPointer();
|
|
795
|
+
return { ok: true, command: "attempt.current", attempt_id: active?.pointer.attempt_id || null };
|
|
796
|
+
}
|
|
797
|
+
if (second === "status") {
|
|
798
|
+
const active = await readAttemptPointer();
|
|
799
|
+
const id = third || active?.pointer.attempt_id;
|
|
800
|
+
if (!id)
|
|
801
|
+
throw new CliError("No active workspace Attempt; pass an Attempt ID.", "ATTEMPT_NOT_ACTIVE", 2);
|
|
802
|
+
const status = await api("GET", `/v1/agent/attempts/${encodeURIComponent(id)}`);
|
|
803
|
+
return { ok: true, command: "attempt.status", ...status, folder: attemptDirectory(id) };
|
|
804
|
+
}
|
|
805
|
+
if (second === "finish") {
|
|
806
|
+
const active = await readAttemptPointer();
|
|
807
|
+
if (!active)
|
|
808
|
+
throw new CliError("No active workspace Attempt.", "ATTEMPT_NOT_ACTIVE", 2);
|
|
809
|
+
const id = active.pointer.attempt_id;
|
|
810
|
+
const server = await api("GET", `/v1/agent/attempts/${encodeURIComponent(id)}`);
|
|
811
|
+
const outcome = flag(parsed, "outcome");
|
|
812
|
+
if (!outcome || !["completed", "blocked", "incomplete", "abandoned"].includes(outcome))
|
|
813
|
+
throw new CliError("Pass --outcome completed|blocked|incomplete|abandoned.", "ATTEMPT_OUTCOME_REQUIRED", 2);
|
|
814
|
+
const bundle = await finalizeClient(active.pointer, active.workspace, { ...server, outcome });
|
|
815
|
+
await api("POST", `/v1/agent/attempts/${encodeURIComponent(id)}/finish`, { outcome });
|
|
816
|
+
return { ok: true, command: "attempt.finish", attempt_id: id, outcome, client_bundle: bundle.path, sha256: bundle.sha256, files: bundle.included, excluded: bundle.excluded };
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (first === "connect")
|
|
820
|
+
return connect();
|
|
821
|
+
if (first === "disconnect")
|
|
822
|
+
return daemonRpc("/disconnect", { method: "POST", body: "{}" });
|
|
823
|
+
if (first === "whoami")
|
|
824
|
+
return { ok: true, command: "whoami", result: await api("GET", "/v1/agent/whoami") };
|
|
825
|
+
if (first === "context" || first === "capabilities")
|
|
826
|
+
return { ok: true, command: first, result: await api("GET", `/v1/agent/${first}`) };
|
|
827
|
+
if (first === "schema")
|
|
828
|
+
return { ok: true, command: "schema", result: await api("GET", second ? `/v1/agent/schema/${encodeURIComponent(second)}` : "/v1/agent/schema") };
|
|
829
|
+
if (first === "guide") {
|
|
830
|
+
const result = await api("GET", "/v1/agent/docs/agent-authoring-playbook");
|
|
831
|
+
return {
|
|
832
|
+
ok: true, command: "guide", playbook_version: result.playbook_version,
|
|
833
|
+
fingerprint: result.documentation_fingerprint, public_url: result.public_url,
|
|
834
|
+
markdown: result.markdown,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
if (first === "docs") {
|
|
838
|
+
if (!second)
|
|
839
|
+
return { ok: true, command: "docs", result: await api("GET", "/v1/agent/docs") };
|
|
840
|
+
if (second === "search") {
|
|
841
|
+
const query = parsed.args.slice(2).join(" ");
|
|
842
|
+
return { ok: true, command: "docs.search", result: await api("GET", `/v1/agent/docs/search?q=${encodeURIComponent(query)}`) };
|
|
843
|
+
}
|
|
844
|
+
if (second === "open") {
|
|
845
|
+
const slug = required(third, "Documentation slug is required.");
|
|
846
|
+
const result = await api("GET", `/v1/agent/docs/${encodeURIComponent(slug)}`);
|
|
847
|
+
return parsed.json ? { ok: true, command: "docs.open", result } : String(result.markdown || "");
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (first === "config" && second === "set" && third === "origin") {
|
|
851
|
+
const origin = validateOrigin(required(parsed.args[3], "Origin URL is required."));
|
|
852
|
+
let daemonStopped = false;
|
|
853
|
+
try {
|
|
854
|
+
await daemonRpc("/stop", { method: "POST", body: "{}" });
|
|
855
|
+
daemonStopped = true;
|
|
856
|
+
}
|
|
857
|
+
catch (error) {
|
|
858
|
+
if (!(error instanceof CliError) || !["AGENT_DAEMON_NOT_RUNNING", "AGENT_DAEMON_UNAVAILABLE"].includes(error.code))
|
|
859
|
+
throw error;
|
|
860
|
+
}
|
|
861
|
+
await writeConfig({ apiOrigin: origin });
|
|
862
|
+
return { ok: true, command: "config.set.origin", origin, daemon_stopped: daemonStopped, reconnect_required: true };
|
|
863
|
+
}
|
|
864
|
+
if (first === "agent") {
|
|
865
|
+
if (second === "start")
|
|
866
|
+
return { ok: true, command: "agent.start", ...(await startDaemon(true)) };
|
|
867
|
+
if (second === "status")
|
|
868
|
+
return { command: "agent.status", ...(await daemonRpc("/status")) };
|
|
869
|
+
if (second === "stop")
|
|
870
|
+
return { command: "agent.stop", ...(await daemonRpc("/stop", { method: "POST", body: "{}" })) };
|
|
871
|
+
if (second === "instructions")
|
|
872
|
+
return INSTRUCTIONS;
|
|
873
|
+
if (second === "init")
|
|
874
|
+
return initInstructions(required(flag(parsed, "provider", "generic"), "Provider is required."));
|
|
875
|
+
}
|
|
876
|
+
if (["component", "behavior", "example"].includes(first)) {
|
|
877
|
+
const plural = `${first}s`;
|
|
878
|
+
if (second === "search") {
|
|
879
|
+
const query = parsed.args.slice(2).join(" ");
|
|
880
|
+
const parameters = new URLSearchParams({ query });
|
|
881
|
+
const limit = flag(parsed, "limit");
|
|
882
|
+
const offset = flag(parsed, "offset");
|
|
883
|
+
if (limit)
|
|
884
|
+
parameters.set("limit", limit);
|
|
885
|
+
if (offset)
|
|
886
|
+
parameters.set("offset", offset);
|
|
887
|
+
return { ok: true, command: `${first}.search`, result: await api("GET", `/v1/agent/catalogue/${plural}/search?${parameters}`) };
|
|
888
|
+
}
|
|
889
|
+
if (second === "inspect")
|
|
890
|
+
return { ok: true, command: `${first}.inspect`, result: await api("GET", `/v1/agent/catalogue/${plural}/${encodeURIComponent(required(third, "ID is required."))}`) };
|
|
891
|
+
}
|
|
892
|
+
if (isLibraryKind(first)) {
|
|
893
|
+
const result = await library(parsed, first, second, third);
|
|
894
|
+
if (result !== undefined)
|
|
895
|
+
return result;
|
|
896
|
+
}
|
|
897
|
+
if (first === "studio") {
|
|
898
|
+
const action = required(second, "Studio command is required.");
|
|
899
|
+
if (action === "create") {
|
|
900
|
+
const title = required(flag(parsed, "title"), "--title is required.");
|
|
901
|
+
const target = required(flag(parsed, "target"), "--target is required.");
|
|
902
|
+
const concept = required(flag(parsed, "concept"), "--concept is required.");
|
|
903
|
+
const brief = {
|
|
904
|
+
concept,
|
|
905
|
+
teaching_purpose: flag(parsed, "teaching-purpose"),
|
|
906
|
+
learner_action: flag(parsed, "learner-action"),
|
|
907
|
+
expected_visual_behavior: flag(parsed, "expected-visual-behavior"),
|
|
908
|
+
graph_purpose: flag(parsed, "graph-purpose"),
|
|
909
|
+
graph_x_semantics: flag(parsed, "graph-x"),
|
|
910
|
+
graph_y_semantics: flag(parsed, "graph-y"),
|
|
911
|
+
graph_sync_semantics: flag(parsed, "graph-sync"),
|
|
912
|
+
language: flag(parsed, "language", "bilingual"),
|
|
913
|
+
requires_graph: target === "combined" || Boolean(flag(parsed, "graph-purpose")),
|
|
914
|
+
requires_assessment: parsed.flags.has("assessment"),
|
|
915
|
+
additional_instruction: flag(parsed, "additional-instruction"),
|
|
916
|
+
selected_components: flag(parsed, "components")?.split(",").map((value) => value.trim()).filter(Boolean),
|
|
917
|
+
selected_behavior: flag(parsed, "behavior"),
|
|
918
|
+
important_values: flag(parsed, "important-values")?.split(",").map((value) => value.trim()).filter(Boolean),
|
|
919
|
+
};
|
|
920
|
+
const result = await api("POST", "/v1/agent/studio", {
|
|
921
|
+
client_command_id: flag(parsed, "command-id", randomUUID()),
|
|
922
|
+
title, authoring_target: target, brief,
|
|
923
|
+
});
|
|
924
|
+
// A newly created Studio asset has no approved revision or draft; it is
|
|
925
|
+
// already the isolated scratch surface. The flag makes that intent
|
|
926
|
+
// explicit without inventing a second server-side lifecycle.
|
|
927
|
+
return { ok: true, command: "studio.create", scratch: parsed.flags.has("scratch"), result };
|
|
928
|
+
}
|
|
929
|
+
if (action === "brief") {
|
|
930
|
+
const updating = third === "update";
|
|
931
|
+
const briefAsset = required(updating ? parsed.args[3] : third, "Asset ID is required.");
|
|
932
|
+
const route = `/v1/agent/studio/${encodeURIComponent(briefAsset)}/brief`;
|
|
933
|
+
if (!updating)
|
|
934
|
+
return { ok: true, command: "studio.brief", result: await api("GET", route) };
|
|
935
|
+
const current = await api("GET", route);
|
|
936
|
+
const names = {
|
|
937
|
+
concept: "concept", "teaching-purpose": "teaching_purpose",
|
|
938
|
+
"learner-action": "learner_action", "expected-visual-behavior": "expected_visual_behavior",
|
|
939
|
+
"graph-purpose": "graph_purpose", "graph-x": "graph_x_semantics",
|
|
940
|
+
"graph-y": "graph_y_semantics", "graph-sync": "graph_sync_semantics",
|
|
941
|
+
language: "language", "additional-instruction": "additional_instruction",
|
|
942
|
+
behavior: "selected_behavior",
|
|
943
|
+
};
|
|
944
|
+
const brief = { ...current.brief };
|
|
945
|
+
for (const [option, field] of Object.entries(names)) {
|
|
946
|
+
const value = flag(parsed, option);
|
|
947
|
+
if (value !== undefined)
|
|
948
|
+
brief[field] = value;
|
|
949
|
+
}
|
|
950
|
+
if (parsed.flags.has("assessment"))
|
|
951
|
+
brief.requires_assessment = true;
|
|
952
|
+
if (parsed.flags.has("no-assessment"))
|
|
953
|
+
brief.requires_assessment = false;
|
|
954
|
+
if (flag(parsed, "graph-purpose"))
|
|
955
|
+
brief.requires_graph = true;
|
|
956
|
+
if (flag(parsed, "components"))
|
|
957
|
+
brief.selected_components = flag(parsed, "components").split(",").map((value) => value.trim()).filter(Boolean);
|
|
958
|
+
if (flag(parsed, "important-values"))
|
|
959
|
+
brief.important_values = flag(parsed, "important-values").split(",").map((value) => value.trim()).filter(Boolean);
|
|
960
|
+
const result = await api("PATCH", route, { brief, base_brief_hash: current.brief_hash });
|
|
961
|
+
return { ok: true, command: "studio.brief.update", result };
|
|
962
|
+
}
|
|
963
|
+
const asset = required(third, "Asset ID is required.");
|
|
964
|
+
const target = workspace(parsed, asset);
|
|
965
|
+
if (action === "checkout")
|
|
966
|
+
return checkout(parsed, asset);
|
|
967
|
+
if (action === "status")
|
|
968
|
+
return studioStatus(parsed, asset);
|
|
969
|
+
if (action === "source")
|
|
970
|
+
return { ok: true, command: "studio.source", asset, source: await target.source() };
|
|
971
|
+
if (["validate", "compile", "preview"].includes(action))
|
|
972
|
+
return { ok: true, command: `studio.${action}`, asset, source_hash: sourceHash(await target.source()), result: await api("POST", `/v1/agent/studio/${encodeURIComponent(asset)}/${action}`, { source: await target.source() }) };
|
|
973
|
+
if (action === "history")
|
|
974
|
+
return { ok: true, command: "studio.history", asset, result: await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/history`) };
|
|
975
|
+
if (action === "save") {
|
|
976
|
+
const source = await target.source();
|
|
977
|
+
const lock = await target.lock();
|
|
978
|
+
const hash = sourceHash(source);
|
|
979
|
+
try {
|
|
980
|
+
const saved = await api("POST", `/v1/agent/studio/${encodeURIComponent(asset)}/drafts`, { source, base_snapshot_public_id: lock.base_snapshot_public_id, base_revision_public_id: lock.base_revision_public_id });
|
|
981
|
+
await target.writeLock(studioLockAfterSave(lock, saved, hash));
|
|
982
|
+
return { ok: true, command: "studio.save", asset, source_hash: hash, saved: saved.saved !== false, changed: saved.changed !== false, draft_created: saved.draft_created !== false, reason: saved.reason || null, draft: saved, approval: "not_requested" };
|
|
983
|
+
}
|
|
984
|
+
catch (error) {
|
|
985
|
+
if (error instanceof CliError && (error.exitCode === 3 || error.code === "AGENT_NETWORK_FAILURE")) {
|
|
986
|
+
try {
|
|
987
|
+
const remote = await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/status`);
|
|
988
|
+
const reconciled = reconciledStudioLock(lock, remote, hash);
|
|
989
|
+
if (reconciled) {
|
|
990
|
+
await target.writeLock(reconciled);
|
|
991
|
+
return { ok: true, command: "studio.save", asset, source_hash: hash, saved: true, changed: false, draft_created: false, reconciled: true, reason: "identical_remote_head", draft: remote.head_snapshot, approval: "not_requested" };
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
catch { /* The original save error remains the useful failure. */ }
|
|
995
|
+
}
|
|
996
|
+
if (error instanceof CliError && error.exitCode === 3)
|
|
997
|
+
throw new CliError(error.message, "STUDIO_DRAFT_CONFLICT", 3, { ...structuredErrorDetails(error.details), local_source_preserved: true, source: target.sourcePath });
|
|
998
|
+
throw error;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
if (action === "open") {
|
|
1002
|
+
const view = flag(parsed, "view", "canvas");
|
|
1003
|
+
if (!VIEW_QUERY[view])
|
|
1004
|
+
throw new CliError(`Unknown Studio view: ${view}`, "STUDIO_VIEW_INVALID", 2);
|
|
1005
|
+
const context = await api("GET", "/v1/agent/context");
|
|
1006
|
+
const publicUrl = required(String(context.authoring_playbook_url || ""), "Server did not provide a public Studio origin.");
|
|
1007
|
+
const url = new URL(`/maker/interactive/studio/${encodeURIComponent(asset)}`, new URL(publicUrl).origin);
|
|
1008
|
+
url.search = new URLSearchParams(VIEW_QUERY[view]).toString();
|
|
1009
|
+
openExternal(url.toString());
|
|
1010
|
+
await reportBrowserActivity(asset, "browser.open", { view, status: "completed" });
|
|
1011
|
+
return { ok: true, command: "studio.open", asset, view, url: url.toString(), browser: "system" };
|
|
1012
|
+
}
|
|
1013
|
+
if (action === "screenshot")
|
|
1014
|
+
return capture(parsed, asset, false);
|
|
1015
|
+
if (action === "inspect")
|
|
1016
|
+
return capture(parsed, asset, true);
|
|
1017
|
+
if (action === "observe") {
|
|
1018
|
+
const result = await capture({ ...parsed, flags: new Map(parsed.flags).set("out", path.join(stateDirectory(), "last-observation.png")) }, asset, false, "browser.observe");
|
|
1019
|
+
return { ok: true, command: "studio.observe", asset, source_hash: result.source_hash, runtime: result.runtime, browser: result.browser };
|
|
1020
|
+
}
|
|
1021
|
+
if (action === "drive") {
|
|
1022
|
+
const scenarioPath = required(flag(parsed, "scenario"), "--scenario <file.json> is required.");
|
|
1023
|
+
const scenarioFile = path.resolve(scenarioPath);
|
|
1024
|
+
if ((await stat(scenarioFile)).size > 65536)
|
|
1025
|
+
throw new CliError("Scenario exceeds 64 KiB.", "STUDIO_SCENARIO_INVALID", 2);
|
|
1026
|
+
let scenarioDocument;
|
|
1027
|
+
try {
|
|
1028
|
+
scenarioDocument = JSON.parse(await readFile(scenarioFile, "utf8"));
|
|
1029
|
+
}
|
|
1030
|
+
catch {
|
|
1031
|
+
throw new CliError("Scenario is not valid JSON.", "STUDIO_SCENARIO_INVALID", 2);
|
|
1032
|
+
}
|
|
1033
|
+
const scenario = parseScenario(scenarioDocument);
|
|
1034
|
+
const candidate = await target.source();
|
|
1035
|
+
const hash = sourceHash(candidate);
|
|
1036
|
+
const remote = await studioStatus(parsed, asset);
|
|
1037
|
+
const remoteHash = String(remote.remote_source_hash || "");
|
|
1038
|
+
// A new scratch asset deliberately has no server source yet. The
|
|
1039
|
+
// browser opens its empty Studio route, then receives the unsaved local
|
|
1040
|
+
// candidate through the bounded authoring/test bridge.
|
|
1041
|
+
if (!remoteHash && remote.remote_snapshot_public_id) {
|
|
1042
|
+
throw new CliError("Studio has a draft without a readable source.", "STUDIO_SOURCE_UNAVAILABLE", 3);
|
|
1043
|
+
}
|
|
1044
|
+
const destination = path.resolve(flag(parsed, "out", `studio-drive-${asset}-${Date.now()}`));
|
|
1045
|
+
await mkdir(destination, { recursive: true });
|
|
1046
|
+
const scenarioHash = createHash("sha256").update(JSON.stringify(scenario)).digest("hex");
|
|
1047
|
+
const driveMetadata = { source_hash: hash, view: "learner", viewport: flag(parsed, "viewport", "desktop"), scenario_hash: scenarioHash, step_count: scenario.steps.length };
|
|
1048
|
+
let response;
|
|
1049
|
+
try {
|
|
1050
|
+
response = await daemonRpc("/browser/drive", {
|
|
1051
|
+
method: "POST",
|
|
1052
|
+
body: JSON.stringify({ asset, sourceHash: remoteHash, candidate, candidateHash: hash, scenario,
|
|
1053
|
+
viewport: flag(parsed, "viewport", "desktop"),
|
|
1054
|
+
out: path.join(destination, "initial.png"), timeoutMs: Number(flag(parsed, "timeout", "30000")) }),
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
catch (error) {
|
|
1058
|
+
const failure = error instanceof CliError ? error : new CliError(String(error));
|
|
1059
|
+
await reportBrowserActivity(asset, "studio.drive", { ...driveMetadata, status: "failed", error_code: failure.code }).catch(() => undefined);
|
|
1060
|
+
throw error;
|
|
1061
|
+
}
|
|
1062
|
+
await writeFile(path.join(destination, "result.json"), `${JSON.stringify(response.result, null, 2)}\n`);
|
|
1063
|
+
await reportBrowserActivity(asset, "studio.drive", { ...driveMetadata, status: "completed" });
|
|
1064
|
+
return { ok: true, command: "studio.drive", asset, source_hash: hash, output: destination,
|
|
1065
|
+
result: path.join(destination, "result.json"), steps: scenario.steps.length };
|
|
1066
|
+
}
|
|
1067
|
+
if (action === "capture-preview") {
|
|
1068
|
+
const { hash } = await assertBrowserSource(parsed, asset);
|
|
1069
|
+
const destination = path.resolve(flag(parsed, "out", `preview-capture-${new Date().toISOString().replace(/[:.]/g, "-")}`));
|
|
1070
|
+
const response = await daemonRpc("/browser/preview-observation", {
|
|
1071
|
+
method: "POST",
|
|
1072
|
+
body: JSON.stringify({
|
|
1073
|
+
asset, sourceHash: hash, out: destination,
|
|
1074
|
+
viewport: flag(parsed, "viewport", "480x320"),
|
|
1075
|
+
mode: flag(parsed, "mode", "semantic"),
|
|
1076
|
+
duration: Number(flag(parsed, "duration", "8")),
|
|
1077
|
+
fps: Number(flag(parsed, "fps", "4")),
|
|
1078
|
+
timeoutMs: Number(flag(parsed, "timeout", "30000")),
|
|
1079
|
+
}),
|
|
1080
|
+
});
|
|
1081
|
+
const result = response.result;
|
|
1082
|
+
await reportBrowserActivity(asset, "browser.observe", {
|
|
1083
|
+
source_hash: hash,
|
|
1084
|
+
status: "completed",
|
|
1085
|
+
view: "canvas",
|
|
1086
|
+
viewport: flag(parsed, "viewport", "480x320"),
|
|
1087
|
+
runtime_fingerprint: result.manifest?.runtime_fingerprint,
|
|
1088
|
+
runtime_ready: true,
|
|
1089
|
+
screenshot_sha256: createHash("sha256").update(await readFile(result.contact_sheet)).digest("hex"),
|
|
1090
|
+
});
|
|
1091
|
+
return {
|
|
1092
|
+
ok: true, command: "studio.capture-preview", asset, source_hash: hash,
|
|
1093
|
+
manifest: result.manifest, timeline: result.timeline,
|
|
1094
|
+
output: result.output, contact_sheet: result.contact_sheet, frames: result.frames,
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
if (action === "certify") {
|
|
1098
|
+
const { source } = await assertBrowserSource(parsed, asset);
|
|
1099
|
+
const observed = await capture({ ...parsed, flags: new Map(parsed.flags).set("out", path.join(stateDirectory(), "last-certification.png")) }, asset, false, "browser.observe");
|
|
1100
|
+
const runtimeObservation = certificationRuntimeObservation(observed.runtime);
|
|
1101
|
+
return { ok: true, command: "studio.certify", asset, result: await api("POST", `/v1/agent/studio/${encodeURIComponent(asset)}/certify`, { source, runtime_observation: runtimeObservation }) };
|
|
1102
|
+
}
|
|
1103
|
+
if (action === "export") {
|
|
1104
|
+
const revision = required(flag(parsed, "revision"), "--revision is required.");
|
|
1105
|
+
const out = path.resolve(flag(parsed, "out", `${asset}-${revision}.kspkg`));
|
|
1106
|
+
if (!out.endsWith(".kspkg"))
|
|
1107
|
+
throw new CliError("Export output must end in .kspkg.", "STUDIO_EXPORT_EXTENSION_INVALID", 2);
|
|
1108
|
+
await ensureCompatibility();
|
|
1109
|
+
const bytes = await rawBinaryApi("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/revisions/${encodeURIComponent(revision)}/export`);
|
|
1110
|
+
await mkdir(path.dirname(out), { recursive: true });
|
|
1111
|
+
await writeFile(out, bytes);
|
|
1112
|
+
return { ok: true, command: "studio.export", asset, revision, path: out, bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"), format: "kheleshikhi.package.v2/interactivePart", approved_revision_only: true };
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
throw new CliError(`Unknown command: ${parsed.args.join(" ")}`, "AGENT_COMMAND_UNKNOWN", 2);
|
|
1116
|
+
}
|
|
1117
|
+
function help() {
|
|
1118
|
+
return [
|
|
1119
|
+
`NerdBro Studio Agent ${VERSION}`,
|
|
1120
|
+
"",
|
|
1121
|
+
"Commands: setup, connect, disconnect, whoami, context, guide, schema [topic], capabilities",
|
|
1122
|
+
" docs | docs search <query> | docs open <slug>",
|
|
1123
|
+
" agent start|status|stop|instructions|init --provider claude|codex|generic",
|
|
1124
|
+
" component|behavior|example search <query>|inspect <id>",
|
|
1125
|
+
" studio checkout|status|source|history|validate|compile|preview|save <asset>",
|
|
1126
|
+
" studio create --scratch --title <text> --target <target> --concept <text> [--command-id <id>]",
|
|
1127
|
+
" studio brief <asset> | studio brief update <asset> [--concept <text> ...]",
|
|
1128
|
+
" studio open|screenshot|inspect|observe|capture-preview|certify <asset>",
|
|
1129
|
+
" studio drive <asset> --scenario scenario.json [--workspace dir] [--viewport desktop|mobile] [--out dir]",
|
|
1130
|
+
" studio inspect <asset> --view final|learner --viewport desktop|mobile",
|
|
1131
|
+
" studio capture-preview <asset> [--mode semantic|fixed] [--viewport 480x320] [--duration 8] [--fps 4] [--out dir]",
|
|
1132
|
+
" studio export <asset> --revision <revision> [--out file.kspkg]",
|
|
1133
|
+
"",
|
|
1134
|
+
"Library authoring (<kind> = experience|audio|component|behavior):",
|
|
1135
|
+
" experience|audio search <query> [--limit n] | inspect <stable-id>",
|
|
1136
|
+
" audio upload <file.m4a|file.mp4> AAC in MP4/M4A, at most 24 MiB",
|
|
1137
|
+
" audio create <stable-id> --name <text> --media <sha256> [--category c] [--language l]",
|
|
1138
|
+
" experience create <stable-id> --name <text> --kind <poem|story|...>",
|
|
1139
|
+
" (--scene-asset <asset-id> | --scene-package <kind:id> --scene <scene-id>)",
|
|
1140
|
+
" [--master-audio <audio-id>] [--duration-ms <n>]",
|
|
1141
|
+
" component create <stable-id> --name <text> [--category c] [--visual v] [--palette p]",
|
|
1142
|
+
" behavior create <stable-id> --name <text> [--declarative]",
|
|
1143
|
+
" <kind> checkout <stable-id> [--workspace dir] [--force]",
|
|
1144
|
+
" <kind> status|save <stable-id> [--workspace dir]",
|
|
1145
|
+
" <kind> validate|certify <stable-id> [--revision <revision>]",
|
|
1146
|
+
" experience|behavior preview <stable-id> local source.json -> preview.json",
|
|
1147
|
+
" experience|behavior export <stable-id> --revision <revision> [--out file.kspkg]",
|
|
1148
|
+
" <kind> inspect-browser <stable-id> [--viewport desktop|mobile|WxH]",
|
|
1149
|
+
"",
|
|
1150
|
+
"Flow: create or checkout -> edit source.json -> status -> save (draft) -> validate -> certify.",
|
|
1151
|
+
"Certification is technical evidence; approval and publication are human-only in the Studio.",
|
|
1152
|
+
"Docs, schemas and contracts are fetched live from the server; none is bundled.",
|
|
1153
|
+
"",
|
|
1154
|
+
"Every command supports --json.",
|
|
1155
|
+
"Finish an Attempt with: attempt finish --outcome completed|blocked|incomplete|abandoned.",
|
|
1156
|
+
].join("\n");
|
|
1157
|
+
}
|
|
1158
|
+
async function main() {
|
|
1159
|
+
const parsed = parse(process.argv.slice(2));
|
|
1160
|
+
let active = null;
|
|
1161
|
+
let commandId;
|
|
1162
|
+
try {
|
|
1163
|
+
if (!["_daemon", "--version", "version", "help", "--help"].includes(parsed.args[0] || "")) {
|
|
1164
|
+
active = await readAttemptPointer();
|
|
1165
|
+
commandId = `cmd_${randomUUID().replaceAll("-", "")}`;
|
|
1166
|
+
setCliTrace({ attemptId: active?.pointer.attempt_id, commandId });
|
|
1167
|
+
}
|
|
1168
|
+
const result = await run(parsed);
|
|
1169
|
+
if (active && commandId && parsed.args[0] !== "attempt")
|
|
1170
|
+
await saveCommandEvidence(active.pointer, commandId, parsed.args.slice(0, 2).join("."), result);
|
|
1171
|
+
output(result, parsed.json);
|
|
1172
|
+
}
|
|
1173
|
+
catch (error) {
|
|
1174
|
+
const failure = error instanceof CliError ? error : new CliError(String(error));
|
|
1175
|
+
if (active && commandId)
|
|
1176
|
+
await saveCommandEvidence(active.pointer, commandId, parsed.args.slice(0, 2).join("."), { ok: false, code: failure.code, message: failure.message }).catch(() => undefined);
|
|
1177
|
+
const result = { ok: false, code: failure.code, message: failure.message, details: failure.details };
|
|
1178
|
+
if (parsed.json)
|
|
1179
|
+
output(result, true);
|
|
1180
|
+
else
|
|
1181
|
+
process.stderr.write(`nerdbro: ${failure.message}\n`);
|
|
1182
|
+
process.exitCode = failure.exitCode;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
function invokedAsMain() {
|
|
1186
|
+
const executable = process.argv[1];
|
|
1187
|
+
if (!executable)
|
|
1188
|
+
return false;
|
|
1189
|
+
try {
|
|
1190
|
+
return fileURLToPath(import.meta.url) === realpathSync(executable);
|
|
1191
|
+
}
|
|
1192
|
+
catch {
|
|
1193
|
+
return fileURLToPath(import.meta.url) === path.resolve(executable);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
if (invokedAsMain())
|
|
1197
|
+
void main();
|
|
1198
|
+
//# sourceMappingURL=cli.js.map
|