roforge-cli 0.3.2 → 0.3.4
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/bin/roforge.js +67 -4
- package/dist/RoForge.rbxm +0 -0
- package/dist/RoForgeBridge.rbxm +0 -0
- package/package.json +2 -1
- package/src/bridge/server.js +76 -4
- package/src/config.js +1 -0
- package/src/install.js +72 -0
- package/src/pro.js +126 -0
- package/src/session.js +12 -1
- package/src/tools/studio.js +5 -2
- package/src/tui/tui.js +8 -5
package/bin/roforge.js
CHANGED
|
@@ -9,7 +9,7 @@ import { Session } from "../src/session.js";
|
|
|
9
9
|
import { BridgeServer } from "../src/bridge/server.js";
|
|
10
10
|
import { probeMcp } from "../src/mcp.js";
|
|
11
11
|
import { TUI } from "../src/tui/tui.js";
|
|
12
|
-
import { bold, dim, red, green, cyan, yellow, gray } from "../src/tui/ansi.js";
|
|
12
|
+
import { bold, dim, red, green, cyan, yellow, gray, magenta } from "../src/tui/ansi.js";
|
|
13
13
|
|
|
14
14
|
const argv = process.argv.slice(2);
|
|
15
15
|
const command = argv[0] || "tui";
|
|
@@ -262,6 +262,66 @@ async function main() {
|
|
|
262
262
|
return;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
case "install-plugin": {
|
|
266
|
+
const { PLUGINS, findPluginSource, installPlugin, pluginsDir } = await import("../src/install.js");
|
|
267
|
+
const which = (flags._pos && flags._pos[0]) || "bridge";
|
|
268
|
+
if (flags.list || flags.l) {
|
|
269
|
+
console.log(bold("Roblox Studio plugins folder:") + ` ${pluginsDir()}`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (!PLUGINS[which]) {
|
|
273
|
+
console.error(red(`unknown plugin: ${which} (expected: ${Object.keys(PLUGINS).join(" | ")})`));
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
const src = findPluginSource(which);
|
|
277
|
+
if (!src) {
|
|
278
|
+
console.error(red("no built plugin found in this install."));
|
|
279
|
+
console.error(dim(" from a git clone: rojo build -o studio-bridge/dist/RoForgeBridge.rbxm studio-bridge/default.project.json"));
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
const { dest } = installPlugin(which);
|
|
283
|
+
console.log(bold("RoForge plugin installed") + dim(" — " + PLUGINS[which].desc + "\n"));
|
|
284
|
+
console.log(` ${green("✓")} ${src}\n → ${dest}\n`);
|
|
285
|
+
console.log(bold("Next:"));
|
|
286
|
+
console.log(" 1. start (or restart) Roblox Studio");
|
|
287
|
+
console.log(" 2. File → Plugins → Manage Plugins — " + which + " is now listed");
|
|
288
|
+
console.log(" 3. run `roforge studio` (or the TUI) and paste the printed token into the plugin dock");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
case "pro": {
|
|
293
|
+
const { queryProStatus, renderProStatus, probeBridge, startOwnBridge } = await import("../src/pro.js");
|
|
294
|
+
const port = flags.port ? Number(flags.port) : cfg.bridge.port;
|
|
295
|
+
const host = cfg.bridge.host;
|
|
296
|
+
const token = cfg.bridge.token;
|
|
297
|
+
const base = `http://${host}:${port}`;
|
|
298
|
+
console.log(bold("RoForge Pro") + dim(" — license status\n"));
|
|
299
|
+
// Attach to an already-running bridge (e.g. `roforge studio`);
|
|
300
|
+
// otherwise start a throwaway one for the plugin to connect to.
|
|
301
|
+
const health = await probeBridge(base);
|
|
302
|
+
if (health) {
|
|
303
|
+
const res = await queryProStatus(null, { port, token, baseUrl: base });
|
|
304
|
+
console.log(renderProStatus(res, { bold, dim, red, green, magenta, yellow }) + "\n");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const bridge = startOwnBridge({ port, host, token });
|
|
308
|
+
try {
|
|
309
|
+
await bridge.start();
|
|
310
|
+
} catch (e) {
|
|
311
|
+
console.error(red(`bridge could not start on port ${port}: ${e.message}`));
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
// The plugin pings every ~1s; give it a moment to show up if it's up.
|
|
315
|
+
const deadline = Date.now() + 6000;
|
|
316
|
+
while (!bridge.connected && Date.now() < deadline) {
|
|
317
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
318
|
+
}
|
|
319
|
+
const res = await queryProStatus(bridge, { port, token });
|
|
320
|
+
console.log(renderProStatus(res, { bold, dim, red, green, magenta, yellow }) + "\n");
|
|
321
|
+
bridge.stop();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
265
325
|
case "help":
|
|
266
326
|
case "--help":
|
|
267
327
|
case "-h": {
|
|
@@ -375,6 +435,8 @@ ${bold("Usage")}
|
|
|
375
435
|
roforge tools list all tools
|
|
376
436
|
roforge login --provider <p> store a key (gemini|groq|openrouter|anthropic|openai)
|
|
377
437
|
roforge providers list providers, keys, and auto-routing order
|
|
438
|
+
roforge install-plugin install the RoForge Bridge plugin into Studio
|
|
439
|
+
roforge pro show RoForge Pro license status (needs Studio bridge)
|
|
378
440
|
roforge analyze <file...> run the official Luau analyzer on files
|
|
379
441
|
roforge config [set k v] show / set configuration
|
|
380
442
|
roforge version
|
|
@@ -382,14 +444,15 @@ ${bold("Usage")}
|
|
|
382
444
|
${bold("How it connects to Studio")}
|
|
383
445
|
1. Built-in MCP (recommended): Studio → File → Studio Settings → Beta Features →
|
|
384
446
|
${bold("MCP Server")} — roforge talks to it at http://localhost:3004/mcp automatically.
|
|
385
|
-
2. RoForge Bridge plugin
|
|
386
|
-
|
|
447
|
+
2. RoForge Bridge plugin (not in the Roblox Toolbox — install from here):
|
|
448
|
+
${bold("roforge install-plugin")} → Studio: File → Plugins (it's now listed)
|
|
449
|
+
then paste the bridge token (shown by ${bold("roforge studio")}) into the plugin dock.
|
|
387
450
|
|
|
388
451
|
${bold("Model providers (BYOK, zero backend)")}
|
|
389
452
|
auto (default): first configured key wins, free tiers first:
|
|
390
453
|
gemini → groq → openrouter → anthropic → openai
|
|
391
454
|
free tiers: Gemini 2.5 Flash (~1,500 req/day), Groq Llama 3.3 70B (~1,000 req/day),
|
|
392
|
-
OpenRouter ":free" models (e.g.
|
|
455
|
+
OpenRouter ":free" models (e.g. nvidia/nemotron-3-super-120b-a12b:free)
|
|
393
456
|
pin: --provider <p> or --model <provider>:<model> · ROFORGE_FREE_FIRST=0
|
|
394
457
|
|
|
395
458
|
${bold("Config & keys")}
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roforge-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "RoForge \u2014 Claude-Code-style local AI agent for Roblox Studio. BYOK, zero backend, zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"bin",
|
|
32
32
|
"src",
|
|
33
|
+
"dist",
|
|
33
34
|
"demo",
|
|
34
35
|
"README.md"
|
|
35
36
|
],
|
package/src/bridge/server.js
CHANGED
|
@@ -10,11 +10,12 @@ import http from "node:http";
|
|
|
10
10
|
import { json } from "./wire.js";
|
|
11
11
|
|
|
12
12
|
export class BridgeServer {
|
|
13
|
-
constructor({ port, host = "127.0.0.1", token, jobTimeoutMs = 60000 }) {
|
|
13
|
+
constructor({ port, host = "127.0.0.1", token, jobTimeoutMs = 60000, completedTtlMs = 30000 }) {
|
|
14
14
|
this.port = port;
|
|
15
15
|
this.host = host;
|
|
16
16
|
this.token = token;
|
|
17
17
|
this.jobTimeoutMs = jobTimeoutMs;
|
|
18
|
+
this.completedTtlMs = completedTtlMs;
|
|
18
19
|
this.jobs = new Map(); // id → {id, tool, args, status, resolve, timer}
|
|
19
20
|
this.lastPingAt = null;
|
|
20
21
|
this.lastSeenAt = null;
|
|
@@ -45,7 +46,7 @@ export class BridgeServer {
|
|
|
45
46
|
stop() {
|
|
46
47
|
for (const job of this.jobs.values()) {
|
|
47
48
|
clearTimeout(job.timer);
|
|
48
|
-
job.resolve({ ok: false, error: "bridge shut down" });
|
|
49
|
+
if (typeof job.resolve === "function") job.resolve({ ok: false, error: "bridge shut down" });
|
|
49
50
|
}
|
|
50
51
|
this.jobs.clear();
|
|
51
52
|
if (this.server) {
|
|
@@ -114,20 +115,91 @@ export class BridgeServer {
|
|
|
114
115
|
}
|
|
115
116
|
clearTimeout(job.timer);
|
|
116
117
|
const out = body.error ? { ok: false, error: body.error } : { ok: true, result: body.result ?? "" };
|
|
117
|
-
|
|
118
|
-
job.
|
|
118
|
+
job.status = "done";
|
|
119
|
+
job.out = out;
|
|
120
|
+
if (typeof job.resolve === "function") job.resolve(out);
|
|
121
|
+
this._retire(job);
|
|
119
122
|
emit("job_done", { id: job.id, ...out });
|
|
120
123
|
json(res, 200, { ok: true });
|
|
121
124
|
});
|
|
122
125
|
return;
|
|
123
126
|
}
|
|
124
127
|
|
|
128
|
+
// Enqueue a job from an authenticated client (used by `roforge pro`
|
|
129
|
+
// attaching to an already-running bridge). The plugin still discovers
|
|
130
|
+
// it via the normal GET /jobs claim path.
|
|
131
|
+
if (req.method === "POST" && path === "/v1/bridge/jobs/enqueue") {
|
|
132
|
+
let body = {};
|
|
133
|
+
const chunks = [];
|
|
134
|
+
let size = 0;
|
|
135
|
+
req.on("data", (c) => {
|
|
136
|
+
size += c.length;
|
|
137
|
+
if (size < 1024 * 1024) chunks.push(c);
|
|
138
|
+
});
|
|
139
|
+
req.on("end", () => {
|
|
140
|
+
try {
|
|
141
|
+
body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
|
|
142
|
+
} catch {
|
|
143
|
+
body = {};
|
|
144
|
+
}
|
|
145
|
+
if (typeof body.tool !== "string" || !body.tool) {
|
|
146
|
+
return json(res, 400, { error: "missing tool", code: "BAD_REQUEST" });
|
|
147
|
+
}
|
|
148
|
+
const id = this.enqueueRemote(body.tool, body.args ?? {});
|
|
149
|
+
json(res, 200, { ok: true, id });
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Poll a (possibly completed) job's status — read by an attaching client.
|
|
155
|
+
const gs = /^\/v1\/bridge\/jobs\/(\w+)$/.exec(path);
|
|
156
|
+
if (req.method === "GET" && gs) {
|
|
157
|
+
const st = this.jobStatus(gs[1]);
|
|
158
|
+
if (!st) return json(res, 404, { error: "unknown job", code: "JOB_NOT_FOUND" });
|
|
159
|
+
return json(res, 200, { ok: true, ...st });
|
|
160
|
+
}
|
|
161
|
+
|
|
125
162
|
return json(res, 404, { error: `no route: ${req.method} ${path}`, code: "NOT_FOUND" });
|
|
126
163
|
} catch (e) {
|
|
127
164
|
json(res, 500, { error: e.message, code: "INTERNAL" });
|
|
128
165
|
}
|
|
129
166
|
}
|
|
130
167
|
|
|
168
|
+
// Keep a finished job readable for a short window (so an attaching client
|
|
169
|
+
// can poll its result), then drop it. Unref'd: housekeeping must not keep
|
|
170
|
+
// the CLI process (or test runner) alive.
|
|
171
|
+
_retire(job) {
|
|
172
|
+
const t = setTimeout(() => {
|
|
173
|
+
if (this.jobs.get(job.id) === job) this.jobs.delete(job.id);
|
|
174
|
+
}, this.completedTtlMs);
|
|
175
|
+
if (typeof t.unref === "function") t.unref();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Enqueue a job on behalf of a remote (attaching) client. Returns the id to
|
|
179
|
+
// poll. The plugin claims it through the normal GET /jobs path.
|
|
180
|
+
enqueueRemote(tool, args, { timeoutMs = this.jobTimeoutMs } = {}) {
|
|
181
|
+
const id = `job_${this.nextJobId++}`;
|
|
182
|
+
const job = { id, tool, args, status: "pending", remote: true, out: null, resolve: null, claimedAt: null, timer: null };
|
|
183
|
+
job.timer = setTimeout(() => {
|
|
184
|
+
if (this.jobs.get(id) === job && job.status !== "done") {
|
|
185
|
+
job.status = "timeout";
|
|
186
|
+
job.out = { ok: false, error: `studio job timed out after ${Math.round(timeoutMs / 1000)}s` };
|
|
187
|
+
this._retire(job);
|
|
188
|
+
}
|
|
189
|
+
}, timeoutMs);
|
|
190
|
+
this.jobs.set(id, job);
|
|
191
|
+
return id;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Current status of a job for a poller: {id, status} while in flight, or
|
|
195
|
+
// {id, status:"done"|"timeout", ...out} once finished. null if unknown.
|
|
196
|
+
jobStatus(id) {
|
|
197
|
+
const job = this.jobs.get(id);
|
|
198
|
+
if (!job) return null;
|
|
199
|
+
if (job.status === "pending" || job.status === "claimed") return { id, status: job.status };
|
|
200
|
+
return { id, status: job.status, ...(job.out || {}) };
|
|
201
|
+
}
|
|
202
|
+
|
|
131
203
|
// Submit a tool job and wait for the plugin's result. Resolves to
|
|
132
204
|
// {ok:true, result:string} or {ok:false, error:string}.
|
|
133
205
|
submit(tool, args, { timeoutMs = this.jobTimeoutMs } = {}) {
|
package/src/config.js
CHANGED
|
@@ -188,6 +188,7 @@ export function resolveConfig() {
|
|
|
188
188
|
if (process.env.ROFORGE_PROVIDER) cfg.provider = process.env.ROFORGE_PROVIDER;
|
|
189
189
|
if (process.env.ROFORGE_MCP_URL) cfg.mcpUrl = process.env.ROFORGE_MCP_URL;
|
|
190
190
|
if (process.env.ROFORGE_BRIDGE_PORT) cfg.bridge.port = Number(process.env.ROFORGE_BRIDGE_PORT);
|
|
191
|
+
if (process.env.ROFORGE_BRIDGE_TOKEN) cfg.bridge.token = process.env.ROFORGE_BRIDGE_TOKEN;
|
|
191
192
|
if (process.env.ROFORGE_STUDIO_MODE) cfg.studioMode = process.env.ROFORGE_STUDIO_MODE;
|
|
192
193
|
if (process.env.ROFORGE_MAX_ITERATIONS) cfg.maxIterations = Number(process.env.ROFORGE_MAX_ITERATIONS);
|
|
193
194
|
if (process.env.ROFORGE_FREE_FIRST === "0" || process.env.ROFORGE_FREE_FIRST === "false") cfg.freeFirst = false;
|
package/src/install.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Plugin installation: copy a built .rbxm into the OS Roblox Studio plugins
|
|
2
|
+
// folder so it appears under Studio's plugin page.
|
|
3
|
+
//
|
|
4
|
+
// Sources are resolved in this order:
|
|
5
|
+
// 1. the npm package's bundled copy (cli/dist/<name>.rbxm) — works when
|
|
6
|
+
// installed via `npm i -g roforge-cli`
|
|
7
|
+
// 2. the repo layout (<repoRoot>/studio-bridge/dist or <repoRoot>/client/dist)
|
|
8
|
+
// — works when running from a git clone
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const here = path.dirname(fileURLToPath(import.meta.url)); // cli/src
|
|
15
|
+
|
|
16
|
+
export const PLUGINS = {
|
|
17
|
+
bridge: {
|
|
18
|
+
dest: "RoForgeBridge.rbxm",
|
|
19
|
+
desc: "RoForge Bridge (loopback bridge driven by the roforge CLI — tools, vision, Pro status)",
|
|
20
|
+
repoRel: path.join("..", "..", "studio-bridge", "dist"),
|
|
21
|
+
},
|
|
22
|
+
client: {
|
|
23
|
+
dest: "RoForge.rbxm",
|
|
24
|
+
desc: "RoForge (in-Studio chat dock; standalone mode)",
|
|
25
|
+
repoRel: path.join("..", "..", "client", "dist"),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Studio scans these folders for plugins.
|
|
30
|
+
export function pluginsDir(env = process.env, platform = process.platform, home = os.homedir()) {
|
|
31
|
+
switch (platform) {
|
|
32
|
+
case "win32":
|
|
33
|
+
return path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Roblox", "Plugins");
|
|
34
|
+
case "darwin":
|
|
35
|
+
return path.join(home, "Documents", "Roblox", "Plugins");
|
|
36
|
+
default:
|
|
37
|
+
return path.join(env.XDG_DATA_HOME || path.join(home, ".local", "share"), "Roblox", "Plugins");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Find the built .rbxm for `name`. Returns an absolute path or null.
|
|
42
|
+
export function findPluginSource(name, { packageDir = path.join(here, "..") } = {}) {
|
|
43
|
+
const meta = PLUGINS[name];
|
|
44
|
+
if (!meta) return null;
|
|
45
|
+
const candidates = [
|
|
46
|
+
path.join(packageDir, "dist", meta.dest), // npm bundle
|
|
47
|
+
path.join(here, meta.repoRel, meta.dest), // git clone
|
|
48
|
+
];
|
|
49
|
+
for (const c of candidates) {
|
|
50
|
+
if (fs.existsSync(c)) return c;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Copy the plugin into the plugins folder. `destDir` overrides the target
|
|
56
|
+
// (tests). Returns { src, dest }.
|
|
57
|
+
export function installPlugin(name, { destDir } = {}) {
|
|
58
|
+
const meta = PLUGINS[name];
|
|
59
|
+
if (!meta) throw new Error(`unknown plugin: ${name} (expected: ${Object.keys(PLUGINS).join(" | ")})`);
|
|
60
|
+
const src = findPluginSource(name);
|
|
61
|
+
if (!src) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`no built plugin found (${meta.dest}). From a git clone run: ` +
|
|
64
|
+
`rojo build -o studio-bridge/dist/RoForgeBridge.rbxm studio-bridge/default.project.json`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const dir = destDir || pluginsDir();
|
|
68
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
69
|
+
const dest = path.join(dir, meta.dest);
|
|
70
|
+
fs.copyFileSync(src, dest);
|
|
71
|
+
return { src, dest };
|
|
72
|
+
}
|
package/src/pro.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// RoForge Pro — CLI-side status plumbing.
|
|
2
|
+
//
|
|
3
|
+
// The entitlement itself is checked inside Studio by the bridge plugin
|
|
4
|
+
// (MarketplaceService only exists there). This module reaches the connected
|
|
5
|
+
// Studio and renders its `forge_pro` report for `roforge pro`.
|
|
6
|
+
//
|
|
7
|
+
// Two ways to reach Studio, tried in order:
|
|
8
|
+
// 1. ATTACH — if a bridge is already running on the configured port (e.g.
|
|
9
|
+
// `roforge studio` is up and the plugin is bound to it), enqueue the
|
|
10
|
+
// forge_pro job on that bridge and poll for the result. This is the
|
|
11
|
+
// normal case: you check Pro while your Studio session is live.
|
|
12
|
+
// 2. OWN — otherwise start a throwaway bridge on the configured port, wait
|
|
13
|
+
// for the plugin (bound to the same port + token) to connect, submit,
|
|
14
|
+
// and stop. This lets `roforge pro` work standalone too.
|
|
15
|
+
|
|
16
|
+
import { BridgeServer } from "./bridge/server.js";
|
|
17
|
+
|
|
18
|
+
const HEALTH_PATH = "/health";
|
|
19
|
+
|
|
20
|
+
// Probe for an already-running bridge on baseUrl. Returns the /health body
|
|
21
|
+
// ({ok, service, connected, port}) or null when nothing is listening.
|
|
22
|
+
export async function probeBridge(baseUrl, { timeoutMs = 1200 } = {}) {
|
|
23
|
+
try {
|
|
24
|
+
const r = await fetch(`${baseUrl}${HEALTH_PATH}`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
25
|
+
if (!r.ok) return null;
|
|
26
|
+
const j = await r.json();
|
|
27
|
+
return j && j.service === "roforge-bridge" ? j : null;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Enqueue `tool` on a running bridge and poll for the result. Resolves to
|
|
34
|
+
// {ok, result?/error}. `connected` lets the caller fail fast when the plugin
|
|
35
|
+
// isn't actually bound to this bridge.
|
|
36
|
+
export async function remoteSubmit(baseUrl, token, tool, args, { timeoutMs = 20000, pollMs = 150 } = {}) {
|
|
37
|
+
const auth = { authorization: `Bearer ${token}`, "content-type": "application/json" };
|
|
38
|
+
const enq = await fetch(`${baseUrl}/v1/bridge/jobs/enqueue`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: auth,
|
|
41
|
+
body: JSON.stringify({ tool, args }),
|
|
42
|
+
});
|
|
43
|
+
if (!enq.ok) {
|
|
44
|
+
let msg = `HTTP ${enq.status}`;
|
|
45
|
+
try { msg = (await enq.json()).error || msg; } catch { /* keep status */ }
|
|
46
|
+
return { ok: false, error: msg };
|
|
47
|
+
}
|
|
48
|
+
const { id } = await enq.json();
|
|
49
|
+
const deadline = Date.now() + timeoutMs;
|
|
50
|
+
while (Date.now() < deadline) {
|
|
51
|
+
const st = await (await fetch(`${baseUrl}/v1/bridge/jobs/${id}`, { headers: { authorization: `Bearer ${token}` } })).json();
|
|
52
|
+
if (st.status === "done") {
|
|
53
|
+
return st.error ? { ok: false, error: st.error } : { ok: true, result: st.result ?? "" };
|
|
54
|
+
}
|
|
55
|
+
if (st.status === "timeout") return { ok: false, error: st.error || "studio job timed out" };
|
|
56
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
57
|
+
}
|
|
58
|
+
return { ok: false, error: "timed out waiting for Studio" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Query Pro status, attaching to a running bridge when present. Returns
|
|
62
|
+
// { text } on success, { error } otherwise. `bridge` may be null to force the
|
|
63
|
+
// "own bridge" path (used by tests).
|
|
64
|
+
export async function queryProStatus(bridge, { port, token, baseUrl, timeoutMs = 20000 } = {}) {
|
|
65
|
+
const base = baseUrl || `http://127.0.0.1:${port}`;
|
|
66
|
+
|
|
67
|
+
// Path 1: attach to a running bridge.
|
|
68
|
+
if (!bridge) {
|
|
69
|
+
const health = await probeBridge(base);
|
|
70
|
+
if (health) {
|
|
71
|
+
if (!health.connected) {
|
|
72
|
+
return {
|
|
73
|
+
error:
|
|
74
|
+
"A bridge is running but Studio is not connected. Open Roblox Studio with the " +
|
|
75
|
+
"RoForge Bridge plugin active and paste the bridge token into it.",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const out = await remoteSubmit(base, token, "forge_pro", {}, { timeoutMs });
|
|
79
|
+
if (!out.ok) return { error: out.error };
|
|
80
|
+
return { text: String(out.result ?? "") };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Path 2: own bridge (already started by the caller).
|
|
85
|
+
if (!bridge || !bridge.connected) {
|
|
86
|
+
return {
|
|
87
|
+
error:
|
|
88
|
+
"No Studio bridge connection. Pro status is checked inside Studio (MarketplaceService), " +
|
|
89
|
+
"so this needs the RoForge Bridge plugin running and connected.",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const out = await bridge.submit("forge_pro", {}, { timeoutMs });
|
|
93
|
+
if (!out.ok) return { error: out.error };
|
|
94
|
+
return { text: String(out.result ?? "") };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Start a throwaway bridge for the "own bridge" path. Returns the started
|
|
98
|
+
// BridgeServer or throws on EADDRINUSE.
|
|
99
|
+
export function startOwnBridge({ port, host = "127.0.0.1", token }) {
|
|
100
|
+
const bridge = new BridgeServer({ port, host, token });
|
|
101
|
+
return bridge;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Render the forge_pro report (plain text from the plugin) with minimal
|
|
105
|
+
// styling: the tier header gets color, the rest stays as-is.
|
|
106
|
+
export function renderProStatus(res, { bold, dim, red, green, magenta, yellow } = {}) {
|
|
107
|
+
const id = (fn, s) => (fn ? fn(s) : String(s));
|
|
108
|
+
if (res && res.error) {
|
|
109
|
+
return (
|
|
110
|
+
id(red, res.error) +
|
|
111
|
+
"\n" +
|
|
112
|
+
id(dim, " 1. open Roblox Studio with the RoForge Bridge plugin\n") +
|
|
113
|
+
id(dim, " 2. run `roforge studio` here and paste the token it prints into the plugin\n") +
|
|
114
|
+
id(dim, " 3. re-run `roforge pro` while both are running")
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const lines = String(res?.text ?? "").split("\n");
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
if (line.startsWith("RoForge Pro: PRO")) out.push(id(green, line));
|
|
121
|
+
else if (line.startsWith("RoForge Pro: FREE")) out.push(id(yellow, line));
|
|
122
|
+
else if (line.startsWith("Unlock Pro:")) out.push(id(magenta, line));
|
|
123
|
+
else out.push(line);
|
|
124
|
+
}
|
|
125
|
+
return out.join("\n");
|
|
126
|
+
}
|
package/src/session.js
CHANGED
|
@@ -60,7 +60,18 @@ export class Session {
|
|
|
60
60
|
if (this.studioInfo.bridge) studio.push("The RoForge Bridge plugin is available (forge_* tools) — it connects when Studio is open and the bridge plugin is active.");
|
|
61
61
|
const caps = (this.studioInfo.mcpCapture || []).map((n) => `studio_${n}`).join(", ");
|
|
62
62
|
if (caps) studio.push(`Studio's MCP exposes vision tools (${caps}) — they return an image you can SEE; prefer them for visual checks.`);
|
|
63
|
-
if (!studio.length)
|
|
63
|
+
if (!studio.length) {
|
|
64
|
+
studio.push(
|
|
65
|
+
"No Studio connection yet — forge_* tools will error until Studio is connected. " +
|
|
66
|
+
"If the user asks how to connect, give EXACTLY these steps (do not invent others — the RoForge " +
|
|
67
|
+
"Bridge plugin is NOT in the Roblox Toolbox): (1) run `roforge install-plugin` to install the " +
|
|
68
|
+
"bundled plugin into Studio's plugins folder, or in Studio use File → Plugins → Manage Plugins → " +
|
|
69
|
+
"Install File… with studio-bridge/dist/RoForgeBridge.rbxm from https://github.com/hacvilke/roforge; " +
|
|
70
|
+
"(2) open the RoForge Bridge dock and paste the bridge token shown by `roforge studio` (or in this " +
|
|
71
|
+
"TUI's /studio output); (3) the status turns green when connected. Alternative: enable Studio's " +
|
|
72
|
+
"built-in MCP (File → Studio Settings → Beta Features → MCP Server) — no plugin needed."
|
|
73
|
+
);
|
|
74
|
+
}
|
|
64
75
|
|
|
65
76
|
return `You are RoForge, a local AI agent for Roblox development, running on the user's machine (Claude-Code-style). You work on two surfaces:
|
|
66
77
|
|
package/src/tools/studio.js
CHANGED
|
@@ -28,6 +28,7 @@ const BRIDGE_TOOL_NAMES = [
|
|
|
28
28
|
"forge_diff",
|
|
29
29
|
"forge_export",
|
|
30
30
|
"forge_import",
|
|
31
|
+
"forge_pro",
|
|
31
32
|
];
|
|
32
33
|
|
|
33
34
|
const BRIDGE_DESCRIPTIONS = {
|
|
@@ -53,8 +54,9 @@ const BRIDGE_DESCRIPTIONS = {
|
|
|
53
54
|
forge_bulk_create: "Create many instances in one call (paste-style): items[] of {path: parent path, class_name, name?, properties?}. Destructive.",
|
|
54
55
|
forge_snapshot: "Capture the current instance tree under a name, so forge_diff can show what changed later. Keeps the last 10.",
|
|
55
56
|
forge_diff: "Compare a snapshot to the current instance tree: added (+) and removed (-) instances. Omit name to diff the most recent snapshot.",
|
|
56
|
-
forge_export: "Export a DataModel subtree as JSON (properties, script sources truncated, attributes). Defaults to workspace, depth 3 (max 6).",
|
|
57
|
-
forge_import: "Apply a forge_export JSON back into Studio: recreates the instance tree (properties, sources, attributes) under a parent. dry_run=true only reports. Destructive; max 500 nodes.",
|
|
57
|
+
forge_export: "Export a DataModel subtree as JSON (properties, script sources truncated, attributes). Defaults to workspace, depth 3 (max 6; 10 with RoForge Pro).",
|
|
58
|
+
forge_import: "Apply a forge_export JSON back into Studio: recreates the instance tree (properties, sources, attributes) under a parent. dry_run=true only reports. Destructive; max 500 nodes (2500 with RoForge Pro).",
|
|
59
|
+
forge_pro: "Report the current RoForge Pro entitlement: Free or Pro, which pass/product the Studio user owns, and the active limits + Pro features. Call it to know whether the user has Pro.",
|
|
58
60
|
};
|
|
59
61
|
|
|
60
62
|
const BRIDGE_SCHEMAS = {
|
|
@@ -213,6 +215,7 @@ const BRIDGE_SCHEMAS = {
|
|
|
213
215
|
},
|
|
214
216
|
additionalProperties: false,
|
|
215
217
|
},
|
|
218
|
+
forge_pro: { type: "object", properties: {}, additionalProperties: false },
|
|
216
219
|
};
|
|
217
220
|
|
|
218
221
|
// Tools that modify the DataModel — gated by the approval prompt.
|
package/src/tui/tui.js
CHANGED
|
@@ -303,11 +303,14 @@ export class TUI {
|
|
|
303
303
|
else lines.push(`${red("○")} studio MCP (built-in): not reachable @ ${this.session.cfg.mcpUrl}` + dim(" (File → Studio Settings → Beta Features → MCP Server)"));
|
|
304
304
|
if (this.session.bridgeServer) {
|
|
305
305
|
const b = this.session.bridgeServer;
|
|
306
|
-
|
|
307
|
-
b.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
306
|
+
if (b.connected) {
|
|
307
|
+
lines.push(`${green("●")} bridge plugin: connected (http://${b.host}:${b.port})`);
|
|
308
|
+
} else {
|
|
309
|
+
lines.push(
|
|
310
|
+
`${yellow("○")} bridge plugin: waiting for Studio (http://${b.host}:${b.port})` +
|
|
311
|
+
dim(` — run \`roforge install-plugin\` if not installed; token to paste in the plugin dock: ${b.token}`)
|
|
312
|
+
);
|
|
313
|
+
}
|
|
311
314
|
}
|
|
312
315
|
return lines.join("\n");
|
|
313
316
|
}
|