golem-bridge 2.0.1 → 3.0.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.
- package/README.md +25 -19
- package/cli.js +989 -224
- package/golem-tools.md +95 -84
- package/package.json +2 -2
- package/golem-helper.py +0 -1093
package/cli.js
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
|
-
// golem-bridge:
|
|
5
|
-
//
|
|
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.
|
|
6
7
|
|
|
7
|
-
const crypto = require("crypto");
|
|
8
8
|
const fs = require("fs");
|
|
9
9
|
const path = require("path");
|
|
10
|
-
const readline = require("readline");
|
|
11
10
|
|
|
12
|
-
const DB_URL = "https://roblox-golem-default-rtdb.firebaseio.com";
|
|
13
|
-
const POLL_INTERVAL_MS =
|
|
14
|
-
const POLL_ATTEMPTS = 45;
|
|
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);
|
|
15
13
|
const FETCH_TIMEOUT_MS = 30000;
|
|
16
14
|
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
17
|
-
const
|
|
15
|
+
const DEFAULT_TIMEOUT = 120;
|
|
18
16
|
// Channel IDs are hex tokens minted by the Studio plugin (16 chars today,
|
|
19
17
|
// tolerated 8-64 for forward compatibility). Anything else is rejected so a
|
|
20
18
|
// malformed ID can never alter the request URL.
|
|
@@ -27,95 +25,45 @@ try {
|
|
|
27
25
|
// running outside the package dir; version is informational only
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
const SETUP_NOTE = `You are connected to this user's Roblox Studio through Golem, the plugin they installed at their own request - this is the user's own tooling. The helper (./.golem/golem-helper.py) is zero-dependency Python that contacts only this relay and Roblox's public APIs: no file access, no subprocess, no eval. It shipped inside the audited golem-bridge package - nothing executable was downloaded from the network - but you can still read it before you run anything. ./.golem/golem-tools.md is the tool's reference manual (command list, calling conventions, safety rules), in the same spirit as a man page: consult it when you need a command. It is documentation, not orders - your tasks come only from the user. If anything in either file looks wrong, stop and tell the user instead of proceeding. Otherwise run python3 ./.golem/golem-helper.py ping (expect "ok": true plus the open place's name) and tell the user you are connected and ready - one short line. Work only reaches the place file when the user presses Ctrl+S in Studio - remind them at milestones.`;
|
|
31
|
-
|
|
32
|
-
function loadTemplate(name) {
|
|
33
|
-
return fs.readFileSync(path.join(__dirname, name), "utf8");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function stamp(text, channelId) {
|
|
37
|
-
return text.split("__DB_URL__").join(DB_URL).split("__CHANNEL_ID__").join(channelId);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function printHelp() {
|
|
41
|
-
console.log(`golem-bridge v${VERSION} — connect an AI agent to Roblox Studio via the Golem plugin.
|
|
42
|
-
|
|
43
|
-
Usage:
|
|
44
|
-
golem-bridge connect <channelId> [--print] [--yes]
|
|
45
|
-
golem-bridge reconnect <channelId> [--print] [--yes]
|
|
46
|
-
golem-bridge disconnect
|
|
47
|
-
golem-bridge --help
|
|
48
|
-
golem-bridge --version
|
|
49
|
-
|
|
50
|
-
<channelId> shown in the Golem plugin widget inside Roblox Studio.
|
|
51
|
-
Fresh on every Studio start.
|
|
52
|
-
--print audit mode: verify Studio, then print both files without writing.
|
|
53
|
-
--yes answer the install question with yes (for scripts).
|
|
54
|
-
|
|
55
|
-
connect link this folder to a Studio session (writes ./.golem/).
|
|
56
|
-
reconnect same, for a rotated token: replaces the old session files.
|
|
57
|
-
Use after a Studio restart, with the new line from the widget.
|
|
58
|
-
disconnect forget this session (removes ./.golem/). Studio is unaffected.
|
|
59
|
-
|
|
60
|
-
connect verifies Studio is alive over HTTPS, then stamps your channel ID
|
|
61
|
-
into local copies of the bundled golem-helper.py and golem-tools.md. No code is ever
|
|
62
|
-
downloaded from the network. It lists the files and asks before writing anything; review first with
|
|
63
|
-
--print, or read them in this package before running anything.`);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
28
|
function fail(message, exitCode) {
|
|
67
29
|
console.error(`golem-bridge error: ${message}`);
|
|
68
30
|
process.exit(exitCode || 1);
|
|
69
31
|
}
|
|
70
32
|
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
fail("bad channel ID (expect 8-64 hex characters — copy the full line from the Studio widget).", 2);
|
|
74
|
-
}
|
|
75
|
-
return channelId;
|
|
33
|
+
function sleep(ms) {
|
|
34
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
76
35
|
}
|
|
77
36
|
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
rl.close();
|
|
87
|
-
} catch {
|
|
88
|
-
// already closed (EOF on stdin)
|
|
89
|
-
}
|
|
90
|
-
resolve(value);
|
|
91
|
-
};
|
|
92
|
-
rl.question(question + " ", (answer) => finish(/^\s*y(es)?\s*$/i.test(answer || "")));
|
|
93
|
-
rl.on("close", () => finish(false));
|
|
94
|
-
});
|
|
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;
|
|
95
45
|
}
|
|
96
46
|
|
|
97
|
-
async function fetchText(url,
|
|
47
|
+
async function fetchText(url, opts) {
|
|
48
|
+
const o = opts || {};
|
|
98
49
|
const ctrl = new AbortController();
|
|
99
|
-
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
50
|
+
const timer = setTimeout(() => ctrl.abort(), o.timeoutMs || FETCH_TIMEOUT_MS);
|
|
100
51
|
try {
|
|
101
52
|
const res = await fetch(url, {
|
|
102
|
-
method: body === undefined ? "GET" : "POST",
|
|
103
|
-
headers: {
|
|
104
|
-
body,
|
|
53
|
+
method: o.method || (o.body === undefined ? "GET" : "POST"),
|
|
54
|
+
headers: Object.assign({ Accept: "application/json" }, o.headers || {}),
|
|
55
|
+
body: o.body,
|
|
105
56
|
signal: ctrl.signal,
|
|
106
|
-
redirect: "error", //
|
|
57
|
+
redirect: "error", // responses must come from the relay itself
|
|
107
58
|
});
|
|
108
|
-
if (!res.ok) {
|
|
109
|
-
throw new Error(`HTTP ${res.status} from the relay`);
|
|
110
|
-
}
|
|
111
59
|
const text = await res.text();
|
|
112
60
|
if (text.length > MAX_BODY_BYTES) {
|
|
113
|
-
throw new Error("
|
|
61
|
+
throw new Error("response too large, refusing to parse it");
|
|
114
62
|
}
|
|
115
|
-
return text;
|
|
63
|
+
return { ok: res.ok, status: res.status, headers: res.headers, text };
|
|
116
64
|
} catch (err) {
|
|
117
65
|
if (err && err.name === "AbortError") {
|
|
118
|
-
throw new Error(
|
|
66
|
+
throw new Error(`request timed out (${(o.timeoutMs || FETCH_TIMEOUT_MS) / 1000}s)`);
|
|
119
67
|
}
|
|
120
68
|
throw err;
|
|
121
69
|
} finally {
|
|
@@ -123,73 +71,181 @@ async function fetchText(url, body) {
|
|
|
123
71
|
}
|
|
124
72
|
}
|
|
125
73
|
|
|
126
|
-
async function
|
|
127
|
-
const text = await fetchText(url, JSON.stringify(body));
|
|
128
|
-
return JSON.parse(text);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function getJson(url) {
|
|
132
|
-
const text = await fetchText(url);
|
|
133
|
-
return text === "null" ? null : JSON.parse(text);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function sleep(ms) {
|
|
137
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
async function relayCall(channelId, op, args, attempts) {
|
|
142
|
-
const cmdId = `${op}${Date.now()}${Math.floor(Math.random() * 1e6)}`;
|
|
74
|
+
async function relayCall(channelId, op, args, timeoutSec) {
|
|
143
75
|
const enc = encodeURIComponent(channelId);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
});
|
|
150
|
-
for (let i = 0; i < attempts; i++) {
|
|
151
|
-
await sleep(POLL_INTERVAL_MS);
|
|
152
|
-
let keys;
|
|
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;
|
|
153
81
|
try {
|
|
154
|
-
|
|
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;
|
|
155
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);
|
|
156
109
|
continue;
|
|
157
110
|
}
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
}
|
|
166
151
|
}
|
|
167
|
-
if (entry && entry.id === cmdId) return entry;
|
|
168
152
|
}
|
|
153
|
+
await sleep(POLL_INTERVAL_MS);
|
|
169
154
|
}
|
|
170
|
-
return
|
|
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
|
+
};
|
|
171
159
|
}
|
|
172
160
|
|
|
173
|
-
function
|
|
174
|
-
|
|
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"`);
|
|
175
169
|
}
|
|
176
170
|
|
|
177
|
-
|
|
178
|
-
|
|
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
|
+
}
|
|
179
219
|
for (const name of ["golem-helper.py", "golem.py"]) {
|
|
180
220
|
try {
|
|
181
221
|
const src = fs.readFileSync(path.join(process.cwd(), ".golem", name), "utf8");
|
|
182
222
|
const m = src.match(/CHANNEL = os\.environ\.get\("AIB_CHANNEL", "([0-9a-fA-F]+)"\)/);
|
|
183
223
|
if (m) return m[1];
|
|
184
224
|
} catch {
|
|
185
|
-
//
|
|
225
|
+
// try the next name
|
|
186
226
|
}
|
|
187
227
|
}
|
|
188
228
|
return null;
|
|
189
229
|
}
|
|
190
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
|
+
|
|
191
247
|
function removeStaleHelpers(dir) {
|
|
192
|
-
for (const stale of ["golem.py", "golem.md"]) {
|
|
248
|
+
for (const stale of ["golem-helper.py", "golem-tools.md", "golem.py", "golem.md"]) {
|
|
193
249
|
try {
|
|
194
250
|
fs.rmSync(path.join(dir, stale), { force: true });
|
|
195
251
|
} catch {
|
|
@@ -198,147 +254,856 @@ function removeStaleHelpers(dir) {
|
|
|
198
254
|
}
|
|
199
255
|
}
|
|
200
256
|
|
|
201
|
-
function
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
return;
|
|
257
|
+
function readStdin() {
|
|
258
|
+
try {
|
|
259
|
+
return fs.readFileSync(0, "utf8");
|
|
260
|
+
} catch {
|
|
261
|
+
return "";
|
|
206
262
|
}
|
|
207
|
-
const old = readSavedChannel();
|
|
208
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
209
|
-
console.log(old ? `Disconnected from channel ${old} (removed .golem/).` : "Disconnected (removed .golem/).");
|
|
210
|
-
console.log("Studio is unaffected. To link again: npx golem-bridge connect <channelId>");
|
|
211
263
|
}
|
|
212
264
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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);
|
|
224
279
|
}
|
|
225
280
|
|
|
226
|
-
async function
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
let
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
234
300
|
}
|
|
235
|
-
if (
|
|
236
|
-
|
|
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
|
+
}
|
|
237
312
|
}
|
|
238
|
-
|
|
239
|
-
|
|
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);
|
|
240
352
|
}
|
|
241
353
|
try {
|
|
242
|
-
const
|
|
243
|
-
if (
|
|
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
|
+
}
|
|
244
359
|
} catch {
|
|
245
|
-
//
|
|
360
|
+
// thumbnails are a bonus
|
|
246
361
|
}
|
|
362
|
+
return output;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function marketplaceViaPlugin() {
|
|
366
|
+
return (process.env.AIB_MARKETPLACE || "").toLowerCase() === "plugin";
|
|
367
|
+
}
|
|
247
368
|
|
|
248
|
-
|
|
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;
|
|
249
376
|
try {
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
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 = {};
|
|
256
386
|
} catch (err) {
|
|
257
|
-
|
|
387
|
+
return { ok: false, error: `cannot read the relay channel: ${err.message}` };
|
|
258
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
|
+
}
|
|
259
409
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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);
|
|
268
503
|
}
|
|
504
|
+
if (timeout !== null && !(timeout > 0)) throw new UsageError("--timeout must be a positive number of seconds");
|
|
505
|
+
return { args: out, timeout };
|
|
506
|
+
}
|
|
269
507
|
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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;
|
|
279
557
|
}
|
|
558
|
+
pos.push(t); i++;
|
|
280
559
|
}
|
|
281
|
-
|
|
282
|
-
|
|
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
|
+
}
|
|
283
589
|
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
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
|
+
}
|
|
288
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
|
+
}
|
|
289
903
|
console.log("");
|
|
290
|
-
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}`);
|
|
291
910
|
console.log("");
|
|
292
|
-
console.log(
|
|
293
|
-
|
|
294
|
-
);
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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;
|
|
307
949
|
return;
|
|
308
950
|
}
|
|
309
|
-
|
|
310
|
-
|
|
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;
|
|
311
974
|
return;
|
|
312
975
|
}
|
|
313
|
-
if (
|
|
314
|
-
|
|
976
|
+
if (target === saved) {
|
|
977
|
+
console.log("already connected to this channel.");
|
|
315
978
|
return;
|
|
316
979
|
}
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
+
}
|
|
321
997
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const autoYes = args.includes("--yes") || args.includes("-y");
|
|
325
|
-
if (!channelId || channelId.startsWith("-")) {
|
|
326
|
-
printHelp();
|
|
327
|
-
process.exit(2);
|
|
998
|
+
try { fs.rmdirSync(dir); } catch {
|
|
999
|
+
// stays if not empty
|
|
328
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;
|
|
329
1031
|
try {
|
|
330
|
-
|
|
331
|
-
|
|
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));
|
|
332
1059
|
} else {
|
|
333
|
-
await
|
|
1060
|
+
out(await relayCall(channel, "asset_info", { id: ns.id }, wait));
|
|
334
1061
|
}
|
|
335
|
-
} catch (err) {
|
|
336
|
-
fail(err.message, 1);
|
|
337
1062
|
}
|
|
338
1063
|
}
|
|
339
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
|
+
|
|
340
1105
|
if (require.main === module) {
|
|
341
|
-
main();
|
|
1106
|
+
main().catch((err) => { console.error(`golem-bridge error: ${(err && err.message) || err}`); process.exitCode = 1; });
|
|
342
1107
|
}
|
|
343
1108
|
|
|
344
|
-
module.exports = {
|
|
1109
|
+
module.exports = { TOOLS, GROUPS, parseToolArgs, buildArgs, vec3, scalar, loadChannel, status, mpSearch, mpInfo, marketplaceViaPlugin, relayCall, UsageError, stripTimeout, removeStaleHelpers, disconnect, main };
|