@el4cteo/rbx-studio-mcp 0.4.5 → 0.5.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.
@@ -0,0 +1,116 @@
1
+ import { z } from "zod";
2
+ import { json, text } from "../lib/format.js";
3
+ import { defineTool } from "../lib/tool.js";
4
+ export function registerTerrainTools(context) {
5
+ const { bridge } = context;
6
+ defineTool(context, {
7
+ name: "terrain",
8
+ title: "Build and edit terrain",
9
+ description: "Fills, repaints and clears Roblox terrain — hills, water, caves, roads.\n\n" +
10
+ "Terrain is not made of instances, so none of the instance tools reach " +
11
+ "it: there is nothing to `create`, no path for `find`, and no property " +
12
+ "for `modify`. This is the only way to shape it short of writing " +
13
+ "FillBall calls by hand through `execute_luau`.\n\n" +
14
+ "`fill` takes an ARRAY of solids and applies them as one undo step, " +
15
+ "which is how terrain is actually built: a hill is several overlapping " +
16
+ "balls, a road is a row of blocks. Shapes are `block` (needs `size`), " +
17
+ "`ball` (needs `radius`), `cylinder` (needs `radius` and `height`) and " +
18
+ "`wedge` (needs `size`).\n\n" +
19
+ "To CARVE, fill with material `Air`. That is not a special mode — a " +
20
+ "cave is a ball of Air inside a hill, and a tunnel is a row of them.\n\n" +
21
+ "`replace` swaps one material for another inside a region and leaves " +
22
+ "the shape alone, which is how you turn a grass hill to snow without " +
23
+ "rebuilding it. `clear` empties a region, or everything with " +
24
+ "confirm=true. `stats` says whether the place uses terrain at all " +
25
+ "-- call it first in an unfamiliar place. It cannot say WHERE the " +
26
+ "terrain is: Roblox exposes no bounding box for it, only the fixed " +
27
+ "limit. Take a `screenshot` to see the shape.\n\n" +
28
+ "Positions are the centre of the solid, in studs, as \"x, y, z\". " +
29
+ "Terrain snaps to a 4-stud voxel grid, so small features come out " +
30
+ "blockier than the numbers suggest; nothing thinner than about 4 studs " +
31
+ "survives.",
32
+ inputSchema: {
33
+ op: z
34
+ .enum(["fill", "replace", "clear", "stats"])
35
+ .default("stats")
36
+ .describe("'fill' adds solids (use material Air to carve), 'replace' swaps a " +
37
+ "material in place, 'clear' empties a region or everything, " +
38
+ "'stats' reports what is there."),
39
+ shapes: z
40
+ .array(z.object({
41
+ shape: z
42
+ .enum(["block", "ball", "cylinder", "wedge"])
43
+ .default("block")
44
+ .describe("Which solid to fill."),
45
+ position: z
46
+ .string()
47
+ .describe('Centre of the solid in studs, e.g. "0, 20, 0".'),
48
+ size: z
49
+ .string()
50
+ .optional()
51
+ .describe('block and wedge: extent in studs, e.g. "100, 20, 100".'),
52
+ radius: z.number().optional().describe("ball and cylinder: radius in studs."),
53
+ height: z.number().optional().describe("cylinder: height in studs."),
54
+ orientation: z
55
+ .string()
56
+ .optional()
57
+ .describe('Rotation in degrees, e.g. "0, 45, 0". Omit for none.'),
58
+ material: z
59
+ .string()
60
+ .default("Grass")
61
+ .describe('Terrain material — Grass, Rock, Sand, Water, Snow, Basalt, ' +
62
+ 'Mud, LeafyGrass... Use "Air" to carve out existing terrain.'),
63
+ }))
64
+ .max(100)
65
+ .optional()
66
+ .describe("fill only: the solids to apply, together, as one undo step."),
67
+ position: z
68
+ .string()
69
+ .optional()
70
+ .describe('replace and clear: centre of the region, e.g. "0, 0, 0".'),
71
+ size: z
72
+ .string()
73
+ .optional()
74
+ .describe('replace and clear: extent of the region in studs, e.g. "512, 256, 512".'),
75
+ from: z.string().optional().describe("replace only: the material to look for."),
76
+ to: z.string().optional().describe("replace only: the material to write instead."),
77
+ confirm: z
78
+ .boolean()
79
+ .optional()
80
+ .describe("clear only: required to empty ALL terrain. Omit it and give " +
81
+ "`position`/`size` to clear one region instead."),
82
+ studioId: z.string().optional().describe("Target Studio; omit for the active one."),
83
+ },
84
+ readOnly: false,
85
+ destructive: true,
86
+ }, async (args) => {
87
+ // Terrain writes are slow in a way instance writes are not: the engine
88
+ // rebuilds the voxel mesh for the whole affected volume before it
89
+ // answers, and a large region takes real seconds.
90
+ const timeoutMs = 60_000;
91
+ if (args.op === "stats") {
92
+ const response = await bridge.call("terrain.stats", {}, { studioId: args.studioId, timeoutMs });
93
+ return json(response, response.cells === 0
94
+ ? "This place has no terrain yet. `fill` a block of Grass to start one."
95
+ : undefined);
96
+ }
97
+ if (args.op === "fill") {
98
+ if (args.shapes === undefined || args.shapes.length === 0) {
99
+ return text('fill needs `shapes`, e.g. [{ shape: "ball", position: "0, 10, 0", radius: 24, material: "Grass" }].');
100
+ }
101
+ const response = await bridge.call("terrain.fill", { shapes: args.shapes }, { studioId: args.studioId, timeoutMs });
102
+ return json(response, "Terrain snaps to 4-stud voxels, so take a `screenshot` before " +
103
+ "building on top of this — the result is blockier than the numbers.");
104
+ }
105
+ if (args.op === "replace") {
106
+ if (args.from === undefined || args.to === undefined) {
107
+ return text('replace needs `from` and `to`, e.g. from="Grass" to="Snow".');
108
+ }
109
+ const response = await bridge.call("terrain.replace", { position: args.position, size: args.size, from: args.from, to: args.to }, { studioId: args.studioId, timeoutMs });
110
+ return json(response);
111
+ }
112
+ const response = await bridge.call("terrain.clear", { position: args.position, size: args.size, confirm: args.confirm }, { studioId: args.studioId, timeoutMs });
113
+ return json(response);
114
+ });
115
+ }
116
+ //# sourceMappingURL=terrain.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"terrain.js","sourceRoot":"","sources":["../../src/tools/terrain.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@el4cteo/rbx-studio-mcp",
3
- "version": "0.4.5",
4
- "description": "MCP server for Roblox Studio. 29 tools, push-based SSE bridge, editor-safe script edits, one-step undo.",
3
+ "version": "0.5.0",
4
+ "description": "MCP server for Roblox Studio. 31 tools, push-based SSE bridge, editor-safe script edits, one-step undo.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "start": "node dist/index.js",
25
25
  "build:plugin": "node scripts/check-plugin.mjs && node scripts/build-plugin.mjs",
26
26
  "sourcemap": "node scripts/sourcemap.mjs",
27
- "test": "node scripts/test-plugin.mjs && node scripts/test-transport.mjs && node scripts/test-bridge.mjs && node scripts/test-failover.mjs",
27
+ "test": "node scripts/test-plugin.mjs && node scripts/test-transport.mjs && node scripts/test-bridge.mjs && node scripts/test-console.mjs && node scripts/test-failover.mjs",
28
28
  "build:all": "npm run build && npm run build:plugin",
29
29
  "install:plugin": "node scripts/install-plugin.mjs",
30
30
  "check:plugin": "node scripts/check-plugin.mjs",
@@ -50,7 +50,7 @@
50
50
  "zod": "^4.5.4"
51
51
  },
52
52
  "devDependencies": {
53
- "@types/node": "^26.4.1",
53
+ "@types/node": "^26.5.0",
54
54
  "typescript": "^7.0.2"
55
55
  },
56
56
  "author": "EL4CTEO",
@@ -0,0 +1,524 @@
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
+ -- The transport's own view of the connection, which the console only ever
44
+ -- receives second-hand as a string to display.
45
+ status: () -> string,
46
+ studioId: () -> string,
47
+ }
48
+
49
+ local hooks: Hooks? = nil
50
+
51
+ function Commands.setup(value: Hooks)
52
+ hooks = value
53
+ end
54
+
55
+ type Entry = {
56
+ name: string,
57
+ usage: string,
58
+ summary: string,
59
+ -- Answered by the bridge rather than here. Listed all the same: a user
60
+ -- should not have to know which side of the wire a command lives on.
61
+ remote: boolean?,
62
+ run: ((args: { string }) -> ())?,
63
+ }
64
+
65
+ local entries: { Entry } = {}
66
+ local byName: { [string]: Entry } = {}
67
+
68
+ local function define(entry: Entry)
69
+ table.insert(entries, entry)
70
+ byName[entry.name] = entry
71
+ end
72
+
73
+ --[[
74
+ Sends a command to the bridge and prints whatever comes back.
75
+
76
+ Answers arrive as rows rather than as text, so the bridge decides what a
77
+ warning IS and this decides what a warning looks like. Both sides formatting
78
+ is how a diagnostic ends up rendered two different ways in one log.
79
+ ]]
80
+ local function remote(command: string, args: { string }, line: string)
81
+ local current = hooks
82
+ local response = Net.postJson("/console", {
83
+ studioId = if current ~= nil then current.studioId() else "",
84
+ command = command,
85
+ args = args,
86
+ line = line,
87
+ })
88
+
89
+ if not response.ok then
90
+ Console.setPromptBusy(false)
91
+ Console.log("error", string.format("%s failed", command), response.error)
92
+ return
93
+ end
94
+
95
+ local decoded = Net.decode(response.body)
96
+ --[[
97
+ The bridge is the authority on whether an agent is running, so the caret
98
+ follows its answer rather than what this side assumed when it sent the
99
+ line. A prompt that never started -- no harness on PATH is the common one
100
+ -- would otherwise leave the panel looking busy for the rest of the
101
+ session.
102
+ ]]
103
+ Console.setPromptBusy(typeof(decoded) == "table" and (decoded :: any).running == true)
104
+
105
+ local rows = if typeof(decoded) == "table" then (decoded :: any).lines else nil
106
+ if typeof(rows) ~= "table" then
107
+ Console.log("error", string.format("%s returned nothing readable", command))
108
+ return
109
+ end
110
+
111
+ for _, row in rows :: { any } do
112
+ if typeof(row) == "table" and typeof(row.message) == "string" then
113
+ local level = if typeof(row.level) == "string" then row.level else "dim"
114
+ Console.log(
115
+ level :: any,
116
+ row.message,
117
+ if typeof(row.detail) == "string" then row.detail else nil
118
+ )
119
+ end
120
+ end
121
+ end
122
+
123
+ -- Local commands ----------------------------------------------------------
124
+
125
+ define({
126
+ name = "help",
127
+ usage = "help",
128
+ summary = "list every command",
129
+ run = function()
130
+ Console.log("info", "commands")
131
+ for _, entry in entries do
132
+ Console.log("dim", " " .. entry.usage, entry.summary)
133
+ end
134
+ Console.log(
135
+ "dim",
136
+ " anything else",
137
+ "sent to a coding agent -- run `agent` to see which"
138
+ )
139
+ end,
140
+ })
141
+
142
+ define({
143
+ name = "clear",
144
+ usage = "clear",
145
+ summary = "empty the log",
146
+ run = function()
147
+ Console.clear()
148
+ end,
149
+ })
150
+
151
+ define({
152
+ name = "reconnect",
153
+ usage = "reconnect",
154
+ summary = "drop the connection and dial the bridge again",
155
+ run = function()
156
+ local current = hooks
157
+ if current then
158
+ current.reconnect()
159
+ end
160
+ end,
161
+ })
162
+
163
+ define({
164
+ name = "status",
165
+ usage = "status",
166
+ summary = "connection, port and build of this plugin",
167
+ run = function()
168
+ local current = hooks
169
+ Console.log("info", "session")
170
+ Console.log(
171
+ "dim",
172
+ " connection",
173
+ if current ~= nil then current.status() else "unknown"
174
+ )
175
+ Console.log("dim", " bridge", Config.baseUrl())
176
+ Console.log(
177
+ "dim",
178
+ " plugin",
179
+ string.format(
180
+ "v%s build %s protocol %d",
181
+ Config.PLUGIN_VERSION,
182
+ Config.BUILD_ID,
183
+ Config.PROTOCOL_VERSION
184
+ )
185
+ )
186
+ Console.log("dim", " theme", Themes.activeId())
187
+ end,
188
+ })
189
+
190
+ define({
191
+ name = "clients",
192
+ usage = "clients",
193
+ summary = "which MCP clients share this bridge",
194
+ run = function()
195
+ Console.reportClients()
196
+ end,
197
+ })
198
+
199
+ define({
200
+ name = "theme",
201
+ usage = "theme [name]",
202
+ summary = "switch colour preset, or list them",
203
+ run = function(args)
204
+ local wanted = args[1]
205
+ if wanted == nil then
206
+ local active = Themes.activeId()
207
+ Console.log("info", "themes")
208
+ for _, theme in Themes.list() do
209
+ Console.log(
210
+ if theme.id == active then "ok" else "dim",
211
+ " " .. theme.id,
212
+ if theme.id == active then "in use" else nil
213
+ )
214
+ end
215
+ return
216
+ end
217
+
218
+ -- `Themes.use` answers false both for an unknown id and for re-picking
219
+ -- the current one, so the two are separated here rather than reported as
220
+ -- the same shrug.
221
+ if wanted == Themes.activeId() then
222
+ Console.log("dim", string.format("already on %s", wanted))
223
+ return
224
+ end
225
+ if not Themes.use(wanted) then
226
+ Console.log("error", string.format("no theme called %q", wanted), "run `theme` to list them")
227
+ return
228
+ end
229
+
230
+ Console.applyTheme()
231
+ ThemePicker.applyTheme()
232
+ local current = hooks
233
+ if current then
234
+ current.saveTheme(wanted)
235
+ end
236
+ Console.log("ok", string.format("theme: %s", wanted))
237
+ end,
238
+ })
239
+
240
+ define({
241
+ name = "visuals",
242
+ usage = "visuals",
243
+ summary = "show or hide the activity band",
244
+ run = function()
245
+ local shown = Console.toggleVisuals()
246
+ Console.log("dim", if shown then "visuals on" else "visuals off")
247
+ end,
248
+ })
249
+
250
+ define({
251
+ name = "place",
252
+ usage = "place",
253
+ summary = "what this Studio window has open",
254
+ run = function()
255
+ Console.log("info", if game.Name ~= "" then game.Name else "Untitled place")
256
+ Console.log("dim", " placeId", tostring(game.PlaceId))
257
+ Console.log("dim", " gameId", tostring(game.GameId))
258
+ for _, name in { "Workspace", "ServerScriptService", "ServerStorage", "ReplicatedStorage", "StarterGui" } do
259
+ local service = game:FindFirstChild(name)
260
+ if service ~= nil then
261
+ Console.log("dim", " " .. name, string.format("%d children", #service:GetChildren()))
262
+ end
263
+ end
264
+ end,
265
+ })
266
+
267
+ --[[
268
+ Every level the log can show, so `log` can be checked against a real list
269
+ rather than silently accepting a typo and hiding everything.
270
+ ]]
271
+ local LEVELS = { "ok", "error", "warn", "info", "dim", "call", "reply" }
272
+
273
+ define({
274
+ name = "log",
275
+ usage = "log [level|all]",
276
+ summary = "show only one kind of row",
277
+ run = function(args)
278
+ local wanted = args[1]
279
+ if wanted == nil or wanted == "all" then
280
+ Console.setFilter(nil)
281
+ Console.log("dim", "showing everything")
282
+ return
283
+ end
284
+ if not table.find(LEVELS, wanted) then
285
+ Console.log(
286
+ "error",
287
+ string.format("no level called %q", wanted),
288
+ table.concat(LEVELS, " ") .. " all"
289
+ )
290
+ return
291
+ end
292
+ --[[
293
+ An error filter shows warnings too.
294
+
295
+ Someone typing `log error` is looking for what went wrong, and a
296
+ warning is part of that answer. Filtering to the single level would
297
+ hide the row that usually explains the failure below it.
298
+ ]]
299
+ local levels = if wanted == "error" then { "error", "warn" } else { wanted }
300
+ Console.setFilter(levels)
301
+ Console.log("dim", string.format("showing %s only", table.concat(levels, " and ")))
302
+ end,
303
+ })
304
+
305
+ define({
306
+ name = "copy",
307
+ usage = "copy",
308
+ summary = "put the log somewhere it can be selected and copied",
309
+ run = function()
310
+ --[[
311
+ Studio gives plugins no clipboard, and a TextLabel cannot be selected.
312
+
313
+ So the log goes where selection and Ctrl+C already work: a script
314
+ editor tab. The rows are written as comments -- see
315
+ `Console.plainText` -- so the tab opens as something to read rather
316
+ than as a wall of syntax errors, and the document is opened for the
317
+ user rather than merely selected, which turns a two-step
318
+ "find it in Explorer, double-click it" into typing one word.
319
+
320
+ Parented to ServerStorage so it can never end up in a published
321
+ place, and replaced rather than accumulated so running it twice does
322
+ not litter the tree.
323
+ ]]
324
+ local existing = ServerStorage:FindFirstChild("rbx-studio log")
325
+ if existing then
326
+ existing:Destroy()
327
+ end
328
+ local holder = Instance.new("Script")
329
+ holder.Name = "rbx-studio log"
330
+ holder.Source = Console.plainText()
331
+ holder.Enabled = false
332
+ holder.Parent = ServerStorage
333
+ Selection:Set({ holder })
334
+
335
+ --[[
336
+ Opening is best effort, and deliberately not fatal.
337
+
338
+ `OpenScriptDocumentAsync` yields and can fail -- the editor may
339
+ refuse, and it is PluginSecurity, so a future Studio could withdraw
340
+ it. The script exists and is selected either way, which is the part
341
+ that matters; all that is lost is the convenience, so the failure is
342
+ reported as the extra step it costs rather than as an error.
343
+ ]]
344
+ local opened = pcall(function()
345
+ ScriptEditorService:OpenScriptDocumentAsync(holder)
346
+ end)
347
+ if opened then
348
+ Console.log("ok", "log opened in a script tab", "select all and copy")
349
+ else
350
+ Console.log(
351
+ "ok",
352
+ "log written to ServerStorage",
353
+ "selected -- open it from Explorer and copy"
354
+ )
355
+ end
356
+ end,
357
+ })
358
+
359
+ define({
360
+ name = "port",
361
+ usage = "port [number]",
362
+ summary = "show, or move to, the bridge port",
363
+ run = function(args)
364
+ local wanted = args[1]
365
+ if wanted == nil then
366
+ Console.log("dim", string.format("port %d", Config.getPort()))
367
+ return
368
+ end
369
+ local value = tonumber(wanted)
370
+ if value == nil or value ~= math.floor(value) or value < 1 or value > 65535 then
371
+ Console.log("error", string.format("%q is not a port", wanted))
372
+ return
373
+ end
374
+ Config.setPort(value :: number)
375
+ local current = hooks
376
+ if current then
377
+ current.savePort(value :: number)
378
+ Console.log("ok", string.format("port %d -- reconnecting", value))
379
+ current.reconnect()
380
+ end
381
+ end,
382
+ })
383
+
384
+ define({
385
+ name = "version",
386
+ usage = "version",
387
+ summary = "what this plugin is",
388
+ run = function()
389
+ Console.log(
390
+ "info",
391
+ string.format("rbx-studio v%s", Config.PLUGIN_VERSION),
392
+ string.format("build %s protocol %d", Config.BUILD_ID, Config.PROTOCOL_VERSION)
393
+ )
394
+ Console.log("dim", "run doctor to compare it against the installed package")
395
+ end,
396
+ })
397
+
398
+ -- Bridge commands ---------------------------------------------------------
399
+
400
+ define({ name = "doctor", usage = "doctor", summary = "check every part of the setup", remote = true })
401
+ define({ name = "studios", usage = "studios", summary = "Studio windows on this bridge", remote = true })
402
+ define({ name = "use", usage = "use <number|id>", summary = "point calls at one Studio", remote = true })
403
+ define({
404
+ name = "agent",
405
+ usage = "agent [use <id>|new]",
406
+ summary = "which coding agent runs your prompts",
407
+ remote = true,
408
+ })
409
+ define({ name = "stop", usage = "stop", summary = "cancel the running agent", remote = true })
410
+
411
+ -- Dispatch ----------------------------------------------------------------
412
+
413
+ --[[
414
+ Command names starting with `prefix`, for Tab and for the hint.
415
+
416
+ Sorted so the suggestion list does not reorder itself between keystrokes,
417
+ which is exactly the sort of movement that makes a hint unreadable.
418
+ ]]
419
+ function Commands.complete(prefix: string): { string }
420
+ local matches: { string } = {}
421
+ for _, entry in entries do
422
+ if string.sub(entry.name, 1, #prefix) == prefix then
423
+ table.insert(matches, entry.name)
424
+ end
425
+ end
426
+ table.sort(matches)
427
+ return matches
428
+ end
429
+
430
+ --[[
431
+ The nearest command to something that is not one.
432
+
433
+ Only ever a hint, and only for a single edit's distance, because the whole
434
+ point of the free-text path is that an unrecognised word is usually a
435
+ sentence rather than a typo. Suggesting `doctor` for "door" would be worse
436
+ than saying nothing.
437
+ ]]
438
+ local function nearest(word: string): string?
439
+ for _, entry in entries do
440
+ local name = entry.name
441
+ if math.abs(#name - #word) <= 1 then
442
+ -- Cheap and sufficient: a shared prefix of everything but the last
443
+ -- character or two catches the transpositions and dropped letters
444
+ -- that actually happen at a prompt.
445
+ local shared = 0
446
+ while shared < math.min(#name, #word) and string.sub(name, shared + 1, shared + 1) == string.sub(word, shared + 1, shared + 1) do
447
+ shared += 1
448
+ end
449
+ if shared >= #name - 1 and shared >= 3 then
450
+ return name
451
+ end
452
+ end
453
+ end
454
+ return nil
455
+ end
456
+
457
+ --[[
458
+ Runs one submitted line.
459
+
460
+ Everything here is already off the input's thread -- `Prompt` spawns it --
461
+ so a remote command is free to block on the bridge without freezing the
462
+ field the user is typing into.
463
+ ]]
464
+ function Commands.run(line: string)
465
+ local trimmed = string.match(line, "^%s*(.-)%s*$") or ""
466
+ if trimmed == "" then
467
+ return
468
+ end
469
+
470
+ -- Echoed before anything runs, so the log shows what was asked as well as
471
+ -- what came back. Without it a command that prints one line looks like a
472
+ -- line that appeared on its own.
473
+ Console.log("call", trimmed, "you")
474
+
475
+ local words: { string } = {}
476
+ for word in string.gmatch(trimmed, "%S+") do
477
+ table.insert(words, word)
478
+ end
479
+
480
+ local head = string.lower(words[1])
481
+ -- `?` is the one alias, because it is what people type before they have
482
+ -- learned there is a `help`.
483
+ if head == "?" then
484
+ head = "help"
485
+ end
486
+
487
+ local entry = byName[head]
488
+ if entry == nil then
489
+ --[[
490
+ Not a command, so it is a request.
491
+
492
+ A single mistyped word is the one case worth querying: it is short,
493
+ it matches something closely, and sending it to an agent would spend
494
+ real money answering a typo.
495
+ ]]
496
+ if #words == 1 then
497
+ local guess = nearest(head)
498
+ if guess ~= nil then
499
+ Console.log("dim", string.format("no command %q -- did you mean %s?", head, guess))
500
+ return
501
+ end
502
+ end
503
+ Console.setPromptBusy(true)
504
+ remote("prompt", words, trimmed)
505
+ return
506
+ end
507
+
508
+ local args = table.move(words, 2, #words, 1, {} :: { string })
509
+
510
+ if entry.remote then
511
+ remote(entry.name, args, trimmed)
512
+ return
513
+ end
514
+
515
+ local run = entry.run
516
+ if run ~= nil then
517
+ local ok, err = pcall(run, args)
518
+ if not ok then
519
+ Console.log("error", string.format("%s failed", entry.name), tostring(err))
520
+ end
521
+ end
522
+ end
523
+
524
+ return Commands
@@ -9,7 +9,7 @@
9
9
 
10
10
  local Config = {}
11
11
 
12
- Config.PLUGIN_VERSION = "0.4.5"
12
+ Config.PLUGIN_VERSION = "0.5.0"
13
13
 
14
14
  -- Fingerprint of plugin/src, stamped in by scripts/build-plugin.mjs. The server
15
15
  -- computes the same hash from its own copy of the sources and compares, so a