@el4cteo/rbx-studio-mcp 0.6.1 → 0.6.7

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 (75) 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 +8 -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 +235 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/anim.js +159 -0
  19. package/dist/tools/anim.js.map +1 -0
  20. package/dist/tools/audio.js +96 -0
  21. package/dist/tools/audio.js.map +1 -0
  22. package/dist/tools/character.js +95 -5
  23. package/dist/tools/character.js.map +1 -1
  24. package/dist/tools/data.js +292 -0
  25. package/dist/tools/data.js.map +1 -0
  26. package/dist/tools/device.js +77 -7
  27. package/dist/tools/device.js.map +1 -1
  28. package/dist/tools/discover.js +80 -4
  29. package/dist/tools/discover.js.map +1 -1
  30. package/dist/tools/exec.js +96 -2
  31. package/dist/tools/exec.js.map +1 -1
  32. package/dist/tools/input.js +35 -9
  33. package/dist/tools/input.js.map +1 -1
  34. package/dist/tools/perf.js +74 -7
  35. package/dist/tools/perf.js.map +1 -1
  36. package/dist/tools/scripts.js +162 -6
  37. package/dist/tools/scripts.js.map +1 -1
  38. package/dist/tools/spatial.js +135 -0
  39. package/dist/tools/spatial.js.map +1 -0
  40. package/dist/tools/universe.js +177 -0
  41. package/dist/tools/universe.js.map +1 -0
  42. package/dist/tools/upload.js +294 -0
  43. package/dist/tools/upload.js.map +1 -0
  44. package/dist/tools/world.js +675 -51
  45. package/dist/tools/world.js.map +1 -1
  46. package/package.json +2 -2
  47. package/plugin/src/Commands.luau +31 -7
  48. package/plugin/src/Config.luau +65 -65
  49. package/plugin/src/Console.luau +1909 -1843
  50. package/plugin/src/Emulation.luau +172 -0
  51. package/plugin/src/Phrase.luau +816 -618
  52. package/plugin/src/Png.luau +8 -4
  53. package/plugin/src/Prompt.luau +965 -961
  54. package/plugin/src/Secret.luau +86 -0
  55. package/plugin/src/Serialize.luau +440 -8
  56. package/plugin/src/Undo.luau +94 -6
  57. package/plugin/src/handlers/Anim.luau +897 -0
  58. package/plugin/src/handlers/Assets.luau +286 -2
  59. package/plugin/src/handlers/Audio.luau +411 -0
  60. package/plugin/src/handlers/Capture.luau +155 -20
  61. package/plugin/src/handlers/Character.luau +823 -361
  62. package/plugin/src/handlers/Data.luau +539 -0
  63. package/plugin/src/handlers/Device.luau +394 -139
  64. package/plugin/src/handlers/Discover.luau +685 -363
  65. package/plugin/src/handlers/Geometry.luau +722 -450
  66. package/plugin/src/handlers/Instances.luau +84 -4
  67. package/plugin/src/handlers/Perf.luau +227 -0
  68. package/plugin/src/handlers/Scripts.luau +673 -539
  69. package/plugin/src/handlers/Session.luau +3 -0
  70. package/plugin/src/handlers/Spatial.luau +334 -0
  71. package/plugin/src/handlers/Viewport.luau +268 -0
  72. package/plugin/src/handlers/World.luau +89 -15
  73. package/plugin/src/init.server.luau +9 -1
  74. package/scripts/build-plugin.mjs +20 -0
  75. package/scripts/check-plugin.mjs +171 -124
@@ -1,539 +1,673 @@
1
- --!strict
2
- --[[
3
- Script reading, searching, editing and creation.
4
-
5
- Everything here that writes goes through `ScriptEdit`, which routes the change
6
- through `ScriptEditorService:UpdateSourceAsync` rather than assigning
7
- `script.Source`. That is the difference between an edit the Studio editor
8
- agrees with and one that silently loses whatever the user had typed but not
9
- saved. Reads use the editor buffer for the same reason: handing an agent stale
10
- source makes it "fix" changes the user just made.
11
-
12
- Edits are also wrapped in a single `Undo` recording, so a batch across ten
13
- scripts is one Ctrl+Z, and a batch that fails half way is rolled back rather
14
- than left half applied.
15
-
16
- The text manipulation itself lives in `TextEdit`, which has no Roblox
17
- dependencies and is unit tested.
18
- ]]
19
-
20
- local Dispatch = require(script.Parent.Parent.Dispatch)
21
- local Paths = require(script.Parent.Parent.Paths)
22
- local Scope = require(script.Parent.Parent.Scope)
23
- local ScriptEdit = require(script.Parent.Parent.ScriptEdit)
24
- local TextEdit = require(script.Parent.Parent.TextEdit)
25
- local Undo = require(script.Parent.Parent.Undo)
26
-
27
- -- Reading every script's editor buffer is a service call each. A place with more
28
- -- scripts than this is better served by narrowing `path` than by a slow grep
29
- -- that blocks Studio's main thread.
30
- local MAX_SCRIPTS = 3_000
31
- local MAX_MATCHES = 500
32
- local DEFAULT_CONTEXT = 0
33
-
34
- local CREATABLE = {
35
- Script = true,
36
- LocalScript = true,
37
- ModuleScript = true,
38
- }
39
-
40
- local Scripts = {}
41
-
42
- --[[
43
- Resolves a path and insists it holds Luau. Pointing a script tool at an
44
- ordinary instance otherwise fails later with a confusing property error.
45
- ]]
46
- local function resolveScript(path: string): LuaSourceContainer
47
- local instance = Paths.resolve(path)
48
- if not ScriptEdit.isScript(instance) then
49
- Dispatch.fail(
50
- "NOT_A_SCRIPT",
51
- string.format('"%s" is a %s, not a script.', path, instance.ClassName),
52
- "Script tools accept Script, LocalScript and ModuleScript. Use `inspect` "
53
- .. "for other instances, or `find` with className LuaSourceContainer to locate scripts."
54
- )
55
- end
56
- return instance :: LuaSourceContainer
57
- end
58
-
59
- --[[
60
- Reads source, optionally a line window. `startLine`/`endLine` are 1-based and
61
- inclusive, matching the numbers script_edit takes back, so a read and a write
62
- need no off-by-one conversion between them.
63
- ]]
64
- --[[
65
- A short fingerprint of a script's source, for detecting that it moved.
66
-
67
- Handed out by `read` and passed back to `edit`, which refuses to write when
68
- the live source no longer matches. That is the only thing standing between
69
- two agents on one place and a silent overwrite: a line-range edit computed
70
- against source somebody has since changed still applies cleanly, it just
71
- applies to the wrong lines, and nothing anywhere reports it.
72
-
73
- FNV-1a over the whole string, with the length appended. Not a security
74
- hash and does not need to be -- it is guarding against ordinary concurrent
75
- editing, not against someone constructing a collision. The length is there
76
- because it is free and rules out the whole class of same-length accidents.
77
-
78
- The multiply is split into 16-bit halves on purpose. `hash * 16777619` with
79
- a 32-bit hash reaches 2^56, past the 2^53 where doubles stop being exact,
80
- so the low bits -- the ones that carry the mixing -- would be quietly
81
- rounded away.
82
- ]]
83
- local function fingerprint(source: string): string
84
- local hash = 2166136261
85
- local length = #source
86
- local index = 1
87
- while index <= length do
88
- local last = math.min(index + 511, length)
89
- local chunk = { string.byte(source, index, last) }
90
- for _, byte in chunk do
91
- hash = bit32.bxor(hash, byte)
92
- local low = bit32.band(hash, 0xFFFF)
93
- local high = bit32.rshift(hash, 16)
94
- -- 16777619 == 0x01000193, so 0x0193 is 403 and 0x0100 is 256.
95
- hash = bit32.band(low * 403 + bit32.lshift(bit32.band(high * 403 + low * 256, 0xFFFF), 16), 0xFFFFFFFF)
96
- end
97
- index = last + 1
98
- end
99
- return string.format("%08x-%x", hash, length)
100
- end
101
-
102
- function Scripts.read(params: { [string]: any }): { [string]: any }
103
- local paths = params.paths
104
- if typeof(paths) ~= "table" or #paths == 0 then
105
- Dispatch.fail(
106
- "BAD_PARAMS",
107
- "script_read requires a non-empty `paths` array.",
108
- "Use `find` with className LuaSourceContainer to locate scripts."
109
- )
110
- end
111
-
112
- local items: { { [string]: any } } = {}
113
- local failures: { string } = {}
114
- local memo: Paths.NameIndex = {}
115
-
116
- --[[
117
- Each entry may carry its own line window, because the shape that actually
118
- comes up is "line 40 of this one, line 300 of that one" -- and a single
119
- range shared across the whole batch forced one call per script, which is
120
- what batching this tool was for in the first place.
121
-
122
- A plain string still means the whole file, or the batch-wide range when
123
- one was given.
124
- ]]
125
- for _, entry in paths do
126
- local windowed = typeof(entry) == "table"
127
- local path = if windowed then entry.path else entry
128
- local ok, resolved = pcall(resolveScript, path)
129
- if not ok then
130
- local err = resolved :: any
131
- local reason = if typeof(err) == "table"
132
- then (if err.hint then err.message .. " " .. err.hint else err.message)
133
- else tostring(err)
134
- table.insert(failures, string.format("%s: %s", tostring(path), reason))
135
- continue
136
- end
137
-
138
- local target = resolved :: LuaSourceContainer
139
- local whole = ScriptEdit.read(target)
140
- local lines = TextEdit.toLines(whole)
141
- local askedStart = if windowed and entry.startLine ~= nil
142
- then tonumber(entry.startLine)
143
- else tonumber(params.startLine)
144
- local askedEnd = if windowed and entry.endLine ~= nil
145
- then tonumber(entry.endLine)
146
- else tonumber(params.endLine)
147
- local startLine = math.max(askedStart or 1, 1)
148
- local endLine = math.min(askedEnd or #lines, #lines)
149
-
150
- local window: { string } = {}
151
- table.move(lines, startLine, endLine, 1, window)
152
-
153
- table.insert(items, {
154
- path = Paths.of(target, memo),
155
- className = target.ClassName,
156
- lineCount = #lines,
157
- startLine = startLine,
158
- endLine = askedEnd,
159
- source = table.concat(window, "\n"),
160
- -- Of the whole file, never of the window: `edit` compares it against
161
- -- the live source, and a fingerprint of forty lines out of four
162
- -- hundred would call an edit safe that it is not.
163
- revision = fingerprint(whole),
164
- })
165
- end
166
-
167
- return { items = items, failures = failures }
168
- end
169
-
170
- --[[
171
- Applies every edit in a batch, or none of them.
172
-
173
- Atomicity here cannot come from ChangeHistoryService. A recording captures
174
- instance changes, but `UpdateSourceAsync` goes through the script editor's own
175
- per-document history, so cancelling a recording leaves an already-written
176
- script edited -- measured, not assumed. Wrapping this in `Undo.record` would
177
- therefore promise a rollback that never happens.
178
-
179
- So the batch is a two-phase commit instead. Phase one reads and transforms
180
- every script without writing anything, which is where essentially all failures
181
- live: a missing `find`, an ambiguous one, a bad line range, conflicting edits.
182
- Phase two writes the finished text. If a write fails there -- realistically
183
- only a locked or package-owned script -- the scripts already written are
184
- restored from the source captured in phase one.
185
- ]]
186
- function Scripts.edit(params: { [string]: any }): { [string]: any }
187
- local edits = params.edits
188
- if typeof(edits) ~= "table" or #edits == 0 then
189
- Dispatch.fail(
190
- "BAD_PARAMS",
191
- "script_edit requires a non-empty `edits` array.",
192
- "Each edit needs a `path` plus one of `find`/`replace`, "
193
- .. "`startLine`/`replacement`, or `source`."
194
- )
195
- end
196
-
197
- local order: { LuaSourceContainer } = {}
198
- local grouped: { [Instance]: { TextEdit.Edit } } = {}
199
- for position, edit in edits do
200
- TextEdit.validate(edit, position)
201
- local target = resolveScript(edit.path)
202
- local bucket = grouped[target]
203
- if not bucket then
204
- bucket = {}
205
- grouped[target] = bucket
206
- table.insert(order, target)
207
- end
208
- table.insert(bucket :: { TextEdit.Edit }, edit)
209
- end
210
-
211
- -- Phase one: transform everything in memory. Any failure raises here, with
212
- -- nothing written and the place untouched.
213
- type Pending = { target: LuaSourceContainer, before: string, after: string }
214
- local pending: { Pending } = {}
215
- for _, target in order do
216
- local before = ScriptEdit.read(target)
217
- --[[
218
- Refuse before transforming, not after.
219
-
220
- An edit that names the revision it was written against is asking to
221
- be applied to that exact text. If the file has moved on, the honest
222
- answer is to stop: a line range still applies cleanly to changed
223
- source, it just lands on the wrong lines, and a `source` edit throws
224
- away everything written since it was read. Both look like success.
225
-
226
- Checked here so the whole batch fails with nothing written, which is
227
- the promise every other phase-one failure already makes.
228
- ]]
229
- local live: string? = nil
230
- for _, edit in grouped[target] :: { TextEdit.Edit } do
231
- local stated = (edit :: any).revision
232
- if typeof(stated) ~= "string" or stated == "" then
233
- continue
234
- end
235
- live = live or fingerprint(before)
236
- if stated ~= live then
237
- Dispatch.fail(
238
- "STALE_SCRIPT",
239
- string.format(
240
- "%s changed since it was read (expected %s, found %s).",
241
- target:GetFullName(),
242
- stated,
243
- live :: string
244
- ),
245
- "Somebody else edited it -- another agent, or the user typing in the "
246
- .. "editor. Read it again with script_read and rebuild the edit "
247
- .. "against what is there now."
248
- )
249
- end
250
- end
251
-
252
- local after = TextEdit.apply(target:GetFullName(), before, grouped[target] :: { TextEdit.Edit })
253
- table.insert(pending, { target = target, before = before, after = after })
254
- end
255
-
256
- -- Phase two: write. `written` is the compensation log for a mid-batch failure.
257
- local written: { Pending } = {}
258
- local memo: Paths.NameIndex = {}
259
- local results: { { [string]: any } } = {}
260
-
261
- for _, entry in pending do
262
- -- Wrapped in a closure rather than passed to pcall directly: `write`
263
- -- returns nothing, and pcall's typed signature expects a value back.
264
- local ok, err = pcall(function()
265
- ScriptEdit.write(entry.target, function()
266
- return entry.after
267
- end)
268
- end)
269
-
270
- if not ok then
271
- for index = #written, 1, -1 do
272
- local done = written[index]
273
- -- Best effort: a restore that fails leaves that script edited, and
274
- -- the original error still describes what actually went wrong.
275
- pcall(function()
276
- ScriptEdit.write(done.target, function()
277
- return done.before
278
- end)
279
- end)
280
- end
281
- error(err, 0)
282
- end
283
-
284
- table.insert(written, entry)
285
- table.insert(results, {
286
- path = Paths.of(entry.target, memo),
287
- className = entry.target.ClassName,
288
- edits = #(grouped[entry.target] :: { TextEdit.Edit }),
289
- lineCount = #TextEdit.toLines(entry.after),
290
- lineDelta = #TextEdit.toLines(entry.after) - #TextEdit.toLines(entry.before),
291
- })
292
- end
293
-
294
- return { items = results }
295
- end
296
-
297
- --[[
298
- Searches script source. Matches come from the editor buffer, so text the user
299
- has typed but not saved is found too -- which is the state an agent about to
300
- edit the file actually needs to see.
301
- ]]
302
- function Scripts.grep(params: { [string]: any }): { [string]: any }
303
- local pattern = params.pattern
304
- if typeof(pattern) ~= "string" or pattern == "" then
305
- Dispatch.fail("BAD_PARAMS", "script_grep requires a `pattern`.")
306
- end
307
-
308
- local root = if params.path then Paths.resolve(params.path) else game
309
- local literal = params.literal == true
310
- local ignoreCase = params.ignoreCase == true
311
- local contextLines = math.clamp(tonumber(params.contextLines) or DEFAULT_CONTEXT, 0, 10)
312
- local limit = math.min(tonumber(params.limit) or 100, MAX_MATCHES)
313
- local offset = tonumber(params.offset) or 0
314
- local classFilter = params.className
315
-
316
- local needle = if ignoreCase then string.lower(pattern) else pattern
317
-
318
- local targets: { LuaSourceContainer } = {}
319
- for _, instance in root:GetDescendants() do
320
- if not instance:IsA("LuaSourceContainer") then
321
- continue
322
- end
323
- if root == game and Scope.isNoisy(instance) then
324
- continue
325
- end
326
- if classFilter and not instance:IsA(classFilter) then
327
- continue
328
- end
329
- table.insert(targets, instance)
330
- end
331
-
332
- if #targets > MAX_SCRIPTS then
333
- Dispatch.fail(
334
- "TOO_BROAD",
335
- string.format("That search covers %d scripts, over the %d limit.", #targets, MAX_SCRIPTS),
336
- "Narrow it with `path` to search one service or folder instead of the whole place."
337
- )
338
- end
339
-
340
- local matches: { { [string]: any } } = {}
341
- local total = 0
342
- local memo: Paths.NameIndex = {}
343
-
344
- for _, target in targets do
345
- local lines = TextEdit.toLines(ScriptEdit.read(target))
346
- local path: string? = nil
347
-
348
- for number, line in lines do
349
- local haystack = if ignoreCase then string.lower(line) else line
350
- -- An invalid Lua pattern raises rather than simply not matching, so it
351
- -- has to be caught and reported as a pattern problem, not a no-match.
352
- local ok, from = pcall(string.find, haystack, needle, 1, literal)
353
- if not ok then
354
- Dispatch.fail(
355
- "BAD_PATTERN",
356
- string.format("%s is not a valid Lua pattern: %s", pattern, tostring(from)),
357
- "Lua patterns escape with %, not backslash, and have no alternation. "
358
- .. "Set `literal` to search for the text exactly as written."
359
- )
360
- end
361
- if not from then
362
- continue
363
- end
364
-
365
- total += 1
366
- if total <= offset or #matches >= limit then
367
- continue
368
- end
369
-
370
- if not path then
371
- path = Paths.of(target, memo)
372
- end
373
-
374
- local entry: { [string]: any } = {
375
- path = path,
376
- line = number,
377
- text = line,
378
- }
379
- if contextLines > 0 then
380
- local before: { string } = {}
381
- local after: { string } = {}
382
- table.move(lines, math.max(number - contextLines, 1), number - 1, 1, before)
383
- table.move(lines, number + 1, math.min(number + contextLines, #lines), 1, after)
384
- entry.before = before
385
- entry.after = after
386
- end
387
- table.insert(matches, entry)
388
- end
389
- end
390
-
391
- return {
392
- items = matches,
393
- total = total,
394
- offset = offset,
395
- searched = #targets,
396
- }
397
- end
398
-
399
- --[[
400
- Creates scripts. Source is assigned directly here rather than through
401
- `UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
402
- open in the editor and there is no buffer to conflict with. Every later edit
403
- goes through the editor path.
404
- ]]
405
- --[[
406
- Names the starter container a script was just parented into, or nil.
407
-
408
- These four are copied into the player rather than run where they sit, so a
409
- `Script` with a non-Legacy RunContext inside one runs BOTH in the original
410
- and in every copy. Roblox does warn about it -- "will cause it to run
411
- multiple times" -- but that warning is emitted by Studio itself and never
412
- reaches `console`, so an agent following the "prefer Script with runContext
413
- Client over LocalScript" advice writes a double-running script and is given
414
- no way to find out.
415
- ]]
416
- local STARTER_CONTAINERS = {
417
- "StarterGui",
418
- "StarterPack",
419
- "StarterPlayerScripts",
420
- "StarterCharacterScripts",
421
- }
422
-
423
- local function starterContainer(instance: Instance): string?
424
- for _, className in STARTER_CONTAINERS do
425
- if instance:FindFirstAncestorOfClass(className :: any) then
426
- return className
427
- end
428
- end
429
- return nil
430
- end
431
-
432
- function Scripts.create(params: { [string]: any }): { [string]: any }
433
- local requests = params.scripts
434
- if typeof(requests) ~= "table" or #requests == 0 then
435
- Dispatch.fail(
436
- "BAD_PARAMS",
437
- "script_create requires a non-empty `scripts` array.",
438
- "Each entry needs `parent`, `name` and `className`."
439
- )
440
- end
441
-
442
- for position, request in requests do
443
- if typeof(request.name) ~= "string" or request.name == "" then
444
- Dispatch.fail("BAD_PARAMS", string.format("scripts[%d] has no `name`.", position))
445
- end
446
- if not CREATABLE[request.className] then
447
- Dispatch.fail(
448
- "BAD_PARAMS",
449
- string.format('scripts[%d] has className "%s".', position, tostring(request.className)),
450
- "Use Script, LocalScript or ModuleScript. Prefer a Script with "
451
- .. "runContext Client over LocalScript in new work -- except inside "
452
- .. "StarterGui, StarterPack, StarterPlayerScripts or "
453
- .. "StarterCharacterScripts, where LocalScript is still the right "
454
- .. "class."
455
- )
456
- end
457
- end
458
-
459
- -- No shared path memo here: each creation changes its parent's children, so a
460
- -- cached sibling grouping would go stale mid-batch and mis-number the paths.
461
- local warnings: { string } = {}
462
-
463
- local created, recorded = Undo.record("StudioMCP.ScriptCreate", "MCP create script", function()
464
- local created: { { [string]: any } } = {}
465
-
466
- for _, request in requests do
467
- local parent = Paths.resolve(request.parent)
468
- local instance = Instance.new(request.className) :: LuaSourceContainer
469
-
470
- instance.Name = request.name
471
- if typeof(request.source) == "string" then
472
- (instance :: ScriptEdit.SourceContainer).Source = request.source
473
- end
474
-
475
- if typeof(request.runContext) == "string" and instance:IsA("Script") then
476
- local ok, runContext = pcall(function()
477
- return (Enum.RunContext :: any)[request.runContext]
478
- end)
479
- if not ok or runContext == nil then
480
- Dispatch.fail(
481
- "BAD_PARAMS",
482
- string.format('"%s" is not a RunContext.', tostring(request.runContext)),
483
- "Use Legacy, Server or Client."
484
- )
485
- end
486
- instance.RunContext = runContext
487
- end
488
- if request.disabled == true and instance:IsA("BaseScript") then
489
- instance.Disabled = true
490
- end
491
-
492
- instance.Parent = parent
493
-
494
- if instance:IsA("Script") and instance.RunContext ~= Enum.RunContext.Legacy then
495
- local container = starterContainer(instance)
496
- if container then
497
- table.insert(
498
- warnings,
499
- string.format(
500
- '%s is a Script with RunContext %s inside %s. That container is '
501
- .. "COPIED into each player, so the script runs once where it "
502
- .. "sits and again in every copy. Make it a LocalScript "
503
- .. "instead -- a Legacy Script there would not run at all. "
504
- .. "Studio warns about this in its own Output, which `console` "
505
- .. "cannot read.",
506
- instance.Name,
507
- instance.RunContext.Name,
508
- container
509
- )
510
- )
511
- end
512
- end
513
-
514
- table.insert(created, {
515
- path = Paths.of(instance),
516
- className = instance.ClassName,
517
- })
518
- end
519
-
520
- return created
521
- end)
522
-
523
- return {
524
- items = created,
525
- undoStep = if recorded then "MCP create script" else nil,
526
- warnings = if #warnings > 0 then warnings else nil,
527
- }
528
- end
529
-
530
- function Scripts.register()
531
- Dispatch.registerAll("script", {
532
- read = Scripts.read,
533
- edit = Scripts.edit,
534
- grep = Scripts.grep,
535
- create = Scripts.create,
536
- })
537
- end
538
-
539
- return Scripts
1
+ --!strict
2
+ --[[
3
+ Script reading, searching, editing and creation.
4
+
5
+ Everything here that writes goes through `ScriptEdit`, which routes the change
6
+ through `ScriptEditorService:UpdateSourceAsync` rather than assigning
7
+ `script.Source`. That is the difference between an edit the Studio editor
8
+ agrees with and one that silently loses whatever the user had typed but not
9
+ saved. Reads use the editor buffer for the same reason: handing an agent stale
10
+ source makes it "fix" changes the user just made.
11
+
12
+ Edits are also wrapped in a single `Undo` recording, so a batch across ten
13
+ scripts is one Ctrl+Z, and a batch that fails half way is rolled back rather
14
+ than left half applied.
15
+
16
+ The text manipulation itself lives in `TextEdit`, which has no Roblox
17
+ dependencies and is unit tested.
18
+ ]]
19
+
20
+ local Dispatch = require(script.Parent.Parent.Dispatch)
21
+ local Paths = require(script.Parent.Parent.Paths)
22
+ local Scope = require(script.Parent.Parent.Scope)
23
+ local ScriptEdit = require(script.Parent.Parent.ScriptEdit)
24
+ local TextEdit = require(script.Parent.Parent.TextEdit)
25
+ local Undo = require(script.Parent.Parent.Undo)
26
+
27
+ -- Reading every script's editor buffer is a service call each. A place with more
28
+ -- scripts than this is better served by narrowing `path` than by a slow grep
29
+ -- that blocks Studio's main thread.
30
+ local MAX_SCRIPTS = 3_000
31
+ local MAX_MATCHES = 500
32
+ local DEFAULT_CONTEXT = 0
33
+
34
+ local CREATABLE = {
35
+ Script = true,
36
+ LocalScript = true,
37
+ ModuleScript = true,
38
+ }
39
+
40
+ local Scripts = {}
41
+
42
+ --[[
43
+ Resolves a path and insists it holds Luau. Pointing a script tool at an
44
+ ordinary instance otherwise fails later with a confusing property error.
45
+ ]]
46
+ local function resolveScript(path: string): LuaSourceContainer
47
+ local instance = Paths.resolve(path)
48
+ if not ScriptEdit.isScript(instance) then
49
+ Dispatch.fail(
50
+ "NOT_A_SCRIPT",
51
+ string.format('"%s" is a %s, not a script.', path, instance.ClassName),
52
+ "Script tools accept Script, LocalScript and ModuleScript. Use `inspect` "
53
+ .. "for other instances, or `find` with className LuaSourceContainer to locate scripts."
54
+ )
55
+ end
56
+ return instance :: LuaSourceContainer
57
+ end
58
+
59
+ --[[
60
+ Reads source, optionally a line window. `startLine`/`endLine` are 1-based and
61
+ inclusive, matching the numbers script_edit takes back, so a read and a write
62
+ need no off-by-one conversion between them.
63
+ ]]
64
+ --[[
65
+ A short fingerprint of a script's source, for detecting that it moved.
66
+
67
+ Handed out by `read` and passed back to `edit`, which refuses to write when
68
+ the live source no longer matches. That is the only thing standing between
69
+ two agents on one place and a silent overwrite: a line-range edit computed
70
+ against source somebody has since changed still applies cleanly, it just
71
+ applies to the wrong lines, and nothing anywhere reports it.
72
+
73
+ FNV-1a over the whole string, with the length appended. Not a security
74
+ hash and does not need to be -- it is guarding against ordinary concurrent
75
+ editing, not against someone constructing a collision. The length is there
76
+ because it is free and rules out the whole class of same-length accidents.
77
+
78
+ The multiply is split into 16-bit halves on purpose. `hash * 16777619` with
79
+ a 32-bit hash reaches 2^56, past the 2^53 where doubles stop being exact,
80
+ so the low bits -- the ones that carry the mixing -- would be quietly
81
+ rounded away.
82
+ ]]
83
+ local function fingerprint(source: string): string
84
+ local hash = 2166136261
85
+ local length = #source
86
+ local index = 1
87
+ while index <= length do
88
+ local last = math.min(index + 511, length)
89
+ local chunk = { string.byte(source, index, last) }
90
+ for _, byte in chunk do
91
+ hash = bit32.bxor(hash, byte)
92
+ local low = bit32.band(hash, 0xFFFF)
93
+ local high = bit32.rshift(hash, 16)
94
+ -- 16777619 == 0x01000193, so 0x0193 is 403 and 0x0100 is 256.
95
+ hash = bit32.band(low * 403 + bit32.lshift(bit32.band(high * 403 + low * 256, 0xFFFF), 16), 0xFFFFFFFF)
96
+ end
97
+ index = last + 1
98
+ end
99
+ return string.format("%08x-%x", hash, length)
100
+ end
101
+
102
+ --[[
103
+ Whether a script is really the source of truth, or a copy of a file on disk.
104
+
105
+ Studio can bind a script to a file outside it -- the external-editor
106
+ workflows, and the Auto-Reimport beta. When that binding exists, whatever is
107
+ written here is overwritten the next time the file changes, and the write
108
+ looks like it worked right up until it silently does not. That failure is
109
+ invisible from inside the data model: the script has a Source property like
110
+ any other.
111
+
112
+ `InstanceFileSyncService` knows, and says so at plugin identity. It is read
113
+ only -- there is no call here that starts, stops or redirects a sync -- so
114
+ the worst this can do is add a sentence to a reply.
115
+
116
+ Guarded end to end, and deliberately silent when unavailable: a Studio
117
+ without the service, or a place with no sync set up, should cost this note
118
+ and nothing else.
119
+ ]]
120
+ local function syncedFile(target: Instance): string?
121
+ local service = game:FindService("InstanceFileSyncService")
122
+ if service == nil then
123
+ return nil
124
+ end
125
+ local ok, status = pcall(function()
126
+ return (service :: any):GetStatus(target)
127
+ end)
128
+ if not ok or status == nil then
129
+ return nil
130
+ end
131
+ local name = tostring(status):gsub("Enum%.InstanceFileSyncStatus%.", "")
132
+ -- "NotSynced" is the ordinary case and saying it every time would be noise.
133
+ if name == "NotSynced" or name == "Unknown" or name == "nil" then
134
+ return nil
135
+ end
136
+ return name
137
+ end
138
+
139
+ function Scripts.read(params: { [string]: any }): { [string]: any }
140
+ local paths = params.paths
141
+ if typeof(paths) ~= "table" or #paths == 0 then
142
+ Dispatch.fail(
143
+ "BAD_PARAMS",
144
+ "script_read requires a non-empty `paths` array.",
145
+ "Use `find` with className LuaSourceContainer to locate scripts."
146
+ )
147
+ end
148
+
149
+ local items: { { [string]: any } } = {}
150
+ local failures: { string } = {}
151
+ local memo: Paths.NameIndex = {}
152
+
153
+ --[[
154
+ Each entry may carry its own line window, because the shape that actually
155
+ comes up is "line 40 of this one, line 300 of that one" -- and a single
156
+ range shared across the whole batch forced one call per script, which is
157
+ what batching this tool was for in the first place.
158
+
159
+ A plain string still means the whole file, or the batch-wide range when
160
+ one was given.
161
+ ]]
162
+ for _, entry in paths do
163
+ local windowed = typeof(entry) == "table"
164
+ local path = if windowed then entry.path else entry
165
+ local ok, resolved = pcall(resolveScript, path)
166
+ if not ok then
167
+ local err = resolved :: any
168
+ local reason = if typeof(err) == "table"
169
+ then (if err.hint then err.message .. " " .. err.hint else err.message)
170
+ else tostring(err)
171
+ table.insert(failures, string.format("%s: %s", tostring(path), reason))
172
+ continue
173
+ end
174
+
175
+ local target = resolved :: LuaSourceContainer
176
+ local whole = ScriptEdit.read(target)
177
+ local lines = TextEdit.toLines(whole)
178
+ local askedStart = if windowed and entry.startLine ~= nil
179
+ then tonumber(entry.startLine)
180
+ else tonumber(params.startLine)
181
+ local askedEnd = if windowed and entry.endLine ~= nil
182
+ then tonumber(entry.endLine)
183
+ else tonumber(params.endLine)
184
+ local startLine = math.max(askedStart or 1, 1)
185
+ local endLine = math.min(askedEnd or #lines, #lines)
186
+
187
+ local window: { string } = {}
188
+ table.move(lines, startLine, endLine, 1, window)
189
+
190
+ table.insert(items, {
191
+ path = Paths.of(target, memo),
192
+ className = target.ClassName,
193
+ lineCount = #lines,
194
+ startLine = startLine,
195
+ endLine = askedEnd,
196
+ source = table.concat(window, "\n"),
197
+ -- Of the whole file, never of the window: `edit` compares it against
198
+ -- the live source, and a fingerprint of forty lines out of four
199
+ -- hundred would call an edit safe that it is not.
200
+ revision = fingerprint(whole),
201
+ -- Absent unless the script is bound to a file on disk, in which case
202
+ -- editing it here is a race against whatever writes that file.
203
+ fileSync = syncedFile(target),
204
+ })
205
+ end
206
+
207
+ return { items = items, failures = failures }
208
+ end
209
+
210
+ --[[
211
+ Applies every edit in a batch, or none of them.
212
+
213
+ Atomicity here cannot come from ChangeHistoryService. A recording captures
214
+ instance changes, but `UpdateSourceAsync` goes through the script editor's own
215
+ per-document history, so cancelling a recording leaves an already-written
216
+ script edited -- measured, not assumed. Wrapping this in `Undo.record` would
217
+ therefore promise a rollback that never happens.
218
+
219
+ So the batch is a two-phase commit instead. Phase one reads and transforms
220
+ every script without writing anything, which is where essentially all failures
221
+ live: a missing `find`, an ambiguous one, a bad line range, conflicting edits.
222
+ Phase two writes the finished text. If a write fails there -- realistically
223
+ only a locked or package-owned script -- the scripts already written are
224
+ restored from the source captured in phase one.
225
+ ]]
226
+ function Scripts.edit(params: { [string]: any }): { [string]: any }
227
+ local edits = params.edits
228
+ if typeof(edits) ~= "table" or #edits == 0 then
229
+ Dispatch.fail(
230
+ "BAD_PARAMS",
231
+ "script_edit requires a non-empty `edits` array.",
232
+ "Each edit needs a `path` plus one of `find`/`replace`, "
233
+ .. "`startLine`/`replacement`, or `source`."
234
+ )
235
+ end
236
+
237
+ local order: { LuaSourceContainer } = {}
238
+ local grouped: { [Instance]: { TextEdit.Edit } } = {}
239
+ for position, edit in edits do
240
+ TextEdit.validate(edit, position)
241
+ local target = resolveScript(edit.path)
242
+ local bucket = grouped[target]
243
+ if not bucket then
244
+ bucket = {}
245
+ grouped[target] = bucket
246
+ table.insert(order, target)
247
+ end
248
+ table.insert(bucket :: { TextEdit.Edit }, edit)
249
+ end
250
+
251
+ -- Phase one: transform everything in memory. Any failure raises here, with
252
+ -- nothing written and the place untouched.
253
+ type Pending = { target: LuaSourceContainer, before: string, after: string }
254
+ local pending: { Pending } = {}
255
+ for _, target in order do
256
+ local before = ScriptEdit.read(target)
257
+ --[[
258
+ Refuse before transforming, not after.
259
+
260
+ An edit that names the revision it was written against is asking to
261
+ be applied to that exact text. If the file has moved on, the honest
262
+ answer is to stop: a line range still applies cleanly to changed
263
+ source, it just lands on the wrong lines, and a `source` edit throws
264
+ away everything written since it was read. Both look like success.
265
+
266
+ Checked here so the whole batch fails with nothing written, which is
267
+ the promise every other phase-one failure already makes.
268
+ ]]
269
+ local live: string? = nil
270
+ for _, edit in grouped[target] :: { TextEdit.Edit } do
271
+ local stated = (edit :: any).revision
272
+ if typeof(stated) ~= "string" or stated == "" then
273
+ continue
274
+ end
275
+ live = live or fingerprint(before)
276
+ if stated ~= live then
277
+ Dispatch.fail(
278
+ "STALE_SCRIPT",
279
+ string.format(
280
+ "%s changed since it was read (expected %s, found %s).",
281
+ target:GetFullName(),
282
+ stated,
283
+ live :: string
284
+ ),
285
+ "Somebody else edited it -- another agent, or the user typing in the "
286
+ .. "editor. Read it again with script_read and rebuild the edit "
287
+ .. "against what is there now."
288
+ )
289
+ end
290
+ end
291
+
292
+ local after = TextEdit.apply(target:GetFullName(), before, grouped[target] :: { TextEdit.Edit })
293
+ table.insert(pending, { target = target, before = before, after = after })
294
+ end
295
+
296
+ -- Phase two: write. `written` is the compensation log for a mid-batch failure.
297
+ local written: { Pending } = {}
298
+ local memo: Paths.NameIndex = {}
299
+ local results: { { [string]: any } } = {}
300
+
301
+ for _, entry in pending do
302
+ -- Wrapped in a closure rather than passed to pcall directly: `write`
303
+ -- returns nothing, and pcall's typed signature expects a value back.
304
+ local ok, err = pcall(function()
305
+ ScriptEdit.write(entry.target, function()
306
+ return entry.after
307
+ end)
308
+ end)
309
+
310
+ if not ok then
311
+ for index = #written, 1, -1 do
312
+ local done = written[index]
313
+ -- Best effort: a restore that fails leaves that script edited, and
314
+ -- the original error still describes what actually went wrong.
315
+ pcall(function()
316
+ ScriptEdit.write(done.target, function()
317
+ return done.before
318
+ end)
319
+ end)
320
+ end
321
+ error(err, 0)
322
+ end
323
+
324
+ table.insert(written, entry)
325
+ table.insert(results, {
326
+ path = Paths.of(entry.target, memo),
327
+ className = entry.target.ClassName,
328
+ edits = #(grouped[entry.target] :: { TextEdit.Edit }),
329
+ lineCount = #TextEdit.toLines(entry.after),
330
+ lineDelta = #TextEdit.toLines(entry.after) - #TextEdit.toLines(entry.before),
331
+ })
332
+ end
333
+
334
+ return { items = results }
335
+ end
336
+
337
+ --[[
338
+ Searches script source. Matches come from the editor buffer, so text the user
339
+ has typed but not saved is found too -- which is the state an agent about to
340
+ edit the file actually needs to see.
341
+ ]]
342
+ function Scripts.grep(params: { [string]: any }): { [string]: any }
343
+ local pattern = params.pattern
344
+ if typeof(pattern) ~= "string" or pattern == "" then
345
+ Dispatch.fail("BAD_PARAMS", "script_grep requires a `pattern`.")
346
+ end
347
+
348
+ local root = if params.path then Paths.resolve(params.path) else game
349
+ local literal = params.literal == true
350
+ local ignoreCase = params.ignoreCase == true
351
+ local contextLines = math.clamp(tonumber(params.contextLines) or DEFAULT_CONTEXT, 0, 10)
352
+ local limit = math.min(tonumber(params.limit) or 100, MAX_MATCHES)
353
+ local offset = tonumber(params.offset) or 0
354
+ local classFilter = params.className
355
+
356
+ local needle = if ignoreCase then string.lower(pattern) else pattern
357
+
358
+ local targets: { LuaSourceContainer } = {}
359
+ for _, instance in root:GetDescendants() do
360
+ if not instance:IsA("LuaSourceContainer") then
361
+ continue
362
+ end
363
+ if root == game and Scope.isNoisy(instance) then
364
+ continue
365
+ end
366
+ if classFilter and not instance:IsA(classFilter) then
367
+ continue
368
+ end
369
+ table.insert(targets, instance)
370
+ end
371
+
372
+ if #targets > MAX_SCRIPTS then
373
+ Dispatch.fail(
374
+ "TOO_BROAD",
375
+ string.format("That search covers %d scripts, over the %d limit.", #targets, MAX_SCRIPTS),
376
+ "Narrow it with `path` to search one service or folder instead of the whole place."
377
+ )
378
+ end
379
+
380
+ local matches: { { [string]: any } } = {}
381
+ local total = 0
382
+ local memo: Paths.NameIndex = {}
383
+
384
+ for _, target in targets do
385
+ local lines = TextEdit.toLines(ScriptEdit.read(target))
386
+ local path: string? = nil
387
+
388
+ for number, line in lines do
389
+ local haystack = if ignoreCase then string.lower(line) else line
390
+ -- An invalid Lua pattern raises rather than simply not matching, so it
391
+ -- has to be caught and reported as a pattern problem, not a no-match.
392
+ local ok, from = pcall(string.find, haystack, needle, 1, literal)
393
+ if not ok then
394
+ Dispatch.fail(
395
+ "BAD_PATTERN",
396
+ string.format("%s is not a valid Lua pattern: %s", pattern, tostring(from)),
397
+ "Lua patterns escape with %, not backslash, and have no alternation. "
398
+ .. "Set `literal` to search for the text exactly as written."
399
+ )
400
+ end
401
+ if not from then
402
+ continue
403
+ end
404
+
405
+ total += 1
406
+ if total <= offset or #matches >= limit then
407
+ continue
408
+ end
409
+
410
+ if not path then
411
+ path = Paths.of(target, memo)
412
+ end
413
+
414
+ local entry: { [string]: any } = {
415
+ path = path,
416
+ line = number,
417
+ text = line,
418
+ }
419
+ if contextLines > 0 then
420
+ local before: { string } = {}
421
+ local after: { string } = {}
422
+ table.move(lines, math.max(number - contextLines, 1), number - 1, 1, before)
423
+ table.move(lines, number + 1, math.min(number + contextLines, #lines), 1, after)
424
+ entry.before = before
425
+ entry.after = after
426
+ end
427
+ table.insert(matches, entry)
428
+ end
429
+ end
430
+
431
+ return {
432
+ items = matches,
433
+ total = total,
434
+ offset = offset,
435
+ searched = #targets,
436
+ }
437
+ end
438
+
439
+ --[[
440
+ Creates scripts. Source is assigned directly here rather than through
441
+ `UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
442
+ open in the editor and there is no buffer to conflict with. Every later edit
443
+ goes through the editor path.
444
+ ]]
445
+ --[[
446
+ Names the starter container a script was just parented into, or nil.
447
+
448
+ These four are copied into the player rather than run where they sit, so a
449
+ `Script` with a non-Legacy RunContext inside one runs BOTH in the original
450
+ and in every copy. Roblox does warn about it -- "will cause it to run
451
+ multiple times" -- but that warning is emitted by Studio itself and never
452
+ reaches `console`, so an agent following the "prefer Script with runContext
453
+ Client over LocalScript" advice writes a double-running script and is given
454
+ no way to find out.
455
+ ]]
456
+ local STARTER_CONTAINERS = {
457
+ "StarterGui",
458
+ "StarterPack",
459
+ "StarterPlayerScripts",
460
+ "StarterCharacterScripts",
461
+ }
462
+
463
+ local function starterContainer(instance: Instance): string?
464
+ for _, className in STARTER_CONTAINERS do
465
+ if instance:FindFirstAncestorOfClass(className :: any) then
466
+ return className
467
+ end
468
+ end
469
+ return nil
470
+ end
471
+
472
+ function Scripts.create(params: { [string]: any }): { [string]: any }
473
+ local requests = params.scripts
474
+ if typeof(requests) ~= "table" or #requests == 0 then
475
+ Dispatch.fail(
476
+ "BAD_PARAMS",
477
+ "script_create requires a non-empty `scripts` array.",
478
+ "Each entry needs `parent`, `name` and `className`."
479
+ )
480
+ end
481
+
482
+ for position, request in requests do
483
+ if typeof(request.name) ~= "string" or request.name == "" then
484
+ Dispatch.fail("BAD_PARAMS", string.format("scripts[%d] has no `name`.", position))
485
+ end
486
+ if not CREATABLE[request.className] then
487
+ Dispatch.fail(
488
+ "BAD_PARAMS",
489
+ string.format('scripts[%d] has className "%s".', position, tostring(request.className)),
490
+ "Use Script, LocalScript or ModuleScript. Prefer a Script with "
491
+ .. "runContext Client over LocalScript in new work -- except inside "
492
+ .. "StarterGui, StarterPack, StarterPlayerScripts or "
493
+ .. "StarterCharacterScripts, where LocalScript is still the right "
494
+ .. "class."
495
+ )
496
+ end
497
+ end
498
+
499
+ -- No shared path memo here: each creation changes its parent's children, so a
500
+ -- cached sibling grouping would go stale mid-batch and mis-number the paths.
501
+ local warnings: { string } = {}
502
+
503
+ local created, recorded = Undo.record("StudioMCP.ScriptCreate", "MCP create script", function()
504
+ local created: { { [string]: any } } = {}
505
+
506
+ for _, request in requests do
507
+ local parent = Paths.resolve(request.parent)
508
+ local instance = Instance.new(request.className) :: LuaSourceContainer
509
+
510
+ instance.Name = request.name
511
+ if typeof(request.source) == "string" then
512
+ (instance :: ScriptEdit.SourceContainer).Source = request.source
513
+ end
514
+
515
+ if typeof(request.runContext) == "string" and instance:IsA("Script") then
516
+ local ok, runContext = pcall(function()
517
+ return (Enum.RunContext :: any)[request.runContext]
518
+ end)
519
+ if not ok or runContext == nil then
520
+ Dispatch.fail(
521
+ "BAD_PARAMS",
522
+ string.format('"%s" is not a RunContext.', tostring(request.runContext)),
523
+ "Use Legacy, Server or Client."
524
+ )
525
+ end
526
+ instance.RunContext = runContext
527
+ end
528
+ if request.disabled == true and instance:IsA("BaseScript") then
529
+ instance.Disabled = true
530
+ end
531
+
532
+ instance.Parent = parent
533
+
534
+ if instance:IsA("Script") and instance.RunContext ~= Enum.RunContext.Legacy then
535
+ local container = starterContainer(instance)
536
+ if container then
537
+ --[[
538
+ A Tool is the exception, and a common enough one to be
539
+ worth separating.
540
+
541
+ The general warning ends "make it a LocalScript", which
542
+ is right for a bare script in StarterGui or StarterPack
543
+ and WRONG for the inside of a weapon: damage, ammo and
544
+ hit detection belong on the server, and a LocalScript
545
+ there hands all three to the client. The duplicate the
546
+ warning is about is harmless here too -- the copy left in
547
+ StarterPack is never held by anyone, so nothing it
548
+ listens for ever fires.
549
+ ]]
550
+ local insideTool = false
551
+ local walk: Instance? = instance.Parent
552
+ while walk ~= nil and walk ~= game do
553
+ if walk:IsA("Tool") then
554
+ insideTool = true
555
+ break
556
+ end
557
+ walk = walk.Parent
558
+ end
559
+
560
+ if insideTool then
561
+ table.insert(
562
+ warnings,
563
+ string.format(
564
+ "%s is a Script with RunContext %s inside a Tool in %s. That is "
565
+ .. "usually right -- the Tool is copied into each player's "
566
+ .. "Backpack and the script runs there, on the server, which "
567
+ .. "is where damage and ammo belong. The copy left behind in "
568
+ .. "%s also runs, but nobody holds it, so nothing it waits for "
569
+ .. "happens. Keep it a Script, not a LocalScript.",
570
+ instance.Name,
571
+ instance.RunContext.Name,
572
+ container,
573
+ container
574
+ )
575
+ )
576
+ else
577
+ table.insert(
578
+ warnings,
579
+ string.format(
580
+ '%s is a Script with RunContext %s inside %s. That container is '
581
+ .. "COPIED into each player, so the script runs once where it "
582
+ .. "sits and again in every copy. Make it a LocalScript "
583
+ .. "instead -- a Legacy Script there would not run at all. "
584
+ .. "Studio warns about this in its own Output, which `console` "
585
+ .. "cannot read.",
586
+ instance.Name,
587
+ instance.RunContext.Name,
588
+ container
589
+ )
590
+ )
591
+ end
592
+ end
593
+ end
594
+
595
+ table.insert(created, {
596
+ path = Paths.of(instance),
597
+ className = instance.ClassName,
598
+ })
599
+ end
600
+
601
+ return created
602
+ end)
603
+
604
+ return {
605
+ items = created,
606
+ undoStep = if recorded then "MCP create script" else nil,
607
+ warnings = if #warnings > 0 then warnings else nil,
608
+ }
609
+ end
610
+
611
+ --[[
612
+ Opens a script in the user's editor, at a line.
613
+
614
+ The gap this closes is a conversational one. An agent that has found the bug
615
+ says "it is line 214 of Combat" and the user then has to go and find Combat,
616
+ open it, and scroll -- every time, for every finding. `ScriptEditorService`
617
+ can just put it on their screen.
618
+
619
+ Deliberately not automatic. Nothing else in this server opens windows, and a
620
+ tool that rearranged the user's editor as a side effect of reading a file
621
+ would be intolerable on a batch of twenty. It happens when it is asked for.
622
+ ]]
623
+ function Scripts.open(params: { [string]: any }): { [string]: any }
624
+ local target = resolveScript(params.path)
625
+ local line = math.max(tonumber(params.line) or 1, 1)
626
+
627
+ local service = game:GetService("ScriptEditorService")
628
+ local ok, err = pcall(function()
629
+ (service :: any):OpenScriptDocumentAsync(target)
630
+ end)
631
+ if not ok then
632
+ Dispatch.fail(
633
+ "OPEN_FAILED",
634
+ string.format("Could not open %s: %s", target:GetFullName(), tostring(err))
635
+ )
636
+ end
637
+
638
+ --[[
639
+ Moving the cursor is a second, separate operation, and a failure to move
640
+ it is not a failure to open. A document that opened but did not scroll is
641
+ still in front of the user; raising here would report the whole thing as
642
+ broken over the smaller half.
643
+ ]]
644
+ local movedTo: number? = nil
645
+ pcall(function()
646
+ for _, document in (service :: any):GetScriptDocuments() do
647
+ if document:GetScript() == target then
648
+ document:RequestSetSelectionAsync(line, 1, line, 1)
649
+ movedTo = line
650
+ break
651
+ end
652
+ end
653
+ end)
654
+
655
+ return {
656
+ path = Paths.of(target),
657
+ className = target.ClassName,
658
+ opened = true,
659
+ line = movedTo,
660
+ }
661
+ end
662
+
663
+ function Scripts.register()
664
+ Dispatch.registerAll("script", {
665
+ open = Scripts.open,
666
+ read = Scripts.read,
667
+ edit = Scripts.edit,
668
+ grep = Scripts.grep,
669
+ create = Scripts.create,
670
+ })
671
+ end
672
+
673
+ return Scripts