golem-bridge 2.0.0 → 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 +33 -13
- package/cli.js +1007 -191
- package/{golem.md → golem-tools.md} +95 -84
- package/package.json +2 -2
- package/golem.py +0 -1093
package/cli.js
CHANGED
|
@@ -1,19 +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
10
|
|
|
11
|
-
const DB_URL = "https://roblox-golem-default-rtdb.firebaseio.com";
|
|
12
|
-
const POLL_INTERVAL_MS =
|
|
13
|
-
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);
|
|
14
13
|
const FETCH_TIMEOUT_MS = 30000;
|
|
15
14
|
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
16
|
-
const
|
|
15
|
+
const DEFAULT_TIMEOUT = 120;
|
|
17
16
|
// Channel IDs are hex tokens minted by the Studio plugin (16 chars today,
|
|
18
17
|
// tolerated 8-64 for forward compatibility). Anything else is rejected so a
|
|
19
18
|
// malformed ID can never alter the request URL.
|
|
@@ -26,75 +25,45 @@ try {
|
|
|
26
25
|
// running outside the package dir; version is informational only
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
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.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.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.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.`;
|
|
30
|
-
|
|
31
|
-
function loadTemplate(name) {
|
|
32
|
-
return fs.readFileSync(path.join(__dirname, name), "utf8");
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function stamp(text, channelId) {
|
|
36
|
-
return text.split("__DB_URL__").join(DB_URL).split("__CHANNEL_ID__").join(channelId);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function printHelp() {
|
|
40
|
-
console.log(`golem-bridge v${VERSION} — connect an AI agent to Roblox Studio via the Golem plugin.
|
|
41
|
-
|
|
42
|
-
Usage:
|
|
43
|
-
golem-bridge connect <channelId> [--print]
|
|
44
|
-
golem-bridge reconnect <channelId> [--print]
|
|
45
|
-
golem-bridge disconnect
|
|
46
|
-
golem-bridge --help
|
|
47
|
-
golem-bridge --version
|
|
48
|
-
|
|
49
|
-
<channelId> shown in the Golem plugin widget inside Roblox Studio.
|
|
50
|
-
Fresh on every Studio start.
|
|
51
|
-
--print audit mode: verify Studio, then print both files without writing.
|
|
52
|
-
|
|
53
|
-
connect link this folder to a Studio session (writes ./.golem/).
|
|
54
|
-
reconnect same, for a rotated token: replaces the old session files.
|
|
55
|
-
Use after a Studio restart, with the new line from the widget.
|
|
56
|
-
disconnect forget this session (removes ./.golem/). Studio is unaffected.
|
|
57
|
-
|
|
58
|
-
connect verifies Studio is alive over HTTPS, then stamps your channel ID
|
|
59
|
-
into local copies of the bundled golem.py and golem.md. No code is ever
|
|
60
|
-
downloaded from the network. You can still review both files first with
|
|
61
|
-
--print, or read them in this package before running anything.`);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
28
|
function fail(message, exitCode) {
|
|
65
29
|
console.error(`golem-bridge error: ${message}`);
|
|
66
30
|
process.exit(exitCode || 1);
|
|
67
31
|
}
|
|
68
32
|
|
|
69
|
-
function
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
72
43
|
}
|
|
73
|
-
return
|
|
44
|
+
return fallback;
|
|
74
45
|
}
|
|
75
46
|
|
|
76
|
-
async function fetchText(url,
|
|
47
|
+
async function fetchText(url, opts) {
|
|
48
|
+
const o = opts || {};
|
|
77
49
|
const ctrl = new AbortController();
|
|
78
|
-
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
50
|
+
const timer = setTimeout(() => ctrl.abort(), o.timeoutMs || FETCH_TIMEOUT_MS);
|
|
79
51
|
try {
|
|
80
52
|
const res = await fetch(url, {
|
|
81
|
-
method: body === undefined ? "GET" : "POST",
|
|
82
|
-
headers: {
|
|
83
|
-
body,
|
|
53
|
+
method: o.method || (o.body === undefined ? "GET" : "POST"),
|
|
54
|
+
headers: Object.assign({ Accept: "application/json" }, o.headers || {}),
|
|
55
|
+
body: o.body,
|
|
84
56
|
signal: ctrl.signal,
|
|
85
|
-
redirect: "error", //
|
|
57
|
+
redirect: "error", // responses must come from the relay itself
|
|
86
58
|
});
|
|
87
|
-
if (!res.ok) {
|
|
88
|
-
throw new Error(`HTTP ${res.status} from the relay`);
|
|
89
|
-
}
|
|
90
59
|
const text = await res.text();
|
|
91
60
|
if (text.length > MAX_BODY_BYTES) {
|
|
92
|
-
throw new Error("
|
|
61
|
+
throw new Error("response too large, refusing to parse it");
|
|
93
62
|
}
|
|
94
|
-
return text;
|
|
63
|
+
return { ok: res.ok, status: res.status, headers: res.headers, text };
|
|
95
64
|
} catch (err) {
|
|
96
65
|
if (err && err.name === "AbortError") {
|
|
97
|
-
throw new Error(
|
|
66
|
+
throw new Error(`request timed out (${(o.timeoutMs || FETCH_TIMEOUT_MS) / 1000}s)`);
|
|
98
67
|
}
|
|
99
68
|
throw err;
|
|
100
69
|
} finally {
|
|
@@ -102,192 +71,1039 @@ async function fetchText(url, body) {
|
|
|
102
71
|
}
|
|
103
72
|
}
|
|
104
73
|
|
|
105
|
-
async function
|
|
106
|
-
const text = await fetchText(url, JSON.stringify(body));
|
|
107
|
-
return JSON.parse(text);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async function getJson(url) {
|
|
111
|
-
const text = await fetchText(url);
|
|
112
|
-
return text === "null" ? null : JSON.parse(text);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function sleep(ms) {
|
|
116
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
async function relayCall(channelId, op, args, attempts) {
|
|
121
|
-
const cmdId = `${op}${Date.now()}${Math.floor(Math.random() * 1e6)}`;
|
|
74
|
+
async function relayCall(channelId, op, args, timeoutSec) {
|
|
122
75
|
const enc = encodeURIComponent(channelId);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
});
|
|
129
|
-
for (let i = 0; i < attempts; i++) {
|
|
130
|
-
await sleep(POLL_INTERVAL_MS);
|
|
131
|
-
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;
|
|
132
81
|
try {
|
|
133
|
-
|
|
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;
|
|
134
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);
|
|
135
109
|
continue;
|
|
136
110
|
}
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
}
|
|
145
151
|
}
|
|
146
|
-
if (entry && entry.id === cmdId) return entry;
|
|
147
152
|
}
|
|
153
|
+
await sleep(POLL_INTERVAL_MS);
|
|
148
154
|
}
|
|
149
|
-
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
|
+
};
|
|
150
159
|
}
|
|
151
160
|
|
|
152
|
-
function
|
|
153
|
-
|
|
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"`);
|
|
154
169
|
}
|
|
155
170
|
|
|
156
|
-
|
|
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;
|
|
157
213
|
try {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
return m ? m[1] : null;
|
|
214
|
+
const raw = fs.readFileSync(path.join(process.cwd(), ".golem", "channel"), "utf8").trim();
|
|
215
|
+
if (raw) return raw;
|
|
161
216
|
} catch {
|
|
162
|
-
|
|
217
|
+
// fall through to the 2.x stamped helper below
|
|
163
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;
|
|
164
229
|
}
|
|
165
230
|
|
|
166
|
-
function
|
|
167
|
-
const
|
|
168
|
-
if (!
|
|
169
|
-
|
|
170
|
-
return;
|
|
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;
|
|
171
236
|
}
|
|
172
|
-
|
|
173
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
174
|
-
console.log(old ? `Disconnected from channel ${old} (removed .golem/).` : "Disconnected (removed .golem/).");
|
|
175
|
-
console.log("Studio is unaffected. To link again: npx golem-bridge connect <channelId>");
|
|
237
|
+
return id;
|
|
176
238
|
}
|
|
177
239
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (!printOnly && old && old.toLowerCase() === channelId.toLowerCase()) {
|
|
182
|
-
console.log(`Already linked to channel ${channelId} — nothing to do.`);
|
|
183
|
-
return;
|
|
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);
|
|
184
243
|
}
|
|
185
|
-
|
|
186
|
-
|
|
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
|
+
}
|
|
187
254
|
}
|
|
188
|
-
await connect(channelId, printOnly);
|
|
189
255
|
}
|
|
190
256
|
|
|
191
|
-
|
|
192
|
-
validateChannel(channelId);
|
|
193
|
-
console.log(`Contacting Golem plugin on channel ${channelId} ...`);
|
|
194
|
-
let entry;
|
|
257
|
+
function readStdin() {
|
|
195
258
|
try {
|
|
196
|
-
|
|
197
|
-
} catch
|
|
198
|
-
|
|
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
|
|
199
300
|
}
|
|
200
|
-
if (
|
|
201
|
-
|
|
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
|
+
}
|
|
202
312
|
}
|
|
203
|
-
|
|
204
|
-
|
|
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);
|
|
205
352
|
}
|
|
206
353
|
try {
|
|
207
|
-
const
|
|
208
|
-
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
|
+
}
|
|
209
359
|
} catch {
|
|
210
|
-
//
|
|
360
|
+
// thumbnails are a bonus
|
|
211
361
|
}
|
|
362
|
+
return output;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function marketplaceViaPlugin() {
|
|
366
|
+
return (process.env.AIB_MARKETPLACE || "").toLowerCase() === "plugin";
|
|
367
|
+
}
|
|
212
368
|
|
|
213
|
-
|
|
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;
|
|
214
376
|
try {
|
|
215
|
-
const
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
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 = {};
|
|
221
386
|
} catch (err) {
|
|
222
|
-
|
|
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 };
|
|
223
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
|
+
}
|
|
224
409
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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);
|
|
233
503
|
}
|
|
504
|
+
if (timeout !== null && !(timeout > 0)) throw new UsageError("--timeout must be a positive number of seconds");
|
|
505
|
+
return { args: out, timeout };
|
|
506
|
+
}
|
|
234
507
|
|
|
235
|
-
|
|
236
|
-
|
|
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
|
+
}
|
|
237
589
|
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
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
|
+
}
|
|
242
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
|
+
}
|
|
243
903
|
console.log("");
|
|
244
|
-
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}`);
|
|
245
910
|
console.log("");
|
|
246
|
-
console.log(
|
|
247
|
-
|
|
248
|
-
);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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;
|
|
261
949
|
return;
|
|
262
950
|
}
|
|
263
|
-
|
|
264
|
-
|
|
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;
|
|
265
974
|
return;
|
|
266
975
|
}
|
|
267
|
-
if (
|
|
268
|
-
|
|
976
|
+
if (target === saved) {
|
|
977
|
+
console.log("already connected to this channel.");
|
|
269
978
|
return;
|
|
270
979
|
}
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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;
|
|
275
1014
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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;
|
|
281
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;
|
|
282
1031
|
try {
|
|
283
|
-
|
|
284
|
-
|
|
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));
|
|
285
1059
|
} else {
|
|
286
|
-
await
|
|
1060
|
+
out(await relayCall(channel, "asset_info", { id: ns.id }, wait));
|
|
287
1061
|
}
|
|
288
|
-
} catch (err) {
|
|
289
|
-
fail(err.message, 1);
|
|
290
1062
|
}
|
|
291
1063
|
}
|
|
292
1064
|
|
|
293
|
-
main()
|
|
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 };
|