@el4cteo/rbx-studio-mcp 0.6.5 → 0.6.8

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 (46) hide show
  1. package/README.md +28 -2
  2. package/dist/bridge/console.js +182 -0
  3. package/dist/bridge/console.js.map +1 -1
  4. package/dist/index.js +4 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/cloudassets.js +233 -0
  7. package/dist/lib/cloudassets.js.map +1 -0
  8. package/dist/lib/credentials.js +180 -0
  9. package/dist/lib/credentials.js.map +1 -0
  10. package/dist/lib/livedata.js +325 -0
  11. package/dist/lib/livedata.js.map +1 -0
  12. package/dist/lib/liveluau.js +83 -0
  13. package/dist/lib/liveluau.js.map +1 -0
  14. package/dist/lib/liveops.js +358 -0
  15. package/dist/lib/liveops.js.map +1 -0
  16. package/dist/lib/opencloud.js +290 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/audio.js +96 -0
  19. package/dist/tools/audio.js.map +1 -0
  20. package/dist/tools/data.js +133 -3
  21. package/dist/tools/data.js.map +1 -1
  22. package/dist/tools/exec.js +56 -1
  23. package/dist/tools/exec.js.map +1 -1
  24. package/dist/tools/scripts.js +124 -4
  25. package/dist/tools/scripts.js.map +1 -1
  26. package/dist/tools/spatial.js +135 -0
  27. package/dist/tools/spatial.js.map +1 -0
  28. package/dist/tools/universe.js +182 -0
  29. package/dist/tools/universe.js.map +1 -0
  30. package/dist/tools/upload.js +294 -0
  31. package/dist/tools/upload.js.map +1 -0
  32. package/dist/tools/world.js +361 -10
  33. package/dist/tools/world.js.map +1 -1
  34. package/package.json +74 -74
  35. package/plugin/src/Commands.luau +646 -622
  36. package/plugin/src/Config.luau +65 -65
  37. package/plugin/src/Phrase.luau +816 -766
  38. package/plugin/src/Prompt.luau +965 -961
  39. package/plugin/src/Secret.luau +86 -0
  40. package/plugin/src/Serialize.luau +759 -499
  41. package/plugin/src/handlers/Assets.luau +636 -587
  42. package/plugin/src/handlers/Audio.luau +411 -0
  43. package/plugin/src/handlers/Geometry.luau +722 -577
  44. package/plugin/src/handlers/Instances.luau +84 -4
  45. package/plugin/src/handlers/Spatial.luau +334 -0
  46. package/plugin/src/init.server.luau +883 -879
@@ -1,622 +1,646 @@
1
- --!strict
2
- --[[
3
- What the console's prompt row does with a line.
4
-
5
- Split from `Console` on purpose. The console owns a log and a widget; half of
6
- these answers are about the transport, the port setting, the place, or the
7
- bridge, and wiring all of that into the console to answer `status` would make
8
- the console the whole plugin.
9
-
10
- Two kinds of line arrive here and they are told apart by one rule: the first
11
- word is either a command in this table or it is not. If it is, it runs here
12
- or on the bridge and prints in milliseconds. If it is not, the whole line is
13
- a request for an agent, and goes to the bridge to be run by whichever coding
14
- tool the user has installed. Nothing has to be marked, quoted or prefixed --
15
- `doctor` is a command and "make the door open when I touch it" is not, and no
16
- person needs that explained.
17
-
18
- Commands answered on the bridge are listed here too, with `remote = true`, so
19
- `help` and Tab-completion describe the whole surface rather than only the
20
- half that happens to run in Luau.
21
- ]]
22
-
23
- local ScriptEditorService = game:GetService("ScriptEditorService")
24
- local Selection = game:GetService("Selection")
25
- local ServerStorage = game:GetService("ServerStorage")
26
-
27
- local Config = require(script.Parent.Config)
28
- local Console = require(script.Parent.Console)
29
- local Net = require(script.Parent.Net)
30
- local ThemePicker = require(script.Parent.ThemePicker)
31
- local Themes = require(script.Parent.Themes)
32
-
33
- local Commands = {}
34
-
35
- export type Hooks = {
36
- -- Drops the connection and dials again. Owned by init.server, which is the
37
- -- only thing holding the transport's lifecycle.
38
- reconnect: () -> (),
39
- -- `plugin:SetSetting` is not reachable from a ModuleScript, so anything that
40
- -- has to outlive the session is handed back to the script that can persist it.
41
- savePort: (number) -> (),
42
- saveTheme: (string) -> (),
43
- -- Whether the panel may open itself on load, and the setter for it.
44
- autoOpen: () -> boolean,
45
- saveAutoOpen: (boolean) -> (),
46
- -- The transport's own view of the connection, which the console only ever
47
- -- receives second-hand as a string to display.
48
- status: () -> string,
49
- studioId: () -> string,
50
- }
51
-
52
- local hooks: Hooks? = nil
53
-
54
- function Commands.setup(value: Hooks)
55
- hooks = value
56
- end
57
-
58
- type Entry = {
59
- name: string,
60
- usage: string,
61
- summary: string,
62
- -- Answered by the bridge rather than here. Listed all the same: a user
63
- -- should not have to know which side of the wire a command lives on.
64
- remote: boolean?,
65
- run: ((args: { string }) -> ())?,
66
- }
67
-
68
- local entries: { Entry } = {}
69
- local byName: { [string]: Entry } = {}
70
-
71
- local function define(entry: Entry)
72
- table.insert(entries, entry)
73
- byName[entry.name] = entry
74
- end
75
-
76
- --[[
77
- Sends a command to the bridge and prints whatever comes back.
78
-
79
- Answers arrive as rows rather than as text, so the bridge decides what a
80
- warning IS and this decides what a warning looks like. Both sides formatting
81
- is how a diagnostic ends up rendered two different ways in one log.
82
- ]]
83
- local function remote(command: string, args: { string }, line: string)
84
- local current = hooks
85
- local response = Net.postJson("/console", {
86
- studioId = if current ~= nil then current.studioId() else "",
87
- command = command,
88
- args = args,
89
- line = line,
90
- })
91
-
92
- if not response.ok then
93
- Console.setPromptBusy(false)
94
- Console.log("error", string.format("%s failed", command), response.error)
95
- return
96
- end
97
-
98
- local decoded = Net.decode(response.body)
99
- --[[
100
- The bridge is the authority on whether an agent is running, so the caret
101
- follows its answer rather than what this side assumed when it sent the
102
- line. A prompt that never started -- no harness on PATH is the common one
103
- -- would otherwise leave the panel looking busy for the rest of the
104
- session.
105
- ]]
106
- Console.setPromptBusy(typeof(decoded) == "table" and (decoded :: any).running == true)
107
-
108
- local rows = if typeof(decoded) == "table" then (decoded :: any).lines else nil
109
- if typeof(rows) ~= "table" then
110
- Console.log("error", string.format("%s returned nothing readable", command))
111
- return
112
- end
113
-
114
- for _, row in rows :: { any } do
115
- if typeof(row) == "table" and typeof(row.message) == "string" then
116
- local level = if typeof(row.level) == "string" then row.level else "dim"
117
- Console.log(
118
- level :: any,
119
- row.message,
120
- if typeof(row.detail) == "string" then row.detail else nil
121
- )
122
- end
123
- end
124
- end
125
-
126
- -- Local commands ----------------------------------------------------------
127
-
128
- define({
129
- name = "help",
130
- usage = "help",
131
- summary = "list every command",
132
- run = function()
133
- Console.log("info", "commands")
134
- for _, entry in entries do
135
- Console.log("dim", " " .. entry.usage, entry.summary)
136
- end
137
- Console.log(
138
- "dim",
139
- " anything else",
140
- "sent to a coding agent -- run `agent` to see which"
141
- )
142
- end,
143
- })
144
-
145
- define({
146
- name = "clear",
147
- usage = "clear",
148
- summary = "empty the log",
149
- run = function()
150
- Console.clear()
151
- end,
152
- })
153
-
154
- define({
155
- name = "reconnect",
156
- usage = "reconnect",
157
- summary = "drop the connection and dial the bridge again",
158
- run = function()
159
- local current = hooks
160
- if current then
161
- current.reconnect()
162
- end
163
- end,
164
- })
165
-
166
- define({
167
- name = "status",
168
- usage = "status",
169
- summary = "connection, bridge, build and theme",
170
- run = function()
171
- local current = hooks
172
- Console.log("info", "session")
173
- Console.log(
174
- "dim",
175
- " connection",
176
- if current ~= nil then current.status() else "unknown"
177
- )
178
- Console.log("dim", " bridge", Config.baseUrl())
179
- Console.log(
180
- "dim",
181
- " plugin",
182
- string.format(
183
- "v%s build %s protocol %d",
184
- Config.PLUGIN_VERSION,
185
- Config.BUILD_ID,
186
- Config.PROTOCOL_VERSION
187
- )
188
- )
189
- Console.log("dim", " theme", Themes.activeId())
190
- end,
191
- })
192
-
193
- define({
194
- name = "clients",
195
- usage = "clients",
196
- summary = "which MCP clients share this bridge",
197
- run = function()
198
- Console.reportClients()
199
- end,
200
- })
201
-
202
- define({
203
- name = "theme",
204
- usage = "theme [name]",
205
- summary = "switch colour preset, or list them",
206
- run = function(args)
207
- local wanted = args[1]
208
- if wanted == nil then
209
- local active = Themes.activeId()
210
- Console.log("info", "themes")
211
- for _, theme in Themes.list() do
212
- Console.log(
213
- if theme.id == active then "ok" else "dim",
214
- " " .. theme.id,
215
- if theme.id == active then "in use" else nil
216
- )
217
- end
218
- return
219
- end
220
-
221
- -- `Themes.use` answers false both for an unknown id and for re-picking
222
- -- the current one, so the two are separated here rather than reported as
223
- -- the same shrug.
224
- if wanted == Themes.activeId() then
225
- Console.log("dim", string.format("already on %s", wanted))
226
- return
227
- end
228
- if not Themes.use(wanted) then
229
- Console.log("error", string.format("no theme called %q", wanted), "run `theme` to list them")
230
- return
231
- end
232
-
233
- Console.applyTheme()
234
- ThemePicker.applyTheme()
235
- local current = hooks
236
- if current then
237
- current.saveTheme(wanted)
238
- end
239
- Console.log("ok", string.format("theme: %s", wanted))
240
- end,
241
- })
242
-
243
- define({
244
- name = "visuals",
245
- usage = "visuals",
246
- summary = "show or hide the activity band",
247
- run = function()
248
- local shown = Console.toggleVisuals()
249
- Console.log("dim", if shown then "visuals on" else "visuals off")
250
- end,
251
- })
252
-
253
- define({
254
- name = "autoopen",
255
- usage = "autoopen [on|off]",
256
- summary = "let this panel open itself, or only ever from the toolbar",
257
- run = function(args)
258
- local current = hooks
259
- if current == nil then
260
- return
261
- end
262
-
263
- local wanted = args[1]
264
- if wanted == nil then
265
- Console.log(
266
- "dim",
267
- if current.autoOpen()
268
- then "autoopen on -- the panel opens with the place, and with Play"
269
- else "autoopen off -- the panel opens only from the toolbar button"
270
- )
271
- return
272
- end
273
-
274
- local value: boolean
275
- if wanted == "on" then
276
- value = true
277
- elseif wanted == "off" then
278
- value = false
279
- else
280
- Console.log("error", string.format("%q is not on or off", wanted), "autoopen [on|off]")
281
- return
282
- end
283
-
284
- if value == current.autoOpen() then
285
- Console.log("dim", string.format("already autoopen %s", wanted))
286
- return
287
- end
288
-
289
- current.saveAutoOpen(value)
290
- --[[
291
- Said plainly, because nothing about it can be seen from here.
292
-
293
- A dock widget's initial state is decided when Studio creates it, and
294
- this one already exists -- so the change shows up at the next load,
295
- not now. A setting that appears to do nothing is a setting the user
296
- runs again, and then a third time, before deciding it is broken.
297
- ]]
298
- if value then
299
- Console.log("ok", "autoopen on", "the panel will come back on its own")
300
- else
301
- Console.log(
302
- "ok",
303
- "autoopen off",
304
- "from the next place or playtest, open it from the toolbar"
305
- )
306
- end
307
- end,
308
- })
309
-
310
- define({
311
- name = "place",
312
- usage = "place",
313
- summary = "what this Studio window has open",
314
- run = function()
315
- Console.log("info", if game.Name ~= "" then game.Name else "Untitled place")
316
- Console.log("dim", " placeId", tostring(game.PlaceId))
317
- Console.log("dim", " gameId", tostring(game.GameId))
318
- for _, name in { "Workspace", "ServerScriptService", "ServerStorage", "ReplicatedStorage", "StarterGui" } do
319
- local service = game:FindFirstChild(name)
320
- if service ~= nil then
321
- Console.log("dim", " " .. name, string.format("%d children", #service:GetChildren()))
322
- end
323
- end
324
- end,
325
- })
326
-
327
- --[[
328
- Every level the log can show, so `log` can be checked against a real list
329
- rather than silently accepting a typo and hiding everything.
330
- ]]
331
- local LEVELS = { "ok", "error", "warn", "info", "dim", "call", "reply" }
332
-
333
- define({
334
- name = "log",
335
- usage = "log [level|all]",
336
- summary = "filter the log by kind, or `all` for everything",
337
- run = function(args)
338
- local wanted = args[1]
339
- if wanted == nil or wanted == "all" then
340
- Console.setFilter(nil)
341
- Console.log("dim", "showing everything")
342
- return
343
- end
344
- if not table.find(LEVELS, wanted) then
345
- Console.log(
346
- "error",
347
- string.format("no level called %q", wanted),
348
- table.concat(LEVELS, " ") .. " all"
349
- )
350
- return
351
- end
352
- --[[
353
- An error filter shows warnings too.
354
-
355
- Someone typing `log error` is looking for what went wrong, and a
356
- warning is part of that answer. Filtering to the single level would
357
- hide the row that usually explains the failure below it.
358
- ]]
359
- local levels = if wanted == "error" then { "error", "warn" } else { wanted }
360
- Console.setFilter(levels)
361
- Console.log("dim", string.format("showing %s only", table.concat(levels, " and ")))
362
- end,
363
- })
364
-
365
- define({
366
- name = "copy",
367
- usage = "copy",
368
- summary = "put the log somewhere it can be selected and copied",
369
- run = function()
370
- --[[
371
- Studio gives plugins no clipboard, and a TextLabel cannot be selected.
372
-
373
- So the log goes where selection and Ctrl+C already work: a script
374
- editor tab. The rows are written as comments -- see
375
- `Console.plainText` -- so the tab opens as something to read rather
376
- than as a wall of syntax errors, and the document is opened for the
377
- user rather than merely selected, which turns a two-step
378
- "find it in Explorer, double-click it" into typing one word.
379
-
380
- Parented to ServerStorage so it can never end up in a published
381
- place, and replaced rather than accumulated so running it twice does
382
- not litter the tree.
383
- ]]
384
- local existing = ServerStorage:FindFirstChild("rbx-studio log")
385
- if existing then
386
- existing:Destroy()
387
- end
388
- local holder = Instance.new("Script")
389
- holder.Name = "rbx-studio log"
390
- holder.Source = Console.plainText()
391
- holder.Enabled = false
392
- holder.Parent = ServerStorage
393
- Selection:Set({ holder })
394
-
395
- --[[
396
- Opening is best effort, and deliberately not fatal.
397
-
398
- `OpenScriptDocumentAsync` yields and can fail -- the editor may
399
- refuse, and it is PluginSecurity, so a future Studio could withdraw
400
- it. The script exists and is selected either way, which is the part
401
- that matters; all that is lost is the convenience, so the failure is
402
- reported as the extra step it costs rather than as an error.
403
- ]]
404
- local opened = pcall(function()
405
- ScriptEditorService:OpenScriptDocumentAsync(holder)
406
- end)
407
- if opened then
408
- Console.log("ok", "log opened in a script tab", "select all and copy")
409
- else
410
- Console.log(
411
- "ok",
412
- "log written to ServerStorage",
413
- "selected -- open it from Explorer and copy"
414
- )
415
- end
416
- end,
417
- })
418
-
419
- define({
420
- name = "port",
421
- usage = "port [number]",
422
- summary = "show, or move to, the bridge port",
423
- run = function(args)
424
- local wanted = args[1]
425
- if wanted == nil then
426
- Console.log("dim", string.format("port %d", Config.getPort()))
427
- return
428
- end
429
- local value = tonumber(wanted)
430
- if value == nil or value ~= math.floor(value) or value < 1 or value > 65535 then
431
- Console.log("error", string.format("%q is not a port", wanted))
432
- return
433
- end
434
- Config.setPort(value :: number)
435
- local current = hooks
436
- if current then
437
- current.savePort(value :: number)
438
- Console.log("ok", string.format("port %d -- reconnecting", value))
439
- current.reconnect()
440
- end
441
- end,
442
- })
443
-
444
- define({
445
- name = "version",
446
- usage = "version",
447
- summary = "version, build and protocol of this plugin",
448
- run = function()
449
- Console.log(
450
- "info",
451
- string.format("rbx-studio v%s", Config.PLUGIN_VERSION),
452
- string.format("build %s protocol %d", Config.BUILD_ID, Config.PROTOCOL_VERSION)
453
- )
454
- Console.log("dim", "run doctor to compare it against the installed package")
455
- end,
456
- })
457
-
458
- -- Bridge commands ---------------------------------------------------------
459
-
460
- define({ name = "doctor", usage = "doctor", summary = "check every part of the setup", remote = true })
461
- define({ name = "studios", usage = "studios", summary = "Studio windows on this bridge", remote = true })
462
- define({ name = "use", usage = "use <number|id>", summary = "point calls at one Studio", remote = true })
463
- define({
464
- name = "agent",
465
- usage = "agent [use <id>|new]",
466
- summary = "which coding agent runs your prompts",
467
- remote = true,
468
- })
469
- define({ name = "stop", usage = "stop", summary = "cancel the running agent", remote = true })
470
-
471
- -- Dispatch ----------------------------------------------------------------
472
-
473
- --[[
474
- What a command looks like to the menu above the prompt.
475
-
476
- Deliberately the same three fields `help` prints. A user who learns the
477
- surface from the menu and a user who learns it from `help` should be
478
- learning the same thing, and two descriptions of one command are two things
479
- to keep in step.
480
- ]]
481
- export type Suggestion = {
482
- name: string,
483
- usage: string,
484
- summary: string,
485
- }
486
-
487
- --[[
488
- Commands starting with `prefix`, described.
489
-
490
- An empty prefix answers with everything, which is what the menu shows the
491
- moment the field is focused: the old row said "type a command" and then left
492
- the reader to guess which ones existed.
493
-
494
- Sorted so the list does not reorder itself between keystrokes, which is
495
- exactly the sort of movement that makes a suggestion unreadable.
496
- ]]
497
- function Commands.suggest(prefix: string): { Suggestion }
498
- local matches: { Suggestion } = {}
499
- for _, entry in entries do
500
- if string.sub(entry.name, 1, #prefix) == prefix then
501
- table.insert(matches, {
502
- name = entry.name,
503
- usage = entry.usage,
504
- summary = entry.summary,
505
- })
506
- end
507
- end
508
- table.sort(matches, function(a, b)
509
- return a.name < b.name
510
- end)
511
- return matches
512
- end
513
-
514
- --[[
515
- Command names starting with `prefix`, for Tab and for the hint.
516
-
517
- Built on `suggest` rather than beside it, so the two can never disagree
518
- about what matches.
519
- ]]
520
- function Commands.complete(prefix: string): { string }
521
- local names: { string } = {}
522
- for _, suggestion in Commands.suggest(prefix) do
523
- table.insert(names, suggestion.name)
524
- end
525
- return names
526
- end
527
-
528
- --[[
529
- The nearest command to something that is not one.
530
-
531
- Only ever a hint, and only for a single edit's distance, because the whole
532
- point of the free-text path is that an unrecognised word is usually a
533
- sentence rather than a typo. Suggesting `doctor` for "door" would be worse
534
- than saying nothing.
535
- ]]
536
- local function nearest(word: string): string?
537
- for _, entry in entries do
538
- local name = entry.name
539
- if math.abs(#name - #word) <= 1 then
540
- -- Cheap and sufficient: a shared prefix of everything but the last
541
- -- character or two catches the transpositions and dropped letters
542
- -- that actually happen at a prompt.
543
- local shared = 0
544
- while shared < math.min(#name, #word) and string.sub(name, shared + 1, shared + 1) == string.sub(word, shared + 1, shared + 1) do
545
- shared += 1
546
- end
547
- if shared >= #name - 1 and shared >= 3 then
548
- return name
549
- end
550
- end
551
- end
552
- return nil
553
- end
554
-
555
- --[[
556
- Runs one submitted line.
557
-
558
- Everything here is already off the input's thread -- `Prompt` spawns it --
559
- so a remote command is free to block on the bridge without freezing the
560
- field the user is typing into.
561
- ]]
562
- function Commands.run(line: string)
563
- local trimmed = string.match(line, "^%s*(.-)%s*$") or ""
564
- if trimmed == "" then
565
- return
566
- end
567
-
568
- -- Echoed before anything runs, so the log shows what was asked as well as
569
- -- what came back. Without it a command that prints one line looks like a
570
- -- line that appeared on its own.
571
- Console.log("call", trimmed, "you")
572
-
573
- local words: { string } = {}
574
- for word in string.gmatch(trimmed, "%S+") do
575
- table.insert(words, word)
576
- end
577
-
578
- local head = string.lower(words[1])
579
- -- `?` is the one alias, because it is what people type before they have
580
- -- learned there is a `help`.
581
- if head == "?" then
582
- head = "help"
583
- end
584
-
585
- local entry = byName[head]
586
- if entry == nil then
587
- --[[
588
- Not a command, so it is a request.
589
-
590
- A single mistyped word is the one case worth querying: it is short,
591
- it matches something closely, and sending it to an agent would spend
592
- real money answering a typo.
593
- ]]
594
- if #words == 1 then
595
- local guess = nearest(head)
596
- if guess ~= nil then
597
- Console.log("dim", string.format("no command %q -- did you mean %s?", head, guess))
598
- return
599
- end
600
- end
601
- Console.setPromptBusy(true)
602
- remote("prompt", words, trimmed)
603
- return
604
- end
605
-
606
- local args = table.move(words, 2, #words, 1, {} :: { string })
607
-
608
- if entry.remote then
609
- remote(entry.name, args, trimmed)
610
- return
611
- end
612
-
613
- local run = entry.run
614
- if run ~= nil then
615
- local ok, err = pcall(run, args)
616
- if not ok then
617
- Console.log("error", string.format("%s failed", entry.name), tostring(err))
618
- end
619
- end
620
- end
621
-
622
- return Commands
1
+ --!strict
2
+ --[[
3
+ What the console's prompt row does with a line.
4
+
5
+ Split from `Console` on purpose. The console owns a log and a widget; half of
6
+ these answers are about the transport, the port setting, the place, or the
7
+ bridge, and wiring all of that into the console to answer `status` would make
8
+ the console the whole plugin.
9
+
10
+ Two kinds of line arrive here and they are told apart by one rule: the first
11
+ word is either a command in this table or it is not. If it is, it runs here
12
+ or on the bridge and prints in milliseconds. If it is not, the whole line is
13
+ a request for an agent, and goes to the bridge to be run by whichever coding
14
+ tool the user has installed. Nothing has to be marked, quoted or prefixed --
15
+ `doctor` is a command and "make the door open when I touch it" is not, and no
16
+ person needs that explained.
17
+
18
+ Commands answered on the bridge are listed here too, with `remote = true`, so
19
+ `help` and Tab-completion describe the whole surface rather than only the
20
+ half that happens to run in Luau.
21
+ ]]
22
+
23
+ local ScriptEditorService = game:GetService("ScriptEditorService")
24
+ local Selection = game:GetService("Selection")
25
+ local ServerStorage = game:GetService("ServerStorage")
26
+
27
+ local Config = require(script.Parent.Config)
28
+ local Console = require(script.Parent.Console)
29
+ local Net = require(script.Parent.Net)
30
+ local Secret = require(script.Parent.Secret)
31
+ local ThemePicker = require(script.Parent.ThemePicker)
32
+ local Themes = require(script.Parent.Themes)
33
+
34
+ local Commands = {}
35
+
36
+ export type Hooks = {
37
+ -- Drops the connection and dials again. Owned by init.server, which is the
38
+ -- only thing holding the transport's lifecycle.
39
+ reconnect: () -> (),
40
+ -- `plugin:SetSetting` is not reachable from a ModuleScript, so anything that
41
+ -- has to outlive the session is handed back to the script that can persist it.
42
+ savePort: (number) -> (),
43
+ saveTheme: (string) -> (),
44
+ -- Whether the panel may open itself on load, and the setter for it.
45
+ autoOpen: () -> boolean,
46
+ saveAutoOpen: (boolean) -> (),
47
+ -- The transport's own view of the connection, which the console only ever
48
+ -- receives second-hand as a string to display.
49
+ status: () -> string,
50
+ studioId: () -> string,
51
+ }
52
+
53
+ local hooks: Hooks? = nil
54
+
55
+ function Commands.setup(value: Hooks)
56
+ hooks = value
57
+ end
58
+
59
+ type Entry = {
60
+ name: string,
61
+ usage: string,
62
+ summary: string,
63
+ -- Answered by the bridge rather than here. Listed all the same: a user
64
+ -- should not have to know which side of the wire a command lives on.
65
+ remote: boolean?,
66
+ run: ((args: { string }) -> ())?,
67
+ }
68
+
69
+ local entries: { Entry } = {}
70
+ local byName: { [string]: Entry } = {}
71
+
72
+ local function define(entry: Entry)
73
+ table.insert(entries, entry)
74
+ byName[entry.name] = entry
75
+ end
76
+
77
+ --[[
78
+ Sends a command to the bridge and prints whatever comes back.
79
+
80
+ Answers arrive as rows rather than as text, so the bridge decides what a
81
+ warning IS and this decides what a warning looks like. Both sides formatting
82
+ is how a diagnostic ends up rendered two different ways in one log.
83
+ ]]
84
+ local function remote(command: string, args: { string }, line: string)
85
+ local current = hooks
86
+ local response = Net.postJson("/console", {
87
+ studioId = if current ~= nil then current.studioId() else "",
88
+ command = command,
89
+ args = args,
90
+ line = line,
91
+ })
92
+
93
+ if not response.ok then
94
+ Console.setPromptBusy(false)
95
+ Console.log("error", string.format("%s failed", command), response.error)
96
+ return
97
+ end
98
+
99
+ local decoded = Net.decode(response.body)
100
+ --[[
101
+ The bridge is the authority on whether an agent is running, so the caret
102
+ follows its answer rather than what this side assumed when it sent the
103
+ line. A prompt that never started -- no harness on PATH is the common one
104
+ -- would otherwise leave the panel looking busy for the rest of the
105
+ session.
106
+ ]]
107
+ Console.setPromptBusy(typeof(decoded) == "table" and (decoded :: any).running == true)
108
+
109
+ local rows = if typeof(decoded) == "table" then (decoded :: any).lines else nil
110
+ if typeof(rows) ~= "table" then
111
+ Console.log("error", string.format("%s returned nothing readable", command))
112
+ return
113
+ end
114
+
115
+ for _, row in rows :: { any } do
116
+ if typeof(row) == "table" and typeof(row.message) == "string" then
117
+ local level = if typeof(row.level) == "string" then row.level else "dim"
118
+ Console.log(
119
+ level :: any,
120
+ row.message,
121
+ if typeof(row.detail) == "string" then row.detail else nil
122
+ )
123
+ end
124
+ end
125
+ end
126
+
127
+ -- Local commands ----------------------------------------------------------
128
+
129
+ define({
130
+ name = "help",
131
+ usage = "help",
132
+ summary = "list every command",
133
+ run = function()
134
+ Console.log("info", "commands")
135
+ for _, entry in entries do
136
+ Console.log("dim", " " .. entry.usage, entry.summary)
137
+ end
138
+ Console.log(
139
+ "dim",
140
+ " anything else",
141
+ "sent to a coding agent -- run `agent` to see which"
142
+ )
143
+ end,
144
+ })
145
+
146
+ define({
147
+ name = "clear",
148
+ usage = "clear",
149
+ summary = "empty the log",
150
+ run = function()
151
+ Console.clear()
152
+ end,
153
+ })
154
+
155
+ define({
156
+ name = "reconnect",
157
+ usage = "reconnect",
158
+ summary = "drop the connection and dial the bridge again",
159
+ run = function()
160
+ local current = hooks
161
+ if current then
162
+ current.reconnect()
163
+ end
164
+ end,
165
+ })
166
+
167
+ define({
168
+ name = "status",
169
+ usage = "status",
170
+ summary = "connection, bridge, build and theme",
171
+ run = function()
172
+ local current = hooks
173
+ Console.log("info", "session")
174
+ Console.log(
175
+ "dim",
176
+ " connection",
177
+ if current ~= nil then current.status() else "unknown"
178
+ )
179
+ Console.log("dim", " bridge", Config.baseUrl())
180
+ Console.log(
181
+ "dim",
182
+ " plugin",
183
+ string.format(
184
+ "v%s build %s protocol %d",
185
+ Config.PLUGIN_VERSION,
186
+ Config.BUILD_ID,
187
+ Config.PROTOCOL_VERSION
188
+ )
189
+ )
190
+ Console.log("dim", " theme", Themes.activeId())
191
+ end,
192
+ })
193
+
194
+ define({
195
+ name = "clients",
196
+ usage = "clients",
197
+ summary = "which MCP clients share this bridge",
198
+ run = function()
199
+ Console.reportClients()
200
+ end,
201
+ })
202
+
203
+ define({
204
+ name = "theme",
205
+ usage = "theme [name]",
206
+ summary = "switch colour preset, or list them",
207
+ run = function(args)
208
+ local wanted = args[1]
209
+ if wanted == nil then
210
+ local active = Themes.activeId()
211
+ Console.log("info", "themes")
212
+ for _, theme in Themes.list() do
213
+ Console.log(
214
+ if theme.id == active then "ok" else "dim",
215
+ " " .. theme.id,
216
+ if theme.id == active then "in use" else nil
217
+ )
218
+ end
219
+ return
220
+ end
221
+
222
+ -- `Themes.use` answers false both for an unknown id and for re-picking
223
+ -- the current one, so the two are separated here rather than reported as
224
+ -- the same shrug.
225
+ if wanted == Themes.activeId() then
226
+ Console.log("dim", string.format("already on %s", wanted))
227
+ return
228
+ end
229
+ if not Themes.use(wanted) then
230
+ Console.log("error", string.format("no theme called %q", wanted), "run `theme` to list them")
231
+ return
232
+ end
233
+
234
+ Console.applyTheme()
235
+ ThemePicker.applyTheme()
236
+ local current = hooks
237
+ if current then
238
+ current.saveTheme(wanted)
239
+ end
240
+ Console.log("ok", string.format("theme: %s", wanted))
241
+ end,
242
+ })
243
+
244
+ define({
245
+ name = "visuals",
246
+ usage = "visuals",
247
+ summary = "show or hide the activity band",
248
+ run = function()
249
+ local shown = Console.toggleVisuals()
250
+ Console.log("dim", if shown then "visuals on" else "visuals off")
251
+ end,
252
+ })
253
+
254
+ define({
255
+ name = "autoopen",
256
+ usage = "autoopen [on|off]",
257
+ summary = "let this panel open itself, or only ever from the toolbar",
258
+ run = function(args)
259
+ local current = hooks
260
+ if current == nil then
261
+ return
262
+ end
263
+
264
+ local wanted = args[1]
265
+ if wanted == nil then
266
+ Console.log(
267
+ "dim",
268
+ if current.autoOpen()
269
+ then "autoopen on -- the panel opens with the place, and with Play"
270
+ else "autoopen off -- the panel opens only from the toolbar button"
271
+ )
272
+ return
273
+ end
274
+
275
+ local value: boolean
276
+ if wanted == "on" then
277
+ value = true
278
+ elseif wanted == "off" then
279
+ value = false
280
+ else
281
+ Console.log("error", string.format("%q is not on or off", wanted), "autoopen [on|off]")
282
+ return
283
+ end
284
+
285
+ if value == current.autoOpen() then
286
+ Console.log("dim", string.format("already autoopen %s", wanted))
287
+ return
288
+ end
289
+
290
+ current.saveAutoOpen(value)
291
+ --[[
292
+ Said plainly, because nothing about it can be seen from here.
293
+
294
+ A dock widget's initial state is decided when Studio creates it, and
295
+ this one already exists -- so the change shows up at the next load,
296
+ not now. A setting that appears to do nothing is a setting the user
297
+ runs again, and then a third time, before deciding it is broken.
298
+ ]]
299
+ if value then
300
+ Console.log("ok", "autoopen on", "the panel will come back on its own")
301
+ else
302
+ Console.log(
303
+ "ok",
304
+ "autoopen off",
305
+ "from the next place or playtest, open it from the toolbar"
306
+ )
307
+ end
308
+ end,
309
+ })
310
+
311
+ define({
312
+ name = "place",
313
+ usage = "place",
314
+ summary = "what this Studio window has open",
315
+ run = function()
316
+ Console.log("info", if game.Name ~= "" then game.Name else "Untitled place")
317
+ Console.log("dim", " placeId", tostring(game.PlaceId))
318
+ Console.log("dim", " gameId", tostring(game.GameId))
319
+ for _, name in { "Workspace", "ServerScriptService", "ServerStorage", "ReplicatedStorage", "StarterGui" } do
320
+ local service = game:FindFirstChild(name)
321
+ if service ~= nil then
322
+ Console.log("dim", " " .. name, string.format("%d children", #service:GetChildren()))
323
+ end
324
+ end
325
+ end,
326
+ })
327
+
328
+ --[[
329
+ Every level the log can show, so `log` can be checked against a real list
330
+ rather than silently accepting a typo and hiding everything.
331
+ ]]
332
+ local LEVELS = { "ok", "error", "warn", "info", "dim", "call", "reply" }
333
+
334
+ define({
335
+ name = "log",
336
+ usage = "log [level|all]",
337
+ summary = "filter the log by kind, or `all` for everything",
338
+ run = function(args)
339
+ local wanted = args[1]
340
+ if wanted == nil or wanted == "all" then
341
+ Console.setFilter(nil)
342
+ Console.log("dim", "showing everything")
343
+ return
344
+ end
345
+ if not table.find(LEVELS, wanted) then
346
+ Console.log(
347
+ "error",
348
+ string.format("no level called %q", wanted),
349
+ table.concat(LEVELS, " ") .. " all"
350
+ )
351
+ return
352
+ end
353
+ --[[
354
+ An error filter shows warnings too.
355
+
356
+ Someone typing `log error` is looking for what went wrong, and a
357
+ warning is part of that answer. Filtering to the single level would
358
+ hide the row that usually explains the failure below it.
359
+ ]]
360
+ local levels = if wanted == "error" then { "error", "warn" } else { wanted }
361
+ Console.setFilter(levels)
362
+ Console.log("dim", string.format("showing %s only", table.concat(levels, " and ")))
363
+ end,
364
+ })
365
+
366
+ define({
367
+ name = "copy",
368
+ usage = "copy",
369
+ summary = "put the log somewhere it can be selected and copied",
370
+ run = function()
371
+ --[[
372
+ Studio gives plugins no clipboard, and a TextLabel cannot be selected.
373
+
374
+ So the log goes where selection and Ctrl+C already work: a script
375
+ editor tab. The rows are written as comments -- see
376
+ `Console.plainText` -- so the tab opens as something to read rather
377
+ than as a wall of syntax errors, and the document is opened for the
378
+ user rather than merely selected, which turns a two-step
379
+ "find it in Explorer, double-click it" into typing one word.
380
+
381
+ Parented to ServerStorage so it can never end up in a published
382
+ place, and replaced rather than accumulated so running it twice does
383
+ not litter the tree.
384
+ ]]
385
+ local existing = ServerStorage:FindFirstChild("rbx-studio log")
386
+ if existing then
387
+ existing:Destroy()
388
+ end
389
+ local holder = Instance.new("Script")
390
+ holder.Name = "rbx-studio log"
391
+ holder.Source = Console.plainText()
392
+ holder.Enabled = false
393
+ holder.Parent = ServerStorage
394
+ Selection:Set({ holder })
395
+
396
+ --[[
397
+ Opening is best effort, and deliberately not fatal.
398
+
399
+ `OpenScriptDocumentAsync` yields and can fail -- the editor may
400
+ refuse, and it is PluginSecurity, so a future Studio could withdraw
401
+ it. The script exists and is selected either way, which is the part
402
+ that matters; all that is lost is the convenience, so the failure is
403
+ reported as the extra step it costs rather than as an error.
404
+ ]]
405
+ local opened = pcall(function()
406
+ ScriptEditorService:OpenScriptDocumentAsync(holder)
407
+ end)
408
+ if opened then
409
+ Console.log("ok", "log opened in a script tab", "select all and copy")
410
+ else
411
+ Console.log(
412
+ "ok",
413
+ "log written to ServerStorage",
414
+ "selected -- open it from Explorer and copy"
415
+ )
416
+ end
417
+ end,
418
+ })
419
+
420
+ define({
421
+ name = "port",
422
+ usage = "port [number]",
423
+ summary = "show, or move to, the bridge port",
424
+ run = function(args)
425
+ local wanted = args[1]
426
+ if wanted == nil then
427
+ Console.log("dim", string.format("port %d", Config.getPort()))
428
+ return
429
+ end
430
+ local value = tonumber(wanted)
431
+ if value == nil or value ~= math.floor(value) or value < 1 or value > 65535 then
432
+ Console.log("error", string.format("%q is not a port", wanted))
433
+ return
434
+ end
435
+ Config.setPort(value :: number)
436
+ local current = hooks
437
+ if current then
438
+ current.savePort(value :: number)
439
+ Console.log("ok", string.format("port %d -- reconnecting", value))
440
+ current.reconnect()
441
+ end
442
+ end,
443
+ })
444
+
445
+ define({
446
+ name = "version",
447
+ usage = "version",
448
+ summary = "version, build and protocol of this plugin",
449
+ run = function()
450
+ Console.log(
451
+ "info",
452
+ string.format("rbx-studio v%s", Config.PLUGIN_VERSION),
453
+ string.format("build %s protocol %d", Config.BUILD_ID, Config.PROTOCOL_VERSION)
454
+ )
455
+ Console.log("dim", "run doctor to compare it against the installed package")
456
+ end,
457
+ })
458
+
459
+ -- Bridge commands ---------------------------------------------------------
460
+
461
+ define({ name = "doctor", usage = "doctor", summary = "check every part of the setup", remote = true })
462
+ define({ name = "studios", usage = "studios", summary = "Studio windows on this bridge", remote = true })
463
+ define({ name = "use", usage = "use <number|id>", summary = "point calls at one Studio", remote = true })
464
+ define({
465
+ name = "agent",
466
+ usage = "agent [use <id>|new]",
467
+ summary = "which coding agent runs your prompts",
468
+ remote = true,
469
+ })
470
+ define({ name = "stop", usage = "stop", summary = "cancel the running agent", remote = true })
471
+ --[[
472
+ Remote on purpose, and not only because the server does the HTTP.
473
+
474
+ The key has to live where `upload` runs, which is the Node process. Storing
475
+ it plugin-side with `plugin:SetSetting` would put a credential that can
476
+ publish under the user's name into the place file's plugin settings, where it
477
+ travels with the .rbxl -- into version control, into a team create session,
478
+ into anyone the place is shared with. It goes to a file in the user's own
479
+ home directory instead.
480
+ ]]
481
+ define({
482
+ name = "cloud",
483
+ usage = "cloud [key|user|group|universe|place|test|forget]",
484
+ summary = "the Open Cloud key `upload` uses",
485
+ remote = true,
486
+ })
487
+
488
+ -- Dispatch ----------------------------------------------------------------
489
+
490
+ --[[
491
+ What a command looks like to the menu above the prompt.
492
+
493
+ Deliberately the same three fields `help` prints. A user who learns the
494
+ surface from the menu and a user who learns it from `help` should be
495
+ learning the same thing, and two descriptions of one command are two things
496
+ to keep in step.
497
+ ]]
498
+ export type Suggestion = {
499
+ name: string,
500
+ usage: string,
501
+ summary: string,
502
+ }
503
+
504
+ --[[
505
+ Commands starting with `prefix`, described.
506
+
507
+ An empty prefix answers with everything, which is what the menu shows the
508
+ moment the field is focused: the old row said "type a command" and then left
509
+ the reader to guess which ones existed.
510
+
511
+ Sorted so the list does not reorder itself between keystrokes, which is
512
+ exactly the sort of movement that makes a suggestion unreadable.
513
+ ]]
514
+ function Commands.suggest(prefix: string): { Suggestion }
515
+ local matches: { Suggestion } = {}
516
+ for _, entry in entries do
517
+ if string.sub(entry.name, 1, #prefix) == prefix then
518
+ table.insert(matches, {
519
+ name = entry.name,
520
+ usage = entry.usage,
521
+ summary = entry.summary,
522
+ })
523
+ end
524
+ end
525
+ table.sort(matches, function(a, b)
526
+ return a.name < b.name
527
+ end)
528
+ return matches
529
+ end
530
+
531
+ --[[
532
+ Command names starting with `prefix`, for Tab and for the hint.
533
+
534
+ Built on `suggest` rather than beside it, so the two can never disagree
535
+ about what matches.
536
+ ]]
537
+ function Commands.complete(prefix: string): { string }
538
+ local names: { string } = {}
539
+ for _, suggestion in Commands.suggest(prefix) do
540
+ table.insert(names, suggestion.name)
541
+ end
542
+ return names
543
+ end
544
+
545
+ --[[
546
+ The nearest command to something that is not one.
547
+
548
+ Only ever a hint, and only for a single edit's distance, because the whole
549
+ point of the free-text path is that an unrecognised word is usually a
550
+ sentence rather than a typo. Suggesting `doctor` for "door" would be worse
551
+ than saying nothing.
552
+ ]]
553
+ local function nearest(word: string): string?
554
+ for _, entry in entries do
555
+ local name = entry.name
556
+ if math.abs(#name - #word) <= 1 then
557
+ -- Cheap and sufficient: a shared prefix of everything but the last
558
+ -- character or two catches the transpositions and dropped letters
559
+ -- that actually happen at a prompt.
560
+ local shared = 0
561
+ while shared < math.min(#name, #word) and string.sub(name, shared + 1, shared + 1) == string.sub(word, shared + 1, shared + 1) do
562
+ shared += 1
563
+ end
564
+ if shared >= #name - 1 and shared >= 3 then
565
+ return name
566
+ end
567
+ end
568
+ end
569
+ return nil
570
+ end
571
+
572
+ --[[
573
+ Runs one submitted line.
574
+
575
+ Everything here is already off the input's thread -- `Prompt` spawns it --
576
+ so a remote command is free to block on the bridge without freezing the
577
+ field the user is typing into.
578
+ ]]
579
+ function Commands.run(line: string)
580
+ local trimmed = string.match(line, "^%s*(.-)%s*$") or ""
581
+ if trimmed == "" then
582
+ return
583
+ end
584
+
585
+ --[[
586
+ Echoed before anything runs, so the log shows what was asked as well as
587
+ what came back. Without it a command that prints one line looks like a
588
+ line that appeared on its own.
589
+
590
+ Masked first. `cloud key <secret>` is the only line that carries a
591
+ credential, and the log is copied, screenshotted and scrolled through --
592
+ see `Secret`.
593
+ ]]
594
+ local shown, hidden = Secret.mask(trimmed)
595
+ Console.log("call", shown, if hidden then "you (hidden)" else "you")
596
+
597
+ local words: { string } = {}
598
+ for word in string.gmatch(trimmed, "%S+") do
599
+ table.insert(words, word)
600
+ end
601
+
602
+ local head = string.lower(words[1])
603
+ -- `?` is the one alias, because it is what people type before they have
604
+ -- learned there is a `help`.
605
+ if head == "?" then
606
+ head = "help"
607
+ end
608
+
609
+ local entry = byName[head]
610
+ if entry == nil then
611
+ --[[
612
+ Not a command, so it is a request.
613
+
614
+ A single mistyped word is the one case worth querying: it is short,
615
+ it matches something closely, and sending it to an agent would spend
616
+ real money answering a typo.
617
+ ]]
618
+ if #words == 1 then
619
+ local guess = nearest(head)
620
+ if guess ~= nil then
621
+ Console.log("dim", string.format("no command %q -- did you mean %s?", head, guess))
622
+ return
623
+ end
624
+ end
625
+ Console.setPromptBusy(true)
626
+ remote("prompt", words, trimmed)
627
+ return
628
+ end
629
+
630
+ local args = table.move(words, 2, #words, 1, {} :: { string })
631
+
632
+ if entry.remote then
633
+ remote(entry.name, args, trimmed)
634
+ return
635
+ end
636
+
637
+ local run = entry.run
638
+ if run ~= nil then
639
+ local ok, err = pcall(run, args)
640
+ if not ok then
641
+ Console.log("error", string.format("%s failed", entry.name), tostring(err))
642
+ end
643
+ end
644
+ end
645
+
646
+ return Commands