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 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: install studio-bridge/dist/RoForgeBridge.rbxm into Studio,
386
- paste the bridge token (shown by ${bold("roforge studio")}) into the plugin.
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. qwen/qwen3-coder:free)
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.2",
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
  ],
@@ -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
- this.jobs.delete(job.id);
118
- job.resolve(out);
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) studio.push("No Studio connection yet — forge_* tools will error until Studio is open with the RoForge Bridge plugin (or enable Studio's built-in MCP beta).");
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
 
@@ -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
- lines.push(
307
- b.connected
308
- ? `${green("●")} bridge plugin: connected (http://${b.host}:${b.port})`
309
- : `${yellow("○")} bridge plugin: waiting for Studio (http://${b.host}:${b.port})`
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
  }