@el4cteo/rbx-studio-mcp 0.2.9 → 0.3.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/dist/index.js +1 -1
- package/dist/tools/exec.js +10 -1
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/scripts.js +37 -10
- package/dist/tools/scripts.js.map +1 -1
- package/package.json +1 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Paths.luau +288 -255
- package/plugin/src/handlers/Exec.luau +314 -270
- package/plugin/src/handlers/Scripts.luau +405 -387
package/plugin/src/Paths.luau
CHANGED
|
@@ -1,255 +1,288 @@
|
|
|
1
|
-
--!strict
|
|
2
|
-
--[[
|
|
3
|
-
Dot-notation instance paths, e.g. "Workspace.Map.Spawn".
|
|
4
|
-
|
|
5
|
-
Paths are the address format for every tool, so resolution failures are the
|
|
6
|
-
most common error an agent will hit. Each failure says which segment broke
|
|
7
|
-
and what does exist at that level, which is usually enough for the model to
|
|
8
|
-
fix the path itself instead of falling back to a blind tree dump.
|
|
9
|
-
]]
|
|
10
|
-
|
|
11
|
-
local Dispatch = require(script.Parent.Dispatch)
|
|
12
|
-
|
|
13
|
-
local Paths = {}
|
|
14
|
-
|
|
15
|
-
--[[
|
|
16
|
-
Splits a path into segments. A leading "game." is optional and stripped, so
|
|
17
|
-
both "game.Workspace.Part" and "Workspace.Part" resolve identically.
|
|
18
|
-
]]
|
|
19
|
-
function Paths.split(path: string): { string }
|
|
20
|
-
local segments: { string } = {}
|
|
21
|
-
for segment in string.gmatch(path, "[^%.]+") do
|
|
22
|
-
table.insert(segments, segment)
|
|
23
|
-
end
|
|
24
|
-
if segments[1] == "game" then
|
|
25
|
-
table.remove(segments, 1)
|
|
26
|
-
end
|
|
27
|
-
return segments
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
--[[
|
|
31
|
-
Splits "Part[3]" into ("Part", 3), or ("Part", nil) when unindexed.
|
|
32
|
-
|
|
33
|
-
Sibling names are not unique in Roblox -- a Workspace with 77 parts all named
|
|
34
|
-
"Part" is completely ordinary -- so a bare dotted path is ambiguous and
|
|
35
|
-
`FindFirstChild` would silently pick the first match. Every path this module
|
|
36
|
-
emits therefore carries a 1-based index whenever the name is shared, and
|
|
37
|
-
`resolve` honours it.
|
|
38
|
-
]]
|
|
39
|
-
local function parseSegment(segment: string): (string, number?)
|
|
40
|
-
local name, index = string.match(segment, "^(.*)%[(%d+)%]$")
|
|
41
|
-
if name and index then
|
|
42
|
-
return name, tonumber(index)
|
|
43
|
-
end
|
|
44
|
-
return segment, nil
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
export type NameIndex = { [Instance]: { [string]: { Instance } } }
|
|
48
|
-
|
|
49
|
-
--[[
|
|
50
|
-
Groups a parent's children by name, memoised across one request. Without the
|
|
51
|
-
memo, formatting paths for N same-named siblings is O(N^2); a listing of a
|
|
52
|
-
few thousand parts would stall Studio's main thread.
|
|
53
|
-
]]
|
|
54
|
-
local function childrenByName(parent: Instance, memo: NameIndex?): { [string]: { Instance } }
|
|
55
|
-
local cached = if memo then memo[parent] else nil
|
|
56
|
-
if cached then
|
|
57
|
-
return cached
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
local byName: { [string]: { Instance } } = {}
|
|
61
|
-
for _, child in parent:GetChildren() do
|
|
62
|
-
local list = byName[child.Name]
|
|
63
|
-
if not list then
|
|
64
|
-
list = {}
|
|
65
|
-
byName[child.Name] = list
|
|
66
|
-
end
|
|
67
|
-
table.insert(list, child)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
if memo then
|
|
71
|
-
memo[parent] = byName
|
|
72
|
-
end
|
|
73
|
-
return byName
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
--[[
|
|
77
|
-
1-based position among identically named siblings, or nil when the name is
|
|
78
|
-
already unique and no index is needed.
|
|
79
|
-
]]
|
|
80
|
-
local function siblingIndex(instance: Instance, memo: NameIndex?): number?
|
|
81
|
-
local parent = instance.Parent
|
|
82
|
-
if not parent then
|
|
83
|
-
return nil
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
local list = childrenByName(parent, memo)[instance.Name]
|
|
87
|
-
if not list or #list <= 1 then
|
|
88
|
-
return nil
|
|
89
|
-
end
|
|
90
|
-
for index, child in list do
|
|
91
|
-
if child == instance then
|
|
92
|
-
return index
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
return nil
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
local function childNames(parent: Instance, limit: number): string
|
|
99
|
-
local names: { string } = {}
|
|
100
|
-
for _, child in parent:GetChildren() do
|
|
101
|
-
table.insert(names, child.Name)
|
|
102
|
-
if #names >= limit then
|
|
103
|
-
table.insert(names, "...")
|
|
104
|
-
break
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
if #names == 0 then
|
|
108
|
-
return "(no children)"
|
|
109
|
-
end
|
|
110
|
-
return table.concat(names, ", ")
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
--[[
|
|
114
|
-
Resolves a path to an Instance, or raises a NOT_FOUND with the failing
|
|
115
|
-
segment and its siblings. `FindFirstChild` is used rather than indexing so a
|
|
116
|
-
missing child never throws a bare Luau error.
|
|
117
|
-
]]
|
|
118
|
-
function Paths.resolve(path: string): Instance
|
|
119
|
-
if typeof(path) ~= "string" or path == "" then
|
|
120
|
-
Dispatch.fail(
|
|
121
|
-
"BAD_PATH",
|
|
122
|
-
"An instance path is required.",
|
|
123
|
-
'Paths are dot-separated from the DataModel root, e.g. "Workspace.Map.Spawn". '
|
|
124
|
-
.. "Use `find` or `tree` to discover valid paths."
|
|
125
|
-
)
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
local segments = Paths.split(path)
|
|
129
|
-
if #segments == 0 then
|
|
130
|
-
return game
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
local current: Instance = game
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
local
|
|
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
|
-
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Dot-notation instance paths, e.g. "Workspace.Map.Spawn".
|
|
4
|
+
|
|
5
|
+
Paths are the address format for every tool, so resolution failures are the
|
|
6
|
+
most common error an agent will hit. Each failure says which segment broke
|
|
7
|
+
and what does exist at that level, which is usually enough for the model to
|
|
8
|
+
fix the path itself instead of falling back to a blind tree dump.
|
|
9
|
+
]]
|
|
10
|
+
|
|
11
|
+
local Dispatch = require(script.Parent.Dispatch)
|
|
12
|
+
|
|
13
|
+
local Paths = {}
|
|
14
|
+
|
|
15
|
+
--[[
|
|
16
|
+
Splits a path into segments. A leading "game." is optional and stripped, so
|
|
17
|
+
both "game.Workspace.Part" and "Workspace.Part" resolve identically.
|
|
18
|
+
]]
|
|
19
|
+
function Paths.split(path: string): { string }
|
|
20
|
+
local segments: { string } = {}
|
|
21
|
+
for segment in string.gmatch(path, "[^%.]+") do
|
|
22
|
+
table.insert(segments, segment)
|
|
23
|
+
end
|
|
24
|
+
if segments[1] == "game" then
|
|
25
|
+
table.remove(segments, 1)
|
|
26
|
+
end
|
|
27
|
+
return segments
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
--[[
|
|
31
|
+
Splits "Part[3]" into ("Part", 3), or ("Part", nil) when unindexed.
|
|
32
|
+
|
|
33
|
+
Sibling names are not unique in Roblox -- a Workspace with 77 parts all named
|
|
34
|
+
"Part" is completely ordinary -- so a bare dotted path is ambiguous and
|
|
35
|
+
`FindFirstChild` would silently pick the first match. Every path this module
|
|
36
|
+
emits therefore carries a 1-based index whenever the name is shared, and
|
|
37
|
+
`resolve` honours it.
|
|
38
|
+
]]
|
|
39
|
+
local function parseSegment(segment: string): (string, number?)
|
|
40
|
+
local name, index = string.match(segment, "^(.*)%[(%d+)%]$")
|
|
41
|
+
if name and index then
|
|
42
|
+
return name, tonumber(index)
|
|
43
|
+
end
|
|
44
|
+
return segment, nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
export type NameIndex = { [Instance]: { [string]: { Instance } } }
|
|
48
|
+
|
|
49
|
+
--[[
|
|
50
|
+
Groups a parent's children by name, memoised across one request. Without the
|
|
51
|
+
memo, formatting paths for N same-named siblings is O(N^2); a listing of a
|
|
52
|
+
few thousand parts would stall Studio's main thread.
|
|
53
|
+
]]
|
|
54
|
+
local function childrenByName(parent: Instance, memo: NameIndex?): { [string]: { Instance } }
|
|
55
|
+
local cached = if memo then memo[parent] else nil
|
|
56
|
+
if cached then
|
|
57
|
+
return cached
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
local byName: { [string]: { Instance } } = {}
|
|
61
|
+
for _, child in parent:GetChildren() do
|
|
62
|
+
local list = byName[child.Name]
|
|
63
|
+
if not list then
|
|
64
|
+
list = {}
|
|
65
|
+
byName[child.Name] = list
|
|
66
|
+
end
|
|
67
|
+
table.insert(list, child)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if memo then
|
|
71
|
+
memo[parent] = byName
|
|
72
|
+
end
|
|
73
|
+
return byName
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
--[[
|
|
77
|
+
1-based position among identically named siblings, or nil when the name is
|
|
78
|
+
already unique and no index is needed.
|
|
79
|
+
]]
|
|
80
|
+
local function siblingIndex(instance: Instance, memo: NameIndex?): number?
|
|
81
|
+
local parent = instance.Parent
|
|
82
|
+
if not parent then
|
|
83
|
+
return nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
local list = childrenByName(parent, memo)[instance.Name]
|
|
87
|
+
if not list or #list <= 1 then
|
|
88
|
+
return nil
|
|
89
|
+
end
|
|
90
|
+
for index, child in list do
|
|
91
|
+
if child == instance then
|
|
92
|
+
return index
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
return nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
local function childNames(parent: Instance, limit: number): string
|
|
99
|
+
local names: { string } = {}
|
|
100
|
+
for _, child in parent:GetChildren() do
|
|
101
|
+
table.insert(names, child.Name)
|
|
102
|
+
if #names >= limit then
|
|
103
|
+
table.insert(names, "...")
|
|
104
|
+
break
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
if #names == 0 then
|
|
108
|
+
return "(no children)"
|
|
109
|
+
end
|
|
110
|
+
return table.concat(names, ", ")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
--[[
|
|
114
|
+
Resolves a path to an Instance, or raises a NOT_FOUND with the failing
|
|
115
|
+
segment and its siblings. `FindFirstChild` is used rather than indexing so a
|
|
116
|
+
missing child never throws a bare Luau error.
|
|
117
|
+
]]
|
|
118
|
+
function Paths.resolve(path: string): Instance
|
|
119
|
+
if typeof(path) ~= "string" or path == "" then
|
|
120
|
+
Dispatch.fail(
|
|
121
|
+
"BAD_PATH",
|
|
122
|
+
"An instance path is required.",
|
|
123
|
+
'Paths are dot-separated from the DataModel root, e.g. "Workspace.Map.Spawn". '
|
|
124
|
+
.. "Use `find` or `tree` to discover valid paths."
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
local segments = Paths.split(path)
|
|
129
|
+
if #segments == 0 then
|
|
130
|
+
return game
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
local current: Instance = game
|
|
134
|
+
local index = 1
|
|
135
|
+
while index <= #segments do
|
|
136
|
+
local segment = segments[index]
|
|
137
|
+
local name, ordinal = parseSegment(segment)
|
|
138
|
+
local nextInstance: Instance? = nil
|
|
139
|
+
local consumed = 1
|
|
140
|
+
|
|
141
|
+
if ordinal then
|
|
142
|
+
-- Explicit disambiguation: take the nth child with this exact name.
|
|
143
|
+
local list = childrenByName(current, nil)[name]
|
|
144
|
+
nextInstance = if list then list[ordinal] else nil
|
|
145
|
+
elseif index == 1 then
|
|
146
|
+
-- Services must be fetched by class name, and are not always
|
|
147
|
+
-- present as children until first accessed.
|
|
148
|
+
local ok, service = pcall(function()
|
|
149
|
+
return game:GetService(name :: any)
|
|
150
|
+
end)
|
|
151
|
+
nextInstance = if ok then service else current:FindFirstChild(name)
|
|
152
|
+
else
|
|
153
|
+
nextInstance = current:FindFirstChild(name)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
--[[
|
|
157
|
+
Instance names may contain dots -- "Dr. Simon" is an ordinary name --
|
|
158
|
+
and the separator is a dot too, so such a name arrives here already
|
|
159
|
+
split into pieces that match nothing. Rejoin greedily, longest first,
|
|
160
|
+
so "Workspace.Dr. Simon.Head" finds the child actually called
|
|
161
|
+
"Dr. Simon" rather than failing on a segment named "Dr".
|
|
162
|
+
|
|
163
|
+
Only attempted after the plain lookup misses, so a place where both
|
|
164
|
+
"Dr" and "Dr. Simon" exist still resolves the short name to itself.
|
|
165
|
+
]]
|
|
166
|
+
if not nextInstance and index < #segments then
|
|
167
|
+
for last = #segments, index + 1, -1 do
|
|
168
|
+
local joined = table.concat(segments, ".", index, last)
|
|
169
|
+
local joinedName, joinedOrdinal = parseSegment(joined)
|
|
170
|
+
local found: Instance? = nil
|
|
171
|
+
if joinedOrdinal then
|
|
172
|
+
local list = childrenByName(current, nil)[joinedName]
|
|
173
|
+
found = if list then list[joinedOrdinal] else nil
|
|
174
|
+
else
|
|
175
|
+
found = current:FindFirstChild(joinedName)
|
|
176
|
+
end
|
|
177
|
+
if found then
|
|
178
|
+
nextInstance = found
|
|
179
|
+
consumed = last - index + 1
|
|
180
|
+
break
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
if not nextInstance then
|
|
186
|
+
local partial = table.concat(segments, ".", 1, index)
|
|
187
|
+
-- `fail` never returns; returning its result keeps that visible to
|
|
188
|
+
-- the type checker so `current` stays non-optional below.
|
|
189
|
+
return Dispatch.fail(
|
|
190
|
+
"NOT_FOUND",
|
|
191
|
+
string.format('No instance at "%s".', partial),
|
|
192
|
+
string.format(
|
|
193
|
+
'"%s" has no child named "%s". It does have: %s',
|
|
194
|
+
current:GetFullName(),
|
|
195
|
+
segment,
|
|
196
|
+
childNames(current, 25)
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
current = nextInstance
|
|
201
|
+
index += consumed
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
return current
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
--[[
|
|
208
|
+
Resolves many paths at once, collecting failures instead of stopping at the
|
|
209
|
+
first one. Batch tools report every bad path in a single response so the
|
|
210
|
+
agent can fix them all in one retry rather than one call per mistake.
|
|
211
|
+
]]
|
|
212
|
+
function Paths.resolveMany(paths: { string }): ({ Instance }, { string })
|
|
213
|
+
local resolved: { Instance } = {}
|
|
214
|
+
local failures: { string } = {}
|
|
215
|
+
for _, path in paths do
|
|
216
|
+
local ok, result = pcall(Paths.resolve, path)
|
|
217
|
+
if ok then
|
|
218
|
+
table.insert(resolved, result :: Instance)
|
|
219
|
+
else
|
|
220
|
+
local err = result :: any
|
|
221
|
+
table.insert(
|
|
222
|
+
failures,
|
|
223
|
+
string.format("%s: %s", path, if typeof(err) == "table" then err.message else tostring(err))
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
return resolved, failures
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
--[[
|
|
231
|
+
Formats an instance into a path that resolves back to that exact instance.
|
|
232
|
+
|
|
233
|
+
`GetFullName` is deliberately not used: it emits "Workspace.Part" for every
|
|
234
|
+
one of 77 parts named "Part", so the paths it produces are not addresses at
|
|
235
|
+
all. Here each segment gains a [n] suffix when its name is shared with a
|
|
236
|
+
sibling.
|
|
237
|
+
|
|
238
|
+
Pass a shared `memo` when formatting many instances in one request.
|
|
239
|
+
|
|
240
|
+
The index is positional, so it shifts if same-named siblings are inserted or
|
|
241
|
+
removed between calls. Read a fresh path after any structural change.
|
|
242
|
+
]]
|
|
243
|
+
function Paths.of(instance: Instance, memo: NameIndex?): string
|
|
244
|
+
--[[
|
|
245
|
+
Refuses anything that is not an Instance, rather than duck-typing it.
|
|
246
|
+
|
|
247
|
+
This walks `.Name` and `.Parent`, which a plain table can also have -- so
|
|
248
|
+
handed a table carrying those two fields it used to return a perfectly
|
|
249
|
+
well-formed path for an object that exists nowhere in the data model.
|
|
250
|
+
That happened: `GeometryService:FragmentAsync` returns `{Index, Instance}`
|
|
251
|
+
wrappers, a handler assigned `.Name` and `.Parent` to the wrapper, and
|
|
252
|
+
this function reported eight new parts by full path when none had been
|
|
253
|
+
created. A fabricated path is worse than an error, because everything
|
|
254
|
+
downstream treats it as real.
|
|
255
|
+
]]
|
|
256
|
+
if typeof(instance) ~= "Instance" then
|
|
257
|
+
error(
|
|
258
|
+
string.format(
|
|
259
|
+
"Paths.of expects an Instance, got %s. This is a bug in the caller, "
|
|
260
|
+
.. "not in the request.",
|
|
261
|
+
typeof(instance)
|
|
262
|
+
),
|
|
263
|
+
2
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
if instance == game then
|
|
268
|
+
return "game"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
local segments: { string } = {}
|
|
272
|
+
local current: Instance? = instance
|
|
273
|
+
|
|
274
|
+
while current ~= nil and current ~= game do
|
|
275
|
+
local node = current :: Instance
|
|
276
|
+
local ordinal = siblingIndex(node, memo)
|
|
277
|
+
table.insert(
|
|
278
|
+
segments,
|
|
279
|
+
1,
|
|
280
|
+
if ordinal then string.format("%s[%d]", node.Name, ordinal) else node.Name
|
|
281
|
+
)
|
|
282
|
+
current = node.Parent
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
return table.concat(segments, ".")
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
return Paths
|