Roblox Studio safety layer for Claude Code (Studio MCP): RemoteEvent security audit, undo, Team Create protection, a confirmation before destructive Luau, and…

A safety layer for building Roblox games with Claude Code through Roblox Studio's built-in MCP server. Claude edits your open place directly; roclaude makes that safe: it audits your RemoteEvents, keeps undo copies of Claude's edits, protects Team Create collaborators' work, and asks you before destructive Luau runs.
Requires Claude Code 2.1.287 or later and Roblox Studio with the MCP server enabled.
/roclaude activity (or Activity above the prompt) shows what Claude did in your place. A card says whether Claude is working or done and sums it up ("Claude edited 2 scripts, changed 1 object"). Below it, everything Claude touched is listed by service with a badge: Read, Edited, Created or Changed; rows in progress light up as reading… or editing….
Click a row to see its change, or step through the changes in order with Prev and Next:
Anchored false → true, Color rgb(99, 68, 44) → rgb(255, 0, 0)), or Created / Deleted. roclaude reads up to five objects' properties just before and after each execute_luau call; changes made through a local variable aren't seen.Where the app can place a side pane, the view opens beside the conversation (the first time Claude touches Studio, and on /roclaude activity). Where it can't, /roclaude activity draws the same view as a live card in the chat. When a turn ends with changes, a note in the transcript lists them with Claude's own summary.
/roclaude remotes: audit your whole gameIt finds every OnServerEvent and OnServerInvoke handler in the open place and sorts each one:
| Status | Meaning |
|---|---|
| UNCHECKED | Uses client values without checking them. Exploiters can send anything. |
| admin-only | Checks who is calling first (isAdmin(player), player.UserId ~= ..., group rank). |
| checked | Checks every client value (typeof, tonumber, ranges, lookups). |
| no args | Takes nothing from the client. |
Handlers inside third-party modules (admin kits like Kohl's Admin or Adonis, Packages) are listed separately, so your own code stands out. The results open in a pane with a button that asks Claude to fix the unchecked handlers.
multi_edit is refused, and Claude is told to read the script again and redo its edit on the current version, so it never overwrites their work. Claude's own edits don't count as changes./roclaude. Destructive-Luau confirmations name them too./roclaude undo refuses when the script changed after Claude's edit; /roclaude undo force overrides.Before each multi_edit, roclaude reads the script and keeps a copy. /roclaude undo puts back the latest one, and a script Claude created is deleted. /roclaude history lists what Claude changed this session. Studio edits made through MCP have no git history, so this is your safety net. Up to 50 copies are kept per session.
When Claude wants to run execute_luau code that deletes instances (:Destroy, :ClearAllChildren), writes DataStores (SetAsync, UpdateAsync, RemoveAsync), kicks or bans players, overwrites .Source, or loads code by asset id, you're asked first, even if you've allowed the tool. Read-only code runs as usual.
What Claude writes through multi_edit is checked, and findings go back to Claude so it fixes them in the same turn:
wait/spawn/delay instead of the task library, game.Players instead of GetService, lowercase :connect, Instance.new with a parent argument, and LocalPlayer in server scripts.After Claude changes a script, it's reminded to playtest, read the Output and fix errors before telling you it's done. Claude also gets these conventions in the multi_edit tool's description, so it sees them exactly when it edits.
A band above the prompt shows the place, the Team Create collaborators, how many edits can be undone, the last playtest result and the last check, for example: ◆ test2 · 👥 alex_dev · 3 edit(s) undoable · playtest: clean · Shop clean.
At the prompt of a Claude Code terminal session:
/plugin install roclaude --marketplace vinkdc/roclaude
Answer y to add the marketplace, then choose a scope.
To connect Claude Code to Studio, turn on Assistant → … → Manage MCP Servers → Enable Studio as MCP server in Roblox Studio, then:
claude mcp add --scope user --transport stdio Roblox_Studio -- cmd.exe /c %LOCALAPPDATA%\Roblox\mcp.bat
On macOS, use /Applications/RobloxStudio.app/Contents/MacOS/StudioMCP as the command. roclaude recognizes any MCP server whose name contains "roclaude" or "studio".
| Command | What it does |
|---|---|
/roclaude | Studio, Team Create and playtest status, and the last check's findings |
/roclaude activity | Shows what Claude touched and replays each change, in a side pane or as a card in the chat |
/roclaude remotes | Audits every RemoteEvent and RemoteFunction handler, and opens the results in a pane |
/roclaude undo | Restores the script from Claude's latest edit; /roclaude undo force restores it even if it changed since |
/roclaude history | Lists Claude's Studio edits this session |
/roclaude hide / show | Hides or shows the band |
roclaude only talks to the Roblox Studio MCP server. It reads no files, makes no network requests, runs no processes and makes no model calls.
| Access | Why |
|---|---|
tool.call on the Studio server's tools | Lets each call run, then: reads script_read results to know what Claude saw; around multi_edit, refuses a stale edit, keeps undo copies and attaches findings; reads the studio name, errors and collaborators from list_roblox_studios and get_console_output results. |
tool.check on execute_luau | Changes "allow" to "ask" for destructive code. It never allows anything that was going to be refused. |
tool.describe on multi_edit | Appends the Roblox and Team Create notes to the tool's description. |
$.mcp.call → script_read | Saves a script before and after Claude's multi_edit, and reads handler scripts for /roclaude remotes. |
$.mcp.call → list_roblox_studios, script_grep | Only for /roclaude remotes: finds the open studio and the scripts that handle remotes. |
$.mcp.call → multi_edit, execute_luau | Only when you run /roclaude undo: puts back the saved source, or deletes a script Claude created. |
Undo copies and audit results stay in the session's memory. Claude Code asks you once to allow the Studio tools roclaude calls itself.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT
hooks/register.tsx 1067 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PropMap, RemoteReport, Snapshot, StudioInfo, TouchAction, TouchedItem } from '../types'
5import { firstSentence, lineDiff } from './diff'
6import {
7 leafOf,
8 normalizePath,
9 parseProps,
10 pathsChanged,
11 propChanges,
12 serviceOf,
13 settle,
14 summarize,
15 touch,
16} from './activity'
17import { auditRemotes, formatIssues, lintLuau, scanRemotes } from './lint'
18import {
19 changesPlace,
20 countErrors,
21 dangersIn,
22 GREP_CAP,
23 grepScripts,
24 isVendor,
25 luauLookup,
26 saysPlaying,
27 scriptKey,
28 stillAsSeen,
29 stripLineNumbers,
30 studioId,
31 studioMode,
32 studioName,
33 studioTool,
34 teamFrom,
35 writesScripts,
36} from './studio'
37import type { SeenText } from './studio'
38
39const lastLint = atom({ plugin: 'roclaude', key: 'lastLint' } as const, null)
40const isHidden = atom({ plugin: 'roclaude', key: 'isHidden' } as const, false)
41const studio = atom({ plugin: 'roclaude', key: 'studio' } as const, null)
42const history = atom({ plugin: 'roclaude', key: 'history' } as const, [])
43const remotes = atom({ plugin: 'roclaude', key: 'remotes' } as const, null)
44const team = atom({ plugin: 'roclaude', key: 'team' } as const, [])
45const staleBlocks = atom({ plugin: 'roclaude', key: 'staleBlocks' } as const, 0)
46const touched = atom({ plugin: 'roclaude', key: 'touched' } as const, [])
47const isWorking = atom({ plugin: 'roclaude', key: 'isWorking' } as const, false)
48const turnNo = atom({ plugin: 'roclaude', key: 'turn' } as const, 0)
49const selected = atom({ plugin: 'roclaude', key: 'selected' } as const, null)
50const cardRun = atom({ plugin: 'roclaude', key: 'cardRun' } as const, 0)
51
52const MAX_SNAPSHOTS = 50
53const STUDIO_TOOL = /^mcp__.*(roblox|studio).*__[a-z_]+$/i
54const DEFAULT_SERVER = 'Roblox_Studio'
55const REMOTES_PANE = 'roclaude-remotes'
56const ACTIVITY_PANE = 'roclaude-activity'
57// The Studio tools roclaude calls itself through $.mcp.call.
58const OWN_CALLS = new Set([
59 'list_roblox_studios',
60 'get_studio_state',
61 'inspect_instance',
62 'script_grep',
63 'script_read',
64 'multi_edit',
65 'execute_luau',
66])
67const NO_STUDIO: StudioInfo = { name: null, server: null, id: null, errors: null, mode: null, untested: 0 }
68const POLL_MS = 10_000
69// At most this many objects per execute_luau get a before → after record.
70const MAX_INSPECT = 5
71const TEST_PROMPT =
72 'Playtest the open place: start it with start_stop_play, give it a few seconds, read get_console_output, stop play, then fix any errors you find and tell me what you changed.'
73
74// A studio_id argument, or none when no studio is known yet.
75const withStudio = (sid: string) => (sid === '' ? {} : { studio_id: sid })
76
77const textOf = (content: readonly { type: string; text?: string }[]) =>
78 content.map(block => (block.type === 'text' ? (block.text ?? '') : '')).join('')
79const shortName = (path: string) => path.split('.').pop() ?? path
80
81// Reads a script through the Studio MCP server: its source, null when it
82// does not exist yet, undefined when it could not be read.
83async function readScript(
84 $: EngineInterface,
85 server: string,
86 sid: string,
87 path: string,
88): Promise<string | null | undefined> {
89 try {
90 const target = path.startsWith('game.') ? path : `game.${path}`
91 const res = await $.mcp.call(server, 'script_read', { ...withStudio(sid), target_file: target })
92 const text = textOf(res.content)
93 if (res.isError) return /Script not found/i.test(text) ? null : undefined
94 return stripLineNumbers(text)
95 } catch {
96 return undefined
97 }
98}
99
100async function undo($: EngineInterface, isForced: boolean): Promise<string> {
101 const list = await read($, history)
102 const last = list[list.length - 1]
103 if (last === undefined) return 'Nothing to undo: Claude has not edited a Studio script this session.'
104
105 const { server, studioId: sid, path } = last
106 const current = await readScript($, server, sid, path)
107 if (current === undefined) {
108 return `Could not read ${path} to undo it. Is Studio still open and in Edit mode?`
109 }
110 // In Team Create someone else may have edited the script since: never
111 // overwrite that work unless asked to.
112 if (!isForced && last.after !== null && current !== null && current !== last.after) {
113 return `${path} changed after Claude's edit (by you or a teammate), so undo would discard that work. Run /roclaude undo force to restore Claude's saved copy anyway.`
114 }
115
116 let res
117 if (last.source === null) {
118 if (current === null) {
119 await update($, history, all => all.slice(0, -1))
120 return `${path} is already gone; dropped that step.`
121 }
122 const code = [`local s = ${luauLookup(path)}`, 'if s then s:Destroy() end', 'return s ~= nil'].join('\n')
123 res = await $.mcp.call(server, 'execute_luau', { ...withStudio(sid), datamodel_type: 'Edit', code })
124 } else {
125 if (current === last.source) {
126 await update($, history, all => all.slice(0, -1))
127 return `${path} already matches its saved copy; dropped that step.`
128 }
129 if (current === null) {
130 return `${path} no longer exists, so there is nothing to restore into.`
131 }
132 res = await $.mcp.call(server, 'multi_edit', {
133 ...withStudio(sid),
134 datamodel_type: 'Edit',
135 file_path: path,
136 edits: [{ old_string: current, new_string: last.source }],
137 })
138 }
139
140 if (res.isError) return `Undo of ${path} failed: ${textOf(res.content).slice(0, 300)}`
141 await update($, history, all => all.slice(0, -1))
142 return last.source === null
143 ? `Undid the creation of ${path} (deleted it).`
144 : `Restored ${path} to how it was before Claude's edit.`
145}
146
147type Target = {
148 path: string
149 kind: TouchedItem['kind']
150 action: TouchAction
151 propsBefore?: PropMap | null
152 propsAfter?: PropMap | null
153}
154let isActivityOpened = false
155// What Claude changed in the running turn, for the note when it ends.
156const changedThisTurn: string[] = []
157const CARD_MARK = 'roclaude activity #'
158
159// Shows the activity view: as a pane where the surface places one, or else
160// as a card in the chat (the command's row draws it). Answers the row's text.
161async function showActivity($: EngineInterface): Promise<string> {
162 const isShown = async () => {
163 try {
164 return (await $.ui.panes()).some(pane => pane.id === ACTIVITY_PANE && pane.isPlaced)
165 } catch {
166 return false
167 }
168 }
169 // Open (or bring forward) the pane, but never wait on it for long: a
170 // surface may hold the open until it can seat the pane.
171 const opening = $.ui.open({ id: ACTIVITY_PANE, title: 'Studio activity' }).then(
172 opened => opened.isPlaced,
173 () => false,
174 )
175 const isPlaced = await Promise.race([opening, $.clock.sleep(1_500).then(() => null)])
176 if (isPlaced === true || (isPlaced === null && (await isShown()))) {
177 return 'Studio activity is open beside the conversation.'
178 }
179 const run = (await read($, cardRun)) + 1
180 await update($, cardRun, () => run)
181 return `${CARD_MARK}${run}`
182}
183
184// An object's properties through the Studio MCP server: null when it does
185// not exist, undefined when they could not be read.
186async function inspectProps(
187 $: EngineInterface,
188 server: string,
189 sid: string,
190 path: string,
191): Promise<PropMap | null | undefined> {
192 try {
193 const res = await $.mcp.call(server, 'inspect_instance', { ...withStudio(sid), path })
194 const text = textOf(res.content)
195 if (res.isError) return /not found|no instance|does not exist|could not find/i.test(text) ? null : undefined
196 return parseProps(text)
197 } catch {
198 return undefined
199 }
200}
201
202// A call on these is starting: show them as being read, edited or changed,
203// and open the activity pane the first time Claude touches the place.
204async function begin($: EngineInterface, targets: readonly Target[], as: NonNullable<TouchedItem['activeAs']>) {
205 if (targets.length === 0) return
206 await update($, touched, list =>
207 targets.reduce<TouchedItem[]>((all, target) => {
208 const known = all.find(one => one.path === normalizePath(target.path))
209 return touch(all, { ...target, action: known?.action ?? 'read', isActive: true, activeAs: as })
210 }, [...list]),
211 )
212 if (!isActivityOpened) {
213 isActivityOpened = true
214 // Where no pane can be placed (an app without side panes, a headless
215 // run) this waits unseen; the band's Activity button shows a card instead.
216 $.ui.open({ id: ACTIVITY_PANE, title: 'Studio activity' }).catch(() => undefined)
217 }
218}
219
220// The call ended: record what it did, or only settle the rows when it failed.
221// A change becomes the one the activity view shows.
222async function finish($: EngineInterface, targets: readonly Target[], isDone: boolean) {
223 if (targets.length === 0) return
224 await update($, touched, list =>
225 targets.reduce<TouchedItem[]>(
226 (all, target) =>
227 isDone ? touch(all, { ...target, isActive: false, activeAs: null }) : settle(all, target.path),
228 [...list],
229 ),
230 )
231 const changes = isDone ? targets.filter(target => target.action !== 'read') : []
232 for (const target of changes) {
233 const path = normalizePath(target.path)
234 if (!changedThisTurn.includes(path)) changedThisTurn.push(path)
235 }
236 const last = changes[changes.length - 1]
237 if (last !== undefined) await update($, selected, () => normalizePath(last.path))
238}
239
240// Asks the Studio MCP server which place is open and whether it is playing,
241// for the band. Writes only what changed, so an idle poll redraws nothing.
242async function probeStudio($: EngineInterface): Promise<void> {
243 try {
244 const known = await read($, studio)
245 const server = known?.server ?? DEFAULT_SERVER
246 let { id, name } = known ?? NO_STUDIO
247 if (id === null || name === null) {
248 const text = textOf((await $.mcp.call(server, 'list_roblox_studios', {})).content)
249 id = studioId(text)
250 name = studioName(text)
251 if (id === null) return
252 }
253 const sid = id
254 const state = await $.mcp.call(server, 'get_studio_state', { studio_id: sid })
255 const mode = state.isError ? (known?.mode ?? null) : studioMode(textOf(state.content))
256
257 if (known?.id === sid && known.name === name && known.server === server && known.mode === mode) return
258 await update($, studio, info => noteMode({ ...(info ?? NO_STUDIO), server, id: sid, name }, mode))
259 } catch {
260 // No Studio MCP server, or Studio is closed: the band says not connected.
261 }
262}
263
264// A playtest that starts counts as testing what came before it.
265function noteMode(info: StudioInfo, mode: StudioInfo['mode']): StudioInfo {
266 if (mode === 'play' && info.mode !== 'play') return { ...info, mode, untested: 0, errors: null }
267 return { ...info, mode }
268}
269
270// Audits every RemoteEvent and RemoteFunction handler in the open place.
271async function scanStudio($: EngineInterface): Promise<RemoteReport | string> {
272 const seen = await read($, studio)
273 const server = seen?.server ?? DEFAULT_SERVER
274 let id = seen?.id ?? null
275 try {
276 // A server that just started needs a few seconds before Studio registers.
277 for (let attempt = 0; id === null && attempt < 4; attempt += 1) {
278 if (attempt > 0) await $.clock.sleep(2_000)
279 const listed = await $.mcp.call(server, 'list_roblox_studios', {})
280 id = studioId(textOf(listed.content))
281 }
282 if (id === null) return 'No Roblox Studio is connected. Open your place with the MCP server enabled in Assistant settings.'
283
284 const grep = await $.mcp.call(server, 'script_grep', { studio_id: id, query: 'OnServer' })
285 if (grep.isError) return `Could not search the place's scripts: ${textOf(grep.content).slice(0, 200)}`
286 const found = grepScripts(textOf(grep.content))
287
288 const handlers: RemoteReport['handlers'][number][] = []
289 for (const script of found.scripts) {
290 const source = await readScript($, server, id, script)
291 if (typeof source !== 'string') continue
292 handlers.push(...scanRemotes(source).map(handler => ({ ...handler, script, isVendor: isVendor(script) })))
293 }
294
295 return {
296 scripts: found.scripts.length,
297 handlers,
298 note:
299 found.matches >= GREP_CAP
300 ? `Studio's script search stops at ${GREP_CAP} matches, so some handlers may be missing.`
301 : null,
302 }
303 } catch (error) {
304 return `Could not reach the Roblox Studio MCP server "${server}": ${error instanceof Error ? error.message : String(error)}`
305 }
306}
307
308const STATUS_ORDER = { unchecked: 0, gated: 1, checked: 2, 'no-args': 3 } as const
309
310// The game's own handlers, worst first, and the third-party ones apart.
311function orderHandlers(report: RemoteReport) {
312 const sorted = [...report.handlers].sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status])
313 const own = sorted.filter(handler => !handler.isVendor)
314 const vendor = sorted.filter(handler => handler.isVendor)
315 const bad = own.filter(handler => handler.status === 'unchecked').length
316 return { own, vendor, bad }
317}
318
319function remotesText(report: RemoteReport): string {
320 const { own, vendor, bad } = orderHandlers(report)
321 const row = (handler: RemoteReport['handlers'][number]) =>
322 ` [${handler.status}] ${handler.script}:${handler.line} ${handler.remote}${handler.unchecked.length > 0 ? ` uses ${handler.unchecked.join(', ')} unchecked` : ''}`
323 return [
324 `RemoteEvent audit: ${own.length} server handler(s) in the game's own scripts, ${bad} unchecked.`,
325 ...own.map(row),
326 ...(vendor.length === 0
327 ? []
328 : [`Third-party modules (${vendor.length} handler(s); review or update them upstream):`, ...vendor.map(row)]),
329 ...(report.note === null ? [] : [report.note]),
330 ].join('\n')
331}
332
333const CONVENTIONS = [
334 '',
335 'roclaude notes for editing this place:',
336 '- Create and change scripts only with multi_edit (new scripts: className plus a first edit with an empty old_string), and read them with script_read. roclaude refuses execute_luau code that writes script source (.Source =, UpdateSourceAsync, Instance.new of a script), because only multi_edit edits can be undone, reviewed and kept safe in Team Create.',
337 '- Read a script in full with script_read before you edit it. In Team Create collaborators may be editing too: if a script changed since you last read it, roclaude refuses the edit and you must read it again first.',
338 '- Use the task library (task.wait, task.spawn, task.defer, task.delay), never the deprecated wait, spawn or delay. Get services with game:GetService("Name"); use PascalCase methods.',
339 '- The server never trusts what a client sends through a RemoteEvent or RemoteFunction: check types with typeof, ranges, rate and ownership. Luau type annotations are not runtime checks.',
340 '- Wrap DataStore, HttpService and MarketplaceService calls in pcall. Players.LocalPlayer exists only on the client.',
341 '- After changing scripts, playtest (start_stop_play), read get_console_output, stop, and fix errors before saying you are done. The person can undo your edits with /roclaude undo.',
342].join('\n')
343
344const EXECUTE_NOTE = [
345 '',
346 'roclaude: do not use this tool to read or write script source. Read scripts with script_read; create and change them with multi_edit. Code that writes source (.Source =, UpdateSourceAsync, Instance.new of a Script, LocalScript or ModuleScript) is refused.',
347].join('\n')
348
349const SCRIPT_WRITE_REFUSAL =
350 'roclaude: this Luau writes script source. Create or change scripts with multi_edit instead (for a new script: file_path like game.ServerScriptService.Name, className Script, LocalScript or ModuleScript, and a first edit with old_string "" holding the source). multi_edit edits can be undone with /roclaude undo, are reviewed for exploits, and are kept safe for Team Create collaborators.'
351
352// The activity view: what Claude touched, each row clickable, and the
353// selected change below it, a script's diff or an object's properties.
354async function drawActivity($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], width: number) {
355 const { Box, Button, Code, Text } = $.ui.resolve(e)
356 const list = await read($, touched)
357 const working = await read($, isWorking)
358 const snapshots = await read($, history)
359 const { title, detail } = summarize(list)
360 const isBusy = working || list.some(one => one.isActive)
361
362 const color = { read: 'merged', edited: 'claude', created: 'success', changed: 'suggestion' } as const
363 const label = { read: 'Read', edited: 'Edited', created: 'Created', changed: 'Changed' } as const
364 const status = isBusy ? 'WORKING' : list.length > 0 ? 'DONE' : 'IDLE'
365 const statusColor = isBusy ? 'warning' : list.length > 0 ? 'claude' : 'inactive'
366
367 const changes = list.filter(one => one.action !== 'read')
368 const chosen = (await read($, selected)) ?? changes[changes.length - 1]?.path ?? null
369 const current = list.find(one => one.path === chosen) ?? null
370 const at = current === null ? -1 : changes.findIndex(one => one.path === current.path)
371 const pick = (path: string) => update($, selected, () => path)
372
373 const services: string[] = []
374 for (const one of list) if (!services.includes(serviceOf(one.path))) services.push(serviceOf(one.path))
375
376 // The selected item's change.
377 let body = <Text dimColor>Select something Claude touched to see what changed.</Text>
378 if (current !== null && current.kind === 'script') {
379 const edits = snapshots.filter(one => normalizePath(one.path) === current.path && one.after !== null)
380 const first = edits[0]
381 const last = edits[edits.length - 1]
382 if (current.action === 'read' || first === undefined || last === undefined) {
383 body = <Text dimColor>Claude read this script and did not change it.</Text>
384 } else {
385 const diff = lineDiff(first.source, last.after ?? '')
386 body = (
387 <Box flexDirection="column">
388 <Text>
389 <Text color="success">+{diff.added}</Text> <Text color="error">−{diff.removed}</Text>
390 <Text dimColor>
391 {' '}
392 over {edits.length} edit{edits.length === 1 ? '' : 's'}
393 </Text>
394 </Text>
395 <Box borderStyle="round" borderColor="inactive">
396 {diff.unified === '' ? (
397 <Text dimColor>No line changes.</Text>
398 ) : (
399 <Code key={`diff-${current.path}`} source={diff.unified} format="diff" language="lua" path={leafOf(current.path)} />
400 )}
401 </Box>
402 </Box>
403 )
404 }
405 } else if (current !== null) {
406 const { propsBefore: before, propsAfter: after } = current
407 if (current.action === 'read') body = <Text dimColor>Claude looked at this object and did not change it.</Text>
408 else if (before === undefined && after === undefined) {
409 body = <Text dimColor>Claude changed this with Luau; its properties could not be read.</Text>
410 } else if (after === null) body = <Text color="error">Deleted.</Text>
411 else if (before === null || before === undefined) body = <Text color="success">Created by Claude.</Text>
412 else {
413 const rows = after === undefined ? [] : propChanges(before, after)
414 body =
415 rows.length === 0 ? (
416 <Text dimColor>No property changes seen.</Text>
417 ) : (
418 <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
419 {rows.slice(0, 12).map(row => (
420 <Box key={`prop-${row.name}`} justifyContent="space-between" gap={2}>
421 <Text dimColor>{row.name}</Text>
422 <Text wrap="truncate-start">
423 <Text color="error">{row.before}</Text>
424 <Text dimColor> → </Text>
425 <Text color="success">{row.after}</Text>
426 </Text>
427 </Box>
428 ))}
429 {rows.length > 12 && <Text dimColor>…and {rows.length - 12} more</Text>}
430 </Box>
431 )
432 }
433 }
434
435 return (
436 <Box flexDirection="column" width={width}>
437 <Box borderStyle="round" borderColor={isBusy ? 'warning' : 'inactive'} paddingX={1} flexDirection="column">
438 <Text bold color={statusColor}>
439 {status}
440 </Text>
441 <Text bold>{title}</Text>
442 {detail !== '' && <Text dimColor>{detail}</Text>}
443 </Box>
444
445 <Box justifyContent="space-between" marginTop={1}>
446 <Text bold dimColor>
447 CLAUDE TOUCHED
448 </Text>
449 <Text bold dimColor>
450 {list.length} {list.length === 1 ? 'ITEM' : 'ITEMS'}
451 </Text>
452 </Box>
453 {list.length === 0 && <Text dimColor>Scripts and objects Claude reads or changes in Studio show up here.</Text>}
454 {services.map(service => (
455 <Box flexDirection="column" key={`svc-${service}`}>
456 <Text dimColor>{service}</Text>
457 {list
458 .filter(one => serviceOf(one.path) === service)
459 .map(one => {
460 const isChosen = current?.path === one.path
461 const where = normalizePath(one.path).split('.').slice(1, -1).join('.')
462 const badge = one.isActive ? (one.activeAs === 'reading' ? 'merged' : 'claude') : color[one.action]
463 return (
464 <Box
465 key={`row-${one.path}`}
466 justifyContent="space-between"
467 alignItems="center"
468 gap={1}
469 paddingLeft={1}
470 backgroundColor={isChosen ? 'subtle' : undefined}
471 >
472 <Box gap={1} flexShrink={1} alignItems="center">
473 <Text bold color={one.kind === 'script' ? 'suggestion' : 'success'}>
474 {one.kind === 'script' ? 'LUA' : 'OBJ'}
475 </Text>
476 <Button key={`pick-${one.path}`} label={leafOf(one.path)} plain onPress={() => pick(one.path)} />
477 {where !== '' && (
478 <Text dimColor wrap="truncate-end">
479 {where}
480 </Text>
481 )}
482 </Box>
483 <Box flexShrink={0} paddingX={1} backgroundColor={badge}>
484 <Text bold color="inverseText">
485 {one.isActive ? `${one.activeAs}…` : label[one.action]}
486 </Text>
487 </Box>
488 </Box>
489 )
490 })}
491 </Box>
492 ))}
493
494 {current !== null && (
495 <Box flexDirection="column" marginTop={1}>
496 <Text bold color="claude">
497 {at >= 0 ? `CHANGE ${at + 1} OF ${changes.length}` : 'READ ONLY'}
498 </Text>
499 <Text bold>{leafOf(current.path)}</Text>
500 <Text dimColor>
501 {current.path} · {label[current.action]}
502 </Text>
503 </Box>
504 )}
505 <Box marginTop={current === null ? 1 : 0}>{body}</Box>
506
507 <Box marginTop={1} gap={1}>
508 {changes.length > 1 && (
509 <>
510 <Button key="prev" label="Prev" onPress={() => pick(changes[Math.max(0, at - 1)]!.path)} />
511 <Button
512 key="next"
513 label="Next"
514 variant="primary"
515 onPress={() => pick(changes[Math.min(changes.length - 1, at + 1)]!.path)}
516 />
517 </>
518 )}
519 {snapshots.length > 0 && (
520 <Button
521 key="undo-last"
522 label="Undo last edit"
523 onPress={async () => {
524 $.ui.toast(await undo($, false))
525 }}
526 />
527 )}
528 {list.length > 0 && !isBusy && (
529 <Button
530 key="clear"
531 label="Clear"
532 onPress={async () => {
533 await update($, touched, () => [])
534 await update($, selected, () => null)
535 }}
536 />
537 )}
538 </Box>
539 </Box>
540 )
541}
542
543export const register: Register = on => {
544 let nudgedThisTurn = false
545 // What Claude last read of each script, to catch edits against a stale copy.
546 const seenText = new Map<string, SeenText>()
547
548 on('session.start', async ($, e, next) => {
549 await $.command.register({
550 name: 'roclaude',
551 description: 'Roblox Studio: audit RemoteEvents, undo Claude’s edits, Team Create status',
552 argumentHint: '[activity|remotes|undo|history|show|hide]',
553 })
554 // Find the open place once the MCP server has had a moment to start.
555 $.clock.after(3_000, () => void probeStudio($))
556 $.clock.every(POLL_MS, () => void probeStudio($))
557 return next(e)
558 })
559
560 on('prompt.submit', async ($, e, next) => {
561 nudgedThisTurn = false
562 changedThisTurn.length = 0
563 await update($, isWorking, () => true)
564 await update($, turnNo, n => n + 1)
565 return next(e)
566 }).catch(($, e, next) => next(e))
567
568 on('turn.complete', async ($, e, next) => {
569 await update($, isWorking, () => false)
570 await update($, touched, list => (list.some(one => one.isActive) ? list.map(one => ({ ...one, isActive: false, activeAs: null })) : list))
571
572 // A turn that changed the place: say so in the transcript, with how to look.
573 if (changedThisTurn.length > 0) {
574 const names = changedThisTurn.map(leafOf)
575 const shown = names.slice(0, 4).join(' · ') + (names.length > 4 ? ` +${names.length - 4}` : '')
576 $.session
577 .append({
578 message: {
579 type: 'system',
580 content: [
581 {
582 type: 'text',
583 text: `Studio activity · Claude changed ${names.length} thing${names.length === 1 ? '' : 's'}: ${shown}\nClaude: ${firstSentence(e.answer)}\nPress Activity above the prompt, or type /roclaude activity, to see each change.`,
584 },
585 ],
586 },
587 })
588 .catch(() => undefined)
589 }
590 return next(e)
591 })
592
593 // Claude reads the notes where it needs them: in multi_edit's description.
594 on('tool.describe', { tool: STUDIO_TOOL }, async ($, e, next) => {
595 const described = await next(e)
596 const name = studioTool(e.tool)?.name
597 if (name === 'multi_edit') return { ...described, description: described.description + CONVENTIONS }
598 if (name === 'execute_luau') return { ...described, description: described.description + EXECUTE_NOTE }
599 return described
600 }).catch(($, e, next) => next(e))
601
602 on('tool.call', { tool: STUDIO_TOOL }, async ($, e, next) => {
603 const found = studioTool(e.tool)
604 if (found === null) return next(e)
605 const args = e as unknown as Record<string, unknown>
606 const usedId = typeof args.studio_id === 'string' ? args.studio_id : null
607
608 const known = await read($, studio)
609 if (known === null || known.server !== found.server || (usedId !== null && known.id !== usedId)) {
610 await update($, studio, info => ({ ...(info ?? NO_STUDIO), server: found.server, id: usedId ?? info?.id ?? null }))
611 }
612
613 if (found.name === 'get_studio_state') {
614 const ran = await next(e)
615 const mode = ran.deny === undefined && ran.isError !== true ? studioMode(ran.text ?? '') : null
616 if (mode !== null) await update($, studio, info => noteMode(info ?? NO_STUDIO, mode))
617 return ran
618 }
619
620 if (found.name === 'start_stop_play') {
621 const ran = await next(e)
622 if (ran.deny === undefined && ran.isError !== true) {
623 await update($, studio, info => noteMode(info ?? NO_STUDIO, args.is_start === true ? 'play' : 'edit'))
624 }
625 return ran
626 }
627
628 if (found.name === 'execute_luau') {
629 const code = typeof args.code === 'string' ? args.code : ''
630 const paths = args.datamodel_type === 'Edit' && changesPlace(code) ? pathsChanged(code).slice(0, MAX_INSPECT) : []
631 const sid = usedId ?? known?.id ?? ''
632 await begin(
633 $,
634 paths.map(path => ({ path, kind: 'instance' as const, action: 'changed' as const })),
635 'changing',
636 )
637 // Each object's properties just before and after, for the before → after view.
638 const before = await Promise.all(paths.map(path => inspectProps($, found.server, sid, path)))
639 const ran = await next(e)
640 const isDone = ran.deny === undefined && ran.isError !== true
641 const after = isDone ? await Promise.all(paths.map(path => inspectProps($, found.server, sid, path))) : []
642 await finish(
643 $,
644 paths.map((path, i) => ({
645 path,
646 kind: 'instance' as const,
647 action: before[i] === null && after[i] != null ? ('created' as const) : ('changed' as const),
648 propsBefore: before[i],
649 propsAfter: after[i],
650 })),
651 isDone,
652 )
653 if (ran.isError === true && saysPlaying(ran.text ?? '')) {
654 await update($, studio, info => noteMode(info ?? NO_STUDIO, 'play'))
655 } else if (ran.deny === undefined && ran.isError !== true && args.datamodel_type === 'Edit' && changesPlace(code)) {
656 await update($, studio, info => ({ ...(info ?? NO_STUDIO), untested: (info?.untested ?? 0) + 1 }))
657 }
658 return ran
659 }
660
661 if (found.name === 'list_roblox_studios') {
662 const ran = await next(e)
663 if (ran.deny === undefined && ran.isError !== true) {
664 const name = studioName(ran.text ?? '')
665 const id = studioId(ran.text ?? '')
666 await update($, studio, info => ({ ...(info ?? NO_STUDIO), name, id: info?.id ?? id }))
667 }
668 return ran
669 }
670
671 if (found.name === 'get_console_output') {
672 const ran = await next(e)
673 if (ran.deny === undefined && ran.isError !== true) {
674 const output = ran.text ?? ''
675 await update($, studio, info => ({ ...(info ?? NO_STUDIO), errors: countErrors(output) }))
676 await update($, team, members => teamFrom(output, members))
677 }
678 return ran
679 }
680
681 if (found.name === 'script_read') {
682 const path = typeof args.target_file === 'string' ? args.target_file : ''
683 const reading = path === '' ? [] : [{ path, kind: 'script' as const, action: 'read' as const }]
684 await begin($, reading, 'reading')
685 const ran = await next(e)
686 await finish($, reading, ran.deny === undefined && ran.isError !== true)
687 if (ran.deny === undefined && ran.isError !== true && path !== '') {
688 const isWhole = args.should_read_entire_file !== false
689 const start = isWhole ? 1 : typeof args.start_line_one_indexed === 'number' ? args.start_line_one_indexed : 1
690 seenText.set(scriptKey(path), { start, lines: stripLineNumbers(ran.text ?? '').split('\n'), isWhole })
691 }
692 return ran
693 }
694
695 if (found.name === 'inspect_instance' || found.name === 'insert_asset') {
696 const isInsert = found.name === 'insert_asset'
697 const parent = typeof args.parentPath === 'string' ? args.parentPath : 'Workspace'
698 const target = isInsert
699 ? `${parent}.${typeof args.assetName === 'string' ? args.assetName : 'Asset'}`
700 : typeof args.path === 'string'
701 ? args.path
702 : ''
703 const items =
704 target === '' ? [] : [{ path: target, kind: 'instance' as const, action: isInsert ? ('created' as const) : ('read' as const) }]
705 await begin($, items, isInsert ? 'changing' : 'reading')
706 const ran = await next(e)
707 const ok = ran.deny === undefined && ran.isError !== true
708 const added = ok && isInsert ? await inspectProps($, found.server, usedId ?? known?.id ?? '', target) : undefined
709 await finish($, isInsert ? items.map(item => ({ ...item, propsBefore: null, propsAfter: added })) : items, ok)
710 if (ok && isInsert) await update($, studio, info => ({ ...(info ?? NO_STUDIO), untested: (info?.untested ?? 0) + 1 }))
711 return ran
712 }
713
714 if (found.name !== 'multi_edit') return next(e)
715
716 const path = typeof args.file_path === 'string' ? args.file_path : ''
717 const editing = [{ path, kind: 'script' as const, action: 'edited' as const }]
718 await begin($, editing, 'editing')
719 const sid = usedId ?? known?.id ?? ''
720 const before = await readScript($, found.server, sid, path)
721
722 // Team Create: refuse an edit made against a copy someone has since changed.
723 const seen = seenText.get(scriptKey(path))
724 if (seen !== undefined && typeof before === 'string' && !stillAsSeen(seen, before)) {
725 seenText.delete(scriptKey(path))
726 await update($, staleBlocks, count => count + 1)
727 await finish($, editing, false)
728 return {
729 deny: `roclaude: ${path} changed after you last read it, likely a Team Create collaborator editing it. Read it again with script_read, then redo your edit on the current version so you don't overwrite their work.`,
730 }
731 }
732
733 const ran = await next(e)
734 if (ran.isError === true && saysPlaying(ran.text ?? '')) {
735 await update($, studio, info => noteMode(info ?? NO_STUDIO, 'play'))
736 }
737 const isEdited = ran.deny === undefined && ran.isError !== true
738 await finish($, before === null ? [{ ...editing[0]!, action: 'created' }] : editing, isEdited)
739 if (!isEdited) return ran
740 await update($, studio, info => ({ ...(info ?? NO_STUDIO), untested: (info?.untested ?? 0) + 1 }))
741
742 const after = before !== undefined ? ((await readScript($, found.server, sid, path)) ?? null) : null
743 if (before !== undefined) {
744 const turn = await read($, turnNo)
745 await update($, history, list => {
746 const id = (list[list.length - 1]?.id ?? 0) + 1
747 const snapshot: Snapshot = { id, server: found.server, studioId: sid, path, source: before, after, turn }
748 return [...list, snapshot].slice(-MAX_SNAPSHOTS)
749 })
750 }
751 if (after !== null) {
752 await update($, remotes, report => {
753 if (report === null) return report
754 const key = scriptKey(path)
755 const kept = report.handlers.filter(handler => scriptKey(handler.script) !== key)
756 const fresh = scanRemotes(after).map(handler => ({ ...handler, script: key, isVendor: isVendor(key) }))
757 if (fresh.length === 0 && kept.length === report.handlers.length) return report
758 return { ...report, handlers: [...kept, ...fresh] }
759 })
760 }
761 // Claude knows the script as its own edit left it.
762 if (after !== null) seenText.set(scriptKey(path), { start: 1, lines: after.split('\n'), isWhole: true })
763 else seenText.delete(scriptKey(path))
764
765 const edits = Array.isArray(args.edits) ? (args.edits as { new_string?: unknown }[]) : []
766 const written = edits.map(edit => (typeof edit.new_string === 'string' ? edit.new_string : '')).join('\n')
767 const className = typeof args.className === 'string' ? args.className : ''
768 const lintPath = className === 'Script' ? `${path}.server.luau` : path
769 const issues = [...lintLuau(lintPath, written), ...auditRemotes(written)].map(issue => ({ ...issue, line: 0 }))
770 await update($, lastLint, () => ({ file: path, issues }))
771
772 const context = [...(ran.context ?? [])]
773 if (issues.length > 0) context.push(formatIssues(path, issues).replace(/^ {2}L0 /gm, ' - '))
774 if (!nudgedThisTurn) {
775 nudgedThisTurn = true
776 context.push(
777 'roclaude: you changed a script in the open place. Before you finish, playtest with start_stop_play, read get_console_output, stop play, and fix any errors. The person can undo your edit with /roclaude undo.',
778 )
779 }
780
781 return { ...ran, context }
782 }).catch(($, e, next) => next(e))
783
784 // Ask the person before Luau that deletes, writes player data or loads outside code.
785 on('tool.check', { tool: STUDIO_TOOL }, async ($, e, next) => {
786 const found = studioTool(e.tool)
787 const verdict = await next(e)
788
789 // roclaude's own calls: reads for /roclaude remotes and snapshots, and the
790 // restore of a /roclaude undo the person typed. Auto mode's classifier gives
791 // no verdict on a plugin's call, so the plugin answers for its own; a deny
792 // rule the person wrote in settings still stands.
793 if (next.origin.plugin === 'roclaude' && found !== null && OWN_CALLS.has(found.name)) {
794 if (verdict.decision === 'deny' && verdict.rule !== undefined) return verdict
795 return { decision: 'allow', reason: `roclaude's own ${found.name}` }
796 }
797
798 if (found?.name !== 'execute_luau' || verdict.decision === 'deny') return verdict
799
800 const input = e.input as { code?: unknown } | null
801 const code = typeof input?.code === 'string' ? input.code : ''
802 if (writesScripts(code)) return { decision: 'deny', reason: SCRIPT_WRITE_REFUSAL }
803
804 const dangers = dangersIn(code)
805 if (dangers.length === 0) return verdict
806
807 const members = await read($, team)
808 const shared = members.length > 1 ? ` Team Create: ${members.join(', ')} will see this change.` : ''
809 return {
810 decision: 'ask',
811 reason: `roclaude: this Luau ${dangers.map(danger => danger.reason).join('; ')}. roclaude cannot undo it.${shared}`,
812 }
813 }).catch(($, e, next) => next(e))
814
815 on('command.run', { command: 'roclaude' }, async ($, e) => {
816 const arg = e.args.trim().toLowerCase()
817 if (arg === 'hide' || arg === 'show') {
818 await update($, isHidden, () => arg === 'hide')
819 return { text: `roclaude band ${arg === 'hide' ? 'hidden' : 'shown'}.` }
820 }
821 if (arg === 'undo' || arg === 'undo force') {
822 return { text: await undo($, arg === 'undo force') }
823 }
824 if (arg === 'activity' || arg === 'files' || arg === 'replay') {
825 return { text: await showActivity($) }
826 }
827 if (arg === 'remotes') {
828 const report = await scanStudio($)
829 if (typeof report === 'string') return { text: report }
830 await update($, remotes, () => report)
831 await $.ui.open({ id: REMOTES_PANE, title: 'RemoteEvent audit' })
832 return { text: remotesText(report) }
833 }
834 if (arg === 'history') {
835 const list = await read($, history)
836 if (list.length === 0) return { text: 'No Studio edits by Claude this session.' }
837 return {
838 text: [
839 `Claude's Studio edits this session, newest last (/roclaude undo reverts the newest):`,
840 ...list.map(s => ` ${s.id}. ${s.path}${s.source === null ? ' (created)' : ''}`),
841 ].join('\n'),
842 }
843 }
844
845 const seen = await read($, studio)
846 if (seen === null) {
847 return {
848 text: 'roclaude is waiting: Claude has not used the Roblox Studio MCP server this session. Try /roclaude remotes, or ask Claude to look at your place.',
849 }
850 }
851 const lint = await read($, lastLint)
852 const list = await read($, history)
853 const members = await read($, team)
854 const blocks = await read($, staleBlocks)
855 return {
856 text: [
857 `Studio: ${seen.name ?? 'connected'}, ${list.length} script edit(s) saved for undo`,
858 `Team Create: ${members.length > 0 ? members.join(', ') : 'no one else seen in Output yet'}${blocks > 0 ? `; ${blocks} stale edit(s) refused` : ''}`,
859 `Last playtest output: ${seen.errors === null ? 'not read yet' : `${seen.errors} error line(s)`}`,
860 lint === null
861 ? 'Last Luau check: none yet.'
862 : lint.issues.length === 0
863 ? `Last Luau check: ${lint.file} is clean.`
864 : formatIssues(lint.file, lint.issues).replace(/^ {2}L0 /gm, ' - '),
865 ].join('\n'),
866 }
867 }).catch(($, e, next) => ({
868 // The command answers even when something in it fails, and says what.
869 text: `roclaude: /roclaude ${e.args} failed (${next.error.kind}). Try again; if it keeps failing, restart the session.`,
870 }))
871
872 on('ui.render', { component: 'Pane', requestId: REMOTES_PANE }, async ($, e) => {
873 const { Box, Button, Text } = $.ui.resolve(e)
874 const report = await read($, remotes)
875 if (report === null) return <Text dimColor>Run /roclaude remotes to audit this game's RemoteEvents.</Text>
876
877 const { own, vendor, bad } = orderHandlers(report)
878 const room = Math.max(3, (e.viewport?.rows ?? 24) - 8)
879 const color = { unchecked: 'error', gated: 'warning', checked: 'success', 'no-args': 'inactive' } as const
880 const label = { unchecked: 'UNCHECKED', gated: 'admin-only', checked: 'checked', 'no-args': 'no args' } as const
881 const unchecked = own
882 .filter(handler => handler.status === 'unchecked')
883 .map(handler => `- ${handler.script}:${handler.line} ${handler.remote} (uses ${handler.unchecked.join(', ')})`)
884
885 return (
886 <Box flexDirection="column">
887 <Text>
888 <Text bold>{own.length}</Text> handler(s) in the game's scripts ·{' '}
889 <Text color={bad > 0 ? 'error' : 'success'}>{bad} unchecked</Text>
890 {vendor.length > 0 && <Text dimColor> · {vendor.length} in third-party modules</Text>}
891 </Text>
892 {own.slice(0, room).map(handler => (
893 <Box flexDirection="column">
894 <Text wrap="truncate-start">
895 <Text color={color[handler.status]}>{label[handler.status].padEnd(10)}</Text> {handler.remote}{' '}
896 <Text dimColor>
897 {handler.script}:{handler.line}
898 </Text>
899 </Text>
900 {handler.unchecked.length > 0 && (
901 <Text dimColor>{' '}trusts: {handler.unchecked.join(', ')}</Text>
902 )}
903 </Box>
904 ))}
905 {own.length > room && <Text dimColor>…and {own.length - room} more (see /roclaude remotes)</Text>}
906 {report.note !== null && <Text dimColor>{report.note}</Text>}
907 {bad > 0 && (
908 <Box>
909 <Button
910 key="fix"
911 label="Ask Claude to fix unchecked"
912 onPress={() =>
913 $.prompt.submit({
914 text: [
915 'Fix these RemoteEvent handlers that roclaude found using client values unchecked. For each, add a permission check or validate every client value (typeof, ranges, cooldowns, ownership), without changing behavior for legitimate players:',
916 ...unchecked,
917 ].join('\n'),
918 })
919 }
920 />
921 </Box>
922 )}
923 </Box>
924 )
925 })
926
927 on('ui.render', { component: 'Pane', requestId: ACTIVITY_PANE }, async ($, e) =>
928 drawActivity($, e, e.props.bodyColumns ?? e.viewport?.columns ?? 50),
929 )
930
931 // Where no pane can be placed, /roclaude activity draws the view in the chat.
932 // Only the newest card is live; older ones point to it.
933 on('ui.render', { component: 'CommandOutput', props: { command: 'roclaude' } }, async ($, e, next) => {
934 const match = e.props.text.match(/roclaude activity #(\d+)/)
935 if (match === null) return next(e)
936 const { Text } = $.ui.resolve(e)
937 if (Number(match[1]) !== (await read($, cardRun))) return <Text dimColor>Studio activity (a newer card is below).</Text>
938 return drawActivity($, e, Math.min(e.viewport?.columns ?? 80, 90))
939 })
940
941 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
942 const seen = await read($, studio)
943 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
944 const { Box, Button, Text } = $.ui.resolve(e)
945 const width = e.props.bodyColumns ?? e.viewport?.columns ?? 80
946
947 // A filled tag: the band's status words, like a progress label.
948 const pill = (key: string, text: string, color: 'warning' | 'success' | 'error' | 'inactive' | 'merged') => (
949 <Box key={key} backgroundColor={color} paddingX={1}>
950 <Text bold color="inverseText">
951 {text}
952 </Text>
953 </Box>
954 )
955 const hide = <Button key="hide" label="Hide" plain onPress={() => update($, isHidden, () => true)} />
956
957 if (seen === null) {
958 return (
959 <Box width={width} justifyContent="space-between" alignItems="center">
960 <Box gap={1} alignItems="center">
961 <Text color="inactive">●</Text>
962 <Text bold>Roblox Studio</Text>
963 {pill('state', 'Not connected', 'inactive')}
964 <Text dimColor>Open a place with the MCP server on</Text>
965 </Box>
966 {hide}
967 </Box>
968 )
969 }
970
971 const lint = await read($, lastLint)
972 const list = await read($, history)
973 const members = await read($, team)
974 const report = await read($, remotes)
975 const unchecked = report === null ? 0 : orderHandlers(report).bad
976 const issues = lint === null ? 0 : lint.issues.length
977 const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
978
979 // One colour says how the place is doing: red needs action, yellow a look.
980 const errors = seen.untested === 0 ? (seen.errors ?? 0) : 0
981 const health = unchecked > 0 || errors > 0 ? 'error' : seen.untested > 0 || issues > 0 ? 'warning' : 'success'
982
983 const left = [
984 <Text key="dot" color={health}>
985 ●
986 </Text>,
987 <Text key="name" bold>
988 {seen.name ?? 'Roblox Studio'}
989 </Text>,
990 ]
991 if (seen.mode === 'play') left.push(pill('mode', 'Playing', 'merged'))
992 else if (seen.mode === 'edit') left.push(pill('mode', 'Edit', 'inactive'))
993 if (seen.untested > 0) left.push(pill('result', `${seen.untested} untested`, 'warning'))
994 else if (errors > 0) left.push(pill('result', plural(errors, 'error'), 'error'))
995 else if (seen.errors !== null) left.push(pill('result', 'Tested', 'success'))
996 if (unchecked > 0) {
997 left.push(
998 <Text key="remotes" color="error">
999 {plural(unchecked, 'unchecked remote')}
1000 </Text>,
1001 )
1002 }
1003 if (issues > 0) {
1004 left.push(
1005 <Text key="issues" color="warning">
1006 {plural(issues, 'issue')} in {shortName(lint?.file ?? '')}
1007 </Text>,
1008 )
1009 }
1010 if (members.length > 0) {
1011 left.push(
1012 <Text key="team" dimColor>
1013 {members.length <= 2 ? members.join(', ') : plural(members.length, 'collaborator')}
1014 </Text>,
1015 )
1016 }
1017 if (list.length > 0) {
1018 left.push(
1019 <Text key="undoable" dimColor>
1020 {list.length} undoable
1021 </Text>,
1022 )
1023 }
1024
1025 const right = []
1026 if ((await read($, touched)).length > 0) {
1027 // Runs the command, so the view shows in a pane or, where none can be
1028 // placed, as a card in the chat.
1029 right.push(
1030 <Button
1031 key="activity"
1032 label="Activity"
1033 onPress={() => {
1034 void $.command.run({ command: 'roclaude', args: 'activity' }).catch(() => undefined)
1035 }}
1036 />,
1037 )
1038 }
1039 if (seen.untested > 0 && seen.mode !== 'play') {
1040 right.push(<Button key="test" label="Test" variant="primary" onPress={() => $.prompt.submit({ text: TEST_PROMPT })} />)
1041 }
1042 if (list.length > 0) {
1043 right.push(
1044 <Button
1045 key="undo"
1046 label="Undo"
1047 onPress={async () => {
1048 $.ui.toast(await undo($, false))
1049 }}
1050 />,
1051 )
1052 }
1053 right.push(hide)
1054
1055 return (
1056 <Box width={width} justifyContent="space-between" alignItems="center">
1057 <Box gap={1} alignItems="center" flexWrap="wrap" flexShrink={1}>
1058 {left}
1059 </Box>
1060 <Box gap={1} alignItems="center" flexShrink={0}>
1061 {right}
1062 </Box>
1063 </Box>
1064 )
1065 })
1066}
1067hooks/diff.ts 111 lines1// Line diffs of a script before and after an edit, for the replay theater.
2
3export type LineDiff = {
4 added: number
5 removed: number
6 /** A unified diff with @@ hunk headers, as a `Code` element draws it under `format: 'diff'`. */
7 unified: string
8}
9
10const CONTEXT = 2
11const MAX_CELLS = 4_000_000
12
13type Op = { kind: ' ' | '-' | '+'; line: string; oldNo: number; newNo: number }
14
15// The edit script between two line lists: a longest common subsequence on
16// what differs once the shared head and tail are set aside.
17function operations(a: readonly string[], b: readonly string[]): Op[] {
18 let head = 0
19 while (head < a.length && head < b.length && a[head] === b[head]) head += 1
20 let tail = 0
21 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail += 1
22
23 const midA = a.slice(head, a.length - tail)
24 const midB = b.slice(head, b.length - tail)
25 const ops: Op[] = []
26 for (let i = 0; i < head; i += 1) ops.push({ kind: ' ', line: a[i] ?? '', oldNo: i + 1, newNo: i + 1 })
27
28 if (midA.length * midB.length > MAX_CELLS) {
29 // Too large to align: the middle reads as replaced.
30 midA.forEach((line, i) => ops.push({ kind: '-', line, oldNo: head + i + 1, newNo: head + 1 }))
31 midB.forEach((line, i) => ops.push({ kind: '+', line, oldNo: head + midA.length + 1, newNo: head + i + 1 }))
32 } else {
33 const n = midA.length
34 const m = midB.length
35 const table: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
36 for (let i = n - 1; i >= 0; i -= 1) {
37 for (let j = m - 1; j >= 0; j -= 1) {
38 table[i]![j] = midA[i] === midB[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
39 }
40 }
41 let i = 0
42 let j = 0
43 while (i < n || j < m) {
44 if (i < n && j < m && midA[i] === midB[j]) {
45 ops.push({ kind: ' ', line: midA[i]!, oldNo: head + i + 1, newNo: head + j + 1 })
46 i += 1
47 j += 1
48 } else if (j < m && (i === n || table[i]![j + 1]! > table[i + 1]![j]!)) {
49 // On a tie the removal goes first, as diffs read.
50 ops.push({ kind: '+', line: midB[j]!, oldNo: head + i + 1, newNo: head + j + 1 })
51 j += 1
52 } else {
53 ops.push({ kind: '-', line: midA[i]!, oldNo: head + i + 1, newNo: head + j + 1 })
54 i += 1
55 }
56 }
57 }
58
59 for (let k = 0; k < tail; k += 1) {
60 const oldNo = a.length - tail + k + 1
61 const newNo = b.length - tail + k + 1
62 ops.push({ kind: ' ', line: a[oldNo - 1] ?? '', oldNo, newNo })
63 }
64 return ops
65}
66
67/** The diff of a script from `before` (null: it did not exist) to `after`. */
68export function lineDiff(before: string | null, after: string): LineDiff {
69 const a = before === null || before === '' ? [] : before.split('\n')
70 const b = after === '' ? [] : after.split('\n')
71 const ops = operations(a, b)
72 const added = ops.filter(op => op.kind === '+').length
73 const removed = ops.filter(op => op.kind === '-').length
74
75 // Hunks: each change with CONTEXT unchanged lines around it, merged when close.
76 const changed = ops.map((op, i) => (op.kind === ' ' ? -1 : i)).filter(i => i !== -1)
77 const hunks: [number, number][] = []
78 for (const i of changed) {
79 const from = Math.max(0, i - CONTEXT)
80 const to = Math.min(ops.length - 1, i + CONTEXT)
81 const last = hunks[hunks.length - 1]
82 if (last !== undefined && from <= last[1] + 1) last[1] = Math.max(last[1], to)
83 else hunks.push([from, to])
84 }
85
86 const lines: string[] = []
87 for (const [from, to] of hunks) {
88 const part = ops.slice(from, to + 1)
89 const oldCount = part.filter(op => op.kind !== '+').length
90 const newCount = part.filter(op => op.kind !== '-').length
91 const first = part[0]!
92 const oldStart = oldCount === 0 ? first.oldNo - 1 : first.oldNo
93 const newStart = newCount === 0 ? first.newNo - 1 : first.newNo
94 lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`)
95 for (const op of part) lines.push(`${op.kind}${op.line}`)
96 }
97 return { added, removed, unified: lines.join('\n') }
98}
99
100/**
101 * The first sentence of Claude's reply that says something, short enough for
102 * a card: a bare "Done." or "All set!" is passed over.
103 */
104export function firstSentence(text: string, max = 160): string {
105 const plain = text.replace(/[`*_#>]/g, '').replace(/\s+/g, ' ').trim()
106 // A sentence ends at . ! or ? followed by a space, so Workspace.House.Door stays whole.
107 const sentences = plain.split(/(?<=[.!?])\s+/)
108 const sentence = (sentences.find(one => one.trim().split(' ').length >= 4) ?? sentences[0] ?? plain).trim()
109 return sentence.length > max ? `${sentence.slice(0, max - 1)}…` : sentence
110}
111hooks/activity.ts 147 lines1// What Claude touched in the open place, for the Studio activity pane.
2import type { PropMap, TouchAction, TouchedItem } from '../types'
3
4// Stronger actions win when Claude touches the same thing twice.
5const STRENGTH: Record<TouchAction, number> = { read: 0, changed: 1, edited: 2, created: 3 }
6
7/** A dot path as one display form: no `game.`, `workspace` as `Workspace`. */
8export function normalizePath(path: string): string {
9 return path
10 .trim()
11 .replace(/^game\./, '')
12 .replace(/^workspace(?=\.|$)/, 'Workspace')
13}
14
15/** The service a path lies in: its first segment. */
16export const serviceOf = (path: string) => normalizePath(path).split('.')[0] ?? path
17
18/** The last segment of a path, what a row shows. */
19export const leafOf = (path: string) => normalizePath(path).split('.').pop() ?? path
20
21/**
22 * Adds or updates one touched item: a new path goes to the end, a known one
23 * keeps its place and its strongest action. `isActive` marks a call in flight.
24 */
25export function touch(
26 list: readonly TouchedItem[],
27 item: Omit<TouchedItem, 'seq' | 'path'> & { path: string },
28): TouchedItem[] {
29 const path = normalizePath(item.path)
30 const at = list.findIndex(one => one.path === path)
31 if (at === -1) {
32 const seq = (list[list.length - 1]?.seq ?? 0) + 1
33 return [...list, { ...item, path, seq }]
34 }
35 const old = list[at]!
36 const action = STRENGTH[item.action] >= STRENGTH[old.action] ? item.action : old.action
37 const next = [...list]
38 next[at] = {
39 ...old,
40 action,
41 kind: item.kind,
42 isActive: item.isActive,
43 activeAs: item.activeAs,
44 // The first state Claude saw, and the latest it left.
45 propsBefore: old.propsBefore !== undefined ? old.propsBefore : item.propsBefore,
46 propsAfter: item.propsAfter !== undefined ? item.propsAfter : old.propsAfter,
47 }
48 return next
49}
50
51/** An object's properties from inspect_instance's JSON answer. */
52export function parseProps(text: string): PropMap | undefined {
53 try {
54 const info = JSON.parse(text) as { properties?: Record<string, unknown> }
55 if (info.properties === undefined) return undefined
56 const props: Record<string, string | number | boolean> = {}
57 for (const [name, value] of Object.entries(info.properties)) {
58 if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') props[name] = value
59 }
60 return props
61 } catch {
62 return undefined
63 }
64}
65
66// Properties that change by themselves or say nothing to a person.
67const NOISE = new Set(['UniqueId', 'AssemblyLinearVelocity', 'AssemblyAngularVelocity', 'Capabilities', 'Sandboxed'])
68
69/** The properties that differ between two states of an object, by name. */
70export function propChanges(before: PropMap, after: PropMap): { name: string; before: string; after: string }[] {
71 const names = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(name => !NOISE.has(name)).sort()
72 return names
73 .filter(name => before[name] !== after[name])
74 .map(name => ({ name, before: showValue(name, before[name]), after: showValue(name, after[name]) }))
75}
76
77/** A property value as a person reads it: colours as RGB, enums by name, rounded numbers. */
78export function showValue(name: string, value: string | number | boolean | undefined): string {
79 if (value === undefined) return '(none)'
80 if (typeof value === 'number') return String(Math.round(value * 1000) / 1000)
81 if (typeof value === 'boolean') return String(value)
82 if (/Color/.test(name) && /^-?[\d.]+, -?[\d.]+, -?[\d.]+$/.test(value)) {
83 const rgb = value.split(',').map(part => Math.round(Number(part) * 255))
84 return `rgb(${rgb.join(', ')})`
85 }
86 if (value.startsWith('Enum.')) return value.split('.').pop() ?? value
87 const numbers = value.split(',').map(part => part.trim())
88 if (numbers.length > 1 && numbers.every(part => /^-?[\d.e-]+$/.test(part))) {
89 return numbers.map(part => String(Math.round(Number(part) * 100) / 100)).join(', ')
90 }
91 return value.length > 40 ? `${value.slice(0, 39)}…` : value
92}
93
94/** Marks every item settled, at the end of a call. */
95export const settle = (list: readonly TouchedItem[], path: string) =>
96 list.map(one => (one.path === normalizePath(path) ? { ...one, isActive: false, activeAs: null } : one))
97
98const ROOT = String.raw`(?:game|workspace)(?:\.[A-Za-z_]\w*)+`
99const MUTATING = 'Destroy|ClearAllChildren|SetAttribute|PivotTo|MoveTo|AddTag|RemoveTag|Remove|ScaleTo|SetPrimaryPartCFrame'
100const ASSIGN = new RegExp(`\\b(${ROOT})\\s*=(?!=)`, 'g')
101const CALL = new RegExp(`\\b(${ROOT})\\s*:\\s*(?:${MUTATING})\\s*\\(`, 'g')
102
103/**
104 * The instances Luau changes, as far as its text shows: `workspace.Part.Color = x`
105 * changes Workspace.Part, `workspace.Old:Destroy()` changes Workspace.Old.
106 * Changes made through a local variable are not seen.
107 */
108export function pathsChanged(code: string): string[] {
109 const found: string[] = []
110 const add = (path: string) => {
111 const normal = normalizePath(path)
112 if (normal.includes('.') && !found.includes(normal)) found.push(normal)
113 }
114 for (const match of code.matchAll(ASSIGN)) {
115 const segments = (match[1] ?? '').split('.')
116 add(segments.slice(0, -1).join('.'))
117 }
118 for (const match of code.matchAll(CALL)) add(match[1] ?? '')
119 return found
120}
121
122/** One line saying what Claude did, for the pane's card. */
123export function summarize(list: readonly TouchedItem[]): { title: string; detail: string } {
124 const scripts = (action: TouchAction) => list.filter(one => one.kind === 'script' && one.action === action)
125 const edited = [...scripts('created'), ...scripts('edited')]
126 const changed = list.filter(one => one.kind === 'instance' && (one.action === 'changed' || one.action === 'created'))
127 const read = list.filter(one => one.action === 'read')
128
129 const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
130 const parts: string[] = []
131 if (edited.length > 0) parts.push(`edited ${plural(edited.length, 'script')}`)
132 if (changed.length > 0) parts.push(`changed ${plural(changed.length, 'object')}`)
133 const title =
134 parts.length > 0
135 ? `Claude ${parts.join(', ')}`
136 : read.length > 0
137 ? `Claude read ${plural(read.length, 'item')}`
138 : 'Nothing touched yet'
139
140 const names = [...edited, ...changed].map(one => leafOf(one.path))
141 const shown = names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3}` : '')
142 const detail = [shown, read.length > 0 && parts.length > 0 ? `read ${read.length}` : '']
143 .filter(text => text !== '')
144 .join(' · ')
145 return { title, detail }
146}
147hooks/lint.ts 251 lines1import type { LintIssue, RemoteHandler } from '../types'
2
3type Rule = {
4 id: string
5 pattern: RegExp
6 message: string
7 // Match against the line with string literals kept (default: blanked).
8 keepsStrings?: boolean
9 unless?: RegExp
10 when?: (path: string) => boolean
11}
12
13const SERVICES = [
14 'Players',
15 'ReplicatedStorage',
16 'ServerStorage',
17 'ServerScriptService',
18 'RunService',
19 'UserInputService',
20 'TweenService',
21 'DataStoreService',
22 'HttpService',
23 'MarketplaceService',
24 'CollectionService',
25 'SoundService',
26 'StarterGui',
27 'Debris',
28]
29
30// A script that runs on the server: under ServerScriptService or
31// ServerStorage, or named `*.server.luau` (how a new Script is passed in).
32export const isServerScript = (path: string) =>
33 /\.server\.luau?$/i.test(path) || /(^|\.)(ServerScriptService|ServerStorage)\./.test(path)
34
35export const RULES: readonly Rule[] = [
36 {
37 id: 'task-wait',
38 pattern: /(^|[^.:\w])wait\s*\(/,
39 unless: /function\s+wait\b/,
40 message: 'wait() is deprecated and throttled; use task.wait().',
41 },
42 {
43 id: 'task-spawn',
44 pattern: /(^|[^.:\w])spawn\s*\(/,
45 unless: /function\s+spawn\b/,
46 message: 'spawn() is deprecated; use task.spawn() or task.defer().',
47 },
48 {
49 id: 'task-delay',
50 pattern: /(^|[^.:\w])delay\s*\(/,
51 unless: /function\s+delay\b/,
52 message: 'delay() is deprecated; use task.delay().',
53 },
54 {
55 id: 'instance-parent-arg',
56 pattern: /Instance\.new\s*\(\s*(["'])[^"']*\1\s*,/,
57 keepsStrings: true,
58 message: "Instance.new's parent argument is slow; set properties first and .Parent last.",
59 },
60 {
61 id: 'get-service',
62 pattern: new RegExp(`\\bgame\\.(${SERVICES.join('|')})\\b`),
63 message: 'Get services with game:GetService("Name") instead of game.Name.',
64 },
65 {
66 id: 'lowercase-method',
67 pattern: /:(connect|disconnect|wait|findFirstChild|getChildren|remove|clone|destroy|isA)\s*\(/,
68 message: 'Lowercase Roblox methods are deprecated; use the PascalCase name (:Connect, :FindFirstChild, :Destroy, ...).',
69 },
70 {
71 id: 'server-localplayer',
72 pattern: /\bLocalPlayer\b/,
73 when: isServerScript,
74 message: 'Players.LocalPlayer is nil on the server; take the player from the event or Players:GetPlayers().',
75 },
76]
77
78const MAX_ISSUES = 30
79
80// Splits one line into code with strings kept and code with strings blanked,
81// dropping comments. `inBlock` carries a --[[ block comment across lines.
82function scanLine(line: string, inBlock: boolean) {
83 let code = ''
84 let bare = ''
85 let i = 0
86
87 if (inBlock) {
88 const end = line.indexOf(']]')
89 if (end === -1) return { code, bare, inBlock: true }
90 i = end + 2
91 }
92
93 while (i < line.length) {
94 const ch = line[i]
95 if (ch === '"' || ch === "'") {
96 let j = i + 1
97 while (j < line.length && line[j] !== ch) j += line[j] === '\\' ? 2 : 1
98 code += line.slice(i, j + 1)
99 bare += ch + ch
100 i = j + 1
101 continue
102 }
103 if (ch === '-' && line[i + 1] === '-') {
104 if (/^--\[=*\[/.test(line.slice(i))) {
105 const end = line.indexOf(']]', i)
106 if (end === -1) return { code, bare, inBlock: true }
107 i = end + 2
108 continue
109 }
110 break
111 }
112 code += ch
113 bare += ch
114 i += 1
115 }
116
117 return { code, bare, inBlock: false }
118}
119
120/**
121 * Checks Luau source for deprecated or risky Roblox patterns. `firstLine` is
122 * the 1-based line the source starts at in its file (for a snippet).
123 */
124export function lintLuau(path: string, source: string, firstLine = 1): LintIssue[] {
125 const issues: LintIssue[] = []
126 const rules = RULES.filter(rule => rule.when === undefined || rule.when(path))
127 let inBlock = false
128
129 source.split(/\r?\n/).forEach((line, index) => {
130 const scanned = scanLine(line, inBlock)
131 inBlock = scanned.inBlock
132
133 for (const rule of rules) {
134 const text = rule.keepsStrings ? scanned.code : scanned.bare
135 if (rule.pattern.test(text) && !rule.unless?.test(text)) {
136 issues.push({ line: firstLine + index, rule: rule.id, message: rule.message })
137 }
138 }
139 })
140
141 return issues.slice(0, MAX_ISSUES)
142}
143
144const HANDLER =
145 /(OnServerEvent:Connect\s*\(\s*function|OnServerInvoke\s*=\s*function)\s*\(([^)]*)\)/
146const HANDLER_LINES = 200
147const GATE_LINES = 6
148const GATE =
149 /\bif\s+not\s+\w*(admin|owner|allowed|whitelist|permi|authori[sz]|staff|mod)\w*\s*\(\s*\w+|\.UserId\s*[~=]=|:GetRankInGroup\s*\(|:IsInGroup\s*\(/i
150
151// Ways a server handler checks a value it got from the client.
152const checks = (name: string) => {
153 const n = name === '...' ? '\\.\\.\\.' : name
154 return new RegExp(
155 [
156 `typeof\\s*\\(\\s*${n}\\b`,
157 `\\btype\\s*\\(\\s*${n}\\b`,
158 `tonumber\\s*\\(\\s*${n}\\b`,
159 `assert\\s*\\([^)]*\\b${n}\\b`,
160 `\\bif\\s+not\\s+${n}\\b`,
161 `\\b${n}\\s*[~=]=`,
162 `[=~]=\\s*${n}\\b`,
163 `table\\.find\\s*\\([^)]*\\b${n}\\b`,
164 `\\[\\s*${n}\\s*\\]`,
165 `math\\.clamp\\s*\\(\\s*${n}\\b`,
166 `select\\s*\\(\\s*["']#["']`,
167 ].join('|'),
168 )
169}
170
171// The lines of the function that opens on lines[start], found by counting
172// Luau blocks (function/do/then/repeat against end/until) outside strings and
173// comments; at most HANDLER_LINES lines.
174function handlerBody(lines: readonly string[], start: number): string[] {
175 let depth = 0
176 let inBlock = false
177 const body: string[] = []
178 for (let i = start; i < lines.length && body.length < HANDLER_LINES; i += 1) {
179 const scanned = scanLine(lines[i] ?? '', inBlock)
180 inBlock = scanned.inBlock
181 for (const word of scanned.bare.match(/\b(function|do|then|repeat|elseif|end|until)\b/g) ?? []) {
182 depth += word === 'end' || word === 'until' || word === 'elseif' ? -1 : 1
183 }
184 if (i > start) body.push(lines[i] ?? '')
185 if (depth <= 0) break
186 }
187 return body
188}
189
190/**
191 * Every RemoteEvent and RemoteFunction server handler in a script, and how it
192 * treats what the client sends: `unchecked` uses values without checking
193 * them, `gated` first limits who may call it (admins, the owner), `checked`
194 * checks every value, `no-args` takes nothing from the client. Luau type
195 * annotations are not checks: an exploiter can send any value whatever the
196 * annotation says.
197 */
198export function scanRemotes(source: string, firstLine = 1): RemoteHandler[] {
199 const lines = source.split(/\r?\n/)
200 const handlers: RemoteHandler[] = []
201
202 lines.forEach((line, index) => {
203 const match = HANDLER.exec(line)
204 if (match === null) return
205
206 const params = (match[2] ?? '')
207 .split(',')
208 .map(param => (param.split(':')[0] ?? '').trim())
209 .filter(param => param.length > 0)
210 .slice(1)
211 const remote = /(\w+)\s*\.\s*OnServer(?:Event|Invoke)/.exec(line)?.[1] ?? '?'
212 const kind = /OnServerInvoke/.test(match[1] ?? '') ? 'function' : 'event'
213 const bodyLines = handlerBody(lines, index)
214 const body = bodyLines.join('\n')
215 const opening = bodyLines.slice(0, GATE_LINES).join('\n')
216 const unchecked = params.filter(param => !checks(param).test(body))
217 const status =
218 params.length === 0 ? 'no-args' : GATE.test(opening) ? 'gated' : unchecked.length > 0 ? 'unchecked' : 'checked'
219
220 handlers.push({
221 line: firstLine + index,
222 remote,
223 kind,
224 status,
225 unchecked: status === 'unchecked' ? unchecked : [],
226 })
227 })
228
229 return handlers
230}
231
232/** The unchecked handlers of `scanRemotes`, as findings for Claude. */
233export function auditRemotes(source: string, firstLine = 1): LintIssue[] {
234 return scanRemotes(source, firstLine)
235 .filter(handler => handler.status === 'unchecked')
236 .map(handler => ({
237 line: handler.line,
238 rule: 'remote-unchecked',
239 message: `This server handler uses ${handler.unchecked.join(', ')} from the client without checking it. Exploiters can send any value (type annotations are not enforced): check typeof, ranges, cooldowns and ownership before use.`,
240 }))
241}
242
243export function formatIssues(file: string, issues: readonly LintIssue[]): string {
244 const lines = issues.map(issue => ` L${issue.line} ${issue.rule}: ${issue.message}`)
245 return [
246 `roclaude found ${issues.length} Roblox/Luau issue(s) in ${file}:`,
247 ...lines,
248 'Fix these in the code you just wrote unless the legacy behavior is intended.',
249 ].join('\n')
250}
251hooks/studio.ts 163 lines1// Pure helpers for the Roblox Studio MCP server's tools.
2
3/** `mcp__Roblox_Studio__multi_edit` → { server: 'Roblox_Studio', name: 'multi_edit' } */
4export function studioTool(tool: string): { server: string; name: string } | null {
5 const match = /^mcp__(.+?)__([a-z_]+)$/.exec(tool)
6 if (match === null || !/roclaude|studio/i.test(match[1] ?? '')) return null
7 return { server: match[1] ?? '', name: match[2] ?? '' }
8}
9
10/** script_read answers ` 1→line`; this gives back the source. */
11export function stripLineNumbers(text: string): string {
12 return text
13 .split('\n')
14 .map(line => line.replace(/^\s*\d+→/, ''))
15 .join('\n')
16}
17
18/** Luau that finds an instance by its dot path (`game.ServerScriptService.Main`). */
19export function luauLookup(path: string): string {
20 const parts = path.replace(/^game\./, '').split('.')
21 const steps = parts.map(part => `:FindFirstChild(${JSON.stringify(part)})`).join('')
22 return `game${steps}`
23}
24
25const SCRIPT_WRITE =
26 /\.Source\s*=(?!=)|UpdateSourceAsync|Instance\.new\s*\(\s*["'](Script|LocalScript|ModuleScript)["']/
27
28/**
29 * Whether Luau run in Studio writes or creates script source, which belongs
30 * in multi_edit, where roclaude can keep undo copies, review the code and
31 * protect Team Create collaborators.
32 */
33export const writesScripts = (code: string) => SCRIPT_WRITE.test(code)
34
35const MUTATION =
36 /[\w\])]\.[A-Za-z_]\w*\s*=(?!=)|:(Destroy|ClearAllChildren|Clone|SetAttribute|PivotTo|MoveTo|AddTag|RemoveTag|Remove|ScaleTo|SetPrimaryPartCFrame)\s*\(|Instance\.new\s*\(/
37
38/** Whether Luau run in Studio likely changes the place (sets a property, adds or removes things). */
39export const changesPlace = (code: string) => MUTATION.test(code)
40
41/** Studio's mode from get_studio_state's answer: `Current Studio Mode: Edit`. */
42export function studioMode(text: string): 'edit' | 'play' | null {
43 const mode = /Current Studio Mode:\s*(\w+)/i.exec(text)?.[1]
44 if (mode === undefined) return null
45 return mode.toLowerCase() === 'edit' ? 'edit' : 'play'
46}
47
48/** Whether a tool's error says the place is in a playtest. */
49export const saysPlaying = (text: string) => /not available in Play mode|in Play mode/i.test(text)
50
51export type Danger = { id: string; reason: string }
52
53const DANGERS: readonly (Danger & { pattern: RegExp })[] = [
54 {
55 id: 'destroy',
56 pattern: /:(Destroy|ClearAllChildren)\s*\(|Debris:AddItem\s*\(/,
57 reason: 'deletes instances from the place',
58 },
59 {
60 id: 'datastore-write',
61 pattern: /:(SetAsync|UpdateAsync|RemoveAsync|IncrementAsync)\s*\(/,
62 reason: 'writes to DataStores, which can change real player data',
63 },
64 {
65 id: 'players',
66 pattern: /:(BanAsync|UnbanAsync|Kick)\s*\(/,
67 reason: 'kicks or bans players',
68 },
69 {
70 id: 'remote-code',
71 pattern: /\brequire\s*\(\s*\d{5,}\s*\)|InsertService:LoadAsset/,
72 reason: 'loads code or assets by id from outside the place',
73 },
74]
75
76/** What a piece of Luau run in Studio would do that the person should approve first. */
77export function dangersIn(code: string): Danger[] {
78 return DANGERS.filter(danger => danger.pattern.test(code)).map(({ id, reason }) => ({ id, reason }))
79}
80
81/** Counts error lines in Studio's Output, as get_console_output returns it. */
82export function countErrors(output: string): number {
83 return output
84 .split('\n')
85 .filter(line => /^[\w.]+:\d+: /.test(line) || /^(Error|Script error)/i.test(line)).length
86}
87
88/** The studio name from list_roblox_studios' JSON answer. */
89export function studioName(text: string): string | null {
90 try {
91 const info = JSON.parse(text) as { studios?: { name?: unknown }[] }
92 const name = info.studios?.[0]?.name
93 return typeof name === 'string' ? name.replace(/\s*\(placeId:.*\)$/, '') : null
94 } catch {
95 return null
96 }
97}
98
99export const GREP_CAP = 50
100
101// Third-party code a game embeds: admin kits and package folders.
102const VENDOR =
103 /Kohl'?s Admin|Adonis|HD ?Admin|Building Tools|(^|[\\/.])Cmdr([\\/.]|$)|(^|[\\/.])(Dev)?Packages([\\/.]|$)|_Index/i
104
105/** Whether a script path (dot or file) lies inside third-party code. */
106export const isVendor = (path: string) => VENDOR.test(path)
107
108/** The scripts script_grep found, in order, from its `Path: X | Line: N | ...` lines. */
109export function grepScripts(text: string): { scripts: string[]; matches: number } {
110 const scripts: string[] = []
111 let matches = 0
112 for (const line of text.split('\n')) {
113 const match = /^Path: (.+?) \| Line: \d+ \|/.exec(line)
114 if (match === null) continue
115 matches += 1
116 const path = match[1] ?? ''
117 if (!scripts.includes(path)) scripts.push(path)
118 }
119 return { scripts, matches }
120}
121
122/**
123 * Who is in the Team Create session, from Studio's Output: `X joined live
124 * editing session.` adds X and a line saying X left removes them.
125 */
126export function teamFrom(output: string, before: readonly string[]): string[] {
127 const team = [...before]
128 for (const line of output.split('\n')) {
129 const joined = /^(\S+) joined (the )?live editing session/i.exec(line)
130 const left = /^(\S+) (left|disconnected from|has left) (the )?live editing session/i.exec(line)
131 const name = joined?.[1] ?? left?.[1]
132 if (name === undefined) continue
133 const at = team.indexOf(name)
134 if (joined !== null && at === -1) team.push(name)
135 if (left !== null && at !== -1) team.splice(at, 1)
136 }
137 return team
138}
139
140/** What Claude last read of a script: its lines from `start` (1-based), whole or a range. */
141export type SeenText = { start: number; lines: readonly string[]; isWhole: boolean }
142
143/** Whether a script still reads as Claude last saw it. */
144export function stillAsSeen(seen: SeenText, source: string): boolean {
145 const lines = source.split('\n')
146 if (seen.isWhole && lines.length !== seen.lines.length) return false
147 return seen.lines.every((line, i) => lines[seen.start - 1 + i] === line)
148}
149
150/** A script path as one key, with or without the leading `game.`. */
151export const scriptKey = (path: string) => path.replace(/^game\./, '')
152
153/** The first studio's id from list_roblox_studios' JSON answer. */
154export function studioId(text: string): string | null {
155 try {
156 const info = JSON.parse(text) as { studios?: { id?: unknown }[] }
157 const id = info.studios?.[0]?.id
158 return typeof id === 'string' ? id : null
159 } catch {
160 return null
161 }
162}
163types/index.d.ts 93 lines1export type LintIssue = { line: number; rule: string; message: string }
2export type LintReport = { file: string; issues: readonly LintIssue[] }
3
4/**
5 * A script as it was before Claude's multi_edit (`source`, null when the edit
6 * created it) and right after (`after`, null when it could not be read).
7 */
8export type Snapshot = {
9 id: number
10 server: string
11 studioId: string
12 path: string
13 source: string | null
14 after: string | null
15 /** The turn the edit was made in, for replaying one turn. */
16 turn: number
17}
18
19/**
20 * The Studio MCP server last used and the place's state: its mode, how many
21 * changes Claude made since the last playtest, and that playtest's errors.
22 */
23export type StudioInfo = {
24 name: string | null
25 server: string | null
26 id: string | null
27 errors: number | null
28 mode: 'edit' | 'play' | null
29 untested: number
30}
31
32export type RemoteStatus = 'unchecked' | 'gated' | 'checked' | 'no-args'
33export type RemoteHandler = {
34 line: number
35 remote: string
36 kind: 'event' | 'function'
37 status: RemoteStatus
38 unchecked: readonly string[]
39}
40export type RemoteReport = {
41 scripts: number
42 /** `isVendor`: inside a third-party module (an admin kit, a package), reported apart. */
43 handlers: readonly (RemoteHandler & { script: string; isVendor: boolean })[]
44 note: string | null
45}
46
47export type TouchAction = 'read' | 'edited' | 'created' | 'changed'
48/** An object's properties as Studio's inspect_instance reports them. */
49export type PropMap = Readonly<Record<string, string | number | boolean>>
50/** Something in the place Claude read or changed this session. */
51export type TouchedItem = {
52 path: string
53 kind: 'script' | 'instance'
54 action: TouchAction
55 /** A call on it is in flight, and as what. */
56 isActive: boolean
57 activeAs: 'reading' | 'editing' | 'changing' | null
58 seq: number
59 /**
60 * An object's properties before Claude first changed it this session (null:
61 * it did not exist) and after its latest change (null: it was deleted);
62 * absent when they were not captured.
63 */
64 propsBefore?: PropMap | null
65 propsAfter?: PropMap | null
66}
67
68declare module 'claude-code' {
69 interface PluginState {
70 'roclaude': {
71 lastLint: LintReport | null
72 isHidden: boolean
73 studio: StudioInfo | null
74 history: readonly Snapshot[]
75 remotes: RemoteReport | null
76 /** Who Studio's Output says is in the Team Create session. */
77 team: readonly string[]
78 /** Edits refused because the script changed after Claude read it. */
79 staleBlocks: number
80 /** What Claude read and changed this session, for the activity pane. */
81 touched: readonly TouchedItem[]
82 /** Whether Claude's turn is running. */
83 isWorking: boolean
84 /** Counts the person's prompts, so edits can be grouped by turn. */
85 turn: number
86 /** The touched item whose change the activity view shows. */
87 selected: string | null
88 /** Counts activity cards drawn in the chat; only the newest is live. */
89 cardRun: number
90 }
91 }
92}
93