roforge-cli 0.3.4 → 0.3.6

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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roforge-cli",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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": {
package/src/plugins.js ADDED
@@ -0,0 +1,455 @@
1
+ // RoForge CLI — strict declarative plugin system.
2
+ //
3
+ // A plugin is a JSON file. That is the whole trust model:
4
+ //
5
+ // • NO code fields. Not a script, not a handler, not an eval, not a
6
+ // template engine. The validator is a strict allowlist — any key that is
7
+ // not explicitly permitted (at any depth) rejects the plugin. A plugin
8
+ // therefore cannot contain malicious intent by construction.
9
+ // • Actions are limited to four: http (public https only, no
10
+ // localhost/private addresses), command (allowlisted binary, argv array,
11
+ // no shell), read-file (relative path inside the project root, size-capped),
12
+ // transform (placeholder fill from validated args only).
13
+ // • The CLI never passes its own API keys or environment to plugins. There
14
+ // is no variable expansion at all — the only template syntax is
15
+ // {{argname}} and only for args declared in the tool's input_schema.
16
+ // • Mutations are approval-gated: http POST/PUT and command tools always
17
+ // require approval.
18
+ // • Every output is size-capped (per-tool, hard ceiling 64 KB).
19
+ //
20
+ // Placement: <project>/plugins/*.json (project-local) and
21
+ // ~/.roforge/plugins/*.json (user-wide). ROFORGE_PLUGINS_DIR overrides for tests.
22
+
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { execFile } from "node:child_process";
26
+
27
+ export const HARD_OUTPUT_LIMIT = 65536;
28
+ const DEFAULT_OUTPUT_LIMIT = 8000;
29
+ const DEFAULT_HTTP_TIMEOUT_MS = 15000;
30
+ const DEFAULT_READ_BYTES = 256 * 1024;
31
+ const MAX_READ_BYTES = 1024 * 1024;
32
+ const MAX_TOOLS = 50;
33
+ const MAX_MANIFEST_BYTES = 256 * 1024;
34
+ const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,47}$/;
35
+
36
+ // Fields that are never allowed at the top level, even if a future allowlist
37
+ // regressed — these are the classic "hidden code" shapes.
38
+ const BANNED_TOP_KEYS = /^(code|script|eval|exec|shell|js|javascript|python|lua|luau|handler|entrypoint|main)$/i;
39
+
40
+ const ACTION_TYPES = new Set(["http", "command", "read-file", "transform"]);
41
+ const HTTP_METHODS = new Set(["GET", "POST", "PUT"]);
42
+ const FORBIDDEN_ARG_CHARS = /[;|&`$<>\n\r]/;
43
+ const PRIVATE_HOST_RE =
44
+ /^(localhost|127\.[0-9.]+|0\.0\.0\.0|::1|10\.[0-9.]+|192\.168\.[0-9.]+|172\.(1[6-9]|2[0-9]|3[01])\.[0-9.]+|169\.254\.[0-9.]+|metadata\.google\.internal|.*\.local|.*\.internal)$/i;
45
+
46
+ function isObj(v) {
47
+ return v !== null && typeof v === "object" && !Array.isArray(v);
48
+ }
49
+
50
+ function err(msg, extra = {}) {
51
+ return { ok: false, error: msg, ...extra };
52
+ }
53
+
54
+ function checkName(name, what) {
55
+ if (typeof name !== "string" || !NAME_RE.test(name)) {
56
+ return err(`${what} name must match ${NAME_RE} (got ${JSON.stringify(name)})`);
57
+ }
58
+ if (name.startsWith("forge_")) {
59
+ return err(`${what} name "${name}" is reserved for Studio bridge tools`);
60
+ }
61
+ return { ok: true, value: name };
62
+ }
63
+
64
+ function checkDesc(desc) {
65
+ if (typeof desc !== "string" || desc.length === 0 || desc.length > 300) {
66
+ return err("description must be a non-empty string (max 300 chars)");
67
+ }
68
+ return { ok: true, value: desc };
69
+ }
70
+
71
+ // input_schema: JSON-schema object shape, strict, no $ref, no patterns
72
+ // beyond simple strings. Returns the property names for template checks.
73
+ function checkInputSchema(schema) {
74
+ if (!isObj(schema)) return err("input_schema must be an object");
75
+ const allowed = new Set(["type", "properties", "required", "additionalProperties", "description"]);
76
+ for (const k of Object.keys(schema)) {
77
+ if (!allowed.has(k)) return err(`input_schema: unknown key "${k}"`);
78
+ }
79
+ if (schema.type !== "object") return err('input_schema.type must be "object"');
80
+ if (schema.additionalProperties !== false) {
81
+ return err('input_schema must set "additionalProperties": false (strict)');
82
+ }
83
+ const props = schema.properties || {};
84
+ const propNames = new Set();
85
+ for (const [pname, pdef] of Object.entries(props)) {
86
+ if (!NAME_RE.test(pname)) return err(`input_schema property "${pname}" has an invalid name`);
87
+ if (!isObj(pdef)) return err(`input_schema property "${pname}" must be an object`);
88
+ const pAllowed = new Set(["type", "description", "minimum", "maximum", "minLength", "maxLength", "items"]);
89
+ for (const k of Object.keys(pdef)) {
90
+ if (!pAllowed.has(k)) return err(`input_schema property "${pname}": unknown key "${k}"`);
91
+ }
92
+ if (!["string", "integer", "number", "boolean", "array"].includes(pdef.type)) {
93
+ return err(`input_schema property "${pname}": unsupported type "${pdef.type}"`);
94
+ }
95
+ if (pdef.type === "array" && !isObj(pdef.items)) {
96
+ return err(`input_schema property "${pname}": array needs "items"`);
97
+ }
98
+ propNames.add(pname);
99
+ }
100
+ const required = schema.required || [];
101
+ if (!Array.isArray(required)) return err('input_schema.required must be an array');
102
+ for (const r of required) {
103
+ if (!propNames.has(r)) return err(`input_schema.required references unknown property "${r}"`);
104
+ }
105
+ return { ok: true, value: propNames };
106
+ }
107
+
108
+ // Template strings: only {{argname}} placeholders, and only names that are
109
+ // declared in the input schema. Everything else is a literal.
110
+ function checkTemplate(str, propNames, what) {
111
+ if (typeof str !== "string") return err(`${what} must be a string`);
112
+ if (str.length > 20000) return err(`${what} too long`);
113
+ const refs = [...str.matchAll(/{{\s*([a-zA-Z0-9_][a-zA-Z0-9_-]*)\s*}}/g)];
114
+ for (const m of refs) {
115
+ if (!propNames.has(m[1])) {
116
+ return err(`${what} references undeclared arg "${m[1]}" (only input_schema properties may be used)`);
117
+ }
118
+ }
119
+ // Any other {{...}} (env-ish, nested, malformed) is rejected.
120
+ const stripped = str.replace(/{{\s*[a-zA-Z0-9_][a-zA-Z0-9_-]*\s*}}/g, "");
121
+ if (stripped.includes("{{") || stripped.includes("}}")) {
122
+ return err(`${what} contains an unsupported {{...}} expression (only declared args may be referenced)`);
123
+ }
124
+ return { ok: true, value: str };
125
+ }
126
+
127
+ function fillTemplate(str, args) {
128
+ return str.replace(/{{\s*([a-zA-Z0-9_][a-zA-Z0-9_-]*)\s*}}/g, (_, name) => {
129
+ const v = args[name];
130
+ if (v === undefined || v === null) return "";
131
+ return encodeURIComponent(String(v));
132
+ });
133
+ }
134
+ // Raw fill (command argv): no URL-encoding.
135
+ function fillTemplateRaw(str, args) {
136
+ return str.replace(/{{\s*([a-zA-Z0-9_][a-zA-Z0-9_-]*)\s*}}/g, (_, name) => {
137
+ const v = args[name];
138
+ if (v === undefined || v === null) return "";
139
+ return String(v);
140
+ });
141
+ }
142
+
143
+ function checkOutputMax(v) {
144
+ if (v === undefined) return { ok: true, value: DEFAULT_OUTPUT_LIMIT };
145
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 1 || v > HARD_OUTPUT_LIMIT) {
146
+ return err(`output_max_chars must be an integer 1..${HARD_OUTPUT_LIMIT}`);
147
+ }
148
+ return { ok: true, value: v };
149
+ }
150
+
151
+ // ---- action validators (strict allowlists) ----
152
+
153
+ function checkHttpAction(a, propNames) {
154
+ const allowed = new Set(["type", "method", "url", "headers", "body", "query", "timeout_ms", "output_max_chars"]);
155
+ for (const k of Object.keys(a)) if (!allowed.has(k)) return err(`http action: unknown key "${k}"`);
156
+ if (!HTTP_METHODS.has(a.method)) return err('http action.method must be GET, POST, or PUT');
157
+ if (typeof a.url !== "string" || a.url.length > 2000) return err("http action.url must be a string");
158
+ let u;
159
+ try {
160
+ u = new URL(a.url);
161
+ } catch {
162
+ return err(`http action.url is not a valid URL: ${a.url}`);
163
+ }
164
+ if (u.protocol !== "https:") return err("http action.url must be https (public web only)");
165
+ const host = u.hostname.toLowerCase();
166
+ if (PRIVATE_HOST_RE.test(host)) return err(`http action.url host "${host}" is not public (localhost/private ranges are forbidden)`);
167
+ if (u.port !== "" && u.port !== "443") return err("http action.url may only use the default https port");
168
+
169
+ let query = {};
170
+ if (a.query !== undefined) {
171
+ if (!isObj(a.query)) return err("http action.query must be an object");
172
+ const QUERY_KEY_RE = /^[A-Za-z0-9._-]{1,64}$/; // API param names (e.g. universeIds)
173
+ for (const [k, v] of Object.entries(a.query)) {
174
+ if (!QUERY_KEY_RE.test(k)) return err(`http action.query key "${k}" invalid`);
175
+ const t = checkTemplate(String(v), propNames, `http action.query.${k}`);
176
+ if (!t.ok) return t;
177
+ query[k] = t.value;
178
+ }
179
+ }
180
+ const headers = a.headers !== undefined ? (isObj(a.headers) ? a.headers : err("http action.headers must be an object")) : {};
181
+ if (isObj(a.headers)) {
182
+ for (const [k, v] of Object.entries(a.headers)) {
183
+ if (typeof v !== "string" || v.length > 500) return err(`http header "${k}" must be a short string`);
184
+ }
185
+ }
186
+ let body;
187
+ if (a.body !== undefined) {
188
+ if (a.method === "GET") return err("http action.body is not allowed with GET");
189
+ const t = checkTemplate(String(a.body), propNames, "http action.body");
190
+ if (!t.ok) return t;
191
+ body = t.value;
192
+ }
193
+ const to =
194
+ a.timeout_ms !== undefined
195
+ ? typeof a.timeout_ms === "number" && a.timeout_ms >= 100 && a.timeout_ms <= 60000
196
+ ? a.timeout_ms
197
+ : err("http action.timeout_ms must be 100..60000")
198
+ : DEFAULT_HTTP_TIMEOUT_MS;
199
+ if (!Number.isInteger(to)) return to;
200
+ const out = checkOutputMax(a.output_max_chars);
201
+ if (!out.ok) return out;
202
+ return { ok: true, value: { kind: "http", method: a.method, url: a.url, query, headers, body, timeoutMs: to, outputMax: out.value } };
203
+ }
204
+
205
+ function checkCommandAction(a, propNames, allowedCommands) {
206
+ const allowed = new Set(["type", "command", "args", "output_max_chars"]);
207
+ for (const k of Object.keys(a)) if (!allowed.has(k)) return err(`command action: unknown key "${k}"`);
208
+ if (!allowedCommands.includes(a.command)) {
209
+ return err(`command action: "${a.command}" is not in the allowlist (${allowedCommands.join(", ")})`);
210
+ }
211
+ const args = [];
212
+ if (a.args !== undefined) {
213
+ if (!Array.isArray(a.args) || a.args.length > 32) return err("command action.args must be an array (max 32)");
214
+ for (const v of a.args) {
215
+ if (typeof v !== "string" || v.length > 200) return err("command action.args entries must be strings (max 200)");
216
+ if (FORBIDDEN_ARG_CHARS.test(v)) return err(`command arg "${v}" contains shell metacharacters (not allowed)`);
217
+ const t = checkTemplate(v, propNames, "command arg");
218
+ if (!t.ok) return t;
219
+ args.push(t.value);
220
+ }
221
+ }
222
+ const out = checkOutputMax(a.output_max_chars);
223
+ if (!out.ok) return out;
224
+ return { ok: true, value: { kind: "command", command: a.command, args, outputMax: out.value } };
225
+ }
226
+
227
+ function checkReadFileAction(a) {
228
+ const allowed = new Set(["type", "path", "max_bytes"]);
229
+ for (const k of Object.keys(a)) if (!allowed.has(k)) return err(`read-file action: unknown key "${k}"`);
230
+ if (typeof a.path !== "string" || a.path.length === 0 || a.path.length > 500) return err("read-file action.path must be a string");
231
+ if (path.isAbsolute(a.path) || a.path.startsWith("~")) return err("read-file action.path must be relative to the project root");
232
+ const norm = path.normalize(a.path);
233
+ if (norm === ".." || norm.startsWith(`..${path.sep}`) || norm.split(path.sep).includes("..")) {
234
+ return err("read-file action.path may not traverse above the project root");
235
+ }
236
+ const mb = a.max_bytes !== undefined
237
+ ? typeof a.max_bytes === "number" && Number.isInteger(a.max_bytes) && a.max_bytes >= 1 && a.max_bytes <= MAX_READ_BYTES
238
+ ? a.max_bytes
239
+ : err(`read-file action.max_bytes must be 1..${MAX_READ_BYTES}`)
240
+ : DEFAULT_READ_BYTES;
241
+ if (!Number.isInteger(mb)) return mb;
242
+ return { ok: true, value: { kind: "read-file", path: norm, maxBytes: mb, outputMax: Math.min(DEFAULT_OUTPUT_LIMIT, mb) } };
243
+ }
244
+
245
+ function checkTransformAction(a, propNames) {
246
+ const allowed = new Set(["type", "template", "output_max_chars"]);
247
+ for (const k of Object.keys(a)) if (!allowed.has(k)) return err(`transform action: unknown key "${k}"`);
248
+ const t = checkTemplate(a.template, propNames, "transform action.template");
249
+ if (!t.ok) return t;
250
+ const out = checkOutputMax(a.output_max_chars);
251
+ if (!out.ok) return out;
252
+ return { ok: true, value: { kind: "transform", template: t.value, outputMax: out.value } };
253
+ }
254
+
255
+ // ---- manifest validation ----
256
+
257
+ export function validateManifest(obj, allowedCommands = ALLOWED_COMMANDS) {
258
+ if (!isObj(obj)) return err("manifest must be a JSON object");
259
+ for (const k of Object.keys(obj)) {
260
+ if (BANNED_TOP_KEYS.test(k)) {
261
+ return err(`plugin manifest contains a code field ("${k}") — plugins are declarative only`);
262
+ }
263
+ }
264
+ const allowed = new Set(["name", "version", "description", "tools"]);
265
+ for (const k of Object.keys(obj)) if (!allowed.has(k)) return err(`unknown manifest key "${k}"`);
266
+ const name = checkName(obj.name, "plugin");
267
+ if (!name.ok) return name;
268
+ if (typeof obj.version !== "string" || !/^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/.test(obj.version)) {
269
+ return err('version must be semver-like "1.2.3"');
270
+ }
271
+ const desc = checkDesc(obj.description);
272
+ if (!desc.ok) return desc;
273
+ if (!Array.isArray(obj.tools) || obj.tools.length < 1 || obj.tools.length > MAX_TOOLS) {
274
+ return err(`tools must be an array of 1..${MAX_TOOLS}`);
275
+ }
276
+ const tools = [];
277
+ const seen = new Set();
278
+ for (const t of obj.tools) {
279
+ if (!isObj(t)) return err("each tool must be an object");
280
+ const tAllowed = new Set(["name", "description", "input_schema", "action"]);
281
+ for (const k of Object.keys(t)) {
282
+ if (BANNED_TOP_KEYS.test(k)) return err(`tool contains a code field ("${k}") — plugins are declarative only`);
283
+ if (!tAllowed.has(k)) return err(`tool: unknown key "${k}"`);
284
+ }
285
+ const tn = checkName(t.name, "tool");
286
+ if (!tn.ok) return tn;
287
+ if (seen.has(tn.value)) return err(`duplicate tool name "${tn.value}"`);
288
+ seen.add(tn.value);
289
+ const td = checkDesc(t.description);
290
+ if (!td.ok) return td;
291
+ const sch = checkInputSchema(t.input_schema);
292
+ if (!sch.ok) return err(`tool "${tn.value}": ${sch.error}`);
293
+ const action = t.action;
294
+ if (!isObj(action)) return err(`tool "${tn.value}": action must be an object`);
295
+ if (!ACTION_TYPES.has(action.type)) {
296
+ return err(`tool "${tn.value}": action.type must be one of ${[...ACTION_TYPES].join(", ")}`);
297
+ }
298
+ let av;
299
+ switch (action.type) {
300
+ case "http":
301
+ av = checkHttpAction(action, sch.value);
302
+ break;
303
+ case "command":
304
+ av = checkCommandAction(action, sch.value, allowedCommands);
305
+ break;
306
+ case "read-file":
307
+ av = checkReadFileAction(action);
308
+ break;
309
+ case "transform":
310
+ av = checkTransformAction(action, sch.value);
311
+ break;
312
+ }
313
+ if (!av.ok) return err(`tool "${tn.value}": ${av.error}`);
314
+ const destructive = av.value.kind === "command" || (av.value.kind === "http" && av.value.method !== "GET");
315
+ tools.push({
316
+ name: tn.value,
317
+ description: td.value,
318
+ inputSchema: t.input_schema,
319
+ action: av.value,
320
+ requiresApproval: destructive,
321
+ });
322
+ }
323
+ return { ok: true, manifest: { name: name.value, version: obj.version, description: desc.value, tools } };
324
+ }
325
+
326
+ // Default allowlist; overridable per load call (tests / future config).
327
+ const ALLOWED_COMMANDS = ["roforge"];
328
+
329
+ // ---- execution ----
330
+
331
+ function capText(text, max, label) {
332
+ const s = String(text);
333
+ if (s.length <= max) return s;
334
+ return `${s.slice(0, max)}\n… truncated (${s.length - max} more chars; ${label} is capped at ${max})`;
335
+ }
336
+
337
+ export function pluginToolExecutor(plugin, tool) {
338
+ return async (args) => {
339
+ const a = tool.action;
340
+ args = isObj(args) ? args : {};
341
+ try {
342
+ if (a.kind === "http") {
343
+ const url = new URL(a.url);
344
+ for (const [k, tmpl] of Object.entries(a.query)) url.searchParams.set(k, fillTemplate(tmpl, args));
345
+ const res = await fetch(url.toString(), {
346
+ method: a.method,
347
+ headers: a.headers,
348
+ body: a.body !== undefined ? fillTemplate(a.body, args) : undefined,
349
+ signal: AbortSignal.timeout(a.timeoutMs),
350
+ });
351
+ const text = await res.text();
352
+ if (!res.ok) return `ERROR: HTTP ${res.status} ${res.statusText} from ${a.url}`;
353
+ return capText(text, a.outputMax, "plugin output");
354
+ }
355
+ if (a.kind === "command") {
356
+ const out = await new Promise((resolve, reject) => {
357
+ execFile(a.command, a.args.map((t) => fillTemplateRaw(t, args)), {
358
+ timeout: 30000,
359
+ maxBuffer: Math.max(a.outputMax * 2, 64 * 1024),
360
+ }, (e, stdout, stderr) => (e ? reject(e) : resolve(stdout)));
361
+ });
362
+ return capText(out, a.outputMax, "plugin output");
363
+ }
364
+ if (a.kind === "read-file") {
365
+ const root = process.cwd();
366
+ const full = path.resolve(root, a.path);
367
+ if (full !== root && !full.startsWith(root + path.sep)) return "ERROR: path escapes the project root";
368
+ if (!fs.existsSync(full) || !fs.statSync(full).isFile()) return `ERROR: file not found: ${a.path}`;
369
+ const st = fs.statSync(full);
370
+ if (st.size > a.maxBytes) return `ERROR: file too large (${st.size} > ${a.maxBytes} bytes)`;
371
+ return capText(fs.readFileSync(full, "utf8"), a.outputMax, "file output");
372
+ }
373
+ if (a.kind === "transform") {
374
+ // Plain-text output: raw (unencoded) values.
375
+ return capText(fillTemplateRaw(a.template, args), a.outputMax, "plugin output");
376
+ }
377
+ } catch (e) {
378
+ return `ERROR: ${e.message || String(e)}`;
379
+ }
380
+ return "ERROR: unknown action";
381
+ };
382
+ }
383
+
384
+ // ---- discovery + build ----
385
+
386
+ export function pluginDirs({ cwd, configDirBase }) {
387
+ const dirs = [];
388
+ if (process.env.ROFORGE_PLUGINS_DIR) dirs.push(process.env.ROFORGE_PLUGINS_DIR);
389
+ if (cwd) dirs.push(path.join(cwd, "plugins"));
390
+ if (configDirBase) dirs.push(path.join(configDirBase, "plugins"));
391
+ return [...new Set(dirs)];
392
+ }
393
+
394
+ export function loadPlugins({ cwd, configDirBase, allowedCommands = ALLOWED_COMMANDS } = {}) {
395
+ // Bind the allowlist for this load (validateManifest reads ALLOWED_COMMANDS
396
+ // via the closure below).
397
+ const tools = [];
398
+ const plugins = [];
399
+ const errors = [];
400
+ const seenTool = new Set();
401
+ for (const dir of pluginDirs({ cwd, configDirBase })) {
402
+ let entries = [];
403
+ try {
404
+ entries = fs.readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
405
+ } catch {
406
+ continue;
407
+ }
408
+ for (const f of entries) {
409
+ const file = path.join(dir, f);
410
+ let raw;
411
+ try {
412
+ raw = fs.readFileSync(file);
413
+ } catch {
414
+ errors.push({ file, error: "unreadable" });
415
+ continue;
416
+ }
417
+ if (raw.length > MAX_MANIFEST_BYTES) {
418
+ errors.push({ file, error: `manifest too large (${raw.length} > ${MAX_MANIFEST_BYTES} bytes)` });
419
+ continue;
420
+ }
421
+ let obj;
422
+ try {
423
+ obj = JSON.parse(raw.toString("utf8"));
424
+ } catch (e) {
425
+ errors.push({ file, error: `invalid JSON: ${e.message}` });
426
+ continue;
427
+ }
428
+ const res = validateManifest(obj, allowedCommands);
429
+ if (!res.ok) {
430
+ errors.push({ file, error: res.error });
431
+ continue;
432
+ }
433
+ const m = res.manifest;
434
+ const dupTools = m.tools.filter((t) => seenTool.has(t.name));
435
+ if (dupTools.length) {
436
+ errors.push({ file, error: `tool name conflict with an already-loaded plugin: ${dupTools.map((t) => t.name).join(", ")}` });
437
+ continue;
438
+ }
439
+ plugins.push({ name: m.name, version: m.version, file, toolCount: m.tools.length });
440
+ for (const t of m.tools) {
441
+ seenTool.add(t.name);
442
+ tools.push({
443
+ name: t.name,
444
+ description: `[plugin:${m.name}] ${t.description}`,
445
+ inputSchema: t.inputSchema,
446
+ tier: "plugin",
447
+ plugin: m.name,
448
+ requiresApproval: t.requiresApproval,
449
+ execute: pluginToolExecutor(m, t),
450
+ });
451
+ }
452
+ }
453
+ }
454
+ return { tools, plugins, errors };
455
+ }
package/src/session.js CHANGED
@@ -49,7 +49,14 @@ export class Session {
49
49
  luauAnalyzePath: this.luauAnalyzePath,
50
50
  });
51
51
  this.tools = info.tools;
52
- this.studioInfo = { mcp: info.mcp, bridge: info.bridge, mcpToolCount: info.mcpToolCount, mcpCapture: info.mcpCapture || [] };
52
+ this.studioInfo = {
53
+ mcp: info.mcp,
54
+ bridge: info.bridge,
55
+ mcpToolCount: info.mcpToolCount,
56
+ mcpCapture: info.mcpCapture || [],
57
+ plugins: info.plugins || [],
58
+ pluginErrors: info.pluginErrors || [],
59
+ };
53
60
  this.cfg._activeModel = this.model;
54
61
  return this.studioInfo;
55
62
  }
@@ -90,7 +97,19 @@ Rules:
90
97
  Current state:
91
98
  - Model: ${this.model} (${this.providerName}${PROVIDERS[this.providerName] && PROVIDERS[this.providerName].hasFreeTier && this.cfg.freeFirst !== false ? ", free tier" : ""})
92
99
  - ${studio.join(" ")}
93
- - Tools: ${this.tools.map((t) => t.name).join(", ")}`;
100
+ - Tools: ${this.tools.map((t) => t.name).join(", ")}${
101
+ this.studioInfo.plugins && this.studioInfo.plugins.length
102
+ ? `\n\nPlugins (strict declarative JSON — no code, outputs capped, mutations approval-gated): ${this.studioInfo.plugins
103
+ .map((p) => `${p.name}@${p.version} (${p.toolCount} tools)`)
104
+ .join(", ")}.`
105
+ : ""
106
+ }${
107
+ this.studioInfo.pluginErrors && this.studioInfo.pluginErrors.length
108
+ ? `\n\nRejected plugins (tell the user if they ask; fix the JSON and restart): ${this.studioInfo.pluginErrors
109
+ .map((e) => `${e.file}: ${e.error}`)
110
+ .join("; ")}.`
111
+ : ""
112
+ }`;
94
113
  }
95
114
 
96
115
  async send(userText) {
@@ -7,6 +7,8 @@ import { robloxTools } from "./roblox.js";
7
7
  import { projectTools } from "./project.js";
8
8
  import { bridgeTools, mcpToolsFromList, mcpCaptureNames } from "./studio.js";
9
9
  import { McpClient } from "../mcp.js";
10
+ import { loadPlugins } from "../plugins.js";
11
+ import { configDir } from "../config.js";
10
12
 
11
13
  export async function buildTools({ cfg, cwd, bridgeServer, luauAnalyzePath }) {
12
14
  const tools = [];
@@ -24,6 +26,13 @@ export async function buildTools({ cfg, cwd, bridgeServer, luauAnalyzePath }) {
24
26
  add(projectTools({ cwd, luauAnalyzePath }));
25
27
  if (bridgeServer) add(bridgeTools(bridgeServer));
26
28
 
29
+ // Strict declarative plugins (JSON only — no code fields, ever).
30
+ const pluginInfo =
31
+ cfg && cfg.plugins && cfg.plugins.enabled === false
32
+ ? { tools: [], plugins: [], pluginErrors: [] }
33
+ : loadPlugins({ cwd, configDirBase: configDir() });
34
+ add(pluginInfo.tools);
35
+
27
36
  // MCP tier (official, built into Studio)
28
37
  if (cfg.studioMode === "mcp" || cfg.studioMode === "auto") {
29
38
  try {
@@ -34,14 +43,30 @@ export async function buildTools({ cfg, cwd, bridgeServer, luauAnalyzePath }) {
34
43
  add(mcpToolsFromList(client, raw));
35
44
  cfg._mcpClient = client;
36
45
  cfg._mcpConnected = true;
37
- return { tools, mcp: true, bridge: Boolean(bridgeServer), mcpToolCount: raw.length, mcpCapture: mcpCaptureNames(raw) };
46
+ return {
47
+ tools,
48
+ mcp: true,
49
+ bridge: Boolean(bridgeServer),
50
+ mcpToolCount: raw.length,
51
+ mcpCapture: mcpCaptureNames(raw),
52
+ plugins: pluginInfo.plugins,
53
+ pluginErrors: pluginInfo.errors,
54
+ };
38
55
  }
39
56
  } catch {
40
57
  /* fall through to bridge-only */
41
58
  }
42
59
  }
43
60
 
44
- return { tools, mcp: false, bridge: Boolean(bridgeServer), mcpToolCount: 0, mcpCapture: [] };
61
+ return {
62
+ tools,
63
+ mcp: false,
64
+ bridge: Boolean(bridgeServer),
65
+ mcpToolCount: 0,
66
+ mcpCapture: [],
67
+ plugins: pluginInfo.plugins,
68
+ pluginErrors: pluginInfo.errors,
69
+ };
45
70
  }
46
71
 
47
72
  export function listToolNames(tools) {
@@ -29,6 +29,10 @@ const BRIDGE_TOOL_NAMES = [
29
29
  "forge_export",
30
30
  "forge_import",
31
31
  "forge_pro",
32
+ "forge_pro_features",
33
+ "forge_cloud_snapshot",
34
+ "forge_cloud_restore",
35
+ "forge_team_share",
32
36
  ];
33
37
 
34
38
  const BRIDGE_DESCRIPTIONS = {
@@ -57,6 +61,10 @@ const BRIDGE_DESCRIPTIONS = {
57
61
  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
62
  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
63
  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.",
64
+ forge_pro_features: "Status of the Pro feature component (closed module): installed or absent, plus its report (snapshot store, team workspace, MCP relay). Read-only; call before offering Pro features.",
65
+ forge_cloud_snapshot: "PRO: save a named snapshot of a place subtree (default: workspace) to the Pro store for later restore. Needs the Pro module installed and the Pro pass.",
66
+ forge_cloud_restore: "PRO: restore a named Pro snapshot into the place (default under workspace). Destructive: rebuilds the instance tree. Needs the Pro module installed and the Pro pass.",
67
+ forge_team_share: "PRO: publish a reference (place id, checkpoint label, or note) to the shared team workspace visible to teammates in the place. Destructive: writes the shared store. Needs the Pro module installed and the Pro pass.",
60
68
  };
61
69
 
62
70
  const BRIDGE_SCHEMAS = {
@@ -216,6 +224,33 @@ const BRIDGE_SCHEMAS = {
216
224
  additionalProperties: false,
217
225
  },
218
226
  forge_pro: { type: "object", properties: {}, additionalProperties: false },
227
+ forge_pro_features: { type: "object", properties: {}, additionalProperties: false },
228
+ forge_cloud_snapshot: {
229
+ type: "object",
230
+ properties: {
231
+ path: { type: "string", description: "Dotted subtree root (default: workspace)" },
232
+ name: { type: "string", description: "Snapshot label (default: auto)" },
233
+ },
234
+ additionalProperties: false,
235
+ },
236
+ forge_cloud_restore: {
237
+ type: "object",
238
+ properties: {
239
+ name: { type: "string", description: "Snapshot label to restore" },
240
+ path: { type: "string", description: "Dotted parent to restore into (default: workspace)" },
241
+ },
242
+ required: ["name"],
243
+ additionalProperties: false,
244
+ },
245
+ forge_team_share: {
246
+ type: "object",
247
+ properties: {
248
+ label: { type: "string", description: "Short name, e.g. 'level1-wip'" },
249
+ ref: { type: "string", description: "The reference to share, e.g. 'place:12345'" },
250
+ },
251
+ required: ["label", "ref"],
252
+ additionalProperties: false,
253
+ },
219
254
  };
220
255
 
221
256
  // Tools that modify the DataModel — gated by the approval prompt.
@@ -229,6 +264,8 @@ const DESTRUCTIVE = new Set([
229
264
  "forge_undo",
230
265
  "forge_bulk_create",
231
266
  "forge_import",
267
+ "forge_cloud_restore",
268
+ "forge_team_share",
232
269
  ]);
233
270
 
234
271
  export function bridgeTools(bridgeServer) {
package/src/tui/tui.js CHANGED
@@ -312,6 +312,12 @@ export class TUI {
312
312
  );
313
313
  }
314
314
  }
315
+ if (s.plugins && s.plugins.length) {
316
+ lines.push(`${green("●")} plugins: ${s.plugins.map((p) => `${p.name}@${p.version}`).join(", ")} (declarative JSON, no code)`);
317
+ }
318
+ if (s.pluginErrors && s.pluginErrors.length) {
319
+ lines.push(`${red("✕")} rejected plugins: ${s.pluginErrors.map((e) => `${e.file}: ${e.error}`).join("; ")}`);
320
+ }
315
321
  return lines.join("\n");
316
322
  }
317
323