@el4cteo/rbx-studio-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/dist/bridge/rpc.js +243 -0
- package/dist/bridge/rpc.js.map +1 -0
- package/dist/bridge/server.js +281 -0
- package/dist/bridge/server.js.map +1 -0
- package/dist/index.js +104 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/apidump.js +269 -0
- package/dist/lib/apidump.js.map +1 -0
- package/dist/lib/errors.js +38 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/format.js +191 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/pluginbuild.js +83 -0
- package/dist/lib/pluginbuild.js.map +1 -0
- package/dist/lib/png.js +84 -0
- package/dist/lib/png.js.map +1 -0
- package/dist/lib/protocol.js +22 -0
- package/dist/lib/protocol.js.map +1 -0
- package/dist/lib/tool.js +27 -0
- package/dist/lib/tool.js.map +1 -0
- package/dist/resources.js +70 -0
- package/dist/resources.js.map +1 -0
- package/dist/tools/api.js +78 -0
- package/dist/tools/api.js.map +1 -0
- package/dist/tools/character.js +94 -0
- package/dist/tools/character.js.map +1 -0
- package/dist/tools/debug.js +211 -0
- package/dist/tools/debug.js.map +1 -0
- package/dist/tools/device.js +74 -0
- package/dist/tools/device.js.map +1 -0
- package/dist/tools/discover.js +217 -0
- package/dist/tools/discover.js.map +1 -0
- package/dist/tools/exec.js +191 -0
- package/dist/tools/exec.js.map +1 -0
- package/dist/tools/input.js +96 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/instances.js +261 -0
- package/dist/tools/instances.js.map +1 -0
- package/dist/tools/perf.js +367 -0
- package/dist/tools/perf.js.map +1 -0
- package/dist/tools/playtest.js +153 -0
- package/dist/tools/playtest.js.map +1 -0
- package/dist/tools/screenshot.js +75 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/scripts.js +316 -0
- package/dist/tools/scripts.js.map +1 -0
- package/dist/tools/session.js +152 -0
- package/dist/tools/session.js.map +1 -0
- package/dist/tools/world.js +281 -0
- package/dist/tools/world.js.map +1 -0
- package/package.json +62 -0
- package/plugin/default.project.json +6 -0
- package/plugin/src/Config.luau +59 -0
- package/plugin/src/Console.luau +657 -0
- package/plugin/src/Context.luau +35 -0
- package/plugin/src/Dispatch.luau +90 -0
- package/plugin/src/Editor.luau +142 -0
- package/plugin/src/Emulation.luau +151 -0
- package/plugin/src/LogBuffer.luau +277 -0
- package/plugin/src/Net.luau +102 -0
- package/plugin/src/Paths.luau +255 -0
- package/plugin/src/Phrase.luau +465 -0
- package/plugin/src/Png.luau +238 -0
- package/plugin/src/Scope.luau +78 -0
- package/plugin/src/ScriptEdit.luau +100 -0
- package/plugin/src/Serialize.luau +287 -0
- package/plugin/src/TextEdit.luau +296 -0
- package/plugin/src/Transport.luau +328 -0
- package/plugin/src/Undo.luau +72 -0
- package/plugin/src/Visuals.luau +710 -0
- package/plugin/src/handlers/Api.luau +242 -0
- package/plugin/src/handlers/Assets.luau +145 -0
- package/plugin/src/handlers/Capture.luau +187 -0
- package/plugin/src/handlers/Character.luau +361 -0
- package/plugin/src/handlers/Debug.luau +391 -0
- package/plugin/src/handlers/Device.luau +119 -0
- package/plugin/src/handlers/Discover.luau +289 -0
- package/plugin/src/handlers/Exec.luau +270 -0
- package/plugin/src/handlers/Geometry.luau +261 -0
- package/plugin/src/handlers/Input.luau +287 -0
- package/plugin/src/handlers/Instances.luau +389 -0
- package/plugin/src/handlers/Perf.luau +645 -0
- package/plugin/src/handlers/Playtest.luau +205 -0
- package/plugin/src/handlers/Scripts.luau +387 -0
- package/plugin/src/handlers/Session.luau +168 -0
- package/plugin/src/handlers/Viewport.luau +302 -0
- package/plugin/src/handlers/World.luau +176 -0
- package/plugin/src/init.server.luau +317 -0
- package/scripts/build-plugin.mjs +157 -0
- package/scripts/check-plugin.mjs +97 -0
- package/scripts/install-plugin.mjs +39 -0
- package/scripts/latency.mjs +201 -0
- package/scripts/locate-luau.mjs +51 -0
- package/scripts/sourcemap.mjs +58 -0
- package/scripts/test-plugin.mjs +82 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Driving Studio's playtests.
|
|
4
|
+
|
|
5
|
+
`StudioTestService`, not `RunService:Run`. Run/Pause/Stop are marked
|
|
6
|
+
PluginSecurity and are permitted, and they do nothing: called from a real
|
|
7
|
+
plugin handler -- not just through the loadstring sandbox, which was the first
|
|
8
|
+
wrong explanation -- `Run()` returns successfully and leaves Studio in edit
|
|
9
|
+
mode with nothing logged. StudioTestService is the service Roblox added for
|
|
10
|
+
this, and it reaches further: Play mode with a character, and multiplayer
|
|
11
|
+
tests, neither of which RunService offered at all.
|
|
12
|
+
|
|
13
|
+
The Execute calls yield until the test ends -- specifically until something
|
|
14
|
+
inside the session calls `EndTest`. That is the wrong shape for a request
|
|
15
|
+
that should answer promptly, so the call is started on its own task and the
|
|
16
|
+
reply describes the state it reached. The result, whenever the test finally
|
|
17
|
+
produces one, is kept for a later `state` to collect.
|
|
18
|
+
]]
|
|
19
|
+
|
|
20
|
+
local RunService = game:GetService("RunService")
|
|
21
|
+
local StudioTestService = game:GetService("StudioTestService")
|
|
22
|
+
|
|
23
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
24
|
+
|
|
25
|
+
-- Long enough for Studio to actually enter the mode before the state is read
|
|
26
|
+
-- back, so the reply describes what happened rather than what was asked for.
|
|
27
|
+
local SETTLE_SECONDS = 1.5
|
|
28
|
+
|
|
29
|
+
local Playtest = {}
|
|
30
|
+
|
|
31
|
+
-- Outcome of the most recent test, which arrives long after the call that
|
|
32
|
+
-- started it returned.
|
|
33
|
+
local pending = false
|
|
34
|
+
local lastResult: any = nil
|
|
35
|
+
local lastError: string? = nil
|
|
36
|
+
local startedAt: number? = nil
|
|
37
|
+
|
|
38
|
+
local function service(): any
|
|
39
|
+
return StudioTestService :: any
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
local function editModeActive(): boolean?
|
|
43
|
+
local ok, value = pcall(function()
|
|
44
|
+
return service().EditModeActive
|
|
45
|
+
end)
|
|
46
|
+
return if ok and typeof(value) == "boolean" then value else nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
local function state(): { [string]: any }
|
|
50
|
+
return {
|
|
51
|
+
isEdit = RunService:IsEdit(),
|
|
52
|
+
isRunning = RunService:IsRunning(),
|
|
53
|
+
isRunMode = RunService:IsRunMode(),
|
|
54
|
+
editModeActive = editModeActive(),
|
|
55
|
+
playerCount = #game:GetService("Players"):GetPlayers(),
|
|
56
|
+
testPending = pending,
|
|
57
|
+
-- Kept from the previous test rather than discarded, since whatever
|
|
58
|
+
-- EndTest returned is the only thing a test session can hand back.
|
|
59
|
+
lastResult = lastResult,
|
|
60
|
+
lastError = lastError,
|
|
61
|
+
runningForSeconds = if startedAt then math.floor(os.clock() - startedAt) else nil,
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
--[[
|
|
66
|
+
Starts a test without waiting for it to finish.
|
|
67
|
+
|
|
68
|
+
Every failure here has to survive on its own thread: nothing is watching it
|
|
69
|
+
by the time it fails, so the reason is stored for the next `state` instead of
|
|
70
|
+
being raised into a caller that has already been answered.
|
|
71
|
+
]]
|
|
72
|
+
local function launch(name: string, call: () -> any)
|
|
73
|
+
pending = true
|
|
74
|
+
lastResult = nil
|
|
75
|
+
lastError = nil
|
|
76
|
+
startedAt = os.clock()
|
|
77
|
+
|
|
78
|
+
task.spawn(function()
|
|
79
|
+
local ok, result = pcall(call)
|
|
80
|
+
pending = false
|
|
81
|
+
startedAt = nil
|
|
82
|
+
if ok then
|
|
83
|
+
lastResult = result
|
|
84
|
+
else
|
|
85
|
+
lastError = string.format("%s failed: %s", name, tostring(result))
|
|
86
|
+
end
|
|
87
|
+
end)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
function Playtest.control(params: { [string]: any }): { [string]: any }
|
|
91
|
+
local op = params.op
|
|
92
|
+
local before = state()
|
|
93
|
+
|
|
94
|
+
if op == "state" then
|
|
95
|
+
return { changed = false, state = before }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
--[[
|
|
99
|
+
Ending a test has to happen inside it.
|
|
100
|
+
|
|
101
|
+
`LeaveTest` reads like the plugin-side exit and is not: it refuses with
|
|
102
|
+
"can only be called from the client DataModel of a running Studio test
|
|
103
|
+
session", and a playtest client can never reach this bridge because Studio
|
|
104
|
+
bars client sessions from making HTTP requests. So that route is closed for
|
|
105
|
+
good, not merely awkward.
|
|
106
|
+
|
|
107
|
+
`EndTest` works from the playtest's *server* session, which does connect --
|
|
108
|
+
and its value travels back to whichever plugin called ExecutePlayModeAsync.
|
|
109
|
+
The server tool routes a stop to that session; this op is what it calls.
|
|
110
|
+
]]
|
|
111
|
+
if op == "endTest" then
|
|
112
|
+
--[[
|
|
113
|
+
Deferred, because EndTest destroys the DataModel this handler is
|
|
114
|
+
running in -- including the connection carrying the reply. Called
|
|
115
|
+
inline it does end the test and the caller sees only DISCONNECTED,
|
|
116
|
+
which reads as a failed stop for something that worked perfectly.
|
|
117
|
+
|
|
118
|
+
The reply goes out first and the teardown follows. Whether it
|
|
119
|
+
actually ended is not taken on trust either way: the caller confirms
|
|
120
|
+
against the editor session, which survives.
|
|
121
|
+
]]
|
|
122
|
+
task.delay(0.25, function()
|
|
123
|
+
pcall(function()
|
|
124
|
+
service():EndTest(params.value or "stopped by studio-mcp")
|
|
125
|
+
end)
|
|
126
|
+
end)
|
|
127
|
+
return { changed = true, deferred = true, state = before }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if op == "stop" then
|
|
131
|
+
if before.isEdit and not before.isRunning and not pending then
|
|
132
|
+
return { changed = false, reason = "no test is running", state = before }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
-- Only reachable when the caller could not find a playtest session to end
|
|
136
|
+
-- from. Stops a Run-mode session, which has no separate DataModel.
|
|
137
|
+
pcall(function()
|
|
138
|
+
RunService:Stop()
|
|
139
|
+
end)
|
|
140
|
+
|
|
141
|
+
task.wait(SETTLE_SECONDS)
|
|
142
|
+
local after = state()
|
|
143
|
+
return {
|
|
144
|
+
changed = after.isRunning ~= before.isRunning or after.isEdit ~= before.isEdit,
|
|
145
|
+
state = after,
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
if pending then
|
|
150
|
+
Dispatch.fail(
|
|
151
|
+
"ALREADY_RUNNING",
|
|
152
|
+
"A test is already running.",
|
|
153
|
+
"Stop it first, or call with op 'state' to see how it is doing."
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
-- ExecutePlayModeAsync rejects a nil argument outright -- "Argument 1 missing
|
|
158
|
+
-- or nil" -- even though the parameter is a Variant, so an omitted `args`
|
|
159
|
+
-- becomes an empty string rather than a failed launch.
|
|
160
|
+
local args = if params.args == nil then "" else params.args
|
|
161
|
+
if op == "play" then
|
|
162
|
+
launch("ExecutePlayModeAsync", function()
|
|
163
|
+
return service():ExecutePlayModeAsync(args)
|
|
164
|
+
end)
|
|
165
|
+
elseif op == "run" then
|
|
166
|
+
launch("ExecuteRunModeAsync", function()
|
|
167
|
+
return service():ExecuteRunModeAsync(args)
|
|
168
|
+
end)
|
|
169
|
+
elseif op == "multiplayer" then
|
|
170
|
+
local players = tonumber(params.players) or 2
|
|
171
|
+
if players < 1 or players > 8 then
|
|
172
|
+
Dispatch.fail("BAD_PARAMS", "players must be between 1 and 8.")
|
|
173
|
+
end
|
|
174
|
+
launch("ExecuteMultiplayerTestAsync", function()
|
|
175
|
+
return service():ExecuteMultiplayerTestAsync(players, args)
|
|
176
|
+
end)
|
|
177
|
+
else
|
|
178
|
+
Dispatch.fail("BAD_PARAMS", string.format("unknown op %q", tostring(op)))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
task.wait(SETTLE_SECONDS)
|
|
182
|
+
local after = state()
|
|
183
|
+
|
|
184
|
+
-- Whether the mode moved, not whether the call was accepted. Accepting and
|
|
185
|
+
-- doing nothing is exactly how RunService:Run behaved here.
|
|
186
|
+
local moved = after.isRunning ~= before.isRunning
|
|
187
|
+
or after.isEdit ~= before.isEdit
|
|
188
|
+
or after.editModeActive ~= before.editModeActive
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
changed = moved or after.testPending,
|
|
192
|
+
reason = if after.lastError then after.lastError
|
|
193
|
+
elseif not moved and not after.testPending then "Studio accepted the call but the mode did not change"
|
|
194
|
+
else nil,
|
|
195
|
+
state = after,
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
function Playtest.register()
|
|
200
|
+
Dispatch.registerAll("playtest", {
|
|
201
|
+
control = Playtest.control,
|
|
202
|
+
})
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
return Playtest
|
|
@@ -0,0 +1,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
|
+
for _, path in paths do
|
|
79
|
+
local ok, resolved = pcall(resolveScript, path)
|
|
80
|
+
if not ok then
|
|
81
|
+
local err = resolved :: any
|
|
82
|
+
local reason = if typeof(err) == "table"
|
|
83
|
+
then (if err.hint then err.message .. " " .. err.hint else err.message)
|
|
84
|
+
else tostring(err)
|
|
85
|
+
table.insert(failures, string.format("%s: %s", path, reason))
|
|
86
|
+
continue
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
local target = resolved :: LuaSourceContainer
|
|
90
|
+
local lines = TextEdit.toLines(ScriptEdit.read(target))
|
|
91
|
+
local startLine = math.max(tonumber(params.startLine) or 1, 1)
|
|
92
|
+
local endLine = math.min(tonumber(params.endLine) or #lines, #lines)
|
|
93
|
+
|
|
94
|
+
local window: { string } = {}
|
|
95
|
+
table.move(lines, startLine, endLine, 1, window)
|
|
96
|
+
|
|
97
|
+
table.insert(items, {
|
|
98
|
+
path = Paths.of(target, memo),
|
|
99
|
+
className = target.ClassName,
|
|
100
|
+
lineCount = #lines,
|
|
101
|
+
startLine = startLine,
|
|
102
|
+
source = table.concat(window, "\n"),
|
|
103
|
+
})
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
return { items = items, failures = failures }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
--[[
|
|
110
|
+
Applies every edit in a batch, or none of them.
|
|
111
|
+
|
|
112
|
+
Atomicity here cannot come from ChangeHistoryService. A recording captures
|
|
113
|
+
instance changes, but `UpdateSourceAsync` goes through the script editor's own
|
|
114
|
+
per-document history, so cancelling a recording leaves an already-written
|
|
115
|
+
script edited -- measured, not assumed. Wrapping this in `Undo.record` would
|
|
116
|
+
therefore promise a rollback that never happens.
|
|
117
|
+
|
|
118
|
+
So the batch is a two-phase commit instead. Phase one reads and transforms
|
|
119
|
+
every script without writing anything, which is where essentially all failures
|
|
120
|
+
live: a missing `find`, an ambiguous one, a bad line range, conflicting edits.
|
|
121
|
+
Phase two writes the finished text. If a write fails there -- realistically
|
|
122
|
+
only a locked or package-owned script -- the scripts already written are
|
|
123
|
+
restored from the source captured in phase one.
|
|
124
|
+
]]
|
|
125
|
+
function Scripts.edit(params: { [string]: any }): { [string]: any }
|
|
126
|
+
local edits = params.edits
|
|
127
|
+
if typeof(edits) ~= "table" or #edits == 0 then
|
|
128
|
+
Dispatch.fail(
|
|
129
|
+
"BAD_PARAMS",
|
|
130
|
+
"script_edit requires a non-empty `edits` array.",
|
|
131
|
+
"Each edit needs a `path` plus one of `find`/`replace`, "
|
|
132
|
+
.. "`startLine`/`replacement`, or `source`."
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
local order: { LuaSourceContainer } = {}
|
|
137
|
+
local grouped: { [Instance]: { TextEdit.Edit } } = {}
|
|
138
|
+
for position, edit in edits do
|
|
139
|
+
TextEdit.validate(edit, position)
|
|
140
|
+
local target = resolveScript(edit.path)
|
|
141
|
+
local bucket = grouped[target]
|
|
142
|
+
if not bucket then
|
|
143
|
+
bucket = {}
|
|
144
|
+
grouped[target] = bucket
|
|
145
|
+
table.insert(order, target)
|
|
146
|
+
end
|
|
147
|
+
table.insert(bucket :: { TextEdit.Edit }, edit)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
-- Phase one: transform everything in memory. Any failure raises here, with
|
|
151
|
+
-- nothing written and the place untouched.
|
|
152
|
+
type Pending = { target: LuaSourceContainer, before: string, after: string }
|
|
153
|
+
local pending: { Pending } = {}
|
|
154
|
+
for _, target in order do
|
|
155
|
+
local before = ScriptEdit.read(target)
|
|
156
|
+
local after = TextEdit.apply(target:GetFullName(), before, grouped[target] :: { TextEdit.Edit })
|
|
157
|
+
table.insert(pending, { target = target, before = before, after = after })
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
-- Phase two: write. `written` is the compensation log for a mid-batch failure.
|
|
161
|
+
local written: { Pending } = {}
|
|
162
|
+
local memo: Paths.NameIndex = {}
|
|
163
|
+
local results: { { [string]: any } } = {}
|
|
164
|
+
|
|
165
|
+
for _, entry in pending do
|
|
166
|
+
-- Wrapped in a closure rather than passed to pcall directly: `write`
|
|
167
|
+
-- returns nothing, and pcall's typed signature expects a value back.
|
|
168
|
+
local ok, err = pcall(function()
|
|
169
|
+
ScriptEdit.write(entry.target, function()
|
|
170
|
+
return entry.after
|
|
171
|
+
end)
|
|
172
|
+
end)
|
|
173
|
+
|
|
174
|
+
if not ok then
|
|
175
|
+
for index = #written, 1, -1 do
|
|
176
|
+
local done = written[index]
|
|
177
|
+
-- Best effort: a restore that fails leaves that script edited, and
|
|
178
|
+
-- the original error still describes what actually went wrong.
|
|
179
|
+
pcall(function()
|
|
180
|
+
ScriptEdit.write(done.target, function()
|
|
181
|
+
return done.before
|
|
182
|
+
end)
|
|
183
|
+
end)
|
|
184
|
+
end
|
|
185
|
+
error(err, 0)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
table.insert(written, entry)
|
|
189
|
+
table.insert(results, {
|
|
190
|
+
path = Paths.of(entry.target, memo),
|
|
191
|
+
className = entry.target.ClassName,
|
|
192
|
+
edits = #(grouped[entry.target] :: { TextEdit.Edit }),
|
|
193
|
+
lineCount = #TextEdit.toLines(entry.after),
|
|
194
|
+
lineDelta = #TextEdit.toLines(entry.after) - #TextEdit.toLines(entry.before),
|
|
195
|
+
})
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
return { items = results }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
--[[
|
|
202
|
+
Searches script source. Matches come from the editor buffer, so text the user
|
|
203
|
+
has typed but not saved is found too -- which is the state an agent about to
|
|
204
|
+
edit the file actually needs to see.
|
|
205
|
+
]]
|
|
206
|
+
function Scripts.grep(params: { [string]: any }): { [string]: any }
|
|
207
|
+
local pattern = params.pattern
|
|
208
|
+
if typeof(pattern) ~= "string" or pattern == "" then
|
|
209
|
+
Dispatch.fail("BAD_PARAMS", "script_grep requires a `pattern`.")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
local root = if params.path then Paths.resolve(params.path) else game
|
|
213
|
+
local literal = params.literal == true
|
|
214
|
+
local ignoreCase = params.ignoreCase == true
|
|
215
|
+
local contextLines = math.clamp(tonumber(params.contextLines) or DEFAULT_CONTEXT, 0, 10)
|
|
216
|
+
local limit = math.min(tonumber(params.limit) or 100, MAX_MATCHES)
|
|
217
|
+
local offset = tonumber(params.offset) or 0
|
|
218
|
+
local classFilter = params.className
|
|
219
|
+
|
|
220
|
+
local needle = if ignoreCase then string.lower(pattern) else pattern
|
|
221
|
+
|
|
222
|
+
local targets: { LuaSourceContainer } = {}
|
|
223
|
+
for _, instance in root:GetDescendants() do
|
|
224
|
+
if not instance:IsA("LuaSourceContainer") then
|
|
225
|
+
continue
|
|
226
|
+
end
|
|
227
|
+
if root == game and Scope.isNoisy(instance) then
|
|
228
|
+
continue
|
|
229
|
+
end
|
|
230
|
+
if classFilter and not instance:IsA(classFilter) then
|
|
231
|
+
continue
|
|
232
|
+
end
|
|
233
|
+
table.insert(targets, instance)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
if #targets > MAX_SCRIPTS then
|
|
237
|
+
Dispatch.fail(
|
|
238
|
+
"TOO_BROAD",
|
|
239
|
+
string.format("That search covers %d scripts, over the %d limit.", #targets, MAX_SCRIPTS),
|
|
240
|
+
"Narrow it with `path` to search one service or folder instead of the whole place."
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
local matches: { { [string]: any } } = {}
|
|
245
|
+
local total = 0
|
|
246
|
+
local memo: Paths.NameIndex = {}
|
|
247
|
+
|
|
248
|
+
for _, target in targets do
|
|
249
|
+
local lines = TextEdit.toLines(ScriptEdit.read(target))
|
|
250
|
+
local path: string? = nil
|
|
251
|
+
|
|
252
|
+
for number, line in lines do
|
|
253
|
+
local haystack = if ignoreCase then string.lower(line) else line
|
|
254
|
+
-- An invalid Lua pattern raises rather than simply not matching, so it
|
|
255
|
+
-- has to be caught and reported as a pattern problem, not a no-match.
|
|
256
|
+
local ok, from = pcall(string.find, haystack, needle, 1, literal)
|
|
257
|
+
if not ok then
|
|
258
|
+
Dispatch.fail(
|
|
259
|
+
"BAD_PATTERN",
|
|
260
|
+
string.format("%s is not a valid Lua pattern: %s", pattern, tostring(from)),
|
|
261
|
+
"Lua patterns escape with %, not backslash, and have no alternation. "
|
|
262
|
+
.. "Set `literal` to search for the text exactly as written."
|
|
263
|
+
)
|
|
264
|
+
end
|
|
265
|
+
if not from then
|
|
266
|
+
continue
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
total += 1
|
|
270
|
+
if total <= offset or #matches >= limit then
|
|
271
|
+
continue
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
if not path then
|
|
275
|
+
path = Paths.of(target, memo)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
local entry: { [string]: any } = {
|
|
279
|
+
path = path,
|
|
280
|
+
line = number,
|
|
281
|
+
text = line,
|
|
282
|
+
}
|
|
283
|
+
if contextLines > 0 then
|
|
284
|
+
local before: { string } = {}
|
|
285
|
+
local after: { string } = {}
|
|
286
|
+
table.move(lines, math.max(number - contextLines, 1), number - 1, 1, before)
|
|
287
|
+
table.move(lines, number + 1, math.min(number + contextLines, #lines), 1, after)
|
|
288
|
+
entry.before = before
|
|
289
|
+
entry.after = after
|
|
290
|
+
end
|
|
291
|
+
table.insert(matches, entry)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
items = matches,
|
|
297
|
+
total = total,
|
|
298
|
+
offset = offset,
|
|
299
|
+
searched = #targets,
|
|
300
|
+
}
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
--[[
|
|
304
|
+
Creates scripts. Source is assigned directly here rather than through
|
|
305
|
+
`UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
|
|
306
|
+
open in the editor and there is no buffer to conflict with. Every later edit
|
|
307
|
+
goes through the editor path.
|
|
308
|
+
]]
|
|
309
|
+
function Scripts.create(params: { [string]: any }): { [string]: any }
|
|
310
|
+
local requests = params.scripts
|
|
311
|
+
if typeof(requests) ~= "table" or #requests == 0 then
|
|
312
|
+
Dispatch.fail(
|
|
313
|
+
"BAD_PARAMS",
|
|
314
|
+
"script_create requires a non-empty `scripts` array.",
|
|
315
|
+
"Each entry needs `parent`, `name` and `className`."
|
|
316
|
+
)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
for position, request in requests do
|
|
320
|
+
if typeof(request.name) ~= "string" or request.name == "" then
|
|
321
|
+
Dispatch.fail("BAD_PARAMS", string.format("scripts[%d] has no `name`.", position))
|
|
322
|
+
end
|
|
323
|
+
if not CREATABLE[request.className] then
|
|
324
|
+
Dispatch.fail(
|
|
325
|
+
"BAD_PARAMS",
|
|
326
|
+
string.format('scripts[%d] has className "%s".', position, tostring(request.className)),
|
|
327
|
+
"Use Script, LocalScript or ModuleScript. Prefer a Script with "
|
|
328
|
+
.. "runContext Client over LocalScript in new work."
|
|
329
|
+
)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
-- No shared path memo here: each creation changes its parent's children, so a
|
|
334
|
+
-- cached sibling grouping would go stale mid-batch and mis-number the paths.
|
|
335
|
+
local created, recorded = Undo.record("StudioMCP.ScriptCreate", "MCP create script", function()
|
|
336
|
+
local created: { { [string]: any } } = {}
|
|
337
|
+
|
|
338
|
+
for _, request in requests do
|
|
339
|
+
local parent = Paths.resolve(request.parent)
|
|
340
|
+
local instance = Instance.new(request.className) :: LuaSourceContainer
|
|
341
|
+
|
|
342
|
+
instance.Name = request.name
|
|
343
|
+
if typeof(request.source) == "string" then
|
|
344
|
+
(instance :: ScriptEdit.SourceContainer).Source = request.source
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
if typeof(request.runContext) == "string" and instance:IsA("Script") then
|
|
348
|
+
local ok, runContext = pcall(function()
|
|
349
|
+
return (Enum.RunContext :: any)[request.runContext]
|
|
350
|
+
end)
|
|
351
|
+
if not ok or runContext == nil then
|
|
352
|
+
Dispatch.fail(
|
|
353
|
+
"BAD_PARAMS",
|
|
354
|
+
string.format('"%s" is not a RunContext.', tostring(request.runContext)),
|
|
355
|
+
"Use Legacy, Server or Client."
|
|
356
|
+
)
|
|
357
|
+
end
|
|
358
|
+
instance.RunContext = runContext
|
|
359
|
+
end
|
|
360
|
+
if request.disabled == true and instance:IsA("BaseScript") then
|
|
361
|
+
instance.Disabled = true
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
instance.Parent = parent
|
|
365
|
+
|
|
366
|
+
table.insert(created, {
|
|
367
|
+
path = Paths.of(instance),
|
|
368
|
+
className = instance.ClassName,
|
|
369
|
+
})
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
return created
|
|
373
|
+
end)
|
|
374
|
+
|
|
375
|
+
return { items = created, undoStep = if recorded then "MCP create script" else nil }
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
function Scripts.register()
|
|
379
|
+
Dispatch.registerAll("script", {
|
|
380
|
+
read = Scripts.read,
|
|
381
|
+
edit = Scripts.edit,
|
|
382
|
+
grep = Scripts.grep,
|
|
383
|
+
create = Scripts.create,
|
|
384
|
+
})
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
return Scripts
|