@el4cteo/rbx-studio-mcp 0.2.9 → 0.3.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.
- package/dist/index.js +1 -1
- package/dist/lib/pluginbuild.js +23 -13
- package/dist/lib/pluginbuild.js.map +1 -1
- package/dist/tools/exec.js +10 -1
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/perf.js +10 -1
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/scripts.js +54 -12
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/session.js +16 -0
- package/dist/tools/session.js.map +1 -1
- package/package.json +1 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Paths.luau +357 -255
- package/plugin/src/handlers/Exec.luau +314 -270
- package/plugin/src/handlers/Scripts.luau +461 -387
|
@@ -1,387 +1,461 @@
|
|
|
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
|
-
function Scripts.read(params: { [string]: any }): { [string]: any }
|
|
65
|
-
local paths = params.paths
|
|
66
|
-
if typeof(paths) ~= "table" or #paths == 0 then
|
|
67
|
-
Dispatch.fail(
|
|
68
|
-
"BAD_PARAMS",
|
|
69
|
-
"script_read requires a non-empty `paths` array.",
|
|
70
|
-
"Use `find` with className LuaSourceContainer to locate scripts."
|
|
71
|
-
)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
local items: { { [string]: any } } = {}
|
|
75
|
-
local failures: { string } = {}
|
|
76
|
-
local memo: Paths.NameIndex = {}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
local
|
|
90
|
-
local
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
+
function Scripts.read(params: { [string]: any }): { [string]: any }
|
|
65
|
+
local paths = params.paths
|
|
66
|
+
if typeof(paths) ~= "table" or #paths == 0 then
|
|
67
|
+
Dispatch.fail(
|
|
68
|
+
"BAD_PARAMS",
|
|
69
|
+
"script_read requires a non-empty `paths` array.",
|
|
70
|
+
"Use `find` with className LuaSourceContainer to locate scripts."
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
local items: { { [string]: any } } = {}
|
|
75
|
+
local failures: { string } = {}
|
|
76
|
+
local memo: Paths.NameIndex = {}
|
|
77
|
+
|
|
78
|
+
--[[
|
|
79
|
+
Each entry may carry its own line window, because the shape that actually
|
|
80
|
+
comes up is "line 40 of this one, line 300 of that one" -- and a single
|
|
81
|
+
range shared across the whole batch forced one call per script, which is
|
|
82
|
+
what batching this tool was for in the first place.
|
|
83
|
+
|
|
84
|
+
A plain string still means the whole file, or the batch-wide range when
|
|
85
|
+
one was given.
|
|
86
|
+
]]
|
|
87
|
+
for _, entry in paths do
|
|
88
|
+
local windowed = typeof(entry) == "table"
|
|
89
|
+
local path = if windowed then entry.path else entry
|
|
90
|
+
local ok, resolved = pcall(resolveScript, path)
|
|
91
|
+
if not ok then
|
|
92
|
+
local err = resolved :: any
|
|
93
|
+
local reason = if typeof(err) == "table"
|
|
94
|
+
then (if err.hint then err.message .. " " .. err.hint else err.message)
|
|
95
|
+
else tostring(err)
|
|
96
|
+
table.insert(failures, string.format("%s: %s", tostring(path), reason))
|
|
97
|
+
continue
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
local target = resolved :: LuaSourceContainer
|
|
101
|
+
local lines = TextEdit.toLines(ScriptEdit.read(target))
|
|
102
|
+
local askedStart = if windowed and entry.startLine ~= nil
|
|
103
|
+
then tonumber(entry.startLine)
|
|
104
|
+
else tonumber(params.startLine)
|
|
105
|
+
local askedEnd = if windowed and entry.endLine ~= nil
|
|
106
|
+
then tonumber(entry.endLine)
|
|
107
|
+
else tonumber(params.endLine)
|
|
108
|
+
local startLine = math.max(askedStart or 1, 1)
|
|
109
|
+
local endLine = math.min(askedEnd or #lines, #lines)
|
|
110
|
+
|
|
111
|
+
local window: { string } = {}
|
|
112
|
+
table.move(lines, startLine, endLine, 1, window)
|
|
113
|
+
|
|
114
|
+
table.insert(items, {
|
|
115
|
+
path = Paths.of(target, memo),
|
|
116
|
+
className = target.ClassName,
|
|
117
|
+
lineCount = #lines,
|
|
118
|
+
startLine = startLine,
|
|
119
|
+
endLine = askedEnd,
|
|
120
|
+
source = table.concat(window, "\n"),
|
|
121
|
+
})
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
return { items = items, failures = failures }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
--[[
|
|
128
|
+
Applies every edit in a batch, or none of them.
|
|
129
|
+
|
|
130
|
+
Atomicity here cannot come from ChangeHistoryService. A recording captures
|
|
131
|
+
instance changes, but `UpdateSourceAsync` goes through the script editor's own
|
|
132
|
+
per-document history, so cancelling a recording leaves an already-written
|
|
133
|
+
script edited -- measured, not assumed. Wrapping this in `Undo.record` would
|
|
134
|
+
therefore promise a rollback that never happens.
|
|
135
|
+
|
|
136
|
+
So the batch is a two-phase commit instead. Phase one reads and transforms
|
|
137
|
+
every script without writing anything, which is where essentially all failures
|
|
138
|
+
live: a missing `find`, an ambiguous one, a bad line range, conflicting edits.
|
|
139
|
+
Phase two writes the finished text. If a write fails there -- realistically
|
|
140
|
+
only a locked or package-owned script -- the scripts already written are
|
|
141
|
+
restored from the source captured in phase one.
|
|
142
|
+
]]
|
|
143
|
+
function Scripts.edit(params: { [string]: any }): { [string]: any }
|
|
144
|
+
local edits = params.edits
|
|
145
|
+
if typeof(edits) ~= "table" or #edits == 0 then
|
|
146
|
+
Dispatch.fail(
|
|
147
|
+
"BAD_PARAMS",
|
|
148
|
+
"script_edit requires a non-empty `edits` array.",
|
|
149
|
+
"Each edit needs a `path` plus one of `find`/`replace`, "
|
|
150
|
+
.. "`startLine`/`replacement`, or `source`."
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
local order: { LuaSourceContainer } = {}
|
|
155
|
+
local grouped: { [Instance]: { TextEdit.Edit } } = {}
|
|
156
|
+
for position, edit in edits do
|
|
157
|
+
TextEdit.validate(edit, position)
|
|
158
|
+
local target = resolveScript(edit.path)
|
|
159
|
+
local bucket = grouped[target]
|
|
160
|
+
if not bucket then
|
|
161
|
+
bucket = {}
|
|
162
|
+
grouped[target] = bucket
|
|
163
|
+
table.insert(order, target)
|
|
164
|
+
end
|
|
165
|
+
table.insert(bucket :: { TextEdit.Edit }, edit)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
-- Phase one: transform everything in memory. Any failure raises here, with
|
|
169
|
+
-- nothing written and the place untouched.
|
|
170
|
+
type Pending = { target: LuaSourceContainer, before: string, after: string }
|
|
171
|
+
local pending: { Pending } = {}
|
|
172
|
+
for _, target in order do
|
|
173
|
+
local before = ScriptEdit.read(target)
|
|
174
|
+
local after = TextEdit.apply(target:GetFullName(), before, grouped[target] :: { TextEdit.Edit })
|
|
175
|
+
table.insert(pending, { target = target, before = before, after = after })
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
-- Phase two: write. `written` is the compensation log for a mid-batch failure.
|
|
179
|
+
local written: { Pending } = {}
|
|
180
|
+
local memo: Paths.NameIndex = {}
|
|
181
|
+
local results: { { [string]: any } } = {}
|
|
182
|
+
|
|
183
|
+
for _, entry in pending do
|
|
184
|
+
-- Wrapped in a closure rather than passed to pcall directly: `write`
|
|
185
|
+
-- returns nothing, and pcall's typed signature expects a value back.
|
|
186
|
+
local ok, err = pcall(function()
|
|
187
|
+
ScriptEdit.write(entry.target, function()
|
|
188
|
+
return entry.after
|
|
189
|
+
end)
|
|
190
|
+
end)
|
|
191
|
+
|
|
192
|
+
if not ok then
|
|
193
|
+
for index = #written, 1, -1 do
|
|
194
|
+
local done = written[index]
|
|
195
|
+
-- Best effort: a restore that fails leaves that script edited, and
|
|
196
|
+
-- the original error still describes what actually went wrong.
|
|
197
|
+
pcall(function()
|
|
198
|
+
ScriptEdit.write(done.target, function()
|
|
199
|
+
return done.before
|
|
200
|
+
end)
|
|
201
|
+
end)
|
|
202
|
+
end
|
|
203
|
+
error(err, 0)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
table.insert(written, entry)
|
|
207
|
+
table.insert(results, {
|
|
208
|
+
path = Paths.of(entry.target, memo),
|
|
209
|
+
className = entry.target.ClassName,
|
|
210
|
+
edits = #(grouped[entry.target] :: { TextEdit.Edit }),
|
|
211
|
+
lineCount = #TextEdit.toLines(entry.after),
|
|
212
|
+
lineDelta = #TextEdit.toLines(entry.after) - #TextEdit.toLines(entry.before),
|
|
213
|
+
})
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
return { items = results }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
--[[
|
|
220
|
+
Searches script source. Matches come from the editor buffer, so text the user
|
|
221
|
+
has typed but not saved is found too -- which is the state an agent about to
|
|
222
|
+
edit the file actually needs to see.
|
|
223
|
+
]]
|
|
224
|
+
function Scripts.grep(params: { [string]: any }): { [string]: any }
|
|
225
|
+
local pattern = params.pattern
|
|
226
|
+
if typeof(pattern) ~= "string" or pattern == "" then
|
|
227
|
+
Dispatch.fail("BAD_PARAMS", "script_grep requires a `pattern`.")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
local root = if params.path then Paths.resolve(params.path) else game
|
|
231
|
+
local literal = params.literal == true
|
|
232
|
+
local ignoreCase = params.ignoreCase == true
|
|
233
|
+
local contextLines = math.clamp(tonumber(params.contextLines) or DEFAULT_CONTEXT, 0, 10)
|
|
234
|
+
local limit = math.min(tonumber(params.limit) or 100, MAX_MATCHES)
|
|
235
|
+
local offset = tonumber(params.offset) or 0
|
|
236
|
+
local classFilter = params.className
|
|
237
|
+
|
|
238
|
+
local needle = if ignoreCase then string.lower(pattern) else pattern
|
|
239
|
+
|
|
240
|
+
local targets: { LuaSourceContainer } = {}
|
|
241
|
+
for _, instance in root:GetDescendants() do
|
|
242
|
+
if not instance:IsA("LuaSourceContainer") then
|
|
243
|
+
continue
|
|
244
|
+
end
|
|
245
|
+
if root == game and Scope.isNoisy(instance) then
|
|
246
|
+
continue
|
|
247
|
+
end
|
|
248
|
+
if classFilter and not instance:IsA(classFilter) then
|
|
249
|
+
continue
|
|
250
|
+
end
|
|
251
|
+
table.insert(targets, instance)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
if #targets > MAX_SCRIPTS then
|
|
255
|
+
Dispatch.fail(
|
|
256
|
+
"TOO_BROAD",
|
|
257
|
+
string.format("That search covers %d scripts, over the %d limit.", #targets, MAX_SCRIPTS),
|
|
258
|
+
"Narrow it with `path` to search one service or folder instead of the whole place."
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
local matches: { { [string]: any } } = {}
|
|
263
|
+
local total = 0
|
|
264
|
+
local memo: Paths.NameIndex = {}
|
|
265
|
+
|
|
266
|
+
for _, target in targets do
|
|
267
|
+
local lines = TextEdit.toLines(ScriptEdit.read(target))
|
|
268
|
+
local path: string? = nil
|
|
269
|
+
|
|
270
|
+
for number, line in lines do
|
|
271
|
+
local haystack = if ignoreCase then string.lower(line) else line
|
|
272
|
+
-- An invalid Lua pattern raises rather than simply not matching, so it
|
|
273
|
+
-- has to be caught and reported as a pattern problem, not a no-match.
|
|
274
|
+
local ok, from = pcall(string.find, haystack, needle, 1, literal)
|
|
275
|
+
if not ok then
|
|
276
|
+
Dispatch.fail(
|
|
277
|
+
"BAD_PATTERN",
|
|
278
|
+
string.format("%s is not a valid Lua pattern: %s", pattern, tostring(from)),
|
|
279
|
+
"Lua patterns escape with %, not backslash, and have no alternation. "
|
|
280
|
+
.. "Set `literal` to search for the text exactly as written."
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
if not from then
|
|
284
|
+
continue
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
total += 1
|
|
288
|
+
if total <= offset or #matches >= limit then
|
|
289
|
+
continue
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
if not path then
|
|
293
|
+
path = Paths.of(target, memo)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
local entry: { [string]: any } = {
|
|
297
|
+
path = path,
|
|
298
|
+
line = number,
|
|
299
|
+
text = line,
|
|
300
|
+
}
|
|
301
|
+
if contextLines > 0 then
|
|
302
|
+
local before: { string } = {}
|
|
303
|
+
local after: { string } = {}
|
|
304
|
+
table.move(lines, math.max(number - contextLines, 1), number - 1, 1, before)
|
|
305
|
+
table.move(lines, number + 1, math.min(number + contextLines, #lines), 1, after)
|
|
306
|
+
entry.before = before
|
|
307
|
+
entry.after = after
|
|
308
|
+
end
|
|
309
|
+
table.insert(matches, entry)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
items = matches,
|
|
315
|
+
total = total,
|
|
316
|
+
offset = offset,
|
|
317
|
+
searched = #targets,
|
|
318
|
+
}
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
--[[
|
|
322
|
+
Creates scripts. Source is assigned directly here rather than through
|
|
323
|
+
`UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
|
|
324
|
+
open in the editor and there is no buffer to conflict with. Every later edit
|
|
325
|
+
goes through the editor path.
|
|
326
|
+
]]
|
|
327
|
+
--[[
|
|
328
|
+
Names the starter container a script was just parented into, or nil.
|
|
329
|
+
|
|
330
|
+
These four are copied into the player rather than run where they sit, so a
|
|
331
|
+
`Script` with a non-Legacy RunContext inside one runs BOTH in the original
|
|
332
|
+
and in every copy. Roblox does warn about it -- "will cause it to run
|
|
333
|
+
multiple times" -- but that warning is emitted by Studio itself and never
|
|
334
|
+
reaches `console`, so an agent following the "prefer Script with runContext
|
|
335
|
+
Client over LocalScript" advice writes a double-running script and is given
|
|
336
|
+
no way to find out.
|
|
337
|
+
]]
|
|
338
|
+
local STARTER_CONTAINERS = {
|
|
339
|
+
"StarterGui",
|
|
340
|
+
"StarterPack",
|
|
341
|
+
"StarterPlayerScripts",
|
|
342
|
+
"StarterCharacterScripts",
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
local function starterContainer(instance: Instance): string?
|
|
346
|
+
for _, className in STARTER_CONTAINERS do
|
|
347
|
+
if instance:FindFirstAncestorOfClass(className :: any) then
|
|
348
|
+
return className
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
return nil
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
function Scripts.create(params: { [string]: any }): { [string]: any }
|
|
355
|
+
local requests = params.scripts
|
|
356
|
+
if typeof(requests) ~= "table" or #requests == 0 then
|
|
357
|
+
Dispatch.fail(
|
|
358
|
+
"BAD_PARAMS",
|
|
359
|
+
"script_create requires a non-empty `scripts` array.",
|
|
360
|
+
"Each entry needs `parent`, `name` and `className`."
|
|
361
|
+
)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
for position, request in requests do
|
|
365
|
+
if typeof(request.name) ~= "string" or request.name == "" then
|
|
366
|
+
Dispatch.fail("BAD_PARAMS", string.format("scripts[%d] has no `name`.", position))
|
|
367
|
+
end
|
|
368
|
+
if not CREATABLE[request.className] then
|
|
369
|
+
Dispatch.fail(
|
|
370
|
+
"BAD_PARAMS",
|
|
371
|
+
string.format('scripts[%d] has className "%s".', position, tostring(request.className)),
|
|
372
|
+
"Use Script, LocalScript or ModuleScript. Prefer a Script with "
|
|
373
|
+
.. "runContext Client over LocalScript in new work -- except inside "
|
|
374
|
+
.. "StarterGui, StarterPack, StarterPlayerScripts or "
|
|
375
|
+
.. "StarterCharacterScripts, where LocalScript is still the right "
|
|
376
|
+
.. "class."
|
|
377
|
+
)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
-- No shared path memo here: each creation changes its parent's children, so a
|
|
382
|
+
-- cached sibling grouping would go stale mid-batch and mis-number the paths.
|
|
383
|
+
local warnings: { string } = {}
|
|
384
|
+
|
|
385
|
+
local created, recorded = Undo.record("StudioMCP.ScriptCreate", "MCP create script", function()
|
|
386
|
+
local created: { { [string]: any } } = {}
|
|
387
|
+
|
|
388
|
+
for _, request in requests do
|
|
389
|
+
local parent = Paths.resolve(request.parent)
|
|
390
|
+
local instance = Instance.new(request.className) :: LuaSourceContainer
|
|
391
|
+
|
|
392
|
+
instance.Name = request.name
|
|
393
|
+
if typeof(request.source) == "string" then
|
|
394
|
+
(instance :: ScriptEdit.SourceContainer).Source = request.source
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
if typeof(request.runContext) == "string" and instance:IsA("Script") then
|
|
398
|
+
local ok, runContext = pcall(function()
|
|
399
|
+
return (Enum.RunContext :: any)[request.runContext]
|
|
400
|
+
end)
|
|
401
|
+
if not ok or runContext == nil then
|
|
402
|
+
Dispatch.fail(
|
|
403
|
+
"BAD_PARAMS",
|
|
404
|
+
string.format('"%s" is not a RunContext.', tostring(request.runContext)),
|
|
405
|
+
"Use Legacy, Server or Client."
|
|
406
|
+
)
|
|
407
|
+
end
|
|
408
|
+
instance.RunContext = runContext
|
|
409
|
+
end
|
|
410
|
+
if request.disabled == true and instance:IsA("BaseScript") then
|
|
411
|
+
instance.Disabled = true
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
instance.Parent = parent
|
|
415
|
+
|
|
416
|
+
if instance:IsA("Script") and instance.RunContext ~= Enum.RunContext.Legacy then
|
|
417
|
+
local container = starterContainer(instance)
|
|
418
|
+
if container then
|
|
419
|
+
table.insert(
|
|
420
|
+
warnings,
|
|
421
|
+
string.format(
|
|
422
|
+
'%s is a Script with RunContext %s inside %s. That container is '
|
|
423
|
+
.. "COPIED into each player, so the script runs once where it "
|
|
424
|
+
.. "sits and again in every copy. Make it a LocalScript "
|
|
425
|
+
.. "instead -- a Legacy Script there would not run at all. "
|
|
426
|
+
.. "Studio warns about this in its own Output, which `console` "
|
|
427
|
+
.. "cannot read.",
|
|
428
|
+
instance.Name,
|
|
429
|
+
instance.RunContext.Name,
|
|
430
|
+
container
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
table.insert(created, {
|
|
437
|
+
path = Paths.of(instance),
|
|
438
|
+
className = instance.ClassName,
|
|
439
|
+
})
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
return created
|
|
443
|
+
end)
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
items = created,
|
|
447
|
+
undoStep = if recorded then "MCP create script" else nil,
|
|
448
|
+
warnings = if #warnings > 0 then warnings else nil,
|
|
449
|
+
}
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
function Scripts.register()
|
|
453
|
+
Dispatch.registerAll("script", {
|
|
454
|
+
read = Scripts.read,
|
|
455
|
+
edit = Scripts.edit,
|
|
456
|
+
grep = Scripts.grep,
|
|
457
|
+
create = Scripts.create,
|
|
458
|
+
})
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
return Scripts
|