@el4cteo/rbx-studio-mcp 0.3.0 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +20 -26
  2. package/dist/index.js +1 -1
  3. package/dist/lib/format.js +18 -2
  4. package/dist/lib/format.js.map +1 -1
  5. package/dist/lib/pluginbuild.js +23 -13
  6. package/dist/lib/pluginbuild.js.map +1 -1
  7. package/dist/tools/api.js +20 -1
  8. package/dist/tools/api.js.map +1 -1
  9. package/dist/tools/debug.js +18 -8
  10. package/dist/tools/debug.js.map +1 -1
  11. package/dist/tools/discover.js +5 -0
  12. package/dist/tools/discover.js.map +1 -1
  13. package/dist/tools/instances.js +49 -2
  14. package/dist/tools/instances.js.map +1 -1
  15. package/dist/tools/perf.js +11 -1
  16. package/dist/tools/perf.js.map +1 -1
  17. package/dist/tools/scripts.js +27 -3
  18. package/dist/tools/scripts.js.map +1 -1
  19. package/dist/tools/session.js +16 -0
  20. package/dist/tools/session.js.map +1 -1
  21. package/package.json +1 -1
  22. package/plugin/src/Config.luau +1 -1
  23. package/plugin/src/Console.luau +257 -116
  24. package/plugin/src/Paths.luau +138 -69
  25. package/plugin/src/Serialize.luau +43 -3
  26. package/plugin/src/ThemePicker.luau +458 -0
  27. package/plugin/src/Themes/Aurora.luau +147 -0
  28. package/plugin/src/Themes/Blueprint.luau +213 -0
  29. package/plugin/src/Themes/Draw.luau +177 -0
  30. package/plugin/src/Themes/Lattice.luau +304 -0
  31. package/plugin/src/Themes/Nebula.luau +182 -0
  32. package/plugin/src/Themes/Observatory.luau +200 -0
  33. package/plugin/src/Themes/Orbit.luau +268 -0
  34. package/plugin/src/Themes/Phosphor.luau +200 -0
  35. package/plugin/src/Themes/Theme.luau +121 -0
  36. package/plugin/src/Themes/Void.luau +230 -0
  37. package/plugin/src/Themes/init.luau +116 -0
  38. package/plugin/src/Visuals.luau +788 -907
  39. package/plugin/src/handlers/Discover.luau +75 -1
  40. package/plugin/src/handlers/Exec.luau +17 -5
  41. package/plugin/src/handlers/Instances.luau +35 -3
  42. package/plugin/src/handlers/Scripts.luau +58 -2
  43. package/plugin/src/init.server.luau +386 -354
@@ -95,25 +95,106 @@ local function siblingIndex(instance: Instance, memo: NameIndex?): number?
95
95
  return nil
96
96
  end
97
97
 
98
+ --[[
99
+ Names a parent's children for an error message, collapsing repeats.
100
+
101
+ Same-named siblings are ordinary in Roblox, and listing them one by one spent
102
+ the whole budget saying "Part, Part, Part" 25 times -- which tells a reader
103
+ nothing and hides the names that would have helped. Repeats are counted
104
+ instead, and the count is exactly what says an index is needed.
105
+ ]]
98
106
  local function childNames(parent: Instance, limit: number): string
99
- local names: { string } = {}
107
+ local order: { string } = {}
108
+ local counts: { [string]: number } = {}
100
109
  for _, child in parent:GetChildren() do
101
- table.insert(names, child.Name)
102
- if #names >= limit then
103
- table.insert(names, "...")
104
- break
110
+ if counts[child.Name] == nil then
111
+ counts[child.Name] = 0
112
+ table.insert(order, child.Name)
105
113
  end
114
+ counts[child.Name] += 1
106
115
  end
107
- if #names == 0 then
116
+
117
+ if #order == 0 then
108
118
  return "(no children)"
109
119
  end
120
+
121
+ local names: { string } = {}
122
+ for _, name in order do
123
+ if #names >= limit then
124
+ table.insert(names, string.format("... %d more", #order - limit))
125
+ break
126
+ end
127
+ local count = counts[name]
128
+ table.insert(names, if count > 1 then string.format("%s (x%d)", name, count) else name)
129
+ end
110
130
  return table.concat(names, ", ")
111
131
  end
112
132
 
133
+ --[[
134
+ Finds one child by a single segment, honouring "Name[2]" and, at the root,
135
+ fetching services by class name -- they are not always present as children
136
+ until first accessed.
137
+
138
+ The second return is a diagnostic for the one miss a caller cannot work out
139
+ from the sibling list: an index past the end of a name that does exist.
140
+ ]]
141
+ local function childBySegment(
142
+ parent: Instance,
143
+ segment: string,
144
+ atRoot: boolean
145
+ ): (Instance?, string?)
146
+ local name, ordinal = parseSegment(segment)
147
+
148
+ if ordinal then
149
+ local list = childrenByName(parent, nil)[name]
150
+ if list == nil then
151
+ return nil, nil
152
+ end
153
+ local child = list[ordinal]
154
+ if child then
155
+ return child, nil
156
+ end
157
+ return nil,
158
+ string.format(
159
+ '"%s" has %d child%s named "%s", so [%d] is past the end. Indexes are '
160
+ .. "1-based and shift when siblings are added or removed -- read a "
161
+ .. "fresh path from `find` or `tree`.",
162
+ parent:GetFullName(),
163
+ #list,
164
+ if #list == 1 then "" else "ren",
165
+ name,
166
+ ordinal
167
+ )
168
+ end
169
+
170
+ if atRoot then
171
+ local ok, service = pcall(function()
172
+ return game:GetService(name :: any)
173
+ end)
174
+ if ok and service then
175
+ return service, nil
176
+ end
177
+ end
178
+ return parent:FindFirstChild(name), nil
179
+ end
180
+
113
181
  --[[
114
182
  Resolves a path to an Instance, or raises a NOT_FOUND with the failing
115
183
  segment and its siblings. `FindFirstChild` is used rather than indexing so a
116
184
  missing child never throws a bare Luau error.
185
+
186
+ Instance names may contain dots -- "Dr. Simon" is an ordinary name, and so is
187
+ "v1.2 backup" -- while the separator is a dot too, so such a name arrives
188
+ here already split into pieces. Each step therefore tries consuming one
189
+ segment first and rejoins further segments only if that leads nowhere, which
190
+ means the search has to BACK TRACK: with both "Dr" and "Dr. Who" present,
191
+ "Workspace.Dr. Who.Head" matches "Dr", fails to find " Who" under it, and
192
+ must return to try "Dr. Who". A first pass without that backtracking left the
193
+ tools emitting `Workspace.Dr. Who` as a path and then refusing to read it
194
+ back, which is worse than either behaviour on its own.
195
+
196
+ Shortest-first keeps the old precedence: where a short name resolves the
197
+ whole remaining path, it still wins over a longer join.
117
198
  ]]
118
199
  function Paths.resolve(path: string): Instance
119
200
  if typeof(path) ~= "string" or path == "" then
@@ -130,78 +211,66 @@ function Paths.resolve(path: string): Instance
130
211
  return game
131
212
  end
132
213
 
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)
214
+ -- The deepest point any branch of the search reached, so a failure still
215
+ -- reports the segment that actually broke rather than the first one tried.
216
+ local deepest = 0
217
+ local deepestParent: Instance = game
218
+ local deepestNote: string? = nil
219
+ -- The segment text the note is about: with a rejoined name it is not
220
+ -- `segments[failedAt]`, and reporting that instead named "Mr" for a path
221
+ -- that asked for "Mr. X[3]".
222
+ local deepestSegment: string? = nil
223
+
224
+ local function walk(current: Instance, index: number): Instance?
225
+ if index > #segments then
226
+ return current
227
+ end
228
+ if index - 1 > deepest then
229
+ deepest = index - 1
230
+ deepestParent = current
231
+ deepestNote = nil
232
+ deepestSegment = nil
154
233
  end
155
234
 
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
235
+ for last = index, #segments do
236
+ local joined = table.concat(segments, ".", index, last)
237
+ local child, note = childBySegment(current, joined, index == 1)
238
+ if note and index - 1 == deepest then
239
+ deepestNote = note
240
+ deepestSegment = joined
241
+ end
242
+ if child then
243
+ local found = walk(child, last + 1)
177
244
  if found then
178
- nextInstance = found
179
- consumed = last - index + 1
180
- break
245
+ return found
181
246
  end
182
247
  end
183
248
  end
249
+ return nil
250
+ end
184
251
 
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
252
+ local resolved = walk(game, 1)
253
+ if resolved then
254
+ return resolved
202
255
  end
203
256
 
204
- return current
257
+ local failedAt = deepest + 1
258
+ local reached = if deepest > 0 then table.concat(segments, ".", 1, deepest) .. "." else ""
259
+ local named = if deepestSegment
260
+ then reached .. deepestSegment
261
+ else table.concat(segments, ".", 1, failedAt)
262
+ return Dispatch.fail(
263
+ "NOT_FOUND",
264
+ string.format('No instance at "%s".', named),
265
+ if deepestNote
266
+ then deepestNote
267
+ else string.format(
268
+ '"%s" has no child named "%s". It does have: %s',
269
+ deepestParent:GetFullName(),
270
+ segments[failedAt],
271
+ childNames(deepestParent, 25)
272
+ )
273
+ )
205
274
  end
206
275
 
207
276
  --[[
@@ -151,6 +151,31 @@ function Serialize.readProperty(instance: Instance, name: string): (boolean, any
151
151
  return true, Serialize.value(value)
152
152
  end
153
153
 
154
+ --[[
155
+ Pulls the numbers out of a composite value written as text.
156
+
157
+ This used to be a single `-?%d+%.?%d*` scan, which is not the grammar Luau
158
+ numbers actually have: "1e3, 0, .5" came back as 1, 3 and 0, so a Position
159
+ written in scientific notation was silently set to a different point in the
160
+ world and reported as applied. Splitting on the separators and handing each
161
+ token to `tonumber` means this agrees with the scalar path, which always
162
+ used `tonumber` -- the two disagreeing about what counts as a number was the
163
+ whole bug.
164
+
165
+ Non-numeric tokens are dropped, so "Vector3.new(1, 2, 3)" and "{1, 2, 3}"
166
+ still read as three numbers.
167
+ ]]
168
+ local function scanTokens(text: string): { number }
169
+ local numbers: { number } = {}
170
+ for token in string.gmatch(text, "[^,%s%(%)%[%]{}<>]+") do
171
+ local value = tonumber(token)
172
+ if value then
173
+ table.insert(numbers, value)
174
+ end
175
+ end
176
+ return numbers
177
+ end
178
+
154
179
  --[[
155
180
  Parses a serialized string back into a Roblox value, given the target type
156
181
  from the API dump. Returns ok=false with a reason the agent can act on
@@ -176,7 +201,22 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
176
201
  if kind == "boolean" then
177
202
  return true, text, nil
178
203
  end
179
- return true, text == "true", nil
204
+ if kind == "number" then
205
+ return true, text ~= 0, nil
206
+ end
207
+ -- This used to be `text == "true"`, which quietly turned "True", "TRUE"
208
+ -- and "1" into false and reported the write as applied. A bool that is
209
+ -- silently the opposite of what was asked is the worst failure this
210
+ -- module can produce, so near misses are accepted and anything else is
211
+ -- refused rather than guessed at.
212
+ local lowered = string.lower(tostring(text))
213
+ if lowered == "true" or lowered == "1" or lowered == "yes" then
214
+ return true, true, nil
215
+ end
216
+ if lowered == "false" or lowered == "0" or lowered == "no" then
217
+ return true, false, nil
218
+ end
219
+ return false, nil, string.format('expected true or false, got "%s"', tostring(text))
180
220
  end
181
221
  if NUMERIC_TYPES[valueType] then
182
222
  local parsed = tonumber(text)
@@ -188,8 +228,8 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
188
228
 
189
229
  local numbers: { number } = {}
190
230
  if kind == "string" then
191
- for match in string.gmatch(text, "-?%d+%.?%d*") do
192
- table.insert(numbers, tonumber(match) :: number)
231
+ for _, token in scanTokens(text) do
232
+ table.insert(numbers, token)
193
233
  end
194
234
  elseif kind == "table" then
195
235
  for _, item in text :: { any } do