@el4cteo/rbx-studio-mcp 0.1.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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/bridge/rpc.js +243 -0
  4. package/dist/bridge/rpc.js.map +1 -0
  5. package/dist/bridge/server.js +281 -0
  6. package/dist/bridge/server.js.map +1 -0
  7. package/dist/index.js +104 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/lib/apidump.js +269 -0
  10. package/dist/lib/apidump.js.map +1 -0
  11. package/dist/lib/errors.js +38 -0
  12. package/dist/lib/errors.js.map +1 -0
  13. package/dist/lib/format.js +191 -0
  14. package/dist/lib/format.js.map +1 -0
  15. package/dist/lib/pluginbuild.js +83 -0
  16. package/dist/lib/pluginbuild.js.map +1 -0
  17. package/dist/lib/png.js +84 -0
  18. package/dist/lib/png.js.map +1 -0
  19. package/dist/lib/protocol.js +22 -0
  20. package/dist/lib/protocol.js.map +1 -0
  21. package/dist/lib/tool.js +27 -0
  22. package/dist/lib/tool.js.map +1 -0
  23. package/dist/resources.js +70 -0
  24. package/dist/resources.js.map +1 -0
  25. package/dist/tools/api.js +78 -0
  26. package/dist/tools/api.js.map +1 -0
  27. package/dist/tools/character.js +94 -0
  28. package/dist/tools/character.js.map +1 -0
  29. package/dist/tools/debug.js +211 -0
  30. package/dist/tools/debug.js.map +1 -0
  31. package/dist/tools/device.js +74 -0
  32. package/dist/tools/device.js.map +1 -0
  33. package/dist/tools/discover.js +217 -0
  34. package/dist/tools/discover.js.map +1 -0
  35. package/dist/tools/exec.js +191 -0
  36. package/dist/tools/exec.js.map +1 -0
  37. package/dist/tools/input.js +96 -0
  38. package/dist/tools/input.js.map +1 -0
  39. package/dist/tools/instances.js +261 -0
  40. package/dist/tools/instances.js.map +1 -0
  41. package/dist/tools/perf.js +367 -0
  42. package/dist/tools/perf.js.map +1 -0
  43. package/dist/tools/playtest.js +153 -0
  44. package/dist/tools/playtest.js.map +1 -0
  45. package/dist/tools/screenshot.js +75 -0
  46. package/dist/tools/screenshot.js.map +1 -0
  47. package/dist/tools/scripts.js +316 -0
  48. package/dist/tools/scripts.js.map +1 -0
  49. package/dist/tools/session.js +152 -0
  50. package/dist/tools/session.js.map +1 -0
  51. package/dist/tools/world.js +281 -0
  52. package/dist/tools/world.js.map +1 -0
  53. package/package.json +62 -0
  54. package/plugin/default.project.json +6 -0
  55. package/plugin/src/Config.luau +59 -0
  56. package/plugin/src/Console.luau +657 -0
  57. package/plugin/src/Context.luau +35 -0
  58. package/plugin/src/Dispatch.luau +90 -0
  59. package/plugin/src/Editor.luau +142 -0
  60. package/plugin/src/Emulation.luau +151 -0
  61. package/plugin/src/LogBuffer.luau +277 -0
  62. package/plugin/src/Net.luau +102 -0
  63. package/plugin/src/Paths.luau +255 -0
  64. package/plugin/src/Phrase.luau +465 -0
  65. package/plugin/src/Png.luau +238 -0
  66. package/plugin/src/Scope.luau +78 -0
  67. package/plugin/src/ScriptEdit.luau +100 -0
  68. package/plugin/src/Serialize.luau +287 -0
  69. package/plugin/src/TextEdit.luau +296 -0
  70. package/plugin/src/Transport.luau +328 -0
  71. package/plugin/src/Undo.luau +72 -0
  72. package/plugin/src/Visuals.luau +710 -0
  73. package/plugin/src/handlers/Api.luau +242 -0
  74. package/plugin/src/handlers/Assets.luau +145 -0
  75. package/plugin/src/handlers/Capture.luau +187 -0
  76. package/plugin/src/handlers/Character.luau +361 -0
  77. package/plugin/src/handlers/Debug.luau +391 -0
  78. package/plugin/src/handlers/Device.luau +119 -0
  79. package/plugin/src/handlers/Discover.luau +289 -0
  80. package/plugin/src/handlers/Exec.luau +270 -0
  81. package/plugin/src/handlers/Geometry.luau +261 -0
  82. package/plugin/src/handlers/Input.luau +287 -0
  83. package/plugin/src/handlers/Instances.luau +389 -0
  84. package/plugin/src/handlers/Perf.luau +645 -0
  85. package/plugin/src/handlers/Playtest.luau +205 -0
  86. package/plugin/src/handlers/Scripts.luau +387 -0
  87. package/plugin/src/handlers/Session.luau +168 -0
  88. package/plugin/src/handlers/Viewport.luau +302 -0
  89. package/plugin/src/handlers/World.luau +176 -0
  90. package/plugin/src/init.server.luau +317 -0
  91. package/scripts/build-plugin.mjs +157 -0
  92. package/scripts/check-plugin.mjs +97 -0
  93. package/scripts/install-plugin.mjs +39 -0
  94. package/scripts/latency.mjs +201 -0
  95. package/scripts/locate-luau.mjs +51 -0
  96. package/scripts/sourcemap.mjs +58 -0
  97. package/scripts/test-plugin.mjs +82 -0
@@ -0,0 +1,317 @@
1
+ --!strict
2
+ --[[
3
+ Studio MCP -- plugin entry point.
4
+
5
+ Owns the toolbar UI, this window's Studio identity, and the command loop.
6
+ Handlers do the actual work; this file only wires them to the transport and
7
+ reports what is happening to the console widget.
8
+ ]]
9
+
10
+ local HttpService = game:GetService("HttpService")
11
+ local RunService = game:GetService("RunService")
12
+
13
+ local Config = require(script.Config)
14
+ local Console = require(script.Console)
15
+ local Dispatch = require(script.Dispatch)
16
+ local LogBuffer = require(script.LogBuffer)
17
+ local Phrase = require(script.Phrase)
18
+ local Transport = require(script.Transport)
19
+ local Debug = require(script.handlers.Debug)
20
+ local Assets = require(script.handlers.Assets)
21
+ local Capture = require(script.handlers.Capture)
22
+ local Character = require(script.handlers.Character)
23
+ local Geometry = require(script.handlers.Geometry)
24
+ local World = require(script.handlers.World)
25
+ local Discover = require(script.handlers.Discover)
26
+ local Exec = require(script.handlers.Exec)
27
+ local Instances = require(script.handlers.Instances)
28
+ local Perf = require(script.handlers.Perf)
29
+ local Playtest = require(script.handlers.Playtest)
30
+ local Viewport = require(script.handlers.Viewport)
31
+ local Input = require(script.handlers.Input)
32
+ local Device = require(script.handlers.Device)
33
+ local Api = require(script.handlers.Api)
34
+ local Scripts = require(script.handlers.Scripts)
35
+ local Session = require(script.handlers.Session)
36
+
37
+ local SETTING_PORT = "port"
38
+ local SETTING_AUTOCONNECT = "autoConnect"
39
+ local SETTING_FORCE_POLL = "forcePoll"
40
+
41
+ --[[
42
+ A fresh id per plugin load, which in practice means one per Studio window.
43
+
44
+ This was originally persisted with `plugin:SetSetting`, on the reasoning that a
45
+ stable id keeps reconnects mapping to the same session. That is wrong as soon
46
+ as the user opens a second window: plugin settings live in one file shared by
47
+ every Studio process, so both windows announce the same id, and the server
48
+ treats the second connection as the first one reconnecting -- closing the
49
+ original stream and making it impossible to address the two places separately.
50
+
51
+ Held in memory instead. A reconnect within one load (the SSE stream hits its
52
+ 30-minute cap) reuses this id, and `plugin.Unloading` detaches cleanly on
53
+ reload, so ghost entries do not accumulate.
54
+ ]]
55
+ local SESSION_ID = HttpService:GenerateGUID(false)
56
+
57
+ local function studioId(): string
58
+ return SESSION_ID
59
+ end
60
+
61
+ --[[
62
+ Whether this copy of the plugin can reach the bridge at all.
63
+
64
+ Pressing Play loads the plugin into the playtest's DataModels as well as the
65
+ editor's, and HttpService refuses every request from a client one: "Http
66
+ requests can only be executed by game server". The transport read that as a
67
+ dropped connection and retried forever, filling the console with red while
68
+ nothing was actually wrong.
69
+
70
+ The editor session and the play session's server can both connect and are
71
+ worth connecting -- addressing a running server is useful. The client half
72
+ simply says so once and stops.
73
+ ]]
74
+ local function canConnect(): (boolean, string?)
75
+ if RunService:IsEdit() or RunService:IsServer() then
76
+ return true, nil
77
+ end
78
+ --[[
79
+ The wording names the fix, because the symptom is indistinguishable
80
+ from a broken server: a user watching this window during a playtest
81
+ sees a console that logs nothing while tools plainly work, and has no
82
+ way to guess that the activity is in a different view of the same
83
+ Studio. Reported once here and again as the strip's caption, since
84
+ one line scrolled off the top is easy to miss.
85
+ ]]
86
+ return false,
87
+ "client view of a playtest -- Studio forbids client sessions from making HTTP "
88
+ .. "requests. The playtest's server session is connected and handling this "
89
+ .. "place; switch Studio to the Server view (Test tab, Current: Server) to "
90
+ .. "watch it work."
91
+ end
92
+
93
+ -- First thing, before any handler or the transport can log: the buffer only
94
+ -- holds what was printed after it subscribed, so every line ahead of this call
95
+ -- is unrecoverable. This runs even in a client session that will never connect,
96
+ -- since the console tool reads it and connectivity is a separate question.
97
+ LogBuffer.start()
98
+
99
+ local storedPort = plugin:GetSetting(SETTING_PORT)
100
+ if typeof(storedPort) == "number" then
101
+ Config.setPort(storedPort)
102
+ end
103
+
104
+ Transport.setForcePoll(plugin:GetSetting(SETTING_FORCE_POLL) == true)
105
+
106
+ Session.register()
107
+ Discover.register()
108
+ Debug.register()
109
+ Instances.register()
110
+ Perf.register(plugin)
111
+ Playtest.register()
112
+ Capture.register()
113
+ Assets.register()
114
+ Character.register()
115
+ Geometry.register()
116
+ World.register()
117
+ Exec.register()
118
+ Viewport.register()
119
+ Scripts.register()
120
+ Input.register()
121
+ Device.register()
122
+ Api.register()
123
+
124
+ local toolbar = plugin:CreateToolbar("Studio MCP")
125
+ local button = toolbar:CreateButton(
126
+ "Studio MCP",
127
+ "Show the Studio MCP console",
128
+ "rbxasset://textures/ui/common/robux.png"
129
+ )
130
+ button.ClickableWhenViewportHidden = true
131
+
132
+ -- Enabled by default: the whole point is that opening a place shows you it
133
+ -- connected without touching anything. Studio remembers the user's choice after
134
+ -- the first time they close it.
135
+ local widget = plugin:CreateDockWidgetPluginGuiAsync(
136
+ "StudioMCP_Console",
137
+ DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, true, false, 560, 320, 360, 200)
138
+ )
139
+ widget.Title = "Studio MCP"
140
+
141
+ local currentStatus: Transport.Status = "disconnected"
142
+
143
+ local function refreshMeta()
144
+ Console.setStatus(
145
+ currentStatus,
146
+ string.format(
147
+ "127.0.0.1:%d %s build %s",
148
+ Config.getPort(),
149
+ Transport.getMode(),
150
+ Config.BUILD_ID
151
+ )
152
+ )
153
+ button:SetActive(currentStatus == "connected")
154
+ end
155
+
156
+ local connect: () -> ()
157
+
158
+ Console.mount(widget, {
159
+ onReconnect = function()
160
+ Console.log("info", "reconnect requested")
161
+ Transport.stop()
162
+ task.wait(0.2)
163
+ connect()
164
+ end,
165
+ onClear = function()
166
+ Console.clear()
167
+ end,
168
+ })
169
+
170
+ Console.log("info", string.format("Studio MCP v%s", Config.PLUGIN_VERSION), "build " .. Config.BUILD_ID)
171
+ Console.log("dim", string.format("place: %s (%d)", game.Name, game.PlaceId))
172
+
173
+ local function onStatus(status: Transport.Status, detail: string?)
174
+ local previous = currentStatus
175
+ currentStatus = status
176
+ refreshMeta()
177
+
178
+ -- Only narrate transitions. The transport re-reports its state on every
179
+ -- reconnect attempt, and echoing an unchanged status would bury real events.
180
+ if status == previous and detail == nil then
181
+ return
182
+ end
183
+
184
+ if status == "connected" then
185
+ Console.log(
186
+ "ok",
187
+ string.format("connected to 127.0.0.1:%d", Config.getPort()),
188
+ if detail then "(" .. detail .. ")" else nil
189
+ )
190
+ elseif status == "connecting" then
191
+ Console.log("dim", string.format("connecting to 127.0.0.1:%d...", Config.getPort()))
192
+ else
193
+ Console.log("error", "disconnected", detail)
194
+ end
195
+ end
196
+
197
+ --[[
198
+ Runs each command on its own task. Handlers may yield -- `UpdateSourceAsync`
199
+ and any `*Async` call does -- and serialising them would let one slow edit
200
+ stall every other request on the stream.
201
+ ]]
202
+ local function onCommand(id: string, op: string, params: { [string]: any }?)
203
+ task.spawn(function()
204
+ local startedAt = os.clock()
205
+ --[[
206
+ Named for what it does, not for how it travels. `script.edit` on
207
+ ServerScriptService.Systems.KillBrick reads as "Edit KillBrick",
208
+ which is the thing someone watching actually wants to know; the wire
209
+ name is kept alongside so the log still maps onto the protocol when
210
+ something needs debugging.
211
+ ]]
212
+ local title = Phrase.of(op, params)
213
+ Console.beginCall(title, Phrase.kindOf(op))
214
+
215
+ local result = Dispatch.invoke(id, op, params)
216
+ local elapsed = string.format("%.0fms", (os.clock() - startedAt) * 1000)
217
+
218
+ Console.recordCall(result.ok, (os.clock() - startedAt) * 1000)
219
+
220
+ if result.ok then
221
+ Console.log("reply", title, elapsed)
222
+ else
223
+ local err = result.error
224
+ Console.log(
225
+ "error",
226
+ string.format("%s failed: %s", title, if err then err.code else "unknown"),
227
+ elapsed
228
+ )
229
+ if err and err.message then
230
+ Console.log("dim", " " .. err.message)
231
+ end
232
+ end
233
+
234
+ Transport.sendResult(result)
235
+ end)
236
+ end
237
+
238
+ function connect()
239
+ local allowed, reason = canConnect()
240
+ if not allowed then
241
+ -- Reported as standby rather than an error: nothing failed, and this
242
+ -- session was never going to connect.
243
+ currentStatus = "disconnected"
244
+ Console.log("dim", "standby", reason)
245
+ Console.setStatus(
246
+ "standby",
247
+ string.format("%s build %s", "client view", Config.BUILD_ID)
248
+ )
249
+ Console.setCaption("switch to the Server view to watch this playtest")
250
+ return
251
+ end
252
+ Transport.start(studioId(), { onCommand = onCommand, onStatus = onStatus })
253
+ plugin:SetSetting(SETTING_AUTOCONNECT, true)
254
+ end
255
+
256
+ button.Click:Connect(function()
257
+ widget.Enabled = not widget.Enabled
258
+ end)
259
+
260
+ plugin.Unloading:Connect(function()
261
+ Transport.stop()
262
+ end)
263
+
264
+ --[[
265
+ Switches this session between the push and long-poll transports.
266
+
267
+ Registered here rather than in a handler module because it is the only
268
+ command that has to reach back into the plugin object and the connection
269
+ loop, both of which live in this file -- and registered THIS far down the
270
+ file on purpose: `connect` is a forward-declared local, so a closure written
271
+ above its declaration captures the global of that name instead, which is nil.
272
+ That cost a session. The plugin loaded and connected perfectly, and then died
273
+ the first time somebody asked it to switch transport.
274
+
275
+ The reply is sent before the reconnect, and the reconnect is deferred:
276
+ tearing the stream down inside the handler would strand the answer to the
277
+ very call that asked for the switch, which is a confusing way to succeed.
278
+ ]]
279
+ Dispatch.registerAll("studio", {
280
+ transport = function(params: { [string]: any }): { [string]: any }
281
+ local requested = params.mode
282
+ if requested ~= nil and requested ~= "sse" and requested ~= "poll" then
283
+ Dispatch.fail("BAD_PARAMS", 'transport mode must be "sse" or "poll".')
284
+ end
285
+ if requested == nil then
286
+ return { mode = Transport.getMode(), forcePoll = Transport.getForcePoll(), changed = false }
287
+ end
288
+
289
+ local wantPoll = requested == "poll"
290
+ if Transport.getMode() == requested and Transport.getForcePoll() == wantPoll then
291
+ return { mode = Transport.getMode(), forcePoll = wantPoll, changed = false }
292
+ end
293
+
294
+ Transport.setForcePoll(wantPoll)
295
+ plugin:SetSetting(SETTING_FORCE_POLL, wantPoll)
296
+ task.defer(function()
297
+ Transport.stop()
298
+ task.wait(0.1)
299
+ connect()
300
+ end)
301
+ return {
302
+ mode = requested,
303
+ forcePoll = wantPoll,
304
+ changed = true,
305
+ note = "Reconnecting on the new transport; the next call will use it.",
306
+ }
307
+ end,
308
+ })
309
+
310
+ refreshMeta()
311
+
312
+ -- Connect on load unless the user explicitly disconnected last session.
313
+ if plugin:GetSetting(SETTING_AUTOCONNECT) ~= false then
314
+ connect()
315
+ else
316
+ Console.log("warn", "auto-connect disabled — press reconnect to start")
317
+ end
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Builds plugin/src into an installable StudioMCP.rbxmx.
3
+ *
4
+ * Rojo would do this too, but requiring contributors (and users building from
5
+ * source) to install a Luau toolchain just to get a plugin file is friction the
6
+ * project does not need. The .rbxmx format is plain XML, so we emit it directly
7
+ * and keep `plugin/default.project.json` around for people who do use Rojo.
8
+ *
9
+ * Usage: node scripts/build-plugin.mjs [outputPath]
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { readdirSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
13
+ import { dirname, join, relative, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17
+ const sourceDir = join(root, "plugin", "src");
18
+ const outputPath = resolve(process.argv[2] ?? join(root, "build", "StudioMCP.rbxmx"));
19
+
20
+ const NEWLINE = "\n";
21
+
22
+ /**
23
+ * Fingerprints plugin/src so the running plugin can be compared against the
24
+ * sources this package ships. Must stay byte-identical to
25
+ * `expectedPluginBuildId` in src/lib/pluginbuild.ts — line endings are
26
+ * normalised because git checkouts differ across platforms.
27
+ */
28
+ function buildId(dir) {
29
+ const files = [];
30
+
31
+ const walk = (current) => {
32
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
33
+ const path = join(current, entry.name);
34
+ if (entry.isDirectory()) {
35
+ walk(path);
36
+ } else if (entry.name.endsWith(".luau")) {
37
+ files.push([
38
+ relative(dir, path).split("\\").join("/"),
39
+ readFileSync(path, "utf8"),
40
+ ]);
41
+ }
42
+ }
43
+ };
44
+ walk(dir);
45
+ files.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
46
+
47
+ const hash = createHash("sha256");
48
+ for (const [path, contents] of files) {
49
+ hash.update(path);
50
+ hash.update(NEWLINE);
51
+ hash.update(contents.replace(/\r\n/g, NEWLINE));
52
+ hash.update(NEWLINE);
53
+ }
54
+ return hash.digest("hex").slice(0, 12);
55
+ }
56
+
57
+ const stamp = buildId(sourceDir);
58
+
59
+ let nextReferent = 0;
60
+
61
+ /** XML text escaping for property values (source goes in CDATA instead). */
62
+ const escapeXml = (value) =>
63
+ value
64
+ .replace(/&/g, "&amp;")
65
+ .replace(/</g, "&lt;")
66
+ .replace(/>/g, "&gt;")
67
+ .replace(/"/g, "&quot;");
68
+
69
+ /**
70
+ * Wraps Luau source in CDATA. A literal "]]>" would close the section early, so
71
+ * it is split across two adjacent sections — the parser rejoins them.
72
+ */
73
+ const cdata = (source) => `<![CDATA[${source.replaceAll("]]>", "]]]]><![CDATA[>")}]]>`;
74
+
75
+ function renderItem(node, depth) {
76
+ const pad = " ".repeat(depth);
77
+ const referent = `RBX${nextReferent++}`;
78
+ const properties = [`${pad} <string name="Name">${escapeXml(node.name)}</string>`];
79
+
80
+ if (node.source !== undefined) {
81
+ properties.push(`${pad} <ProtectedString name="Source">${cdata(node.source)}</ProtectedString>`);
82
+ }
83
+ if (node.className === "Script") {
84
+ // Plugin scripts must not be disabled, and RunContext 0 (Legacy) is what
85
+ // Studio expects for code running in the plugin's own context.
86
+ properties.push(`${pad} <bool name="Disabled">false</bool>`);
87
+ properties.push(`${pad} <token name="RunContext">0</token>`);
88
+ }
89
+
90
+ const children = node.children.map((child) => renderItem(child, depth + 1)).join(NEWLINE);
91
+
92
+ return [
93
+ `${pad}<Item class="${node.className}" referent="${referent}">`,
94
+ `${pad} <Properties>`,
95
+ ...properties,
96
+ `${pad} </Properties>`,
97
+ ...(children ? [children] : []),
98
+ `${pad}</Item>`,
99
+ ].join(NEWLINE);
100
+ }
101
+
102
+ /**
103
+ * Mirrors Rojo's naming rules: `init.server.luau` turns its folder into a
104
+ * Script, `init.luau` into a ModuleScript, and every other .luau file becomes a
105
+ * ModuleScript child. That keeps this builder and Rojo producing the same tree.
106
+ */
107
+ function buildTree(dir, name) {
108
+ const node = { name, className: "Folder", children: [] };
109
+
110
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
111
+ const path = join(dir, entry.name);
112
+
113
+ if (entry.isDirectory()) {
114
+ node.children.push(buildTree(path, entry.name));
115
+ continue;
116
+ }
117
+ if (!entry.name.endsWith(".luau")) continue;
118
+
119
+ let source = readFileSync(path, "utf8");
120
+ if (entry.name === "Config.luau") {
121
+ // Stamped with the fingerprint of the pre-injection sources, which is
122
+ // exactly what the server recomputes at runtime.
123
+ source = source.replace('Config.BUILD_ID = "dev"', `Config.BUILD_ID = "${stamp}"`);
124
+ }
125
+
126
+ if (entry.name === "init.server.luau") {
127
+ node.className = "Script";
128
+ node.source = source;
129
+ } else if (entry.name === "init.luau") {
130
+ node.className = "ModuleScript";
131
+ node.source = source;
132
+ } else {
133
+ node.children.push({
134
+ name: entry.name.replace(/\.luau$/, ""),
135
+ className: "ModuleScript",
136
+ source,
137
+ children: [],
138
+ });
139
+ }
140
+ }
141
+
142
+ return node;
143
+ }
144
+
145
+ const tree = buildTree(sourceDir, "StudioMCP");
146
+ const document = [
147
+ '<roblox xmlns:xmime="http://www.w3.org/2005/05/xmlmime"',
148
+ ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
149
+ ' xsi:noNamespaceSchemaLocation="http://www.roblox.com/roblox.xsd" version="4">',
150
+ renderItem(tree, 1),
151
+ "</roblox>",
152
+ "",
153
+ ].join(NEWLINE);
154
+
155
+ mkdirSync(dirname(outputPath), { recursive: true });
156
+ writeFileSync(outputPath, document, "utf8");
157
+ writeFileSync(join(dirname(outputPath), "plugin-build-id.txt"), stamp + NEWLINE, "utf8");
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Compiles every plugin source file, so a broken one is caught here rather than
3
+ * in Studio.
4
+ *
5
+ * This exists because the feedback loop without it is terrible. Nothing in the
6
+ * Node build reads the Luau at all -- `build:plugin` packs the files into an
7
+ * .rbxmx as text -- so a syntax error ships, installs, and is only discovered
8
+ * when the user focuses Studio and the plugin fails to load. It cost two
9
+ * sessions in one afternoon: a literal newline written into a string where an
10
+ * escape was meant, and a closure that captured a nil global because it sat
11
+ * above the forward declaration of the local it meant to call. The first is a
12
+ * compile error and would have been caught instantly by this. The second is not,
13
+ * which is why `--!strict` analysis runs too when the analyser is available:
14
+ * an unknown global is exactly what it flags.
15
+ *
16
+ * Needs `luau-compile` (and ideally `luau-analyze`) on PATH, in ./tools, or
17
+ * named by the LUAU_COMPILE / LUAU_ANALYZE environment variables. Get them from
18
+ * https://github.com/luau-lang/luau/releases.
19
+ *
20
+ * Silent and exit 0 when everything compiles; prints what failed otherwise.
21
+ *
22
+ * Usage: node scripts/check-plugin.mjs
23
+ */
24
+ import { spawnSync } from "node:child_process";
25
+ import { readdirSync, statSync } from "node:fs";
26
+ import { dirname, join, resolve } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import { locateLuau, missingLuau } from "./locate-luau.mjs";
29
+
30
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
31
+
32
+ function luauFiles(directory) {
33
+ const found = [];
34
+ for (const entry of readdirSync(directory)) {
35
+ const path = join(directory, entry);
36
+ if (statSync(path).isDirectory()) found.push(...luauFiles(path));
37
+ else if (entry.endsWith(".luau")) found.push(path);
38
+ }
39
+ return found;
40
+ }
41
+
42
+ const compiler = locateLuau("LUAU_COMPILE", ["luau-compile.exe", "luau-compile"]);
43
+ if (compiler === null) {
44
+ process.stderr.write(
45
+ "No luau-compile found. Put it on PATH or in ./tools, or set LUAU_COMPILE.\n" +
46
+ "Download: https://github.com/luau-lang/luau/releases\n",
47
+ );
48
+ process.exit(1);
49
+ }
50
+
51
+ const files = luauFiles(join(root, "plugin", "src"));
52
+ const failures = [];
53
+
54
+ for (const file of files) {
55
+ // --binary throws the bytecode away; only the exit status and diagnostics
56
+ // matter, and writing it anywhere would just be litter to clean up.
57
+ const result = spawnSync(compiler, ["--binary", file], { encoding: "utf8" });
58
+ const diagnostics = `${result.stderr ?? ""}${result.status === 0 ? "" : (result.stdout ?? "")}`.trim();
59
+ if (result.status !== 0 || diagnostics.length > 0) {
60
+ failures.push(`${file.slice(root.length + 1)}\n${diagnostics}`);
61
+ }
62
+ }
63
+
64
+ /*
65
+ * One diagnostic from the analyser, deliberately.
66
+ *
67
+ * `LocalShadow` is reported when a name is used as a global and a local of that
68
+ * same name is declared later in the file -- which is the shape of the bug this
69
+ * check was written for, and is unambiguous. Running the analyser without
70
+ * Roblox's type definitions also reports every engine global as unknown, so
71
+ * `script`, `task`, `Color3` and friends produce hundreds of lines of noise;
72
+ * filtering to this one diagnostic gets the signal without needing a
73
+ * definitions file that would then have to be kept current with the engine.
74
+ */
75
+ const analyser = locateLuau("LUAU_ANALYZE", ["luau-analyze.exe", "luau-analyze"]);
76
+ const shadowed = [];
77
+ if (analyser !== null) {
78
+ for (const file of files) {
79
+ const result = spawnSync(analyser, [file], { encoding: "utf8" });
80
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
81
+ for (const line of output.split("\n")) {
82
+ if (line.includes("LocalShadow:")) shadowed.push(line.trim());
83
+ }
84
+ }
85
+ }
86
+
87
+ if (failures.length > 0) {
88
+ process.stderr.write(`${failures.join("\n\n")}\n`);
89
+ }
90
+ if (shadowed.length > 0) {
91
+ process.stderr.write(
92
+ "\nA local is used before it is declared, so the call reaches a nil global " +
93
+ "instead:\n" +
94
+ `${shadowed.join("\n")}\n`,
95
+ );
96
+ }
97
+ process.exit(failures.length > 0 || shadowed.length > 0 ? 1 : 0);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Builds the plugin and drops it into the local Roblox Studio plugins folder.
3
+ *
4
+ * Studio watches that directory and hot-reloads, so re-running this while Studio
5
+ * is open picks up the new build without a restart.
6
+ *
7
+ * Usage: node scripts/install-plugin.mjs
8
+ */
9
+ import { execFileSync } from "node:child_process";
10
+ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
16
+ const built = join(root, "build", "StudioMCP.rbxmx");
17
+
18
+ /** Studio's per-user plugin directory, which differs per platform. */
19
+ function pluginsDir() {
20
+ if (process.platform === "win32") {
21
+ const local = process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local");
22
+ return join(local, "Roblox", "Plugins");
23
+ }
24
+ if (process.platform === "darwin") {
25
+ return join(homedir(), "Documents", "Roblox", "Plugins");
26
+ }
27
+ // Studio only ships for Windows and macOS; anything else is a Wine/Proton
28
+ // layout we cannot guess, so make the user point us at it.
29
+ throw new Error(
30
+ "Roblox Studio does not run natively on this platform. Build with " +
31
+ "`npm run build:plugin` and copy build/StudioMCP.rbxmx into your plugins folder.",
32
+ );
33
+ }
34
+
35
+ execFileSync(process.execPath, [join(root, "scripts", "build-plugin.mjs")], { stdio: "inherit" });
36
+
37
+ const target = pluginsDir();
38
+ if (!existsSync(target)) mkdirSync(target, { recursive: true });
39
+ copyFileSync(built, join(target, "StudioMCP.rbxmx"));