@el4cteo/rbx-studio-mcp 0.6.8 → 0.7.1

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.
@@ -1,673 +1,677 @@
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
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
+ -- Of what is there now, not of what was asked for: the editor can
332
+ -- normalise the text it is handed, and a rev that does not match the
333
+ -- live buffer would refuse the very next edit as stale.
334
+ rev = fingerprint(ScriptEdit.read(entry.target)),
335
+ })
336
+ end
337
+
338
+ return { items = results }
339
+ end
340
+
341
+ --[[
342
+ Searches script source. Matches come from the editor buffer, so text the user
343
+ has typed but not saved is found too -- which is the state an agent about to
344
+ edit the file actually needs to see.
345
+ ]]
346
+ function Scripts.grep(params: { [string]: any }): { [string]: any }
347
+ local pattern = params.pattern
348
+ if typeof(pattern) ~= "string" or pattern == "" then
349
+ Dispatch.fail("BAD_PARAMS", "script_grep requires a `pattern`.")
350
+ end
351
+
352
+ local root = if params.path then Paths.resolve(params.path) else game
353
+ local literal = params.literal == true
354
+ local ignoreCase = params.ignoreCase == true
355
+ local contextLines = math.clamp(tonumber(params.contextLines) or DEFAULT_CONTEXT, 0, 10)
356
+ local limit = math.min(tonumber(params.limit) or 100, MAX_MATCHES)
357
+ local offset = tonumber(params.offset) or 0
358
+ local classFilter = params.className
359
+
360
+ local needle = if ignoreCase then string.lower(pattern) else pattern
361
+
362
+ local targets: { LuaSourceContainer } = {}
363
+ for _, instance in root:GetDescendants() do
364
+ if not instance:IsA("LuaSourceContainer") then
365
+ continue
366
+ end
367
+ if root == game and Scope.isNoisy(instance) then
368
+ continue
369
+ end
370
+ if classFilter and not instance:IsA(classFilter) then
371
+ continue
372
+ end
373
+ table.insert(targets, instance)
374
+ end
375
+
376
+ if #targets > MAX_SCRIPTS then
377
+ Dispatch.fail(
378
+ "TOO_BROAD",
379
+ string.format("That search covers %d scripts, over the %d limit.", #targets, MAX_SCRIPTS),
380
+ "Narrow it with `path` to search one service or folder instead of the whole place."
381
+ )
382
+ end
383
+
384
+ local matches: { { [string]: any } } = {}
385
+ local total = 0
386
+ local memo: Paths.NameIndex = {}
387
+
388
+ for _, target in targets do
389
+ local lines = TextEdit.toLines(ScriptEdit.read(target))
390
+ local path: string? = nil
391
+
392
+ for number, line in lines do
393
+ local haystack = if ignoreCase then string.lower(line) else line
394
+ -- An invalid Lua pattern raises rather than simply not matching, so it
395
+ -- has to be caught and reported as a pattern problem, not a no-match.
396
+ local ok, from = pcall(string.find, haystack, needle, 1, literal)
397
+ if not ok then
398
+ Dispatch.fail(
399
+ "BAD_PATTERN",
400
+ string.format("%s is not a valid Lua pattern: %s", pattern, tostring(from)),
401
+ "Lua patterns escape with %, not backslash, and have no alternation. "
402
+ .. "Set `literal` to search for the text exactly as written."
403
+ )
404
+ end
405
+ if not from then
406
+ continue
407
+ end
408
+
409
+ total += 1
410
+ if total <= offset or #matches >= limit then
411
+ continue
412
+ end
413
+
414
+ if not path then
415
+ path = Paths.of(target, memo)
416
+ end
417
+
418
+ local entry: { [string]: any } = {
419
+ path = path,
420
+ line = number,
421
+ text = line,
422
+ }
423
+ if contextLines > 0 then
424
+ local before: { string } = {}
425
+ local after: { string } = {}
426
+ table.move(lines, math.max(number - contextLines, 1), number - 1, 1, before)
427
+ table.move(lines, number + 1, math.min(number + contextLines, #lines), 1, after)
428
+ entry.before = before
429
+ entry.after = after
430
+ end
431
+ table.insert(matches, entry)
432
+ end
433
+ end
434
+
435
+ return {
436
+ items = matches,
437
+ total = total,
438
+ offset = offset,
439
+ searched = #targets,
440
+ }
441
+ end
442
+
443
+ --[[
444
+ Creates scripts. Source is assigned directly here rather than through
445
+ `UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
446
+ open in the editor and there is no buffer to conflict with. Every later edit
447
+ goes through the editor path.
448
+ ]]
449
+ --[[
450
+ Names the starter container a script was just parented into, or nil.
451
+
452
+ These four are copied into the player rather than run where they sit, so a
453
+ `Script` with a non-Legacy RunContext inside one runs BOTH in the original
454
+ and in every copy. Roblox does warn about it -- "will cause it to run
455
+ multiple times" -- but that warning is emitted by Studio itself and never
456
+ reaches `console`, so an agent following the "prefer Script with runContext
457
+ Client over LocalScript" advice writes a double-running script and is given
458
+ no way to find out.
459
+ ]]
460
+ local STARTER_CONTAINERS = {
461
+ "StarterGui",
462
+ "StarterPack",
463
+ "StarterPlayerScripts",
464
+ "StarterCharacterScripts",
465
+ }
466
+
467
+ local function starterContainer(instance: Instance): string?
468
+ for _, className in STARTER_CONTAINERS do
469
+ if instance:FindFirstAncestorOfClass(className :: any) then
470
+ return className
471
+ end
472
+ end
473
+ return nil
474
+ end
475
+
476
+ function Scripts.create(params: { [string]: any }): { [string]: any }
477
+ local requests = params.scripts
478
+ if typeof(requests) ~= "table" or #requests == 0 then
479
+ Dispatch.fail(
480
+ "BAD_PARAMS",
481
+ "script_create requires a non-empty `scripts` array.",
482
+ "Each entry needs `parent`, `name` and `className`."
483
+ )
484
+ end
485
+
486
+ for position, request in requests do
487
+ if typeof(request.name) ~= "string" or request.name == "" then
488
+ Dispatch.fail("BAD_PARAMS", string.format("scripts[%d] has no `name`.", position - 1))
489
+ end
490
+ if not CREATABLE[request.className] then
491
+ Dispatch.fail(
492
+ "BAD_PARAMS",
493
+ string.format('scripts[%d] has className "%s".', position - 1, tostring(request.className)),
494
+ "Use Script, LocalScript or ModuleScript. Prefer a Script with "
495
+ .. "runContext Client over LocalScript in new work -- except inside "
496
+ .. "StarterGui, StarterPack, StarterPlayerScripts or "
497
+ .. "StarterCharacterScripts, where LocalScript is still the right "
498
+ .. "class."
499
+ )
500
+ end
501
+ end
502
+
503
+ -- No shared path memo here: each creation changes its parent's children, so a
504
+ -- cached sibling grouping would go stale mid-batch and mis-number the paths.
505
+ local warnings: { string } = {}
506
+
507
+ local created, recorded = Undo.record("StudioMCP.ScriptCreate", "MCP create script", function()
508
+ local created: { { [string]: any } } = {}
509
+
510
+ for _, request in requests do
511
+ local parent = Paths.resolve(request.parent)
512
+ local instance = Instance.new(request.className) :: LuaSourceContainer
513
+
514
+ instance.Name = request.name
515
+ if typeof(request.source) == "string" then
516
+ (instance :: ScriptEdit.SourceContainer).Source = request.source
517
+ end
518
+
519
+ if typeof(request.runContext) == "string" and instance:IsA("Script") then
520
+ local ok, runContext = pcall(function()
521
+ return (Enum.RunContext :: any)[request.runContext]
522
+ end)
523
+ if not ok or runContext == nil then
524
+ Dispatch.fail(
525
+ "BAD_PARAMS",
526
+ string.format('"%s" is not a RunContext.', tostring(request.runContext)),
527
+ "Use Legacy, Server or Client."
528
+ )
529
+ end
530
+ instance.RunContext = runContext
531
+ end
532
+ if request.disabled == true and instance:IsA("BaseScript") then
533
+ instance.Disabled = true
534
+ end
535
+
536
+ instance.Parent = parent
537
+
538
+ if instance:IsA("Script") and instance.RunContext ~= Enum.RunContext.Legacy then
539
+ local container = starterContainer(instance)
540
+ if container then
541
+ --[[
542
+ A Tool is the exception, and a common enough one to be
543
+ worth separating.
544
+
545
+ The general warning ends "make it a LocalScript", which
546
+ is right for a bare script in StarterGui or StarterPack
547
+ and WRONG for the inside of a weapon: damage, ammo and
548
+ hit detection belong on the server, and a LocalScript
549
+ there hands all three to the client. The duplicate the
550
+ warning is about is harmless here too -- the copy left in
551
+ StarterPack is never held by anyone, so nothing it
552
+ listens for ever fires.
553
+ ]]
554
+ local insideTool = false
555
+ local walk: Instance? = instance.Parent
556
+ while walk ~= nil and walk ~= game do
557
+ if walk:IsA("Tool") then
558
+ insideTool = true
559
+ break
560
+ end
561
+ walk = walk.Parent
562
+ end
563
+
564
+ if insideTool then
565
+ table.insert(
566
+ warnings,
567
+ string.format(
568
+ "%s is a Script with RunContext %s inside a Tool in %s. That is "
569
+ .. "usually right -- the Tool is copied into each player's "
570
+ .. "Backpack and the script runs there, on the server, which "
571
+ .. "is where damage and ammo belong. The copy left behind in "
572
+ .. "%s also runs, but nobody holds it, so nothing it waits for "
573
+ .. "happens. Keep it a Script, not a LocalScript.",
574
+ instance.Name,
575
+ instance.RunContext.Name,
576
+ container,
577
+ container
578
+ )
579
+ )
580
+ else
581
+ table.insert(
582
+ warnings,
583
+ string.format(
584
+ '%s is a Script with RunContext %s inside %s. That container is '
585
+ .. "COPIED into each player, so the script runs once where it "
586
+ .. "sits and again in every copy. Make it a LocalScript "
587
+ .. "instead -- a Legacy Script there would not run at all. "
588
+ .. "Studio warns about this in its own Output, which `console` "
589
+ .. "cannot read.",
590
+ instance.Name,
591
+ instance.RunContext.Name,
592
+ container
593
+ )
594
+ )
595
+ end
596
+ end
597
+ end
598
+
599
+ table.insert(created, {
600
+ path = Paths.of(instance),
601
+ className = instance.ClassName,
602
+ })
603
+ end
604
+
605
+ return created
606
+ end)
607
+
608
+ return {
609
+ items = created,
610
+ undoStep = if recorded then "MCP create script" else nil,
611
+ warnings = if #warnings > 0 then warnings else nil,
612
+ }
613
+ end
614
+
615
+ --[[
616
+ Opens a script in the user's editor, at a line.
617
+
618
+ The gap this closes is a conversational one. An agent that has found the bug
619
+ says "it is line 214 of Combat" and the user then has to go and find Combat,
620
+ open it, and scroll -- every time, for every finding. `ScriptEditorService`
621
+ can just put it on their screen.
622
+
623
+ Deliberately not automatic. Nothing else in this server opens windows, and a
624
+ tool that rearranged the user's editor as a side effect of reading a file
625
+ would be intolerable on a batch of twenty. It happens when it is asked for.
626
+ ]]
627
+ function Scripts.open(params: { [string]: any }): { [string]: any }
628
+ local target = resolveScript(params.path)
629
+ local line = math.max(tonumber(params.line) or 1, 1)
630
+
631
+ local service = game:GetService("ScriptEditorService")
632
+ local ok, err = pcall(function()
633
+ (service :: any):OpenScriptDocumentAsync(target)
634
+ end)
635
+ if not ok then
636
+ Dispatch.fail(
637
+ "OPEN_FAILED",
638
+ string.format("Could not open %s: %s", target:GetFullName(), tostring(err))
639
+ )
640
+ end
641
+
642
+ --[[
643
+ Moving the cursor is a second, separate operation, and a failure to move
644
+ it is not a failure to open. A document that opened but did not scroll is
645
+ still in front of the user; raising here would report the whole thing as
646
+ broken over the smaller half.
647
+ ]]
648
+ local movedTo: number? = nil
649
+ pcall(function()
650
+ for _, document in (service :: any):GetScriptDocuments() do
651
+ if document:GetScript() == target then
652
+ document:RequestSetSelectionAsync(line, 1, line, 1)
653
+ movedTo = line
654
+ break
655
+ end
656
+ end
657
+ end)
658
+
659
+ return {
660
+ path = Paths.of(target),
661
+ className = target.ClassName,
662
+ opened = true,
663
+ line = movedTo,
664
+ }
665
+ end
666
+
667
+ function Scripts.register()
668
+ Dispatch.registerAll("script", {
669
+ open = Scripts.open,
670
+ read = Scripts.read,
671
+ edit = Scripts.edit,
672
+ grep = Scripts.grep,
673
+ create = Scripts.create,
674
+ })
675
+ end
676
+
677
+ return Scripts