golem-bridge 3.0.0 → 3.2.0

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.
Files changed (4) hide show
  1. package/README.md +107 -94
  2. package/cli.js +1237 -1109
  3. package/golem-tools.md +702 -694
  4. package/package.json +26 -23
package/cli.js CHANGED
@@ -1,1109 +1,1237 @@
1
- #!/usr/bin/env node
2
- "use strict";
3
-
4
- // golem-bridge: drive a live Roblox Studio session from any AI agent.
5
- // Session setup (connect/reconnect/disconnect) plus every Studio tool,
6
- // all through the Firebase command relay. Zero dependencies.
7
-
8
- const fs = require("fs");
9
- const path = require("path");
10
-
11
- const DB_URL = process.env.AIB_FIREBASE_DB || "https://roblox-golem-default-rtdb.firebaseio.com";
12
- const POLL_INTERVAL_MS = Math.max(250, (parseFloat(process.env.AIB_POLL_INTERVAL) || 2) * 1000);
13
- const FETCH_TIMEOUT_MS = 30000;
14
- const MAX_BODY_BYTES = 2 * 1024 * 1024;
15
- const DEFAULT_TIMEOUT = 120;
16
- // Channel IDs are hex tokens minted by the Studio plugin (16 chars today,
17
- // tolerated 8-64 for forward compatibility). Anything else is rejected so a
18
- // malformed ID can never alter the request URL.
19
- const CHANNEL_RE = /^[0-9a-fA-F]{8,64}$/;
20
-
21
- let VERSION = "unknown";
22
- try {
23
- VERSION = require("./package.json").version || "unknown";
24
- } catch {
25
- // running outside the package dir; version is informational only
26
- }
27
-
28
- function fail(message, exitCode) {
29
- console.error(`golem-bridge error: ${message}`);
30
- process.exit(exitCode || 1);
31
- }
32
-
33
- function sleep(ms) {
34
- return new Promise((resolve) => setTimeout(resolve, ms));
35
- }
36
-
37
- function parseRetryAfter(headers, fallback) {
38
- try {
39
- const v = parseFloat(headers.get("retry-after"));
40
- if (Number.isFinite(v) && v >= 0) return v;
41
- } catch {
42
- // fall through
43
- }
44
- return fallback;
45
- }
46
-
47
- async function fetchText(url, opts) {
48
- const o = opts || {};
49
- const ctrl = new AbortController();
50
- const timer = setTimeout(() => ctrl.abort(), o.timeoutMs || FETCH_TIMEOUT_MS);
51
- try {
52
- const res = await fetch(url, {
53
- method: o.method || (o.body === undefined ? "GET" : "POST"),
54
- headers: Object.assign({ Accept: "application/json" }, o.headers || {}),
55
- body: o.body,
56
- signal: ctrl.signal,
57
- redirect: "error", // responses must come from the relay itself
58
- });
59
- const text = await res.text();
60
- if (text.length > MAX_BODY_BYTES) {
61
- throw new Error("response too large, refusing to parse it");
62
- }
63
- return { ok: res.ok, status: res.status, headers: res.headers, text };
64
- } catch (err) {
65
- if (err && err.name === "AbortError") {
66
- throw new Error(`request timed out (${(o.timeoutMs || FETCH_TIMEOUT_MS) / 1000}s)`);
67
- }
68
- throw err;
69
- } finally {
70
- clearTimeout(timer);
71
- }
72
- }
73
-
74
- async function relayCall(channelId, op, args, timeoutSec) {
75
- const enc = encodeURIComponent(channelId);
76
- const cmdId = `c${Date.now()}${Math.floor(Math.random() * 1e6)}`;
77
- const payload = JSON.stringify({ id: cmdId, op, args: args || {}, ts: Math.floor(Date.now() / 1000) });
78
- let sinceKey = null;
79
- for (let attempt = 0; ; attempt++) {
80
- let r;
81
- try {
82
- r = await fetchText(`${DB_URL}/channels/${enc}/cmd.json`, { method: "POST", body: payload });
83
- } catch (err) {
84
- return { ok: false, error: `cannot reach the relay (${err.message}) - check internet/DNS` };
85
- }
86
- if (r.status === 429 && attempt < 3) {
87
- await sleep(parseRetryAfter(r.headers, 5 * (attempt + 1)) * 1000);
88
- continue;
89
- }
90
- if (!r.ok) {
91
- return { ok: false, error: `relay rejected the command: HTTP ${r.status}: ${r.text.slice(0, 200)}` };
92
- }
93
- try {
94
- sinceKey = (JSON.parse(r.text) || {}).name || null;
95
- } catch {
96
- sinceKey = null;
97
- }
98
- break;
99
- }
100
- const deadline = Date.now() + timeoutSec * 1000;
101
- let attempt = 0;
102
- while (Date.now() < deadline) {
103
- let r;
104
- try {
105
- r = await fetchText(`${DB_URL}/channels/${enc}/res.json?shallow=true`, { timeoutMs: 60000 });
106
- } catch {
107
- attempt++;
108
- await sleep(Math.min(15, 0.5 * 2 ** Math.min(attempt, 5)) * 1000);
109
- continue;
110
- }
111
- if (r.status === 429) {
112
- await sleep(parseRetryAfter(r.headers, 6) * 1000);
113
- continue;
114
- }
115
- if (!r.ok) {
116
- await sleep(2000);
117
- continue;
118
- }
119
- attempt = 0;
120
- let keys;
121
- try {
122
- keys = r.text === "null" ? {} : JSON.parse(r.text);
123
- } catch {
124
- keys = {};
125
- }
126
- if (keys && typeof keys === "object") {
127
- for (const key of Object.keys(keys).sort()) {
128
- if (sinceKey !== null && !(key > sinceKey)) continue;
129
- sinceKey = key;
130
- let entry;
131
- try {
132
- const er = await fetchText(`${DB_URL}/channels/${enc}/res/${encodeURIComponent(key)}.json`, {
133
- timeoutMs: 60000,
134
- });
135
- if (!er.ok) continue;
136
- entry = JSON.parse(er.text);
137
- } catch {
138
- continue;
139
- }
140
- if (entry && entry.id === cmdId) {
141
- if (entry.resultEncoded && typeof entry.result === "string") {
142
- try {
143
- entry.result = JSON.parse(entry.result);
144
- } catch {
145
- // keep the raw string
146
- }
147
- delete entry.resultEncoded;
148
- }
149
- return entry;
150
- }
151
- }
152
- }
153
- await sleep(POLL_INTERVAL_MS);
154
- }
155
- return {
156
- ok: false,
157
- error: `timed out after ${timeoutSec}s waiting for Studio to answer '${op}'. Is Roblox Studio open with the Golem plugin connected to the relay?`,
158
- };
159
- }
160
-
161
- function turnReminder(entry) {
162
- if (!entry || !entry.turnOpen) return;
163
- const n = entry.turnTools;
164
- const head =
165
- typeof n === "number"
166
- ? `>> TURN STILL OPEN (${n} tools) - do NOT reply yet.`
167
- : ">> TURN STILL OPEN - do NOT reply yet.";
168
- console.error(`${head} When this task is done, close it with:\n>> npx golem-bridge turn end --note "your reply"`);
169
- }
170
-
171
- // Exit codes: 0 = ok, 1 = Studio reported an error, 2 = usage error.
172
- function out(data, opts) {
173
- const o = opts || {};
174
- if (data && data.ok) {
175
- if (o.rawField && !o.asJson) {
176
- const result = data.result || {};
177
- if (typeof result[o.rawField] === "string") {
178
- process.stdout.write(result[o.rawField]);
179
- if (!result[o.rawField].endsWith("\n")) process.stdout.write("\n");
180
- turnReminder(data);
181
- process.exitCode = 0;
182
- return;
183
- }
184
- }
185
- console.log(JSON.stringify(data, null, 2));
186
- turnReminder(data);
187
- process.exitCode = 0;
188
- return;
189
- }
190
- console.error(JSON.stringify(data, null, 2));
191
- turnReminder(data);
192
- process.exitCode = data && "error" in data ? 1 : 2;
193
- }
194
-
195
- function vec3(s) {
196
- const parts = String(s)
197
- .replace(/,/g, " ")
198
- .split(/\s+/)
199
- .filter((v) => v !== "")
200
- .map(Number);
201
- if (parts.length === 0 || parts.some((v) => !Number.isFinite(v))) {
202
- throw new UsageError(`bad vector "${s}" (expected x,y,z numbers)`);
203
- }
204
- if (parts.length === 1) return { x: parts[0], y: 0, z: 0 };
205
- if (parts.length !== 3) throw new UsageError(`bad vector "${s}" (expected x,y,z)`);
206
- return { x: parts[0], y: parts[1], z: parts[2] };
207
- }
208
-
209
- class UsageError extends Error {}
210
-
211
- function loadChannel() {
212
- if (process.env.AIB_CHANNEL) return process.env.AIB_CHANNEL;
213
- try {
214
- const raw = fs.readFileSync(path.join(process.cwd(), ".golem", "channel"), "utf8").trim();
215
- if (raw) return raw;
216
- } catch {
217
- // fall through to the 2.x stamped helper below
218
- }
219
- for (const name of ["golem-helper.py", "golem.py"]) {
220
- try {
221
- const src = fs.readFileSync(path.join(process.cwd(), ".golem", name), "utf8");
222
- const m = src.match(/CHANNEL = os\.environ\.get\("AIB_CHANNEL", "([0-9a-fA-F]+)"\)/);
223
- if (m) return m[1];
224
- } catch {
225
- // try the next name
226
- }
227
- }
228
- return null;
229
- }
230
-
231
- function requireChannel() {
232
- const id = loadChannel();
233
- if (typeof id !== "string" || !CHANNEL_RE.test(id)) {
234
- out({ ok: false, error: "not connected (no channel saved) - run: npx golem-bridge connect <channelId>" });
235
- return null;
236
- }
237
- return id;
238
- }
239
-
240
- function validateChannel(channelId) {
241
- if (typeof channelId !== "string" || !CHANNEL_RE.test(channelId)) {
242
- fail("bad channel ID (expect 8-64 hex characters — copy the full line from the Studio widget).", 2);
243
- }
244
- return channelId;
245
- }
246
-
247
- function removeStaleHelpers(dir) {
248
- for (const stale of ["golem-helper.py", "golem-tools.md", "golem.py", "golem.md"]) {
249
- try {
250
- fs.rmSync(path.join(dir, stale), { force: true });
251
- } catch {
252
- // cleanup must never block a connect
253
- }
254
- }
255
- }
256
-
257
- function readStdin() {
258
- try {
259
- return fs.readFileSync(0, "utf8");
260
- } catch {
261
- return "";
262
- }
263
- }
264
-
265
- // ---------------------------------------------------------------- marketplace
266
-
267
- const MP_CATEGORIES = {
268
- model: "Model", models: "Model", mesh: "MeshPart", meshes: "MeshPart", meshpart: "MeshPart",
269
- decal: "Decal", decals: "Decal", image: "Decal", images: "Decal", picture: "Decal", texture: "Decal",
270
- audio: "Audio", sound: "Audio", sounds: "Audio", music: "Audio",
271
- video: "Video", videos: "Video", plugin: "Plugin", plugins: "Plugin",
272
- };
273
- const ASSET_TYPE_NAMES = { 1: "Image", 3: "Audio", 4: "Mesh", 9: "Decal", 10: "Model", 18: "Video", 19: "Font", 40: "MeshPart" };
274
-
275
- async function mpGet(url) {
276
- const r = await fetchText(url, { headers: { "User-Agent": "Golem-aib/1.0" }, timeoutMs: 20000 });
277
- if (!r.ok) throw new Error(`HTTP ${r.status} from Roblox`);
278
- return JSON.parse(r.text);
279
- }
280
-
281
- async function mpSearch(query, category, limit, cursor) {
282
- const assetType = MP_CATEGORIES[String(category || "model").toLowerCase()];
283
- if (!assetType) throw new Error(`unknown category '${category}' (valid: model, mesh, image, audio, video, plugin)`);
284
- let n = parseInt(limit, 10);
285
- if (!Number.isFinite(n)) n = 10;
286
- n = Math.max(1, Math.min(n, 50));
287
- let url = `https://apis.roblox.com/toolbox-service/v1/marketplace/${assetType}?keyword=${encodeURIComponent(String(query))}&limit=${n}`;
288
- if (cursor) url += `&cursor=${encodeURIComponent(String(cursor))}`;
289
- const data = await mpGet(url);
290
- const ids = ((data && data.data) || []).filter((it) => it && it.id != null).map((it) => it.id);
291
- const details = {};
292
- const thumbs = {};
293
- for (let i = 0; i < Math.min(ids.length, 12); i++) {
294
- try {
295
- details[ids[i]] = await mpGet(`https://economy.roblox.com/v2/assets/${ids[i]}/details`);
296
- } catch {
297
- // one bad asset must not kill the search
298
- }
299
- if (i < 11) await sleep(400); // the economy API rate limits hard
300
- }
301
- if (ids.length) {
302
- try {
303
- const tdata = await mpGet(
304
- `https://thumbnails.roblox.com/v1/assets?assetIds=${ids.slice(0, 12).join(",")}&size=420x420&format=Png`
305
- );
306
- for (const th of (tdata && tdata.data) || []) {
307
- if (th && th.targetId != null) thumbs[th.targetId] = th;
308
- }
309
- } catch {
310
- // thumbnails are a bonus
311
- }
312
- }
313
- const results = ids.map((aid) => {
314
- const d = details[aid];
315
- const th = thumbs[aid];
316
- const entry = { id: aid, category: assetType };
317
- if (d && typeof d === "object") {
318
- entry.name = d.Name;
319
- entry.assetTypeId = d.AssetTypeId;
320
- entry.assetType = ASSET_TYPE_NAMES[d.AssetTypeId];
321
- if (d.Creator && typeof d.Creator === "object") entry.creator = d.Creator.Name;
322
- if (d.PriceInRobux != null) entry.priceInRobux = d.PriceInRobux;
323
- if (d.IsForSale != null) entry.forSale = d.IsForSale;
324
- if (typeof d.Description === "string" && d.Description) entry.description = d.Description.slice(0, 280);
325
- } else {
326
- entry.detailsUnavailable = true;
327
- }
328
- if (th && typeof th === "object") {
329
- entry.thumbnail = th.imageUrl;
330
- entry.thumbnailState = th.state;
331
- }
332
- return entry;
333
- });
334
- const output = { totalResults: data.totalResults, results };
335
- if (data.nextPageCursor) output.nextPageCursor = data.nextPageCursor;
336
- return output;
337
- }
338
-
339
- async function mpInfo(assetId) {
340
- const aid = parseInt(assetId, 10);
341
- if (!Number.isFinite(aid)) throw new Error(`bad asset id '${assetId}'`);
342
- const d = await mpGet(`https://economy.roblox.com/v2/assets/${aid}/details`);
343
- const output = { id: aid };
344
- if (d && typeof d === "object") {
345
- output.name = d.Name;
346
- output.assetTypeId = d.AssetTypeId;
347
- output.assetType = ASSET_TYPE_NAMES[d.AssetTypeId];
348
- if (d.Creator && typeof d.Creator === "object") output.creator = d.Creator.Name;
349
- if (d.PriceInRobux != null) output.priceInRobux = d.PriceInRobux;
350
- if (d.IsForSale != null) output.forSale = d.IsForSale;
351
- if (typeof d.Description === "string") output.description = d.Description.slice(0, 1000);
352
- }
353
- try {
354
- const tdata = await mpGet(`https://thumbnails.roblox.com/v1/assets?assetIds=${aid}&size=420x420&format=Png`);
355
- if (Array.isArray(tdata && tdata.data) && tdata.data.length) {
356
- output.thumbnail = tdata.data[0].imageUrl;
357
- output.thumbnailState = tdata.data[0].state;
358
- }
359
- } catch {
360
- // thumbnails are a bonus
361
- }
362
- return output;
363
- }
364
-
365
- function marketplaceViaPlugin() {
366
- return (process.env.AIB_MARKETPLACE || "").toLowerCase() === "plugin";
367
- }
368
-
369
- async function status() {
370
- const channel = loadChannel();
371
- if (typeof channel !== "string" || !CHANNEL_RE.test(channel)) {
372
- return { ok: false, error: "not connected (no channel saved) - run: npx golem-bridge connect <channelId>" };
373
- }
374
- let beaconData;
375
- let resKeys;
376
- try {
377
- const enc = encodeURIComponent(channel);
378
- const bb = await fetchText(`${DB_URL}/channels/${enc}/beacons.json?orderBy=${encodeURIComponent('"$key"')}&limitToLast=5`, {
379
- timeoutMs: 30000,
380
- });
381
- beaconData = bb.text === "null" ? {} : JSON.parse(bb.text);
382
- if (typeof beaconData !== "object" || beaconData === null) beaconData = {};
383
- const rb = await fetchText(`${DB_URL}/channels/${enc}/res.json?shallow=true`, { timeoutMs: 30000 });
384
- resKeys = rb.text === "null" ? {} : JSON.parse(rb.text);
385
- if (typeof resKeys !== "object" || resKeys === null) resKeys = {};
386
- } catch (err) {
387
- return { ok: false, error: `cannot read the relay channel: ${err.message}` };
388
- }
389
- const results = Object.values(resKeys).filter((v) => v === true).length;
390
- const beacons = Object.values(beaconData)
391
- .filter((e) => e && (e.op === "hello" || e.op === "paused" || e.op === "revoked"))
392
- .map((e) => [parseFloat(e.ts) || 0, e]);
393
- if (!beacons.length) {
394
- const verdict = results
395
- ? `no beacons, but ${results} cached result(s) - Studio is not running or runs an older plugin; ask the user to fully restart Studio with the current plugin`
396
- : "result channel is empty - the plugin has never posted here: Studio is closed or was not restarted after a plugin update";
397
- return { ok: true, beacon: null, ageSeconds: null, recentResults: results, verdict };
398
- }
399
- beacons.sort((a, b) => a[0] - b[0]);
400
- const [ts, beacon] = beacons[beacons.length - 1];
401
- const age = Math.max(0, Math.floor(Date.now() / 1000 - ts));
402
- const ver = beacon.v || "?";
403
- const verdict =
404
- age <= 900
405
- ? `plugin is LIVE (v${ver}, hello beacon ${age}s ago) - it should answer commands`
406
- : `last hello was ${Math.floor(age / 60)} min ago (v${ver}) - Studio may be closed or hung since then; ask the user to check the Golem window`;
407
- return { ok: true, beacon, ageSeconds: age, recentResults: results, verdict };
408
- }
409
-
410
- // ---------------------------------------------------------------- tool table
411
- //
412
- // pos: ["name"] = required, ["name", default] = optional, ["name", "+"]
413
- // = one-or-more, 3rd element = choices, 4th = value type.
414
- // flags: name: "bool"|"str"|"int"|"float"|"append" or [type, {flag, choices,
415
- // default, required}]. Default flag spelling is the name with _ as -.
416
-
417
- const GROUPS = [
418
- ["Connection", ["ping", "debug", "status"]],
419
- ["Exploring", ["list", "tree", "find", "grep", "count", "look"]],
420
- ["Scripts and Lua", ["read", "script", "lua", "exec"]],
421
- ["Organizing", ["delete", "move", "group", "duplicate", "rename", "selection", "waypoint", "undo", "attr", "tag"]],
422
- ["Moving", ["rotate", "face", "shift", "scale", "place", "pivot"]],
423
- ["Surfaces and physics", ["paint", "anchor", "collide", "terrain"]],
424
- ["Gameplay", ["light", "sound", "prompt", "hitbox", "particles", "sign", "scatter", "match", "weld"]],
425
- ["Effects", ["beam", "trail", "explosion"]],
426
- ["UI", ["ui_screen", "ui_frame", "ui_label", "ui_button", "ui_input", "ui_image", "ui_list"]],
427
- ["Marketplace", ["search", "info", "insert", "apply"]],
428
- ["Playtesting", ["play", "stop", "logs"]],
429
- ["Session", ["say", "turn"]],
430
- ];
431
-
432
- const TOOLS = {
433
- ping: { help: "health check - is Studio connected?" },
434
- debug: { help: "diagnostics: relay round-trip, marketplace reachability, HTTP state" },
435
- status: { help: "is the plugin alive, paused, or the link revoked? (no Studio needed)", local: true },
436
- exec: { help: 'send a raw op JSON: {"op":..., "args":...}', pos: [["json"]] },
437
- lua: { help: "run Lua inside Studio (code arg, or - for stdin)", op: "run", pos: [["code", null]] },
438
- list: { help: "children of a path", pos: [["path", "game"]], flags: { recursive: "bool", max: "int" } },
439
- tree: { help: "recursive tree of a path", pos: [["path", "game"]], flags: { depth: "int" } },
440
- read: { help: "read an instance (script source by default, --json for full record)", pos: ["path"], flags: { json: "bool", props: "str" } },
441
- find: { help: "find instances by name or --tag", pos: [["query", ""]], flags: { cls: ["str", { flag: "--class" }], scope: ["str", { default: "game" }], max: "int", exact: "bool", tag: "str" } },
442
- grep: { help: "search script sources", pos: ["pattern"], flags: { scope: ["str", { default: "game" }], max: "int", i: ["bool", { flag: "-i" }] } },
443
- script: { help: "create/update a script (source from stdin or --source)", pos: ["parent", "name"], flags: { cls: ["str", { flag: "--class", default: "Script", choices: ["Script", "LocalScript", "ModuleScript"] }], mode: ["str", { choices: ["create", "update", "replace"], default: "create" }], source: "str" } },
444
- delete: { help: "delete instances", pos: [["paths", "+"]] },
445
- move: { help: "reparent an instance", pos: ["path", "parent"] },
446
- selection: { help: "read or set the Studio selection", pos: [], flags: { set: "str", clear: "bool" } },
447
- waypoint: { help: "set an undo checkpoint", pos: [["label", "bridge"]] },
448
- say: { help: "post a message to the user's Studio chat (closes the turn)", pos: ["text"] },
449
- turn: { help: "mark work turns: begin ... tools ... end --note (required protocol)", pos: [["action", undefined, ["begin", "end"]]], flags: { note: "str" } },
450
- rotate: { help: "rotate: --axis y --degrees 90 (relative) or --set 0,90,0 (absolute)", pos: ["path"], flags: { axis: "str", degrees: "float", absolute: ["str", { flag: "--set" }], space: ["str", { choices: ["world", "local"], default: "world" }] } },
451
- face: { help: "aim an instance's axis at a world point (keeps position)", pos: ["path", "target"], flags: { axis: ["str", { default: "forward" }] } },
452
- shift: { help: "move by an offset in studs (world or local)", pos: ["path", "offset"], flags: { space: ["str", { choices: ["world", "local"], default: "world" }] } },
453
- scale: { help: "scale a part/model by a relative factor", pos: ["path", ["factor", undefined, undefined, "float"]] },
454
- duplicate: { help: "clone an instance (optionally N times with spacing)", pos: ["path"], flags: { count: ["int", { default: 1 }], offset: "str", parent: "str", name: "str" } },
455
- group: { help: "wrap instances into a Model", pos: [["paths", "+"]], flags: { name: ["str", { default: "Group" }], parent: "str" } },
456
- pivot: { help: "set a model/part pivot (what it rotates around)", op: "set_pivot", pos: ["path"], flags: { position: "str", orientation: "str" } },
457
- place: { help: "absolute position via pivot (parts and models)", pos: ["path", "position"], flags: { orientation: "str" } },
458
- paint: { help: "set color/material/transparency/reflectance on parts", pos: [["paths", "+"]], flags: { color: "str", material: "str", transparency: "float", reflectance: "float" } },
459
- rename: { help: "rename an instance", pos: ["path", "name"] },
460
- look: { help: "aim the Studio editor camera at a path", pos: ["path"], flags: { distance: ["float", { default: 30 }] } },
461
- count: { help: "count instances (cheap, no payloads)", pos: [["scope", "game"]], flags: { cls: ["str", { flag: "--class" }] } },
462
- undo: { help: "one Studio undo step (Ctrl+Z)" },
463
- anchor: { help: "anchor parts in place (models: all their parts)", pos: [["paths", "+"]], flags: { off: "bool" } },
464
- collide: { help: "enable part collision (models: all their parts)", pos: [["paths", "+"]], flags: { off: "bool" } },
465
- light: { help: "add or update a light inside a part", pos: ["path"], flags: { light_type: ["str", { flag: "--type", default: "point", choices: ["point", "spot", "surface"] }], color: "str", range: "float", brightness: "float", shadows: "bool" } },
466
- sound: { help: "add a Sound to a parent, optionally play it", pos: ["parent", "id"], flags: { volume: "float", loop: "bool", play: "bool", name: "str" } },
467
- weld: { help: "weld a model's parts together with WeldConstraints", pos: [["paths", "+"]] },
468
- hitbox: { help: "invisible hitbox part sized to a target", pos: ["path"], flags: { padding: ["float", { default: 0.5 }], name: "str", collide: "bool" } },
469
- prompt: { help: "add a ProximityPrompt to a part (Press E to ...)", pos: ["path", "action"], flags: { object: "str", hold: ["float", { default: 0 }], distance: ["float", { default: 8 }] } },
470
- particles: { help: "attach a particle preset to a part", pos: ["path", ["preset", undefined, ["leaves", "sparks", "smoke", "magic", "fire", "snow", "rain", "bubbles", "dust", "confetti", "fireflies"]]], flags: { rate: "float", color: "str" } },
471
- sign: { help: "place a readable wooden sign", pos: ["text"], flags: { position: "str", parent: "str", size: "str", name: "str" } },
472
- beam: { help: "glowing beam between two parts", pos: ["from", "to"], flags: { color: "str", width: "float", curve: "float", name: "str" } },
473
- trail: { help: "motion trail on a moving part", pos: ["path"], flags: { color: "str", lifetime: "float", name: "str" } },
474
- explosion: { help: "one-shot explosion (visual only)", pos: [], flags: { position: "str", radius: "float" } },
475
- ui_screen: { help: "create a ScreenGui under StarterGui", pos: ["name"], flags: { parent: "str", order: "int" } },
476
- ui_frame: { help: "rounded panel/frame", pos: ["parent", "name"], flags: { position: "str", size: "str", anchor: "str", color: "str", transparency: "float", radius: "int", clip: "bool" } },
477
- ui_label: { help: "text label", pos: ["parent", "name"], flags: { text: "str", position: "str", size: "str", anchor: "str", color: "str", align: "str", font: "str", text_size: "int", wrap: "bool" } },
478
- ui_button: { help: "text button", pos: ["parent", "name"], flags: { text: "str", position: "str", size: "str", anchor: "str", color: "str", text_color: "str", radius: "int", font: "str", text_size: "int" } },
479
- ui_input: { help: "TextBox the player can type into", pos: ["parent", "name"], flags: { placeholder: "str", text: "str", position: "str", size: "str", color: "str", background: "str", radius: "int", text_size: "int" } },
480
- ui_image: { help: "ImageLabel from a Roblox asset id", pos: ["parent", "name"], flags: { asset: "str", position: "str", size: "str", scale: "str" } },
481
- ui_list: { help: "UIListLayout that auto-arranges a container's children", pos: ["parent"], flags: { direction: "str", padding: "int", halign: "str", valign: "str" } },
482
- play: { help: "start play-testing the game (client when possible, Run mode fallback)", pos: [], flags: { mode: ["str", { choices: ["play", "run"] }] } },
483
- stop: { help: "stop the running play test" },
484
- logs: { help: "read Studio output - errors and warnings from the play test", pos: [], flags: { all: "bool", limit: "int", since: "float" } },
485
- attr: { help: "read/set/clear Studio attributes on an instance", op: "attributes", pos: ["path"], flags: { set: "append", clear: "append" } },
486
- tag: { help: "add/remove CollectionService tags", pos: [["paths", "+"]], flags: { add: "append", remove: "append" } },
487
- match: { help: "copy color/material/transparency from one part onto targets", pos: ["from_path", ["to", "+"]] },
488
- scatter: { help: "scatter N copies of a template in a disc around it", pos: ["path"], flags: { count: ["int", { default: 10 }], radius: ["float", { default: 20 }], y_jitter: ["float", { default: 0 }], parent: "str", name: "str" } },
489
- terrain: { help: "fill or clear terrain (block or ball)", pos: [], flags: { action: ["str", { choices: ["fill", "clear"], default: "fill" }], shape: ["str", { choices: ["block", "ball"], default: "block" }], position: "str", size: "str", radius: "float", material: ["str", { default: "Grass" }] } },
490
- search: { help: "search the Roblox Creator Store (models, meshes, images, audio)", pos: ["query"], flags: { category: ["str", { default: "model" }], limit: "int", cursor: "str" }, local: true },
491
- info: { help: "details + thumbnail for one marketplace asset", pos: ["id"], local: true },
492
- insert: { help: "insert a marketplace asset into the place", op: "insert_asset", pos: ["id", ["parent", "Workspace"]], flags: { name: "str" } },
493
- apply: { help: "apply an asset id to a property (Image, Texture, SoundId, MeshId...)", op: "apply_asset", pos: ["id", "path", "prop"] },
494
- };
495
- function stripTimeout(argv) {
496
- const out = [];
497
- let timeout = null;
498
- for (let i = 0; i < argv.length; i++) {
499
- const a = argv[i];
500
- if (a === "--timeout" && i + 1 < argv.length) { timeout = parseFloat(argv[++i]); continue; }
501
- if (a.startsWith("--timeout=")) { timeout = parseFloat(a.slice(10)); continue; }
502
- out.push(a);
503
- }
504
- if (timeout !== null && !(timeout > 0)) throw new UsageError("--timeout must be a positive number of seconds");
505
- return { args: out, timeout };
506
- }
507
-
508
- function parseToolArgs(name, argv) {
509
- const spec = TOOLS[name];
510
- const ns = {};
511
- const bools = {}, vals = {}, shorts = {};
512
- for (const dest of Object.keys(spec.flags || {})) {
513
- const fs = spec.flags[dest];
514
- const t = Array.isArray(fs) ? fs[0] : fs;
515
- const o = Array.isArray(fs) ? (fs[1] || {}) : {};
516
- const flag = o.flag || "--" + dest.replace(/_/g, "-");
517
- if (o.short) shorts[o.short] = dest;
518
- if (t === "bool") { ns[dest] = !!o.invert; bools[flag] = { dest, invert: !!o.invert }; }
519
- else { ns[dest] = t === "append" ? [] : (o.default !== undefined ? o.default : null); vals[flag] = { dest, type: t, choices: o.choices, multi: t === "append" }; }
520
- }
521
- const coerce = (fl, raw) => {
522
- let v = raw;
523
- if (fl.type === "int") { v = parseInt(raw, 10); if (!Number.isInteger(v)) throw new UsageError(`${name}: bad integer for ${fl.dest}: ${raw}`); }
524
- else if (fl.type === "float") { v = parseFloat(raw); if (!Number.isFinite(v)) throw new UsageError(`${name}: bad number for ${fl.dest}: ${raw}`); }
525
- if (fl.choices && !fl.choices.includes(fl.type === "int" ? v : String(v))) throw new UsageError(`${name}: bad value for ${fl.dest}: ${raw} (need ${fl.choices.join("|")})`);
526
- return v;
527
- };
528
- const pos = [];
529
- let i = 0, ddash = false;
530
- while (i < argv.length) {
531
- const t = argv[i];
532
- if (ddash) { pos.push(t); i++; continue; }
533
- if (t === "--") { ddash = true; i++; continue; }
534
- if (t.startsWith("--")) {
535
- const eq = t.indexOf("=");
536
- const fl = eq === -1 ? t : t.slice(0, eq);
537
- if (fl in bools) {
538
- if (eq !== -1) throw new UsageError(`${name}: ${fl} takes no value`);
539
- ns[bools[fl].dest] = !bools[fl].invert; i++; continue;
540
- }
541
- const v = vals[fl];
542
- if (!v) throw new UsageError(`${name}: unknown flag ${fl}`);
543
- let raw;
544
- if (eq !== -1) raw = t.slice(eq + 1);
545
- else { i++; if (i >= argv.length) throw new UsageError(`${name}: ${fl} needs a value`); raw = argv[i]; }
546
- const c = coerce(v, raw);
547
- if (v.multi) ns[v.dest].push(c); else ns[v.dest] = c;
548
- i++; continue;
549
- }
550
- if (t in bools) { ns[bools[t].dest] = !bools[t].invert; i++; continue; }
551
- if (t.startsWith("-") && t.length > 1 && !/^-\d/.test(t)) {
552
- for (const c of t.slice(1)) {
553
- if (!(c in shorts)) throw new UsageError(`${name}: unknown flag -${c}`);
554
- ns[shorts[c]] = true;
555
- }
556
- i++; continue;
557
- }
558
- pos.push(t); i++;
559
- }
560
- const out = {};
561
- let pi = 0;
562
- for (const p of (spec.pos || [])) {
563
- const a = Array.isArray(p) ? p : [p];
564
- const pname = a[0], pdef = a[1], pchoices = a[2], ptype = a[3];
565
- if (pdef === "+") {
566
- if (pi >= pos.length) throw new UsageError(`${name}: need at least one ${pname}`);
567
- out[pname] = pos.slice(pi); pi = pos.length;
568
- } else if (pi < pos.length) {
569
- const raw = pos[pi++];
570
- let v = raw;
571
- if (ptype === "int") { v = parseInt(raw, 10); if (!Number.isInteger(v)) throw new UsageError(`${name}: bad integer for ${pname}: ${raw}`); }
572
- else if (ptype === "float") { v = parseFloat(raw); if (!Number.isFinite(v)) throw new UsageError(`${name}: bad number for ${pname}: ${raw}`); }
573
- if (pchoices && !pchoices.includes(ptype ? v : String(v))) throw new UsageError(`${name}: bad ${pname}: ${raw} (need ${pchoices.join("|")})`);
574
- out[pname] = v;
575
- } else if (pdef !== undefined) out[pname] = pdef;
576
- else throw new UsageError(`${name}: missing ${pname}`);
577
- }
578
- if (pi < pos.length) throw new UsageError(`${name}: too many arguments (got "${pos[pi]}")`);
579
- return Object.assign(ns, out);
580
- }
581
- function scalar(s) {
582
- const t = s.trim();
583
- if (/^[+-]?\d+$/.test(t)) { const n = parseInt(t, 10); if (Number.isSafeInteger(n)) return n; }
584
- if (/^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$/.test(t)) { const f = parseFloat(t); if (Number.isFinite(f)) return f; }
585
- if (t === "true") return true;
586
- if (t === "false") return false;
587
- return s;
588
- }
589
-
590
- function buildArgs(name, ns) {
591
- const V = (s) => vec3(s);
592
- switch (name) {
593
- case "exec": {
594
- let raw;
595
- try { raw = JSON.parse(ns.json); } catch { throw new UsageError("exec: invalid JSON"); }
596
- if (!raw || typeof raw !== "object" || Array.isArray(raw) || !("op" in raw))
597
- throw new UsageError('exec: JSON must be an object with an "op" key');
598
- return { op: raw.op, args: (raw.args && typeof raw.args === "object" && !Array.isArray(raw.args)) ? raw.args : {} };
599
- }
600
- case "lua": {
601
- let code = ns.code;
602
- if (code === "-" || code == null) code = process.stdin.isTTY ? "" : readStdin();
603
- if (!code) throw new UsageError("no Lua code given (pass it as an argument or pipe it in)");
604
- return { op: "run", args: { code } };
605
- }
606
- case "list": {
607
- const a = { path: ns.path };
608
- if (ns.recursive) a.recursive = true;
609
- if (ns.max) a.max = ns.max;
610
- return { op: "list", args: a };
611
- }
612
- case "tree":
613
- return { op: "tree", args: { path: ns.path, depth: ns.depth || 2 } };
614
- case "read": {
615
- const a = { path: ns.path };
616
- if (ns.props) a.props = ns.props.split(",").map((s) => s.trim()).filter(Boolean);
617
- return { op: "read", args: a, asJson: !!ns.json, rawField: "source" };
618
- }
619
- case "find": {
620
- const a = { scope: ns.scope };
621
- if (ns.tag) a.tag = ns.tag;
622
- else a.query = ns.query;
623
- if (ns.cls) a.class = ns.cls;
624
- if (ns.max) a.max = ns.max;
625
- if (ns.exact) a.exact = true;
626
- return { op: "find", args: a };
627
- }
628
- case "grep": {
629
- const a = { pattern: ns.pattern, scope: ns.scope };
630
- if (ns.max) a.max = ns.max;
631
- if (ns.i) a.caseSensitive = false;
632
- return { op: "grep", args: a };
633
- }
634
- case "script": {
635
- let source = ns.source;
636
- if (source == null && !process.stdin.isTTY) source = readStdin();
637
- if (source == null) throw new UsageError("pass the script source via stdin (heredoc/pipe) or --source");
638
- return { op: "script", args: { parent: ns.parent, name: ns.name, class: ns.cls, mode: ns.mode, source } };
639
- }
640
- case "delete":
641
- return { op: "delete", args: { paths: ns.paths } };
642
- case "move":
643
- return { op: "move", args: { path: ns.path, parent: ns.parent } };
644
- case "selection": {
645
- if (ns.clear) return { op: "selection", args: { clear: true } };
646
- if (ns.set) return { op: "selection", args: { set: ns.set.split(",").map((s) => s.trim()).filter(Boolean) } };
647
- return { op: "selection", args: {} };
648
- }
649
- case "waypoint":
650
- return { op: "waypoint", args: { label: ns.label } };
651
- case "say":
652
- return { op: "say", args: { text: ns.text } };
653
- case "turn": {
654
- if (ns.action === "begin") return { op: "turn_begin", args: {} };
655
- const a = {};
656
- if (ns.note) a.note = ns.note;
657
- return { op: "turn_end", args: a };
658
- }
659
- case "rotate": {
660
- const a = { path: ns.path, space: ns.space };
661
- if (ns.absolute != null) a.orientation = V(ns.absolute);
662
- else { a.axis = ns.axis != null ? ns.axis : null; a.degrees = ns.degrees != null ? ns.degrees : null; }
663
- return { op: "rotate", args: a };
664
- }
665
- case "face":
666
- return { op: "face", args: { path: ns.path, target: V(ns.target), axis: ns.axis } };
667
- case "shift":
668
- return { op: "shift", args: { path: ns.path, offset: V(ns.offset), space: ns.space } };
669
- case "scale":
670
- return { op: "scale", args: { path: ns.path, factor: ns.factor } };
671
- case "duplicate": {
672
- const a = { path: ns.path, count: ns.count };
673
- if (ns.offset) a.offset = V(ns.offset);
674
- if (ns.parent) a.parent = ns.parent;
675
- if (ns.name) a.name = ns.name;
676
- return { op: "duplicate", args: a };
677
- }
678
- case "group": {
679
- const a = { paths: ns.paths, name: ns.name };
680
- if (ns.parent) a.parent = ns.parent;
681
- return { op: "group", args: a };
682
- }
683
- case "pivot": {
684
- const a = { path: ns.path };
685
- if (ns.position) a.position = V(ns.position);
686
- if (ns.orientation) a.orientation = V(ns.orientation);
687
- return { op: "set_pivot", args: a };
688
- }
689
- case "place": {
690
- const a = { path: ns.path, position: V(ns.position) };
691
- if (ns.orientation) a.orientation = V(ns.orientation);
692
- return { op: "place", args: a };
693
- }
694
- case "paint": {
695
- const a = { paths: ns.paths };
696
- if (ns.color) a.color = ns.color;
697
- if (ns.material) a.material = ns.material;
698
- if (ns.transparency != null) a.transparency = ns.transparency;
699
- if (ns.reflectance != null) a.reflectance = ns.reflectance;
700
- return { op: "paint", args: a };
701
- }
702
- case "rename":
703
- return { op: "rename", args: { path: ns.path, name: ns.name } };
704
- case "look":
705
- return { op: "look", args: { path: ns.path, distance: ns.distance } };
706
- case "count": {
707
- const a = { scope: ns.scope };
708
- if (ns.cls) a.class = ns.cls;
709
- return { op: "count", args: a };
710
- }
711
- case "undo":
712
- return { op: "undo", args: {} };
713
- case "anchor":
714
- return { op: "anchor", args: { paths: ns.paths, anchored: !ns.off } };
715
- case "collide":
716
- return { op: "collide", args: { paths: ns.paths, canCollide: !ns.off } };
717
- case "light": {
718
- const a = { path: ns.path, type: ns.light_type };
719
- if (ns.color) a.color = ns.color;
720
- if (ns.range != null) a.range = ns.range;
721
- if (ns.brightness != null) a.brightness = ns.brightness;
722
- if (ns.shadows) a.shadows = true;
723
- return { op: "light", args: a };
724
- }
725
- case "sound": {
726
- const a = { parent: ns.parent, id: ns.id };
727
- if (ns.volume != null) a.volume = ns.volume;
728
- if (ns.loop) a.looped = true;
729
- if (ns.play) a.play = true;
730
- if (ns.name) a.name = ns.name;
731
- return { op: "sound", args: a };
732
- }
733
- case "weld":
734
- return { op: "weld", args: { paths: ns.paths } };
735
- case "hitbox": {
736
- const a = { path: ns.path, padding: ns.padding };
737
- if (ns.name) a.name = ns.name;
738
- if (ns.collide) a.canCollide = true;
739
- return { op: "hitbox", args: a };
740
- }
741
- case "prompt": {
742
- const a = { path: ns.path, action: ns.action, hold: ns.hold, distance: ns.distance };
743
- if (ns.object) a.object = ns.object;
744
- return { op: "prompt", args: a };
745
- }
746
- case "particles": {
747
- const a = { path: ns.path, preset: ns.preset };
748
- if (ns.rate != null) a.rate = ns.rate;
749
- if (ns.color) a.color = ns.color;
750
- return { op: "particles", args: a };
751
- }
752
- case "sign": {
753
- const a = { text: ns.text };
754
- if (ns.position) a.position = V(ns.position);
755
- if (ns.parent) a.parent = ns.parent;
756
- if (ns.size) a.size = V(ns.size);
757
- if (ns.name) a.name = ns.name;
758
- return { op: "sign", args: a };
759
- }
760
- case "beam": {
761
- const a = { from: ns.from, to: ns.to };
762
- for (const k of ["color", "width", "curve", "name"]) if (ns[k] != null) a[k] = ns[k];
763
- return { op: "beam", args: a };
764
- }
765
- case "trail": {
766
- const a = { path: ns.path };
767
- for (const k of ["color", "lifetime", "name"]) if (ns[k] != null) a[k] = ns[k];
768
- return { op: "trail", args: a };
769
- }
770
- case "explosion": {
771
- const a = {};
772
- if (ns.position) a.position = V(ns.position);
773
- if (ns.radius != null) a.radius = ns.radius;
774
- return { op: "explosion", args: a };
775
- }
776
- case "ui_screen": {
777
- const a = { name: ns.name };
778
- if (ns.parent) a.parent = ns.parent;
779
- if (ns.order != null) a.order = ns.order;
780
- return { op: "ui_screen", args: a };
781
- }
782
- case "ui_frame": {
783
- const a = { parent: ns.parent, name: ns.name };
784
- for (const k of ["position", "size", "anchor", "color", "transparency", "radius"]) if (ns[k] != null) a[k] = ns[k];
785
- if (ns.clip) a.clip = true;
786
- return { op: "ui_frame", args: a };
787
- }
788
- case "ui_label": {
789
- const a = { parent: ns.parent, name: ns.name };
790
- for (const k of ["text", "position", "size", "anchor", "color", "align", "font", "text_size"]) if (ns[k] != null) a[k] = ns[k];
791
- if (ns.wrap) a.wrap = true;
792
- return { op: "ui_label", args: a };
793
- }
794
- case "ui_button": {
795
- const a = { parent: ns.parent, name: ns.name };
796
- for (const k of ["text", "position", "size", "anchor", "color", "text_color", "radius", "font", "text_size"]) if (ns[k] != null) a[k] = ns[k];
797
- return { op: "ui_button", args: a };
798
- }
799
- case "ui_input": {
800
- const a = { parent: ns.parent, name: ns.name };
801
- for (const k of ["placeholder", "text", "position", "size", "color", "background", "radius", "text_size"]) if (ns[k] != null) a[k] = ns[k];
802
- return { op: "ui_input", args: a };
803
- }
804
- case "ui_image": {
805
- const a = { parent: ns.parent, name: ns.name };
806
- if (ns.asset) a.asset = ns.asset;
807
- for (const k of ["position", "size", "scale"]) if (ns[k] != null) a[k] = ns[k];
808
- return { op: "ui_image", args: a };
809
- }
810
- case "ui_list": {
811
- const a = { parent: ns.parent };
812
- for (const k of ["direction", "padding", "halign", "valign"]) if (ns[k] != null) a[k] = ns[k];
813
- return { op: "ui_list", args: a };
814
- }
815
- case "play": {
816
- const a = {};
817
- if (ns.mode) a.mode = ns.mode;
818
- return { op: "play", args: a };
819
- }
820
- case "stop":
821
- return { op: "stop", args: {} };
822
- case "logs": {
823
- const a = ns.all ? { filter: "all" } : {};
824
- if (ns.limit != null) a.limit = ns.limit;
825
- if (ns.since != null) a.since = ns.since;
826
- return { op: "logs", args: a };
827
- }
828
- case "attr": {
829
- const a = { path: ns.path };
830
- if (ns.set && ns.set.length) {
831
- const o = {};
832
- for (const kv of ns.set) {
833
- const e = kv.indexOf("=");
834
- if (e === -1) throw new UsageError("--set expects K=V");
835
- o[kv.slice(0, e)] = scalar(kv.slice(e + 1));
836
- }
837
- a.set = o;
838
- }
839
- if (ns.clear && ns.clear.length) a.clear = ns.clear;
840
- return { op: "attributes", args: a };
841
- }
842
- case "tag": {
843
- const a = { paths: ns.paths };
844
- if (ns.add && ns.add.length) a.add = ns.add;
845
- if (ns.remove && ns.remove.length) a.remove = ns.remove;
846
- return { op: "tag", args: a };
847
- }
848
- case "match":
849
- return { op: "match", args: { from: ns.from_path, paths: ns.to } };
850
- case "scatter": {
851
- const a = { path: ns.path, count: ns.count, radius: ns.radius, yJitter: ns.y_jitter };
852
- if (ns.parent) a.parent = ns.parent;
853
- if (ns.name) a.name = ns.name;
854
- return { op: "scatter", args: a };
855
- }
856
- case "terrain": {
857
- if (!ns.position) throw new UsageError("terrain: --position x,y,z is required");
858
- const a = { action: ns.action, shape: ns.shape, position: V(ns.position), material: ns.material };
859
- if (ns.shape === "block") {
860
- if (!ns.size) throw new UsageError("--size x,y,z is required for block");
861
- a.size = V(ns.size);
862
- } else {
863
- if (ns.radius == null) throw new UsageError("--radius is required for ball");
864
- a.radius = ns.radius;
865
- }
866
- return { op: "terrain", args: a };
867
- }
868
- case "insert": {
869
- const a = { id: ns.id, parent: ns.parent };
870
- if (ns.name) a.name = ns.name;
871
- return { op: "insert_asset", args: a };
872
- }
873
- case "apply":
874
- return { op: "apply_asset", args: { id: ns.id, path: ns.path, prop: ns.prop } };
875
- default:
876
- throw new UsageError(`unknown tool: ${name}`);
877
- }
878
- }
879
- function toolUsage(name) {
880
- const spec = TOOLS[name];
881
- const parts = [`npx golem-bridge ${name}`];
882
- for (const p of (spec.pos || [])) {
883
- const a = Array.isArray(p) ? p : [p];
884
- if (a[1] === "+") parts.push(`<${a[0]}...>`);
885
- else if (a[1] !== undefined) parts.push(`[${a[0]}]`);
886
- else parts.push(`<${a[0]}>`);
887
- }
888
- if (spec.flags && Object.keys(spec.flags).length) parts.push("[options]");
889
- return parts.join(" ");
890
- }
891
-
892
- function printHelp() {
893
- console.log("golem-bridge: drive a live Roblox Studio session from any AI agent.");
894
- console.log("");
895
- console.log(" Connect to my Roblox Studio, Run: npx golem-bridge connect <id>");
896
- console.log("");
897
- console.log("Session: connect <id> | reconnect [id] | disconnect | manual | help [tool]");
898
- for (const [group, names] of GROUPS) {
899
- console.log("");
900
- console.log(`${group}:`);
901
- for (const n of names) console.log(` ${n} - ${TOOLS[n].help}`);
902
- }
903
- console.log("");
904
- console.log("See one tool: npx golem-bridge help <tool>. Full reference: npx golem-bridge manual.");
905
- }
906
-
907
- function toolHelp(name) {
908
- const spec = TOOLS[name];
909
- console.log(`${name} - ${spec.help}`);
910
- console.log("");
911
- console.log(`Usage: ${toolUsage(name)}`);
912
- const flags = spec.flags || {};
913
- const names = Object.keys(flags);
914
- if (names.length) {
915
- console.log("");
916
- console.log("Options:");
917
- for (const d of names) {
918
- const fs = flags[d];
919
- const t = Array.isArray(fs) ? fs[0] : fs;
920
- const o = Array.isArray(fs) ? (fs[1] || {}) : {};
921
- let line = ` ${o.flag || "--" + d.replace(/_/g, "-")}`;
922
- if (o.short) line += `, -${o.short}`;
923
- if (t !== "bool") line += ` <${t === "append" ? "value (repeatable)" : "value"}>`;
924
- if (o.choices) line += ` (${o.choices.join("|")})`;
925
- if (o.default !== undefined) line += ` [default: ${o.default}]`;
926
- console.log(line);
927
- }
928
- }
929
- }
930
-
931
- const MANUAL_FILE = path.join(__dirname, "golem-tools.md");
932
-
933
- function printManual() {
934
- try {
935
- process.stdout.write(fs.readFileSync(MANUAL_FILE, "utf8").trimEnd() + "\n");
936
- } catch {
937
- fail("manual not found next to cli.js - reinstall the package.", 1);
938
- }
939
- }
940
-
941
- async function connect(id, opts) {
942
- opts = opts || {};
943
- if (id == null) fail("connect: need the channel id from the Studio widget.", 2);
944
- validateChannel(id);
945
- const data = await relayCall(id, "ping", {}, opts.timeout || 60);
946
- if (!data || !data.ok) {
947
- console.error(JSON.stringify({ ok: false, error: "connect: Studio did not answer. Is the plugin running and the id exact?" }, null, 2));
948
- process.exitCode = 1;
949
- return;
950
- }
951
- const res = (data && typeof data.result === "object" && data.result) || {};
952
- const place = res.place || res.placeName || data.place;
953
- if (!opts.print) {
954
- const dir = path.join(process.cwd(), ".golem");
955
- removeStaleHelpers(dir);
956
- fs.mkdirSync(dir, { recursive: true });
957
- fs.writeFileSync(path.join(dir, "channel"), id + "\n");
958
- console.log(`connected. channel saved to ${path.join(dir, "channel")}`);
959
- }
960
- if (place) console.log(`place: ${place}`);
961
- printManual();
962
- }
963
-
964
- async function reconnect(id, opts) {
965
- opts = opts || {};
966
- const saved = loadChannel();
967
- const target = id || saved;
968
- if (!target) fail("reconnect: no saved channel and none given.", 2);
969
- validateChannel(target);
970
- const data = await relayCall(target, "ping", {}, opts.timeout || 60);
971
- if (!data || !data.ok) {
972
- console.error(JSON.stringify({ ok: false, error: "reconnect: Studio did not answer." }, null, 2));
973
- process.exitCode = 1;
974
- return;
975
- }
976
- if (target === saved) {
977
- console.log("already connected to this channel.");
978
- return;
979
- }
980
- const dir = path.join(process.cwd(), ".golem");
981
- removeStaleHelpers(dir);
982
- fs.mkdirSync(dir, { recursive: true });
983
- fs.writeFileSync(path.join(dir, "channel"), target + "\n");
984
- console.log(`reconnected. channel saved to ${path.join(dir, "channel")}`);
985
- }
986
-
987
- function disconnect() {
988
- const dir = path.join(process.cwd(), ".golem");
989
- let removed = false;
990
- for (const name of ["channel", "golem-helper.py", "golem-tools.md", "golem.py", "golem.md"]) {
991
- const f = path.join(dir, name);
992
- try {
993
- if (fs.existsSync(f)) { fs.rmSync(f, { force: true }); removed = true; }
994
- } catch {
995
- // keep going
996
- }
997
- }
998
- try { fs.rmdirSync(dir); } catch {
999
- // stays if not empty
1000
- }
1001
- console.log(removed ? "disconnected (local channel file removed)." : "already disconnected (nothing saved).");
1002
- }
1003
-
1004
- const TIMEOUT_MIN = { ping: 60, debug: 90, search_assets: 180, asset_info: 180, insert_asset: 300 };
1005
-
1006
- async function runRelayTool(name, argv) {
1007
- let args, timeout, ns;
1008
- try {
1009
- ({ args, timeout } = stripTimeout(argv));
1010
- ns = parseToolArgs(name, args);
1011
- } catch (err) {
1012
- if (err instanceof UsageError) { console.error(`golem-bridge: ${err.message}`); process.exitCode = 2; return; }
1013
- throw err;
1014
- }
1015
- let built;
1016
- try {
1017
- built = buildArgs(name, ns);
1018
- } catch (err) {
1019
- if (err instanceof UsageError) { console.error(`golem-bridge: ${err.message}`); process.exitCode = 2; return; }
1020
- throw err;
1021
- }
1022
- const channel = requireChannel();
1023
- if (!channel) return;
1024
- const wait = Math.max(timeout != null ? timeout : DEFAULT_TIMEOUT, TIMEOUT_MIN[built.op] || 0);
1025
- const entry = await relayCall(channel, built.op, built.args, wait);
1026
- out(entry, { asJson: built.asJson, rawField: built.rawField });
1027
- }
1028
-
1029
- async function runLocalTool(name, argv) {
1030
- let args, timeout, ns;
1031
- try {
1032
- ({ args, timeout } = stripTimeout(argv));
1033
- ns = parseToolArgs(name, args);
1034
- } catch (err) {
1035
- if (err instanceof UsageError) { console.error(`golem-bridge: ${err.message}`); process.exitCode = 2; return; }
1036
- throw err;
1037
- }
1038
- if (name === "status") { out(await status()); return; }
1039
- if (name === "search" || name === "info") {
1040
- if (!marketplaceViaPlugin()) {
1041
- try {
1042
- const result = name === "search"
1043
- ? await mpSearch(ns.query, ns.category, ns.limit, ns.cursor)
1044
- : await mpInfo(ns.id);
1045
- out({ ok: true, op: name, result });
1046
- } catch (err) {
1047
- out({ ok: false, op: name, error: name === "search" ? `marketplace search failed: ${err.message}` : `asset info failed: ${err.message}` });
1048
- }
1049
- return;
1050
- }
1051
- const channel = requireChannel();
1052
- if (!channel) return;
1053
- const wait = Math.max(timeout != null ? timeout : DEFAULT_TIMEOUT, 180);
1054
- if (name === "search") {
1055
- const a = { query: ns.query, category: ns.category };
1056
- if (ns.limit) a.limit = ns.limit;
1057
- if (ns.cursor) a.cursor = ns.cursor;
1058
- out(await relayCall(channel, "search_assets", a, wait));
1059
- } else {
1060
- out(await relayCall(channel, "asset_info", { id: ns.id }, wait));
1061
- }
1062
- }
1063
- }
1064
-
1065
- async function main(argv) {
1066
- const args = argv || process.argv.slice(2);
1067
- if (!args.length || args[0] === "--help" || args[0] === "-h") { printHelp(); return; }
1068
- if (args[0] === "--version" || args[0] === "-v") { console.log(VERSION); return; }
1069
- const cmd = args[0];
1070
- const rest = args.slice(1);
1071
- if (cmd === "help") {
1072
- if (!rest.length) { printHelp(); return; }
1073
- if (Object.hasOwn(TOOLS, rest[0])) { toolHelp(rest[0]); return; }
1074
- console.error(`golem-bridge: unknown tool "${rest[0]}"`);
1075
- process.exitCode = 2;
1076
- return;
1077
- }
1078
- if (cmd === "manual") { printManual(); return; }
1079
- if (cmd === "disconnect") { disconnect(); return; }
1080
- if (cmd === "connect" || cmd === "reconnect") {
1081
- let print = false, timeout = null, id = null;
1082
- for (let k = 0; k < rest.length; k++) {
1083
- const a = rest[k];
1084
- if (a === "--print") print = true;
1085
- else if (a === "--yes" || a === "-y") { /* accepted for 2.x scripts; nothing to confirm anymore */ }
1086
- else if (a === "--timeout" && k + 1 < rest.length) timeout = parseFloat(rest[++k]);
1087
- else if (a.startsWith("--timeout=")) timeout = parseFloat(a.slice(10));
1088
- else if (!a.startsWith("-") && id === null) id = a;
1089
- else { console.error(`golem-bridge: ${cmd}: bad argument ${a}`); process.exitCode = 2; return; }
1090
- }
1091
- if (timeout != null && !(timeout > 0)) { console.error("golem-bridge: --timeout must be a positive number of seconds"); process.exitCode = 2; return; }
1092
- if (cmd === "connect") await connect(id, { print, timeout });
1093
- else await reconnect(id, { timeout });
1094
- return;
1095
- }
1096
- if (Object.hasOwn(TOOLS, cmd)) {
1097
- if (TOOLS[cmd].local) await runLocalTool(cmd, rest);
1098
- else await runRelayTool(cmd, rest);
1099
- return;
1100
- }
1101
- console.error(`golem-bridge: unknown command "${cmd}" (try: help)`);
1102
- process.exitCode = 2;
1103
- }
1104
-
1105
- if (require.main === module) {
1106
- main().catch((err) => { console.error(`golem-bridge error: ${(err && err.message) || err}`); process.exitCode = 1; });
1107
- }
1108
-
1109
- module.exports = { TOOLS, GROUPS, parseToolArgs, buildArgs, vec3, scalar, loadChannel, status, mpSearch, mpInfo, marketplaceViaPlugin, relayCall, UsageError, stripTimeout, removeStaleHelpers, disconnect, main };
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // golem-bridge: drive a live Roblox Studio session from any AI agent.
5
+ // Session setup (connect/reconnect/disconnect) plus every Studio tool,
6
+ // all through the Firebase command relay. Zero dependencies.
7
+
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const crypto = require("crypto");
11
+
12
+ // GOLEM_* names are preferred; AIB_* are the legacy aliases kept for
13
+ // backward compatibility with existing setups and scripts.
14
+ function envOf(...names) {
15
+ for (const n of names) {
16
+ const v = process.env[n];
17
+ if (v) return v;
18
+ }
19
+ return undefined;
20
+ }
21
+
22
+ const DB_URL = envOf("GOLEM_FIREBASE_DB", "AIB_FIREBASE_DB") || "https://roblox-golem-default-rtdb.firebaseio.com";
23
+ const POLL_INTERVAL_MS = Math.max(250, (parseFloat(envOf("GOLEM_POLL_INTERVAL", "AIB_POLL_INTERVAL")) || 2) * 1000);
24
+ const FETCH_TIMEOUT_MS = 30000;
25
+ const MAX_BODY_BYTES = 2 * 1024 * 1024;
26
+ const DEFAULT_TIMEOUT = 120;
27
+ // Channel IDs are hex tokens minted by the Studio plugin (16 chars today,
28
+ // tolerated 8-64 for forward compatibility). Anything else is rejected so a
29
+ // malformed ID can never alter the request URL.
30
+ const CHANNEL_RE = /^[0-9a-fA-F]{8,64}$/;
31
+
32
+ let VERSION = "unknown";
33
+ try {
34
+ VERSION = require("./package.json").version || "unknown";
35
+ } catch {
36
+ // running outside the package dir; version is informational only
37
+ }
38
+
39
+ // The relay must be HTTPS (plain HTTP is only allowed for localhost, for
40
+ // local Firebase emulator testing). Trailing slashes are stripped so URL
41
+ // joins stay clean. Takes an optional override for testability.
42
+ function relayBase(url) {
43
+ const raw = String(url !== undefined ? url : DB_URL || "").replace(/\/+$/, "");
44
+ if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/.test(raw)) return raw;
45
+ if (!/^https:\/\//.test(raw)) {
46
+ throw new Error(
47
+ `refusing to talk to a non-HTTPS relay (${raw || "(empty)"}) - check GOLEM_FIREBASE_DB/AIB_FIREBASE_DB`
48
+ );
49
+ }
50
+ return raw;
51
+ }
52
+
53
+ function isTooLarge(err) {
54
+ return !!err && typeof err.message === "string" && err.message.includes("response too large");
55
+ }
56
+
57
+ function fail(message, exitCode) {
58
+ console.error(`golem-bridge error: ${message}`);
59
+ process.exit(exitCode || 1);
60
+ }
61
+
62
+ function sleep(ms) {
63
+ return new Promise((resolve) => setTimeout(resolve, ms));
64
+ }
65
+
66
+ function parseRetryAfter(headers, fallback) {
67
+ try {
68
+ const v = parseFloat(headers.get("retry-after"));
69
+ if (Number.isFinite(v) && v >= 0) return v;
70
+ } catch {
71
+ // fall through
72
+ }
73
+ return fallback;
74
+ }
75
+
76
+ async function fetchText(url, opts) {
77
+ const o = opts || {};
78
+ const ctrl = new AbortController();
79
+ const timer = setTimeout(() => ctrl.abort(), o.timeoutMs || FETCH_TIMEOUT_MS);
80
+ try {
81
+ const res = await fetch(url, {
82
+ method: o.method || (o.body === undefined ? "GET" : "POST"),
83
+ headers: Object.assign({ Accept: "application/json" }, o.headers || {}),
84
+ body: o.body,
85
+ signal: ctrl.signal,
86
+ redirect: "error", // responses must come from the relay itself
87
+ });
88
+ const text = await res.text();
89
+ if (text.length > MAX_BODY_BYTES) {
90
+ throw new Error("response too large, refusing to parse it");
91
+ }
92
+ return { ok: res.ok, status: res.status, headers: res.headers, text };
93
+ } catch (err) {
94
+ if (err && err.name === "AbortError") {
95
+ throw new Error(`request timed out (${(o.timeoutMs || FETCH_TIMEOUT_MS) / 1000}s)`);
96
+ }
97
+ throw err;
98
+ } finally {
99
+ clearTimeout(timer);
100
+ }
101
+ }
102
+
103
+ async function relayCall(channelId, op, args, timeoutSec) {
104
+ let base;
105
+ try {
106
+ base = relayBase();
107
+ } catch (err) {
108
+ return { ok: false, error: err.message };
109
+ }
110
+ const enc = encodeURIComponent(channelId);
111
+ const cmdId = `c${Date.now().toString(36)}${crypto.randomBytes(8).toString("hex")}`;
112
+ const payload = JSON.stringify({ id: cmdId, op, args: args || {}, ts: Math.floor(Date.now() / 1000) });
113
+ let sinceKey = null;
114
+ for (let attempt = 0; ; attempt++) {
115
+ let r;
116
+ try {
117
+ r = await fetchText(`${base}/channels/${enc}/cmd.json`, { method: "POST", body: payload });
118
+ } catch (err) {
119
+ return { ok: false, error: `cannot reach the relay (${err.message}) - check internet/DNS` };
120
+ }
121
+ if (r.status === 429 && attempt < 3) {
122
+ await sleep(parseRetryAfter(r.headers, 5 * (attempt + 1)) * 1000);
123
+ continue;
124
+ }
125
+ if (!r.ok) {
126
+ return { ok: false, error: `relay rejected the command: HTTP ${r.status}: ${r.text.slice(0, 200)}` };
127
+ }
128
+ try {
129
+ sinceKey = (JSON.parse(r.text) || {}).name || null;
130
+ } catch {
131
+ sinceKey = null;
132
+ }
133
+ break;
134
+ }
135
+ const deadline = Date.now() + timeoutSec * 1000;
136
+ let attempt = 0;
137
+ while (Date.now() < deadline) {
138
+ let r;
139
+ try {
140
+ r = await fetchText(`${base}/channels/${enc}/res.json?shallow=true`, { timeoutMs: 60000 });
141
+ } catch (err) {
142
+ if (isTooLarge(err)) {
143
+ return {
144
+ ok: false,
145
+ error: "the result channel is too large to poll - ask the user to restart Studio to rotate to a fresh channel",
146
+ };
147
+ }
148
+ attempt++;
149
+ await sleep(Math.min(15, 0.5 * 2 ** Math.min(attempt, 5)) * 1000);
150
+ continue;
151
+ }
152
+ if (r.status === 429) {
153
+ await sleep(parseRetryAfter(r.headers, 6) * 1000);
154
+ continue;
155
+ }
156
+ if (!r.ok) {
157
+ await sleep(2000);
158
+ continue;
159
+ }
160
+ attempt = 0;
161
+ let keys;
162
+ try {
163
+ keys = r.text === "null" ? {} : JSON.parse(r.text);
164
+ } catch {
165
+ keys = {};
166
+ }
167
+ if (keys && typeof keys === "object") {
168
+ for (const key of Object.keys(keys).sort()) {
169
+ if (sinceKey !== null && !(key > sinceKey)) continue;
170
+ let entry;
171
+ try {
172
+ const er = await fetchText(`${base}/channels/${enc}/res/${encodeURIComponent(key)}.json`, {
173
+ timeoutMs: 60000,
174
+ });
175
+ if (!er.ok) continue; // transient: retry this key on the next poll
176
+ entry = JSON.parse(er.text);
177
+ } catch (err) {
178
+ if (isTooLarge(err)) {
179
+ return {
180
+ ok: false,
181
+ error: `Studio's reply to '${op}' exceeded the relay size cap - narrow the query (path, depth, max)`,
182
+ };
183
+ }
184
+ continue; // transient or corrupt entry: retry this key on the next poll
185
+ }
186
+ sinceKey = key;
187
+ if (entry && entry.id === cmdId) {
188
+ if (entry.resultEncoded && typeof entry.result === "string") {
189
+ try {
190
+ entry.result = JSON.parse(entry.result);
191
+ } catch {
192
+ // keep the raw string
193
+ }
194
+ delete entry.resultEncoded;
195
+ }
196
+ return entry;
197
+ }
198
+ }
199
+ }
200
+ await sleep(POLL_INTERVAL_MS);
201
+ }
202
+ return {
203
+ ok: false,
204
+ error: `timed out after ${timeoutSec}s waiting for Studio to answer '${op}'. Is Roblox Studio open with the Golem plugin connected to the relay?`,
205
+ };
206
+ }
207
+
208
+ function turnReminder(entry) {
209
+ if (!entry || !entry.turnOpen) return;
210
+ const n = entry.turnTools;
211
+ const head =
212
+ typeof n === "number"
213
+ ? `>> TURN STILL OPEN (${n} tools) - do NOT reply yet.`
214
+ : ">> TURN STILL OPEN - do NOT reply yet.";
215
+ console.error(`${head} When this task is done, close it with:\n>> npx golem-bridge turn end --note "your reply"`);
216
+ }
217
+
218
+ // Exit codes: 0 = ok, 1 = Studio/relay reported an error, 2 = usage error.
219
+ // Usage errors never go through out(); they print directly and exit 2.
220
+ function out(data, opts) {
221
+ const o = opts || {};
222
+ if (data && data.ok) {
223
+ if (o.rawField && !o.asJson) {
224
+ const result = data.result || {};
225
+ if (typeof result[o.rawField] === "string") {
226
+ process.stdout.write(result[o.rawField]);
227
+ if (!result[o.rawField].endsWith("\n")) process.stdout.write("\n");
228
+ turnReminder(data);
229
+ process.exitCode = 0;
230
+ return;
231
+ }
232
+ }
233
+ console.log(JSON.stringify(data, null, 2));
234
+ turnReminder(data);
235
+ process.exitCode = 0;
236
+ return;
237
+ }
238
+ console.error(JSON.stringify(data, null, 2));
239
+ turnReminder(data);
240
+ process.exitCode = 1;
241
+ }
242
+
243
+ function vec3(s) {
244
+ const parts = String(s)
245
+ .replace(/,/g, " ")
246
+ .split(/\s+/)
247
+ .filter((v) => v !== "")
248
+ .map(Number);
249
+ if (parts.length === 0 || parts.some((v) => !Number.isFinite(v))) {
250
+ throw new UsageError(`bad vector "${s}" (expected x,y,z numbers)`);
251
+ }
252
+ if (parts.length === 1) return { x: parts[0], y: 0, z: 0 };
253
+ if (parts.length !== 3) throw new UsageError(`bad vector "${s}" (expected x,y,z)`);
254
+ return { x: parts[0], y: parts[1], z: parts[2] };
255
+ }
256
+
257
+ class UsageError extends Error {}
258
+
259
+ function loadChannel() {
260
+ const fromEnv = envOf("GOLEM_CHANNEL", "AIB_CHANNEL");
261
+ if (fromEnv) return fromEnv;
262
+ try {
263
+ const raw = fs.readFileSync(path.join(process.cwd(), ".golem", "channel"), "utf8").trim();
264
+ if (raw) return raw;
265
+ } catch {
266
+ // fall through to the 2.x stamped helper below
267
+ }
268
+ for (const name of ["golem-helper.py", "golem.py"]) {
269
+ try {
270
+ const src = fs.readFileSync(path.join(process.cwd(), ".golem", name), "utf8");
271
+ const m = src.match(/CHANNEL = os\.environ\.get\("AIB_CHANNEL", "([0-9a-fA-F]+)"\)/);
272
+ if (m) return m[1];
273
+ } catch {
274
+ // try the next name
275
+ }
276
+ }
277
+ return null;
278
+ }
279
+
280
+ function isValidChannel(id) {
281
+ return typeof id === "string" && CHANNEL_RE.test(id);
282
+ }
283
+
284
+ function requireChannel() {
285
+ const id = loadChannel();
286
+ if (!isValidChannel(id)) {
287
+ out({ ok: false, error: "not connected (no channel saved) - run: npx golem-bridge connect <channelId>" });
288
+ return null;
289
+ }
290
+ return id;
291
+ }
292
+
293
+ // Leftover filenames from the 2.x Python helper, cleaned up on connect.
294
+ const STALE_HELPER_FILES = ["golem-helper.py", "golem-tools.md", "golem.py", "golem.md"];
295
+
296
+ function removeStaleHelpers(dir) {
297
+ for (const stale of STALE_HELPER_FILES) {
298
+ try {
299
+ fs.rmSync(path.join(dir, stale), { force: true });
300
+ } catch {
301
+ // cleanup must never block a connect
302
+ }
303
+ }
304
+ }
305
+
306
+ // The channel id is a capability secret, so the file must not be
307
+ // world-readable. Returns the path of the written file.
308
+ function saveChannel(dir, id) {
309
+ try {
310
+ removeStaleHelpers(dir);
311
+ fs.mkdirSync(dir, { recursive: true });
312
+ const file = path.join(dir, "channel");
313
+ fs.writeFileSync(file, id + "\n", { mode: 0o600 });
314
+ try {
315
+ fs.chmodSync(file, 0o600); // tighten pre-existing files too (mode only applies on creation)
316
+ } catch {
317
+ // best effort (e.g. filesystems without unix permissions)
318
+ }
319
+ } catch (err) {
320
+ fail(`cannot save the channel file in ${dir}: ${err.message}`, 1);
321
+ }
322
+ return path.join(dir, "channel");
323
+ }
324
+
325
+ function envChannelName() {
326
+ if (process.env.GOLEM_CHANNEL) return "GOLEM_CHANNEL";
327
+ if (process.env.AIB_CHANNEL) return "AIB_CHANNEL";
328
+ return null;
329
+ }
330
+
331
+ function readStdin() {
332
+ try {
333
+ return fs.readFileSync(0, "utf8");
334
+ } catch {
335
+ return "";
336
+ }
337
+ }
338
+
339
+ // ---------------------------------------------------------------- marketplace
340
+ // search/info always run on this machine: Studio's HttpService cannot reach
341
+ // roblox.com, so there is no plugin-side marketplace path.
342
+
343
+ const MP_CATEGORIES = {
344
+ model: "Model", models: "Model", mesh: "MeshPart", meshes: "MeshPart", meshpart: "MeshPart",
345
+ decal: "Decal", decals: "Decal", image: "Decal", images: "Decal", picture: "Decal", texture: "Decal",
346
+ audio: "Audio", sound: "Audio", sounds: "Audio", music: "Audio",
347
+ video: "Video", videos: "Video", plugin: "Plugin", plugins: "Plugin",
348
+ };
349
+ const ASSET_TYPE_NAMES = { 1: "Image", 3: "Audio", 4: "Mesh", 9: "Decal", 10: "Model", 18: "Video", 19: "Font", 40: "MeshPart" };
350
+
351
+ async function mpGet(url) {
352
+ const r = await fetchText(url, { headers: { "User-Agent": `golem-bridge/${VERSION}` }, timeoutMs: 20000 });
353
+ if (!r.ok) throw new Error(`HTTP ${r.status} from Roblox: ${r.text.slice(0, 200)}`);
354
+ return JSON.parse(r.text);
355
+ }
356
+
357
+ async function mpSearch(query, category, limit, cursor) {
358
+ const assetType = MP_CATEGORIES[String(category || "model").toLowerCase()];
359
+ if (!assetType) throw new Error(`unknown category '${category}' (valid: model, mesh, image, audio, video, plugin)`);
360
+ let n = parseInt(limit, 10);
361
+ if (!Number.isFinite(n)) n = 10;
362
+ n = Math.max(1, Math.min(n, 50));
363
+ let url = `https://apis.roblox.com/toolbox-service/v1/marketplace/${assetType}?keyword=${encodeURIComponent(String(query))}&limit=${n}`;
364
+ if (cursor) url += `&cursor=${encodeURIComponent(String(cursor))}`;
365
+ const data = await mpGet(url);
366
+ const ids = ((data && data.data) || []).filter((it) => it && it.id != null).map((it) => it.id);
367
+ const details = {};
368
+ const thumbs = {};
369
+ const enriched = Math.min(ids.length, 12);
370
+ for (let i = 0; i < enriched; i++) {
371
+ try {
372
+ details[ids[i]] = await mpGet(`https://economy.roblox.com/v2/assets/${ids[i]}/details`);
373
+ } catch {
374
+ // one bad asset must not kill the search
375
+ }
376
+ if (i < enriched - 1) await sleep(400); // the economy API rate limits hard
377
+ }
378
+ if (ids.length) {
379
+ try {
380
+ const tdata = await mpGet(
381
+ `https://thumbnails.roblox.com/v1/assets?assetIds=${ids.slice(0, 12).join(",")}&size=420x420&format=Png`
382
+ );
383
+ for (const th of (tdata && tdata.data) || []) {
384
+ if (th && th.targetId != null) thumbs[th.targetId] = th;
385
+ }
386
+ } catch {
387
+ // thumbnails are a bonus
388
+ }
389
+ }
390
+ const results = ids.map((aid) => {
391
+ const d = details[aid];
392
+ const th = thumbs[aid];
393
+ const entry = { id: aid, category: assetType };
394
+ if (d && typeof d === "object") {
395
+ entry.name = d.Name;
396
+ entry.assetTypeId = d.AssetTypeId;
397
+ entry.assetType = ASSET_TYPE_NAMES[d.AssetTypeId];
398
+ if (d.Creator && typeof d.Creator === "object") entry.creator = d.Creator.Name;
399
+ entry.priceInRobux = d.PriceInRobux ?? null; // null = free or unknown
400
+ if (d.IsForSale != null) entry.forSale = d.IsForSale;
401
+ if (typeof d.Description === "string" && d.Description) entry.description = d.Description.slice(0, 280);
402
+ } else {
403
+ entry.detailsUnavailable = true;
404
+ }
405
+ if (th && typeof th === "object") {
406
+ entry.thumbnail = th.imageUrl;
407
+ entry.thumbnailState = th.state;
408
+ }
409
+ return entry;
410
+ });
411
+ const output = { totalResults: (data && data.totalResults) ?? 0, results };
412
+ if (data && data.nextPageCursor) output.nextPageCursor = data.nextPageCursor;
413
+ return output;
414
+ }
415
+
416
+ async function mpInfo(assetId) {
417
+ const text = String(assetId == null ? "" : assetId).trim();
418
+ if (!/^\d+$/.test(text)) throw new Error(`bad asset id '${assetId}'`);
419
+ const aid = parseInt(text, 10);
420
+ const d = await mpGet(`https://economy.roblox.com/v2/assets/${aid}/details`);
421
+ const output = { id: aid };
422
+ if (d && typeof d === "object") {
423
+ output.name = d.Name;
424
+ output.assetTypeId = d.AssetTypeId;
425
+ output.assetType = ASSET_TYPE_NAMES[d.AssetTypeId];
426
+ if (d.Creator && typeof d.Creator === "object") output.creator = d.Creator.Name;
427
+ output.priceInRobux = d.PriceInRobux ?? null; // null = free or unknown
428
+ if (d.IsForSale != null) output.forSale = d.IsForSale;
429
+ if (typeof d.Description === "string") output.description = d.Description.slice(0, 1000);
430
+ }
431
+ try {
432
+ const tdata = await mpGet(`https://thumbnails.roblox.com/v1/assets?assetIds=${aid}&size=420x420&format=Png`);
433
+ if (Array.isArray(tdata && tdata.data) && tdata.data.length) {
434
+ output.thumbnail = tdata.data[0].imageUrl;
435
+ output.thumbnailState = tdata.data[0].state;
436
+ }
437
+ } catch {
438
+ // thumbnails are a bonus
439
+ }
440
+ return output;
441
+ }
442
+
443
+ async function status() {
444
+ const channel = loadChannel();
445
+ if (!isValidChannel(channel)) {
446
+ return { ok: false, error: "not connected (no channel saved) - run: npx golem-bridge connect <channelId>" };
447
+ }
448
+ let beaconData;
449
+ let resKeys;
450
+ try {
451
+ const base = relayBase();
452
+ const enc = encodeURIComponent(channel);
453
+ const bb = await fetchText(`${base}/channels/${enc}/beacons.json?orderBy=${encodeURIComponent('"$key"')}&limitToLast=5`, {
454
+ timeoutMs: 30000,
455
+ });
456
+ beaconData = bb.text === "null" ? {} : JSON.parse(bb.text);
457
+ if (typeof beaconData !== "object" || beaconData === null) beaconData = {};
458
+ const rb = await fetchText(`${base}/channels/${enc}/res.json?shallow=true`, { timeoutMs: 30000 });
459
+ resKeys = rb.text === "null" ? {} : JSON.parse(rb.text);
460
+ if (typeof resKeys !== "object" || resKeys === null) resKeys = {};
461
+ } catch (err) {
462
+ return { ok: false, error: `cannot read the relay channel: ${err.message}` };
463
+ }
464
+ const results = Object.values(resKeys).filter((v) => v === true).length;
465
+ const beacons = Object.values(beaconData)
466
+ .filter((e) => e && (e.op === "hello" || e.op === "revoked"))
467
+ .map((e) => [parseFloat(e.ts) || 0, e]);
468
+ if (!beacons.length) {
469
+ const verdict = results
470
+ ? `no beacons, but ${results} cached result(s) - Studio is not running or runs an older plugin; ask the user to fully restart Studio with the current plugin`
471
+ : "result channel is empty - the plugin has never posted here: Studio is closed or was not restarted after a plugin update";
472
+ return { ok: true, beacon: null, ageSeconds: null, recentResults: results, verdict };
473
+ }
474
+ beacons.sort((a, b) => a[0] - b[0]);
475
+ const [ts, beacon] = beacons[beacons.length - 1];
476
+ const age = ts > 0 ? Math.max(0, Math.floor(Date.now() / 1000 - ts)) : null;
477
+ const ver = beacon.v || "?";
478
+ if (beacon.op === "revoked") {
479
+ const ago = age === null ? "" : age < 120 ? ` ${age}s ago` : ` ${Math.floor(age / 60)} min ago`;
480
+ return {
481
+ ok: true,
482
+ beacon,
483
+ ageSeconds: age,
484
+ recentResults: results,
485
+ verdict: `this channel was ROTATED${ago} - Studio wiped it (END SESSION or restart) and minted a new one; ask the user for the fresh setup line and run: npx golem-bridge reconnect <newId>`,
486
+ };
487
+ }
488
+ const verdict =
489
+ age === null
490
+ ? `last beacon has no timestamp (v${ver}) - ask the user to check the Golem window`
491
+ : age <= 900
492
+ ? `plugin is LIVE (v${ver}, hello beacon ${age}s ago) - it should answer commands`
493
+ : `last hello was ${Math.floor(age / 60)} min ago (v${ver}) - Studio may be closed or hung since then; ask the user to check the Golem window`;
494
+ return { ok: true, beacon, ageSeconds: age, recentResults: results, verdict };
495
+ }
496
+
497
+ // ---------------------------------------------------------------- tool table
498
+ //
499
+ // pos: ["name"] = required, ["name", default] = optional, ["name", "+"]
500
+ // = one-or-more, 3rd element = choices, 4th = value type.
501
+ // flags: name: "bool"|"str"|"int"|"float"|"append" or [type, {flag, choices,
502
+ // default}]. Default flag spelling is the name with _ as -.
503
+ // (The relay op for each tool lives in buildArgs, the single mapping.)
504
+
505
+ const GROUPS = [
506
+ ["Connection", ["ping", "debug", "status"]],
507
+ ["Exploring", ["list", "tree", "find", "grep", "count", "look"]],
508
+ ["Scripts and Lua", ["read", "script", "lua", "exec"]],
509
+ ["Organizing", ["delete", "move", "group", "duplicate", "rename", "selection", "waypoint", "undo", "attr", "tag"]],
510
+ ["Moving", ["rotate", "face", "shift", "scale", "place", "pivot"]],
511
+ ["Surfaces and physics", ["paint", "anchor", "collide", "terrain"]],
512
+ ["Gameplay", ["light", "sound", "prompt", "hitbox", "particles", "sign", "scatter", "match", "weld"]],
513
+ ["Effects", ["beam", "trail", "explosion"]],
514
+ ["UI", ["ui_screen", "ui_frame", "ui_label", "ui_button", "ui_input", "ui_image", "ui_list"]],
515
+ ["Marketplace", ["search", "info", "insert", "apply"]],
516
+ ["Playtesting", ["play", "stop", "logs"]],
517
+ ["Session", ["say", "turn"]],
518
+ ];
519
+
520
+ const TOOLS = {
521
+ ping: { help: "health check - is Studio connected?" },
522
+ debug: { help: "diagnostics: relay round-trip, versions, commands served, errors" },
523
+ status: { help: "is the plugin alive or the link revoked? (no Studio needed)", local: true },
524
+ exec: { help: 'send a raw op JSON: {"op":..., "args":...}', pos: [["json"]] },
525
+ lua: { help: "run Lua inside Studio (code arg, or - for stdin)", pos: [["code", null]] },
526
+ list: { help: "children of a path", pos: [["path", "game"]], flags: { recursive: "bool", max: "int" } },
527
+ tree: { help: "recursive tree of a path", pos: [["path", "game"]], flags: { depth: "int" } },
528
+ read: { help: "read an instance (script source by default, --json for full record)", pos: ["path"], flags: { json: "bool", props: "str" } },
529
+ find: { help: "find instances by name or --tag", pos: [["query", ""]], flags: { cls: ["str", { flag: "--class" }], scope: ["str", { default: "game" }], max: "int", exact: "bool", tag: "str" } },
530
+ grep: { help: "search script sources", pos: ["pattern"], flags: { scope: ["str", { default: "game" }], max: "int", i: ["bool", { flag: "-i" }] } },
531
+ script: { help: "create/update a script (source from stdin or --source)", pos: ["parent", "name"], flags: { cls: ["str", { flag: "--class", default: "Script", choices: ["Script", "LocalScript", "ModuleScript"] }], mode: ["str", { choices: ["create", "update", "replace"], default: "create" }], source: "str" } },
532
+ delete: { help: "delete instances", pos: [["paths", "+"]] },
533
+ move: { help: "reparent an instance", pos: ["path", "parent"] },
534
+ selection: { help: "read or set the Studio selection", pos: [], flags: { set: "str", clear: "bool" } },
535
+ waypoint: { help: "set an undo checkpoint", pos: [["label", "bridge"]] },
536
+ say: { help: "post a message to the user's Studio chat (closes the turn)", pos: ["text"] },
537
+ turn: { help: "mark work turns: begin ... tools ... end --note (required protocol)", pos: [["action", undefined, ["begin", "end"]]], flags: { note: "str" } },
538
+ rotate: { help: "rotate: --axis y --degrees 90 (relative) or --set 0,90,0 (absolute)", pos: ["path"], flags: { axis: "str", degrees: "float", absolute: ["str", { flag: "--set" }], space: ["str", { choices: ["world", "local"], default: "world" }] } },
539
+ face: { help: "aim an instance's axis at a world point (keeps position)", pos: ["path", "target"], flags: { axis: ["str", { default: "forward" }] } },
540
+ shift: { help: "move by an offset in studs (world or local)", pos: ["path", "offset"], flags: { space: ["str", { choices: ["world", "local"], default: "world" }] } },
541
+ scale: { help: "scale a part/model by a relative factor", pos: ["path", ["factor", undefined, undefined, "float"]] },
542
+ duplicate: { help: "clone an instance (optionally N times with spacing)", pos: ["path"], flags: { count: ["int", { default: 1 }], offset: "str", parent: "str", name: "str" } },
543
+ group: { help: "wrap instances into a Model", pos: [["paths", "+"]], flags: { name: ["str", { default: "Group" }], parent: "str" } },
544
+ pivot: { help: "set a model/part pivot (what it rotates around)", pos: ["path"], flags: { position: "str", orientation: "str" } },
545
+ place: { help: "absolute position via pivot (parts and models)", pos: ["path", "position"], flags: { orientation: "str" } },
546
+ paint: { help: "set color/material/transparency/reflectance on parts", pos: [["paths", "+"]], flags: { color: "str", material: "str", transparency: "float", reflectance: "float" } },
547
+ rename: { help: "rename an instance", pos: ["path", "name"] },
548
+ look: { help: "aim the Studio editor camera at a path", pos: ["path"], flags: { distance: ["float", { default: 30 }] } },
549
+ count: { help: "count instances (cheap, no payloads)", pos: [["scope", "game"]], flags: { cls: ["str", { flag: "--class" }] } },
550
+ undo: { help: "one Studio undo step (Ctrl+Z)" },
551
+ anchor: { help: "anchor parts in place (models: all their parts)", pos: [["paths", "+"]], flags: { off: "bool" } },
552
+ collide: { help: "enable part collision (models: all their parts)", pos: [["paths", "+"]], flags: { off: "bool" } },
553
+ light: { help: "add or update a light inside a part", pos: ["path"], flags: { light_type: ["str", { flag: "--type", default: "point", choices: ["point", "spot", "surface"] }], color: "str", range: "float", brightness: "float", shadows: "bool" } },
554
+ sound: { help: "add a Sound to a parent, optionally play it", pos: ["parent", "id"], flags: { volume: "float", loop: "bool", play: "bool", name: "str" } },
555
+ weld: { help: "weld a model's parts together with WeldConstraints", pos: [["paths", "+"]] },
556
+ hitbox: { help: "invisible hitbox part sized to a target", pos: ["path"], flags: { padding: ["float", { default: 0.5 }], name: "str", collide: "bool" } },
557
+ prompt: { help: "add a ProximityPrompt to a part (Press E to ...)", pos: ["path", "action"], flags: { object: "str", hold: ["float", { default: 0 }], distance: ["float", { default: 8 }] } },
558
+ particles: { help: "attach a particle preset to a part", pos: ["path", ["preset", undefined, ["leaves", "sparks", "smoke", "magic", "fire", "snow", "rain", "bubbles", "dust", "confetti", "fireflies"]]], flags: { rate: "float", color: "str" } },
559
+ sign: { help: "place a readable wooden sign", pos: ["text"], flags: { position: "str", parent: "str", size: "str", name: "str" } },
560
+ beam: { help: "glowing beam between two parts", pos: ["from", "to"], flags: { color: "str", width: "float", curve: "float", name: "str" } },
561
+ trail: { help: "motion trail on a moving part", pos: ["path"], flags: { color: "str", lifetime: "float", name: "str" } },
562
+ explosion: { help: "one-shot explosion (visual only)", pos: [], flags: { position: "str", radius: "float" } },
563
+ ui_screen: { help: "create a ScreenGui under StarterGui", pos: ["name"], flags: { parent: "str", order: "int" } },
564
+ ui_frame: { help: "rounded panel/frame", pos: ["parent", "name"], flags: { position: "str", size: "str", anchor: "str", color: "str", transparency: "float", radius: "int", clip: "bool" } },
565
+ ui_label: { help: "text label", pos: ["parent", "name"], flags: { text: "str", position: "str", size: "str", anchor: "str", color: "str", align: "str", font: "str", text_size: "int", wrap: "bool" } },
566
+ ui_button: { help: "text button", pos: ["parent", "name"], flags: { text: "str", position: "str", size: "str", anchor: "str", color: "str", text_color: "str", radius: "int", font: "str", text_size: "int" } },
567
+ ui_input: { help: "TextBox the player can type into", pos: ["parent", "name"], flags: { placeholder: "str", text: "str", position: "str", size: "str", color: "str", background: "str", radius: "int", text_size: "int" } },
568
+ ui_image: { help: "ImageLabel from a Roblox asset id", pos: ["parent", "name"], flags: { asset: "str", position: "str", size: "str", scale: "str" } },
569
+ ui_list: { help: "UIListLayout that auto-arranges a container's children", pos: ["parent"], flags: { direction: "str", padding: "int", halign: "str", valign: "str" } },
570
+ play: { help: "start play-testing the game (client when possible, Run mode fallback)", pos: [], flags: { mode: ["str", { choices: ["play", "run"] }] } },
571
+ stop: { help: "stop the running play test" },
572
+ logs: { help: "read Studio output - errors and warnings from the play test", pos: [], flags: { all: "bool", limit: "int", since: "float" } },
573
+ attr: { help: "read/set/clear Studio attributes on an instance", pos: ["path"], flags: { set: "append", clear: "append" } },
574
+ tag: { help: "add/remove CollectionService tags", pos: [["paths", "+"]], flags: { add: "append", remove: "append" } },
575
+ match: { help: "copy color/material/transparency from one part onto targets", pos: ["from_path", ["to", "+"]] },
576
+ scatter: { help: "scatter N copies of a template in a disc around it", pos: ["path"], flags: { count: ["int", { default: 10 }], radius: ["float", { default: 20 }], y_jitter: ["float", { default: 0 }], parent: "str", name: "str" } },
577
+ terrain: { help: "fill or clear terrain (block or ball)", pos: [], flags: { action: ["str", { choices: ["fill", "clear"], default: "fill" }], shape: ["str", { choices: ["block", "ball"], default: "block" }], position: "str", size: "str", radius: "float", material: ["str", { default: "Grass" }] } },
578
+ search: { help: "search the Roblox Creator Store (models, meshes, images, audio, video, plugins)", pos: ["query"], flags: { category: ["str", { default: "model" }], limit: "int", cursor: "str" }, local: true },
579
+ info: { help: "details + thumbnail for one marketplace asset", pos: ["id"], local: true },
580
+ insert: { help: "insert a marketplace asset into the place", pos: ["id", ["parent", "Workspace"]], flags: { name: "str" } },
581
+ apply: { help: "apply an asset id to a property (Image, Texture, SoundId, MeshId...)", pos: ["id", "path", "prop"] },
582
+ };
583
+ function stripTimeout(argv) {
584
+ const out = [];
585
+ let timeout = null;
586
+ let ddash = false;
587
+ for (let i = 0; i < argv.length; i++) {
588
+ const a = argv[i];
589
+ if (ddash) { out.push(a); continue; }
590
+ if (a === "--") { ddash = true; out.push(a); continue; }
591
+ if (a === "--timeout" && i + 1 < argv.length) { timeout = parseFloat(argv[++i]); continue; }
592
+ if (a.startsWith("--timeout=")) { timeout = parseFloat(a.slice(10)); continue; }
593
+ out.push(a);
594
+ }
595
+ if (timeout !== null && (!(timeout > 0) || !Number.isFinite(timeout)))
596
+ throw new UsageError("--timeout must be a positive, finite number of seconds");
597
+ return { args: out, timeout };
598
+ }
599
+
600
+ function parseToolArgs(name, argv) {
601
+ const spec = TOOLS[name];
602
+ const ns = {};
603
+ const bools = {}, vals = {};
604
+ for (const dest of Object.keys(spec.flags || {})) {
605
+ const fs = spec.flags[dest];
606
+ const t = Array.isArray(fs) ? fs[0] : fs;
607
+ const o = Array.isArray(fs) ? (fs[1] || {}) : {};
608
+ const flag = o.flag || "--" + dest.replace(/_/g, "-");
609
+ if (t === "bool") { ns[dest] = false; bools[flag] = dest; }
610
+ else { ns[dest] = t === "append" ? [] : (o.default !== undefined ? o.default : null); vals[flag] = { dest, type: t, choices: o.choices, multi: t === "append" }; }
611
+ }
612
+ const coerce = (fl, raw) => {
613
+ let v = raw;
614
+ if (fl.type === "int") { v = parseInt(raw, 10); if (!Number.isInteger(v)) throw new UsageError(`${name}: bad integer for ${fl.dest}: ${raw}`); }
615
+ else if (fl.type === "float") { v = parseFloat(raw); if (!Number.isFinite(v)) throw new UsageError(`${name}: bad number for ${fl.dest}: ${raw}`); }
616
+ if (fl.choices && !fl.choices.includes(fl.type === "int" ? v : String(v))) throw new UsageError(`${name}: bad value for ${fl.dest}: ${raw} (need ${fl.choices.join("|")})`);
617
+ return v;
618
+ };
619
+ const pos = [];
620
+ let i = 0, ddash = false;
621
+ while (i < argv.length) {
622
+ const t = argv[i];
623
+ if (ddash) { pos.push(t); i++; continue; }
624
+ if (t === "--") { ddash = true; i++; continue; }
625
+ if (t.startsWith("--")) {
626
+ const eq = t.indexOf("=");
627
+ const fl = eq === -1 ? t : t.slice(0, eq);
628
+ if (fl in bools) {
629
+ if (eq !== -1) throw new UsageError(`${name}: ${fl} takes no value`);
630
+ ns[bools[fl]] = true; i++; continue;
631
+ }
632
+ const v = vals[fl];
633
+ if (!v) throw new UsageError(`${name}: unknown flag ${fl}`);
634
+ let raw;
635
+ if (eq !== -1) raw = t.slice(eq + 1);
636
+ else { i++; if (i >= argv.length) throw new UsageError(`${name}: ${fl} needs a value`); raw = argv[i]; }
637
+ const c = coerce(v, raw);
638
+ if (v.multi) ns[v.dest].push(c); else ns[v.dest] = c;
639
+ i++; continue;
640
+ }
641
+ if (t in bools) { ns[bools[t]] = true; i++; continue; }
642
+ if (t.startsWith("-") && t.length > 1 && !/^-[\d.]/.test(t)) {
643
+ throw new UsageError(`${name}: unknown flag ${t}`);
644
+ }
645
+ pos.push(t); i++;
646
+ }
647
+ const out = {};
648
+ let pi = 0;
649
+ for (const p of (spec.pos || [])) {
650
+ const a = Array.isArray(p) ? p : [p];
651
+ const pname = a[0], pdef = a[1], pchoices = a[2], ptype = a[3];
652
+ if (pdef === "+") {
653
+ if (pi >= pos.length) throw new UsageError(`${name}: need at least one ${pname}`);
654
+ out[pname] = pos.slice(pi); pi = pos.length;
655
+ } else if (pi < pos.length) {
656
+ const raw = pos[pi++];
657
+ let v = raw;
658
+ if (ptype === "int") { v = parseInt(raw, 10); if (!Number.isInteger(v)) throw new UsageError(`${name}: bad integer for ${pname}: ${raw}`); }
659
+ else if (ptype === "float") { v = parseFloat(raw); if (!Number.isFinite(v)) throw new UsageError(`${name}: bad number for ${pname}: ${raw}`); }
660
+ if (pchoices && !pchoices.includes(ptype ? v : String(v))) throw new UsageError(`${name}: bad ${pname}: ${raw} (need ${pchoices.join("|")})`);
661
+ out[pname] = v;
662
+ } else if (pdef !== undefined) out[pname] = pdef;
663
+ else throw new UsageError(`${name}: missing ${pname}`);
664
+ }
665
+ if (pi < pos.length) throw new UsageError(`${name}: too many arguments (got "${pos[pi]}")`);
666
+ return Object.assign(ns, out);
667
+ }
668
+ function scalar(s) {
669
+ const t = s.trim();
670
+ if (/^[+-]?\d+$/.test(t)) { const n = parseInt(t, 10); if (Number.isSafeInteger(n)) return n; }
671
+ if (/^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$/.test(t)) { const f = parseFloat(t); if (Number.isFinite(f)) return f; }
672
+ if (t === "true") return true;
673
+ if (t === "false") return false;
674
+ return s;
675
+ }
676
+
677
+ function buildArgs(name, ns) {
678
+ const V = (s) => vec3(s);
679
+ switch (name) {
680
+ case "ping":
681
+ return { op: "ping", args: {} };
682
+ case "debug":
683
+ return { op: "debug", args: {} };
684
+ case "exec": {
685
+ let raw;
686
+ try { raw = JSON.parse(ns.json); } catch { throw new UsageError("exec: invalid JSON"); }
687
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || typeof raw.op !== "string" || !raw.op)
688
+ throw new UsageError('exec: JSON must be an object with a string "op" key');
689
+ return { op: raw.op, args: (raw.args && typeof raw.args === "object" && !Array.isArray(raw.args)) ? raw.args : {} };
690
+ }
691
+ case "lua": {
692
+ let code = ns.code;
693
+ if (code === "-" || code == null) code = process.stdin.isTTY ? "" : readStdin();
694
+ if (!code) throw new UsageError("no Lua code given (pass it as an argument or pipe it in)");
695
+ return { op: "run", args: { code } };
696
+ }
697
+ case "list": {
698
+ const a = { path: ns.path };
699
+ if (ns.recursive) a.recursive = true;
700
+ if (ns.max != null) {
701
+ if (ns.max <= 0) throw new UsageError("list: --max must be a positive integer");
702
+ a.max = ns.max;
703
+ }
704
+ return { op: "list", args: a };
705
+ }
706
+ case "tree": {
707
+ const depth = ns.depth ?? 2;
708
+ if (depth < 0) throw new UsageError("tree: --depth must be 0 or greater");
709
+ return { op: "tree", args: { path: ns.path, depth } };
710
+ }
711
+ case "read": {
712
+ const a = { path: ns.path };
713
+ if (ns.props) a.props = ns.props.split(",").map((s) => s.trim()).filter(Boolean);
714
+ return { op: "read", args: a, asJson: !!ns.json, rawField: "source" };
715
+ }
716
+ case "find": {
717
+ const a = { scope: ns.scope };
718
+ if (ns.tag) a.tag = ns.tag;
719
+ else if (ns.query) a.query = ns.query;
720
+ else throw new UsageError("find: need a name query or --tag");
721
+ if (ns.cls) a.class = ns.cls;
722
+ if (ns.max != null) {
723
+ if (ns.max <= 0) throw new UsageError("find: --max must be a positive integer");
724
+ a.max = ns.max;
725
+ }
726
+ if (ns.exact) a.exact = true;
727
+ return { op: "find", args: a };
728
+ }
729
+ case "grep": {
730
+ const a = { pattern: ns.pattern, scope: ns.scope };
731
+ if (ns.max != null) {
732
+ if (ns.max <= 0) throw new UsageError("grep: --max must be a positive integer");
733
+ a.max = ns.max;
734
+ }
735
+ if (ns.i) a.caseSensitive = false;
736
+ return { op: "grep", args: a };
737
+ }
738
+ case "script": {
739
+ let source = ns.source;
740
+ if (source == null && !process.stdin.isTTY) source = readStdin();
741
+ if (source == null) throw new UsageError("pass the script source via stdin (heredoc/pipe) or --source");
742
+ return { op: "script", args: { parent: ns.parent, name: ns.name, class: ns.cls, mode: ns.mode, source } };
743
+ }
744
+ case "delete":
745
+ return { op: "delete", args: { paths: ns.paths } };
746
+ case "move":
747
+ return { op: "move", args: { path: ns.path, parent: ns.parent } };
748
+ case "selection": {
749
+ if (ns.clear) return { op: "selection", args: { clear: true } };
750
+ if (ns.set != null) {
751
+ const paths = ns.set.split(",").map((s) => s.trim()).filter(Boolean);
752
+ if (!paths.length) throw new UsageError("selection: --set needs at least one path");
753
+ return { op: "selection", args: { set: paths } };
754
+ }
755
+ return { op: "selection", args: {} };
756
+ }
757
+ case "waypoint":
758
+ return { op: "waypoint", args: { label: ns.label } };
759
+ case "say":
760
+ return { op: "say", args: { text: ns.text } };
761
+ case "turn": {
762
+ if (ns.action === "begin") return { op: "turn_begin", args: {} };
763
+ if (!ns.note) throw new UsageError('turn end: --note "your reply" is required');
764
+ return { op: "turn_end", args: { note: ns.note } };
765
+ }
766
+ case "rotate": {
767
+ const a = { path: ns.path, space: ns.space };
768
+ if (ns.absolute != null) a.orientation = V(ns.absolute);
769
+ else {
770
+ if (ns.axis == null || ns.degrees == null)
771
+ throw new UsageError("rotate: need --set x,y,z or both --axis and --degrees");
772
+ a.axis = ns.axis;
773
+ a.degrees = ns.degrees;
774
+ }
775
+ return { op: "rotate", args: a };
776
+ }
777
+ case "face":
778
+ return { op: "face", args: { path: ns.path, target: V(ns.target), axis: ns.axis } };
779
+ case "shift":
780
+ return { op: "shift", args: { path: ns.path, offset: V(ns.offset), space: ns.space } };
781
+ case "scale":
782
+ if (!(ns.factor > 0)) throw new UsageError("scale: factor must be a positive number");
783
+ return { op: "scale", args: { path: ns.path, factor: ns.factor } };
784
+ case "duplicate": {
785
+ if (!(ns.count >= 1)) throw new UsageError("duplicate: --count must be 1 or greater");
786
+ const a = { path: ns.path, count: ns.count };
787
+ if (ns.offset) a.offset = V(ns.offset);
788
+ if (ns.parent) a.parent = ns.parent;
789
+ if (ns.name) a.name = ns.name;
790
+ return { op: "duplicate", args: a };
791
+ }
792
+ case "group": {
793
+ const a = { paths: ns.paths, name: ns.name };
794
+ if (ns.parent) a.parent = ns.parent;
795
+ return { op: "group", args: a };
796
+ }
797
+ case "pivot": {
798
+ if (ns.position == null && ns.orientation == null)
799
+ throw new UsageError("pivot: need --position and/or --orientation");
800
+ const a = { path: ns.path };
801
+ if (ns.position) a.position = V(ns.position);
802
+ if (ns.orientation) a.orientation = V(ns.orientation);
803
+ return { op: "set_pivot", args: a };
804
+ }
805
+ case "place": {
806
+ const a = { path: ns.path, position: V(ns.position) };
807
+ if (ns.orientation) a.orientation = V(ns.orientation);
808
+ return { op: "place", args: a };
809
+ }
810
+ case "paint": {
811
+ if (ns.color == null && ns.material == null && ns.transparency == null && ns.reflectance == null)
812
+ throw new UsageError("paint: need at least one of --color, --material, --transparency, --reflectance");
813
+ const a = { paths: ns.paths };
814
+ if (ns.color) a.color = ns.color;
815
+ if (ns.material) a.material = ns.material;
816
+ if (ns.transparency != null) a.transparency = ns.transparency;
817
+ if (ns.reflectance != null) a.reflectance = ns.reflectance;
818
+ return { op: "paint", args: a };
819
+ }
820
+ case "rename":
821
+ return { op: "rename", args: { path: ns.path, name: ns.name } };
822
+ case "look":
823
+ return { op: "look", args: { path: ns.path, distance: ns.distance } };
824
+ case "count": {
825
+ const a = { scope: ns.scope };
826
+ if (ns.cls) a.class = ns.cls;
827
+ return { op: "count", args: a };
828
+ }
829
+ case "undo":
830
+ return { op: "undo", args: {} };
831
+ case "anchor":
832
+ return { op: "anchor", args: { paths: ns.paths, anchored: !ns.off } };
833
+ case "collide":
834
+ return { op: "collide", args: { paths: ns.paths, canCollide: !ns.off } };
835
+ case "light": {
836
+ const a = { path: ns.path, type: ns.light_type };
837
+ if (ns.color) a.color = ns.color;
838
+ if (ns.range != null) a.range = ns.range;
839
+ if (ns.brightness != null) a.brightness = ns.brightness;
840
+ if (ns.shadows) a.shadows = true;
841
+ return { op: "light", args: a };
842
+ }
843
+ case "sound": {
844
+ const a = { parent: ns.parent, id: ns.id };
845
+ if (ns.volume != null) a.volume = ns.volume;
846
+ if (ns.loop) a.looped = true;
847
+ if (ns.play) a.play = true;
848
+ if (ns.name) a.name = ns.name;
849
+ return { op: "sound", args: a };
850
+ }
851
+ case "weld":
852
+ return { op: "weld", args: { paths: ns.paths } };
853
+ case "hitbox": {
854
+ const a = { path: ns.path, padding: ns.padding };
855
+ if (ns.name) a.name = ns.name;
856
+ if (ns.collide) a.canCollide = true;
857
+ return { op: "hitbox", args: a };
858
+ }
859
+ case "prompt": {
860
+ const a = { path: ns.path, action: ns.action, hold: ns.hold, distance: ns.distance };
861
+ if (ns.object) a.object = ns.object;
862
+ return { op: "prompt", args: a };
863
+ }
864
+ case "particles": {
865
+ const a = { path: ns.path, preset: ns.preset };
866
+ if (ns.rate != null) a.rate = ns.rate;
867
+ if (ns.color) a.color = ns.color;
868
+ return { op: "particles", args: a };
869
+ }
870
+ case "sign": {
871
+ const a = { text: ns.text };
872
+ if (ns.position) a.position = V(ns.position);
873
+ if (ns.parent) a.parent = ns.parent;
874
+ if (ns.size) a.size = V(ns.size);
875
+ if (ns.name) a.name = ns.name;
876
+ return { op: "sign", args: a };
877
+ }
878
+ case "beam": {
879
+ const a = { from: ns.from, to: ns.to };
880
+ for (const k of ["color", "width", "curve", "name"]) if (ns[k] != null) a[k] = ns[k];
881
+ return { op: "beam", args: a };
882
+ }
883
+ case "trail": {
884
+ const a = { path: ns.path };
885
+ for (const k of ["color", "lifetime", "name"]) if (ns[k] != null) a[k] = ns[k];
886
+ return { op: "trail", args: a };
887
+ }
888
+ case "explosion": {
889
+ const a = {};
890
+ if (ns.position) a.position = V(ns.position);
891
+ if (ns.radius != null) a.radius = ns.radius;
892
+ return { op: "explosion", args: a };
893
+ }
894
+ case "ui_screen": {
895
+ const a = { name: ns.name };
896
+ if (ns.parent) a.parent = ns.parent;
897
+ if (ns.order != null) a.order = ns.order;
898
+ return { op: "ui_screen", args: a };
899
+ }
900
+ case "ui_frame": {
901
+ const a = { parent: ns.parent, name: ns.name };
902
+ for (const k of ["position", "size", "anchor", "color", "transparency", "radius"]) if (ns[k] != null) a[k] = ns[k];
903
+ if (ns.clip) a.clip = true;
904
+ return { op: "ui_frame", args: a };
905
+ }
906
+ case "ui_label": {
907
+ const a = { parent: ns.parent, name: ns.name };
908
+ for (const k of ["text", "position", "size", "anchor", "color", "align", "font", "text_size"]) if (ns[k] != null) a[k] = ns[k];
909
+ if (ns.wrap) a.wrap = true;
910
+ return { op: "ui_label", args: a };
911
+ }
912
+ case "ui_button": {
913
+ const a = { parent: ns.parent, name: ns.name };
914
+ for (const k of ["text", "position", "size", "anchor", "color", "text_color", "radius", "font", "text_size"]) if (ns[k] != null) a[k] = ns[k];
915
+ return { op: "ui_button", args: a };
916
+ }
917
+ case "ui_input": {
918
+ const a = { parent: ns.parent, name: ns.name };
919
+ for (const k of ["placeholder", "text", "position", "size", "color", "background", "radius", "text_size"]) if (ns[k] != null) a[k] = ns[k];
920
+ return { op: "ui_input", args: a };
921
+ }
922
+ case "ui_image": {
923
+ const a = { parent: ns.parent, name: ns.name };
924
+ if (ns.asset) a.asset = ns.asset;
925
+ for (const k of ["position", "size", "scale"]) if (ns[k] != null) a[k] = ns[k];
926
+ return { op: "ui_image", args: a };
927
+ }
928
+ case "ui_list": {
929
+ const a = { parent: ns.parent };
930
+ for (const k of ["direction", "padding", "halign", "valign"]) if (ns[k] != null) a[k] = ns[k];
931
+ return { op: "ui_list", args: a };
932
+ }
933
+ case "play": {
934
+ const a = {};
935
+ if (ns.mode) a.mode = ns.mode;
936
+ return { op: "play", args: a };
937
+ }
938
+ case "stop":
939
+ return { op: "stop", args: {} };
940
+ case "logs": {
941
+ const a = ns.all ? { filter: "all" } : {};
942
+ if (ns.limit != null) {
943
+ if (ns.limit <= 0) throw new UsageError("logs: --limit must be a positive integer");
944
+ a.limit = ns.limit;
945
+ }
946
+ if (ns.since != null) a.since = ns.since;
947
+ return { op: "logs", args: a };
948
+ }
949
+ case "attr": {
950
+ const a = { path: ns.path };
951
+ if (ns.set && ns.set.length) {
952
+ const o = {};
953
+ for (const kv of ns.set) {
954
+ const e = kv.indexOf("=");
955
+ if (e === -1) throw new UsageError("--set expects K=V");
956
+ const key = kv.slice(0, e);
957
+ if (!key) throw new UsageError("--set expects K=V with a non-empty key");
958
+ o[key] = scalar(kv.slice(e + 1));
959
+ }
960
+ a.set = o;
961
+ }
962
+ if (ns.clear && ns.clear.length) a.clear = ns.clear;
963
+ return { op: "attributes", args: a };
964
+ }
965
+ case "tag": {
966
+ const a = { paths: ns.paths };
967
+ if ((!ns.add || !ns.add.length) && (!ns.remove || !ns.remove.length))
968
+ throw new UsageError("tag: need --add and/or --remove");
969
+ for (const t of [...(ns.add || []), ...(ns.remove || [])]) {
970
+ if (!t) throw new UsageError("tag: tag names must not be empty");
971
+ }
972
+ if (ns.add && ns.add.length) a.add = ns.add;
973
+ if (ns.remove && ns.remove.length) a.remove = ns.remove;
974
+ return { op: "tag", args: a };
975
+ }
976
+ case "match":
977
+ return { op: "match", args: { from: ns.from_path, paths: ns.to } };
978
+ case "scatter": {
979
+ if (!(ns.count >= 1)) throw new UsageError("scatter: --count must be 1 or greater");
980
+ const a = { path: ns.path, count: ns.count, radius: ns.radius, yJitter: ns.y_jitter };
981
+ if (ns.parent) a.parent = ns.parent;
982
+ if (ns.name) a.name = ns.name;
983
+ return { op: "scatter", args: a };
984
+ }
985
+ case "terrain": {
986
+ if (!ns.position) throw new UsageError("terrain: --position x,y,z is required");
987
+ const a = { action: ns.action, shape: ns.shape, position: V(ns.position), material: ns.material };
988
+ if (ns.shape === "block") {
989
+ if (!ns.size) throw new UsageError("--size x,y,z is required for block");
990
+ a.size = V(ns.size);
991
+ } else {
992
+ if (ns.radius == null) throw new UsageError("--radius is required for ball");
993
+ a.radius = ns.radius;
994
+ }
995
+ return { op: "terrain", args: a };
996
+ }
997
+ case "insert": {
998
+ const a = { id: ns.id, parent: ns.parent };
999
+ if (ns.name) a.name = ns.name;
1000
+ return { op: "insert_asset", args: a };
1001
+ }
1002
+ case "apply":
1003
+ return { op: "apply_asset", args: { id: ns.id, path: ns.path, prop: ns.prop } };
1004
+ default:
1005
+ throw new UsageError(`unknown tool: ${name}`);
1006
+ }
1007
+ }
1008
+ function toolUsage(name) {
1009
+ const spec = TOOLS[name];
1010
+ const parts = [`npx golem-bridge ${name}`];
1011
+ for (const p of (spec.pos || [])) {
1012
+ const a = Array.isArray(p) ? p : [p];
1013
+ if (a[1] === "+") parts.push(`<${a[0]}...>`);
1014
+ else if (a[1] !== undefined) parts.push(`[${a[0]}]`);
1015
+ else parts.push(`<${a[0]}>`);
1016
+ }
1017
+ if (spec.flags && Object.keys(spec.flags).length) parts.push("[options]");
1018
+ return parts.join(" ");
1019
+ }
1020
+
1021
+ function printHelp() {
1022
+ console.log("golem-bridge: drive a live Roblox Studio session from any AI agent.");
1023
+ console.log("");
1024
+ console.log(" Connect to my Roblox Studio, Run: npx golem-bridge connect <id>");
1025
+ console.log("");
1026
+ console.log("Session: connect <id> | reconnect [id] | disconnect | manual | help [tool]");
1027
+ for (const [group, names] of GROUPS) {
1028
+ console.log("");
1029
+ console.log(`${group}:`);
1030
+ for (const n of names) console.log(` ${n} - ${TOOLS[n].help}`);
1031
+ }
1032
+ console.log("");
1033
+ console.log("See one tool: npx golem-bridge help <tool>. Full reference: npx golem-bridge manual.");
1034
+ }
1035
+
1036
+ function toolHelp(name) {
1037
+ const spec = TOOLS[name];
1038
+ console.log(`${name} - ${spec.help}`);
1039
+ console.log("");
1040
+ console.log(`Usage: ${toolUsage(name)}`);
1041
+ const flags = spec.flags || {};
1042
+ const names = Object.keys(flags);
1043
+ if (names.length) {
1044
+ console.log("");
1045
+ console.log("Options:");
1046
+ for (const d of names) {
1047
+ const fs = flags[d];
1048
+ const t = Array.isArray(fs) ? fs[0] : fs;
1049
+ const o = Array.isArray(fs) ? (fs[1] || {}) : {};
1050
+ let line = ` ${o.flag || "--" + d.replace(/_/g, "-")}`;
1051
+ if (t !== "bool") line += ` <${t === "append" ? "value (repeatable)" : "value"}>`;
1052
+ if (o.choices) line += ` (${o.choices.join("|")})`;
1053
+ if (o.default !== undefined) line += ` [default: ${o.default}]`;
1054
+ console.log(line);
1055
+ }
1056
+ }
1057
+ }
1058
+
1059
+ const MANUAL_FILE = path.join(__dirname, "golem-tools.md");
1060
+
1061
+ function printManual() {
1062
+ try {
1063
+ process.stdout.write(fs.readFileSync(MANUAL_FILE, "utf8").trimEnd() + "\n");
1064
+ } catch {
1065
+ fail("manual not found next to cli.js - reinstall the package.", 1);
1066
+ }
1067
+ }
1068
+
1069
+ async function connect(id, opts) {
1070
+ opts = opts || {};
1071
+ if (id == null) fail("connect: need the channel id from the Studio widget.", 2);
1072
+ if (!isValidChannel(id)) {
1073
+ fail("bad channel ID (expect 8-64 hex characters — copy the full line from the Studio widget).", 2);
1074
+ }
1075
+ const data = await relayCall(id, "ping", {}, opts.timeout || 60);
1076
+ if (!data || !data.ok) {
1077
+ console.error(JSON.stringify({ ok: false, error: "connect: Studio did not answer. Is the plugin running and the id exact?" }, null, 2));
1078
+ process.exitCode = 1;
1079
+ return;
1080
+ }
1081
+ const res = (data && typeof data.result === "object" && data.result) || {};
1082
+ const place = res.place || res.placeName || data.place;
1083
+ if (!opts.print) {
1084
+ const dir = path.join(process.cwd(), ".golem");
1085
+ const saved = saveChannel(dir, id);
1086
+ console.log(`connected. channel saved to ${saved}`);
1087
+ const shadow = envChannelName();
1088
+ if (shadow) console.error(`warning: $${shadow} is set - commands will use it instead of this saved channel.`);
1089
+ }
1090
+ if (place) console.log(`place: ${place}`);
1091
+ printManual();
1092
+ }
1093
+
1094
+ async function reconnect(id, opts) {
1095
+ opts = opts || {};
1096
+ if (opts.print) fail("reconnect: --print is only for connect (reconnect always saves).", 2);
1097
+ const saved = loadChannel();
1098
+ const target = id || saved;
1099
+ if (!target) fail("reconnect: no saved channel and none given.", 2);
1100
+ if (!isValidChannel(target)) {
1101
+ fail("bad channel ID (expect 8-64 hex characters copy the full line from the Studio widget).", 2);
1102
+ }
1103
+ const data = await relayCall(target, "ping", {}, opts.timeout || 60);
1104
+ if (!data || !data.ok) {
1105
+ console.error(JSON.stringify({ ok: false, error: "reconnect: Studio did not answer." }, null, 2));
1106
+ process.exitCode = 1;
1107
+ return;
1108
+ }
1109
+ const res = (data && typeof data.result === "object" && data.result) || {};
1110
+ const place = res.place || res.placeName || data.place;
1111
+ if (target === saved) {
1112
+ console.log("already connected to this channel.");
1113
+ if (place) console.log(`place: ${place}`);
1114
+ return;
1115
+ }
1116
+ const dir = path.join(process.cwd(), ".golem");
1117
+ const file = saveChannel(dir, target);
1118
+ console.log(`reconnected. channel saved to ${file}`);
1119
+ const shadow = envChannelName();
1120
+ if (shadow) console.error(`warning: $${shadow} is set - commands will use it instead of this saved channel.`);
1121
+ if (place) console.log(`place: ${place}`);
1122
+ }
1123
+
1124
+ function disconnect() {
1125
+ const dir = path.join(process.cwd(), ".golem");
1126
+ let removed = false;
1127
+ for (const name of ["channel", ...STALE_HELPER_FILES]) {
1128
+ const f = path.join(dir, name);
1129
+ try {
1130
+ if (fs.existsSync(f)) { fs.rmSync(f, { force: true }); removed = true; }
1131
+ } catch {
1132
+ // keep going
1133
+ }
1134
+ }
1135
+ try { fs.rmdirSync(dir); } catch {
1136
+ // stays if not empty
1137
+ }
1138
+ const shadow = envChannelName();
1139
+ if (shadow) console.error(`warning: $${shadow} is still set - commands stay connected through the environment.`);
1140
+ console.log(removed ? "disconnected (local channel file removed)." : "already disconnected (nothing saved).");
1141
+ }
1142
+
1143
+ // Slow ops need longer minimum waits, but only when the user did not pass an
1144
+ // explicit --timeout: an explicit timeout is always respected.
1145
+ const TIMEOUT_MIN = { ping: 60, debug: 90, insert_asset: 300 };
1146
+
1147
+ function waitFor(op, timeout) {
1148
+ if (timeout != null) return timeout;
1149
+ return Math.max(DEFAULT_TIMEOUT, TIMEOUT_MIN[op] || 0);
1150
+ }
1151
+
1152
+ function parseToolInvocation(name, argv) {
1153
+ const { args, timeout } = stripTimeout(argv);
1154
+ return { ns: parseToolArgs(name, args), timeout };
1155
+ }
1156
+
1157
+ async function runRelayTool(name, argv) {
1158
+ let ns, timeout, built;
1159
+ try {
1160
+ ({ ns, timeout } = parseToolInvocation(name, argv));
1161
+ built = buildArgs(name, ns);
1162
+ } catch (err) {
1163
+ if (err instanceof UsageError) { console.error(`golem-bridge: ${err.message}`); process.exitCode = 2; return; }
1164
+ throw err;
1165
+ }
1166
+ const channel = requireChannel();
1167
+ if (!channel) return;
1168
+ const entry = await relayCall(channel, built.op, built.args, waitFor(built.op, timeout));
1169
+ out(entry, { asJson: built.asJson, rawField: built.rawField });
1170
+ }
1171
+
1172
+ async function runLocalTool(name, argv) {
1173
+ let ns;
1174
+ try {
1175
+ ({ ns } = parseToolInvocation(name, argv));
1176
+ } catch (err) {
1177
+ if (err instanceof UsageError) { console.error(`golem-bridge: ${err.message}`); process.exitCode = 2; return; }
1178
+ throw err;
1179
+ }
1180
+ if (name === "status") { out(await status()); return; }
1181
+ if (name === "search" || name === "info") {
1182
+ try {
1183
+ const result = name === "search"
1184
+ ? await mpSearch(ns.query, ns.category, ns.limit, ns.cursor)
1185
+ : await mpInfo(ns.id);
1186
+ out({ ok: true, op: name, result });
1187
+ } catch (err) {
1188
+ out({ ok: false, op: name, error: name === "search" ? `marketplace search failed: ${err.message}` : `asset info failed: ${err.message}` });
1189
+ }
1190
+ }
1191
+ }
1192
+
1193
+ async function main(argv) {
1194
+ const args = argv || process.argv.slice(2);
1195
+ if (!args.length || args[0] === "--help" || args[0] === "-h") { printHelp(); return; }
1196
+ if (args[0] === "--version" || args[0] === "-v") { console.log(VERSION); return; }
1197
+ const cmd = args[0];
1198
+ const rest = args.slice(1);
1199
+ if (cmd === "help") {
1200
+ if (!rest.length) { printHelp(); return; }
1201
+ if (Object.hasOwn(TOOLS, rest[0])) { toolHelp(rest[0]); return; }
1202
+ console.error(`golem-bridge: unknown tool "${rest[0]}"`);
1203
+ process.exitCode = 2;
1204
+ return;
1205
+ }
1206
+ if (cmd === "manual") { printManual(); return; }
1207
+ if (cmd === "disconnect") { disconnect(); return; }
1208
+ if (cmd === "connect" || cmd === "reconnect") {
1209
+ let print = false, timeout = null, id = null;
1210
+ for (let k = 0; k < rest.length; k++) {
1211
+ const a = rest[k];
1212
+ if (a === "--print") print = true;
1213
+ else if (a === "--yes" || a === "-y") { /* accepted for 2.x scripts; nothing to confirm anymore */ }
1214
+ else if (a === "--timeout" && k + 1 < rest.length) timeout = parseFloat(rest[++k]);
1215
+ else if (a.startsWith("--timeout=")) timeout = parseFloat(a.slice(10));
1216
+ else if (!a.startsWith("-") && id === null) id = a;
1217
+ else { console.error(`golem-bridge: ${cmd}: bad argument ${a}`); process.exitCode = 2; return; }
1218
+ }
1219
+ if (timeout != null && (!(timeout > 0) || !Number.isFinite(timeout))) { console.error("golem-bridge: --timeout must be a positive, finite number of seconds"); process.exitCode = 2; return; }
1220
+ if (cmd === "connect") await connect(id, { print, timeout });
1221
+ else await reconnect(id, { timeout });
1222
+ return;
1223
+ }
1224
+ if (Object.hasOwn(TOOLS, cmd)) {
1225
+ if (TOOLS[cmd].local) await runLocalTool(cmd, rest);
1226
+ else await runRelayTool(cmd, rest);
1227
+ return;
1228
+ }
1229
+ console.error(`golem-bridge: unknown command "${cmd}" (try: help)`);
1230
+ process.exitCode = 2;
1231
+ }
1232
+
1233
+ if (require.main === module) {
1234
+ main().catch((err) => { console.error(`golem-bridge error: ${(err && err.message) || err}`); process.exitCode = 1; });
1235
+ }
1236
+
1237
+ module.exports = { TOOLS, GROUPS, parseToolArgs, buildArgs, vec3, scalar, loadChannel, isValidChannel, relayBase, saveChannel, envChannelName, status, mpSearch, mpInfo, relayCall, UsageError, stripTimeout, removeStaleHelpers, disconnect, main };