Unity no Claude Code: erros de compilação no contexto, painel de testes, build do servidor e guardas de arquivos

Live mods for Claude Code: see how long your plan will last, hand work off between machines, and give Claude eyes on Unity and Blender.
<img src="docs/terminal.svg" alt="Claude Code in a terminal: a band above the prompt shows the 5-hour limit at 38% lasting ~4.7h and the weekly limit at 41% lasting ~52h, three open sessions with tokens and cost, a model recommendation, and a Unity status line" width="100%">
Claude Code mods are plugins of function hooks: TypeScript that runs inside Claude Code and can draw panes and bands, add slash commands, watch tool calls, and feed context back to the model. This repository is a marketplace of five of them, built for daily use on a Max plan and on game projects.
| Mod | What it gives you |
|---|---|
| usage-limits | Your 5-hour and weekly limits with how long they will last at your current pace, tokens and cost across every open session, a per-project breakdown, API-credit tracking, and a model recommendation that keeps you from running dry before the reset. |
| fluxo | /handoff writes where you stopped so the next session on another machine picks it up, plus a sound and notification when a long turn finishes. |
| tarefas | The project's tasks in a plain TASKS.md: a pane, the next task above the prompt with Start and Done buttons, and Claude creating and finishing tasks as it works. |
| unity-tools | Compiler errors handed to Claude seconds after it edits a .cs, EditMode/PlayMode tests it can run on its own, a dedicated-server build pane, and guards that keep it out of Library/, scenes and .meta files. |
| blender-preview | A live thumbnail of the Blender scene with triangle, bone and texture budgets, and a check of every .glb Claude exports. |
[!NOTE] The mods' interface is in Brazilian Portuguese. Translations are very welcome; see Contributing.
You need a recent Claude Code (tested on 2.1.286 – 2.1.295). In a terminal, start claude and install the mods you want:
/plugin install usage-limits --marketplace kevinmedeiros/claude-mods
/plugin install fluxo --marketplace kevinmedeiros/claude-mods
/plugin install tarefas --marketplace kevinmedeiros/claude-mods
/plugin install unity-tools --marketplace kevinmedeiros/claude-mods
/plugin install blender-preview --marketplace kevinmedeiros/claude-mods
Answer y to add the marketplace the first time and pick the user scope. From then on the mods run in every Claude Code session on that machine: the terminal, the Code tab of Claude Desktop, and the IDE extensions. Restart sessions that were already open.
Update with /plugin update <mod>; change options with /plugin → the mod → configure.
<img src="docs/desktop-pane.svg" alt="The /limites pane in Claude Desktop: a 5-hour card at 38% with a bar, the projection hatched to the reset and a pace marker; a weekly card at 41% in red warning that at the current pace it runs out about 80 hours before the reset; and the session's token mix" width="560" align="right">
The percentages come from Anthropic's own rate-limit headers, so they cover the whole account: every session, every machine, and claude.ai. On top of them the mod answers the question the usage page doesn't: will this last until the reset?
/limites pane with bars that show used, projected-to-reset and an even-pace marker, plus the 5-hour pace, the weekly pace in points per day, and how many hours of work are left at the last hour's pace./economia on). The main thread keeps its model while new subagents run on Claude Haiku 5.5.| Option | Default | |
|---|---|---|
adminApiKey | — | sk-ant-admin… key of the Console organization that receives the plan's API credits. Kept in secure storage. |
apiMonthlyCredit | 200 | Max 5x: 100 · Max 20x: 200 |
apiCycleDay | 1 | Day of the month the credit renews |
Continue on another machine where you stopped.
/handoff [note] (or /passagem, if another plugin already has /handoff) has Claude Haiku 5.5 summarize the session (where you stopped, what was done, next steps, gotchas) and writes it to .claude/handoff.md with the files you changed and the git state.autoHandoff is on, every turn that edits files refreshes the file list, so there's a record even if the session dies.The handoff file travels with your project, through a git push or a synced folder. If you'd rather keep it out of git, add .claude/handoff.md to .gitignore and sync the folder instead.
| Option | Default |
|---|---|
longTurnMinutes | 3 |
sound / speak | true / false |
autoHandoff | false (turn on to refresh the file after every turn that edits) |
handoffPath | .claude/handoff.md |
The project's to-do list, kept in a TASKS.md at the project root (the same format as Claude's task-management skill, so you, Claude and your editor can all edit it).
/tarefas opens the pane: Active, Waiting On, Someday and recently done, a field to add a task, Start, ✓ and ↑ (move from Someday to Active)./tarefa <title> [- context] adds one from the prompt.task_add, task_done and task_list tools, so it records work it leaves for later and checks tasks off when it finishes them, with a toast every time. Turn this off with claudeCanEdit./tarefas importar pulls pending items from your notes (sections such as Pendente, Próximos passos or Next steps) and open checkboxes in BACKLOG.md / ROADMAP.md into Someday, without duplicates./handoff carries the open tasks to the other machine.| Option | Default |
|---|---|
tasksPath | TASKS.md |
showBand | true |
claudeCanEdit | true |
Turns on in any session whose folder holds a Unity project. It finds ProjectSettings/ above or up to two levels below the session's folder.
.cs, the mod compiles that assembly with dotnet build from the .csproj Unity generates, out of tree under Temp/, in about a second. The errors come back with the tool result. New files Unity hasn't listed yet are included./testes [editmode|playmode|tudo] runs the Unity Test Runner in batch mode and shows the results in the /unity pane, with a send failures to Claude button. Claude can also call the unity_tests tool itself to check its own changes./build-servidor builds the Linux dedicated server and streams the log. It tells you if the Linux Dedicated Server module is missing.Library/, Temp/, Logs/ or UserSettings/;.unity or .prefab files;.meta files by hand;UnityEngine inside an engine-free core folder.| Option | Default | |
|---|---|---|
projectPath | auto | Project folder, if auto-detection misses |
unityPath | Unity Hub path for the project's version | |
coreDir | Assets/Scripts/Core | Engine-free folder; empty turns the guard off |
compileCheck | true | |
allowSceneEdits | false | |
serverBuildPath | Builds/Server/Server.x86_64 | |
buildMethod | — | e.g. MyGame.Editor.BuildServer.Build; empty uses -buildLinux64Player |
Requirements:
dotnet), for the compile check.Works with Blender connected to Claude Code through a Blender MCP server.
/blender opens the pane..glb export, it reads the file and hands Claude a report: size, triangles, skin and bones, animations, and texture sizes, with a warning for anything over budget./glb <file> checks any .glb.| Option | Default |
|---|---|
maxTriangles | 20000 |
maxBones | 100 |
maxTextureSize | 2048 |
autoPreview | true |
server | Blender |
Everything stays on your machine.
~/.claude/usage-limits/, to each plugin's own store, to .claude/handoff.md and TASKS.md in your project.usage-limits calls the Console cost API, and only when you set an Admin key.fluxo calls Claude Haiku 5.5 when you run /handoff.unity-tools runs dotnet and the Unity editor; blender-preview talks to your local Blender through MCP.plugins/<mod>/
.claude-plugin/plugin.json manifest and options
hooks/register.tsx the hooks module
hooks/*.ts pure logic (unit-tested)
tests/*.test.ts run by `claude plugin test`
types/index.d.ts the mod's state contract
claude plugin validate plugins/usage-limits
claude plugin test plugins/usage-limits
claude --plugin-dir plugins/usage-limits
With --plugin-dir, a mod hot-reloads as you save. To regenerate the images in this README, run bun docs/generate-previews.ts; they are drawn with the mods' own code. See CONTRIBUTING.md.
MIT © Kevin Medeiros. Not affiliated with Anthropic.
hooks/register.tsx 585 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BuildJob, CompileError, Platform, ProjectInfo, TestJob } from '../types'
5import {
6 ancestors,
7 asmdefName,
8 csprojHas,
9 defaultAssembly,
10 defaultEditorPath,
11 dirname,
12 editorVersion,
13 extraTargets,
14 failuresText,
15 formatErrors,
16 guard,
17 isUnder,
18 join,
19 norm,
20 parseCompileErrors,
21 parseTestResults,
22 playbackEngines,
23 projectReferences,
24 rel,
25 testSummary,
26 unityLogProblem,
27} from './unity'
28
29const PANE = 'unity-tools'
30const TOOL = 'mcp__unity-tools__unity_tests'
31
32const project = atom({ plugin: 'unity-tools', key: 'project' } as const, null)
33const compile = atom({ plugin: 'unity-tools', key: 'compile' } as const, {})
34const tests = atom({ plugin: 'unity-tools', key: 'tests' } as const, {})
35const build = atom({ plugin: 'unity-tools', key: 'build' } as const, null)
36
37type Options = {
38 projectPath: string
39 unityPath: string
40 coreDir: string
41 compileCheck: boolean
42 allowSceneEdits: boolean
43 serverBuildPath: string
44 buildMethod: string
45}
46
47/** O que vale enquanto o módulo está carregado. */
48const live = {
49 options: undefined as Options | undefined,
50 /** Assembly de cada pasta já resolvida. */
51 asmByDir: new Map<string, string>(),
52 /** Assemblies compilados nesta sessão (as dependências só compilam uma vez). */
53 built: new Set<string>(),
54 running: new Set<string>(),
55}
56
57const run = async ($: EngineInterface, argv: string[], cwd?: string, timeoutMs = 10_000) => {
58 try {
59 return await $.process.run(argv, { cwd, timeoutMs })
60 } catch {
61 return undefined
62 }
63}
64
65// ---------- projeto ----------
66
67const isProject = async ($: EngineInterface, dir: string) => $.fs.exists(join(dir, 'ProjectSettings/ProjectVersion.txt'))
68
69/** Acha o projeto Unity: a pasta configurada, as de cima da sessão ou até dois níveis abaixo. */
70const findRoot = async ($: EngineInterface, cwd: string, configured: string): Promise<string | undefined> => {
71 if (configured) return (await isProject($, configured)) ? norm(configured) : undefined
72 for (const dir of ancestors(cwd)) if (await isProject($, dir)) return dir
73 const level = async (dir: string) => {
74 try {
75 return (await $.fs.list(dir)).filter(e => e.kind === 'dir' && !e.name.startsWith('.')).map(e => join(dir, e.name))
76 } catch {
77 return []
78 }
79 }
80 for (const child of await level(cwd)) {
81 if (await isProject($, child)) return child
82 for (const grandchild of (await level(child)).slice(0, 40)) if (await isProject($, grandchild)) return grandchild
83 }
84
85 return undefined
86}
87
88const detect = async ($: EngineInterface, cwd: string, options: Options): Promise<ProjectInfo | null> => {
89 const root = await findRoot($, cwd, options.projectPath)
90 if (root === undefined) return null
91 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
92 const version = editorVersion(await $.fs.read(join(root, 'ProjectSettings/ProjectVersion.txt')))
93 const editor = options.unityPath || (version ? defaultEditorPath(version, isWindows) : undefined)
94 const hasEditor = editor !== undefined && (await $.fs.exists(editor))
95 const dotnet = await run($, ['dotnet', '--version'], root)
96 let hasLinuxServer = false
97 if (editor && hasEditor) {
98 const variations = join(playbackEngines(editor, isWindows), 'LinuxStandaloneSupport/Variations')
99 try {
100 hasLinuxServer = (await $.fs.list(variations)).some(e => /server/i.test(e.name))
101 } catch {
102 hasLinuxServer = false
103 }
104 }
105
106 return { root, version, editor, hasEditor, isWindows, hasDotnet: dotnet?.exitCode === 0, hasLinuxServer }
107}
108
109// ---------- compilação ----------
110
111/** O assembly de um script: o .asmdef mais próximo acima dele, ou o padrão do Unity. */
112const asmFor = async ($: EngineInterface, root: string, file: string) => {
113 let dir = dirname(file)
114 const visited: string[] = []
115 while (isUnder(root, dir)) {
116 const cached = live.asmByDir.get(dir)
117 if (cached) return cached
118 visited.push(dir)
119 const asmdef = (await $.fs.list(dir)).find(e => e.kind === 'file' && e.name.endsWith('.asmdef'))
120 if (asmdef) {
121 const name = asmdefName(await $.fs.read(join(dir, asmdef.name)))
122 if (name) {
123 for (const d of visited) live.asmByDir.set(d, name)
124 return name
125 }
126 }
127 dir = dirname(dir)
128 }
129
130 return defaultAssembly(rel(root, file))
131}
132
133/**
134 * Compila um assembly com dotnet build a partir do .csproj que o Unity gera,
135 * fora do projeto (Temp/claude-check). As dependências compilam antes, uma vez.
136 */
137const compileAsm = async (
138 $: EngineInterface,
139 root: string,
140 asm: string,
141 newFiles: string[],
142 depth = 0,
143): Promise<CompileError[] | 'no-csproj'> => {
144 const csprojPath = join(root, `${asm}.csproj`)
145 if (!(await $.fs.exists(csprojPath))) return 'no-csproj'
146 const csproj = await $.fs.read(csprojPath)
147 if (depth < 6) {
148 for (const ref of projectReferences(csproj)) {
149 if (!live.built.has(ref)) await compileAsm($, root, ref, [], depth + 1)
150 }
151 }
152 const out = join(root, 'Temp/claude-check')
153 const targets = join(out, `extra-${asm}.targets`)
154 await $.fs.write(targets, extraTargets(newFiles.filter(f => !csprojHas(csproj, rel(root, f)))))
155 const result = await run(
156 $,
157 [
158 'dotnet',
159 'build',
160 `${asm}.csproj`,
161 '-nologo',
162 '-v',
163 'q',
164 '-clp:NoSummary',
165 `-p:BaseIntermediateOutputPath=${out}/obj/${asm}/`,
166 `-p:IntermediateOutputPath=${out}/obj/${asm}/`,
167 `-p:OutputPath=${out}/bin/`,
168 '-p:BuildProjectReferences=false',
169 `-p:CustomAfterMicrosoftCommonTargets=${targets}`,
170 ],
171 root,
172 180_000,
173 )
174 live.built.add(asm)
175
176 return result ? parseCompileErrors(`${result.stdout}\n${result.stderr}`, root) : []
177}
178
179/** Checa o assembly de um .cs editado; o texto para o Claude, quando há o que dizer. */
180const checkFile = async ($: EngineInterface, info: ProjectInfo, file: string): Promise<string | undefined> => {
181 const asm = await asmFor($, info.root, file)
182 if (live.running.has(`compile:${asm}`)) return undefined
183 live.running.add(`compile:${asm}`)
184 try {
185 const before = (await read($, compile))[asm]
186 const errors = await compileAsm($, info.root, asm, [file])
187 if (errors === 'no-csproj') {
188 return before === undefined
189 ? `unity-tools: não achei ${asm}.csproj. No Unity, use Preferences > External Tools > Regenerate project files ` +
190 'para a checagem de compilação funcionar.'
191 : undefined
192 }
193 const at = await $.clock.now()
194 await update($, compile, all => ({ ...all, [asm]: { errors, at } }))
195 await refreshStatus($)
196 if (errors.length > 0) return formatErrors(asm, errors)
197
198 return before && before.errors.length > 0 ? `Compilação Unity (${asm}): ✓ compila de novo.` : undefined
199 } finally {
200 live.running.delete(`compile:${asm}`)
201 }
202}
203
204// ---------- testes ----------
205
206const runTests = async ($: EngineInterface, platform: Platform): Promise<TestJob> => {
207 const info = await read($, project)
208 const startedAt = await $.clock.now()
209 const fail = async (problem: string) => {
210 const job: TestJob = { platform, status: 'error', startedAt, endedAt: await $.clock.now(), problem }
211 await update($, tests, all => ({ ...all, [platform]: job }))
212 await refreshStatus($)
213 return job
214 }
215 if (!info) return fail('Nenhum projeto Unity nesta sessão.')
216 if (!info.editor || !info.hasEditor) return fail(`Editor do Unity não encontrado (${info.editor ?? 'sem versão'}). Configure unityPath.`)
217 if (live.running.has(`tests:${platform}`)) return fail('Esses testes já estão rodando.')
218
219 live.running.add(`tests:${platform}`)
220 await update($, tests, all => ({ ...all, [platform]: { platform, status: 'running', startedAt } }))
221 await refreshStatus($)
222 const dir = join(info.root, 'Temp/claude-tests')
223 const xml = join(dir, `${platform}.xml`)
224 const log = join(dir, `${platform}.log`)
225 try {
226 await $.fs.write(join(dir, '.keep'), '')
227 const result = await run(
228 $,
229 [info.editor, '-batchmode', '-projectPath', info.root, '-runTests', '-testPlatform', platform, '-testResults', xml, '-logFile', log],
230 info.root,
231 600_000,
232 )
233 const fresh = (await $.fs.exists(xml)) && (await $.fs.stat(xml)).mtimeMs >= startedAt - 1000
234 const parsed = fresh ? parseTestResults(await $.fs.read(xml), platform) : undefined
235 if (parsed) {
236 const job: TestJob = { platform, status: 'done', startedAt, endedAt: await $.clock.now(), result: parsed }
237 await update($, tests, all => ({ ...all, [platform]: job }))
238 await refreshStatus($)
239 return job
240 }
241 let problem: string | undefined
242 if (await $.fs.exists(log)) {
243 const size = (await $.fs.stat(log)).size
244 if (size < 4_000_000) problem = unityLogProblem(await $.fs.read(log))
245 }
246 return fail(
247 problem ??
248 (result === undefined
249 ? 'O Unity não terminou em 10 minutos.'
250 : `O Unity saiu com código ${result.exitCode} sem resultado de testes. Veja o log em ${log}.`),
251 )
252 } finally {
253 live.running.delete(`tests:${platform}`)
254 }
255}
256
257const testsText = (job: TestJob) => {
258 if (job.status === 'error') return `Testes ${job.platform}: não rodaram. ${job.problem ?? ''}`
259 if (!job.result) return `Testes ${job.platform}: rodando…`
260 const r = job.result
261 const head = `Testes ${testSummary(r)} (${Math.round(r.durationSec)}s)`
262
263 return r.failed > 0 ? `${head}\nFalhas:\n${failuresText(r)}` : head
264}
265
266// ---------- build do servidor ----------
267
268const runBuild = async ($: EngineInterface) => {
269 const info = await read($, project)
270 const options = live.options
271 const startedAt = await $.clock.now()
272 const set = (job: BuildJob) => update($, build, () => job)
273 if (!info || !options) return
274 if (!info.editor || !info.hasEditor) {
275 await set({ status: 'error', startedAt, lines: [], problem: 'Editor do Unity não encontrado. Configure unityPath.' })
276 return
277 }
278 if (!info.hasLinuxServer) {
279 await set({
280 status: 'error',
281 startedAt,
282 lines: [],
283 problem:
284 `Falta o módulo "Linux Dedicated Server Build Support" no Unity ${info.version ?? ''}. ` +
285 'Instale pelo Unity Hub (Installs > ⚙ > Add modules) e rode de novo.',
286 })
287 return
288 }
289 if (live.running.has('build')) return
290 live.running.add('build')
291 const output = join(info.root, options.serverBuildPath)
292 const argv = [
293 info.editor,
294 '-batchmode',
295 '-quit',
296 '-projectPath',
297 info.root,
298 '-buildTarget',
299 'Linux64',
300 '-standaloneBuildSubtarget',
301 'Server',
302 '-logFile',
303 '-',
304 ...(options.buildMethod ? ['-executeMethod', options.buildMethod] : ['-buildLinux64Player', output]),
305 ]
306 const lines: string[] = []
307 let lastPush = 0
308 try {
309 await set({ status: 'running', startedAt, lines: [] })
310 let rest = ''
311 const child = $.process.spawn({ argv, cwd: info.root })
312 for await (const { text } of child) {
313 const parts = (rest + text).split(/\r?\n/)
314 rest = parts.pop() ?? ''
315 for (const line of parts) if (line.trim()) lines.push(line)
316 if (lines.length > 400) lines.splice(0, lines.length - 400)
317 const now = await $.clock.now()
318 if (now - lastPush > 1000) {
319 lastPush = now
320 await set({ status: 'running', startedAt, lines: lines.slice(-40) })
321 }
322 }
323 const end = await child.result
324 const all = lines.join('\n')
325 const ok = /Build Finished, Result: Success/i.test(all) || (end.code === 0 && !/Result: Failed/i.test(all))
326 await set({
327 status: ok ? 'done' : 'error',
328 startedAt,
329 endedAt: await $.clock.now(),
330 lines: lines.slice(-40),
331 problem: ok ? undefined : (unityLogProblem(all) ?? `O build falhou (código ${end.code ?? '?'}).`),
332 })
333 $.ui.toast(ok ? `Build do servidor pronto: ${output}` : 'Build do servidor falhou; veja o painel /unity.', { timeoutMs: 10_000 })
334 } catch (error) {
335 await set({ status: 'error', startedAt, lines: lines.slice(-40), problem: `Não consegui iniciar o Unity: ${String(error)}` })
336 } finally {
337 live.running.delete('build')
338 }
339}
340
341// ---------- status ----------
342
343const refreshStatus = async ($: EngineInterface) => {
344 const info = await read($, project)
345 if (!info) return $.ui.status(undefined)
346 const parts: string[] = []
347 const failing = Object.entries(await read($, compile)).filter(([, c]) => c.errors.length > 0)
348 if (failing.length > 0) {
349 const n = failing.reduce((sum, [, c]) => sum + c.errors.length, 0)
350 parts.push(`✗ ${n} erro${n === 1 ? '' : 's'} de compilação (${failing.map(([a]) => a.split('.').pop()).join(', ')})`)
351 } else if (Object.keys(await read($, compile)).length > 0) {
352 parts.push('✓ compila')
353 }
354 for (const job of Object.values(await read($, tests))) {
355 if (!job) continue
356 parts.push(job.status === 'running' ? `${job.platform} rodando…` : job.result ? testSummary(job.result) : `${job.platform} ✗`)
357 }
358 $.ui.status(parts.length > 0 ? `Unity · ${parts.join(' · ')}` : undefined)
359}
360
361const startTests = ($: EngineInterface, platforms: Platform[]) => {
362 void (async () => {
363 for (const platform of platforms) {
364 const job = await runTests($, platform)
365 $.ui.toast(testsText(job).split('\n')[0] ?? '', { timeoutMs: 8000 })
366 }
367 })()
368}
369
370const parsePlatforms = (args: string): Platform[] => {
371 const a = args.trim().toLowerCase()
372 if (a.startsWith('play')) return ['PlayMode']
373 if (a.startsWith('edit') || a === '') return ['EditMode']
374 return ['EditMode', 'PlayMode']
375}
376
377export const register: Register = (on, raw) => {
378 const options: Options = {
379 projectPath: String(raw.projectPath ?? ''),
380 unityPath: String(raw.unityPath ?? ''),
381 coreDir: String(raw.coreDir ?? 'Assets/Scripts/Core'),
382 compileCheck: raw.compileCheck !== false,
383 allowSceneEdits: raw.allowSceneEdits === true,
384 serverBuildPath: String(raw.serverBuildPath ?? 'Builds/Server/Server.x86_64'),
385 buildMethod: String(raw.buildMethod ?? ''),
386 }
387 live.options = options
388
389 on('session.start', async ($, e, next) => {
390 const started = await next(e)
391 const info = await detect($, started.cwd, options)
392 await update($, project, () => info)
393 if (!info) return started
394 await $.command.register({ name: 'unity', description: 'Painel do Unity: compilação, testes e build do servidor' })
395 await $.command.register({
396 name: 'testes',
397 description: 'Roda os testes do Unity em batch (editmode, playmode ou tudo)',
398 argumentHint: '[editmode|playmode|tudo]',
399 })
400 await $.command.register({ name: 'build-servidor', description: 'Gera o build Linux do servidor dedicado' })
401 await $.tool.register({
402 name: 'unity_tests',
403 description:
404 'Roda os testes do Unity deste projeto em batch (EditMode ou PlayMode) e devolve o resumo com as falhas. ' +
405 'Use depois de mudar regras de jogo ou rede para verificar. Leva de 1 a 10 minutos e só funciona com o ' +
406 'Unity Editor fechado para este projeto.',
407 inputSchema: {
408 type: 'object',
409 properties: { platform: { type: 'string', enum: ['EditMode', 'PlayMode'] } },
410 required: ['platform'],
411 },
412 })
413 await refreshStatus($)
414
415 return started
416 })
417
418 // Guardas e checagem de compilação nas ferramentas que escrevem.
419 on('tool.call', async ($, e, next) => {
420 const info = await read($, project)
421 if (!info || (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'Bash')) return next(e)
422
423 const reason =
424 e.tool === 'Bash'
425 ? guard({ tool: 'Bash', command: e.command }, { root: info.root, ...options })
426 : guard(
427 {
428 tool: e.tool,
429 path: e.file_path,
430 newText: e.tool === 'Write' ? e.content : e.new_string,
431 exists: e.tool === 'Write' && e.file_path.endsWith('.meta') ? await $.fs.exists(e.file_path) : undefined,
432 },
433 { root: info.root, ...options },
434 )
435 if (reason) return { deny: reason }
436
437 const ran = await next(e)
438 if (
439 e.tool === 'Bash' ||
440 ran.deny ||
441 ran.isError ||
442 !options.compileCheck ||
443 !info.hasDotnet ||
444 !/\.cs$/i.test(e.file_path) ||
445 !isUnder(join(info.root, 'Assets'), e.file_path)
446 ) {
447 return ran
448 }
449 const note = await checkFile($, info, e.file_path)
450 if (!note || ran.deny !== undefined) return ran
451
452 return { ...ran, context: [...(ran.context ?? []), note] }
453 }).catch(($, e, next) => next(e))
454
455 on('tool.call', { tool: TOOL }, async ($, e) => {
456 const input = e as unknown as { platform?: string }
457 const platform: Platform = input.platform === 'PlayMode' ? 'PlayMode' : 'EditMode'
458
459 return { result: testsText(await runTests($, platform)) }
460 }).catch(() => ({ result: 'unity-tools: não consegui rodar os testes do Unity.' }))
461
462 on('command.run', { command: 'unity' }, async $ => {
463 await $.ui.open({ id: PANE, title: 'Unity' })
464 return { text: 'Painel do Unity aberto.' }
465 })
466
467 on('command.run', { command: 'testes' }, async ($, e) => {
468 const platforms = parsePlatforms(e.args)
469 startTests($, platforms)
470 await $.ui.open({ id: PANE, title: 'Unity' })
471 return { text: `Rodando ${platforms.join(' e ')} em batch; o resultado aparece no painel /unity e na linha de status.` }
472 })
473
474 on('command.run', { command: 'build-servidor' }, async $ => {
475 void runBuild($)
476 await $.ui.open({ id: PANE, title: 'Unity' })
477 return { text: 'Build do servidor iniciado; acompanhe no painel /unity.' }
478 })
479
480 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
481 const { Box, Text, Button } = $.ui.resolve(e)
482 const info = await read($, project)
483 if (!info) return <Text dimColor>Nenhum projeto Unity encontrado a partir da pasta desta sessão.</Text>
484 const compiled = Object.entries(await read($, compile))
485 const jobs = await read($, tests)
486 const job = await read($, build)
487 const now = await $.clock.now()
488 const failures = Object.values(jobs).flatMap(j => (j?.result && j.result.failed > 0 ? [j.result] : []))
489
490 return (
491 <Box flexDirection="column" rowGap={1}>
492 <Box flexDirection="column">
493 <Text bold>Projeto</Text>
494 <Text dimColor>
495 {info.root} · Unity {info.version ?? '?'} {info.hasEditor ? '✓' : '✗ editor não encontrado'} · dotnet{' '}
496 {info.hasDotnet ? '✓' : '✗ (sem checagem de compilação)'} · servidor Linux {info.hasLinuxServer ? '✓' : '✗'}
497 </Text>
498 </Box>
499
500 <Box flexDirection="column">
501 <Text bold>Compilação</Text>
502 {compiled.length === 0 && <Text dimColor>Ainda não houve edição de .cs nesta sessão.</Text>}
503 {compiled.map(([asm, c]) => (
504 <Box key={asm} flexDirection="column">
505 <Text color={c.errors.length > 0 ? 'error' : 'success'}>
506 {c.errors.length > 0 ? '✗' : '✓'} {asm}
507 {c.errors.length > 0 ? ` · ${c.errors.length} erro${c.errors.length === 1 ? '' : 's'}` : ''}
508 </Text>
509 {c.errors.slice(0, 8).map(err => (
510 <Text key={`${err.file}:${err.line}:${err.code}`} dimColor>
511 {' '}
512 {err.file}({err.line}): {err.code} {err.message}
513 </Text>
514 ))}
515 </Box>
516 ))}
517 </Box>
518
519 <Box flexDirection="column">
520 <Text bold>Testes</Text>
521 {(['EditMode', 'PlayMode'] as const).map(p => {
522 const j = jobs[p]
523 if (!j) return <Text key={p} dimColor>{p}: não rodado nesta sessão</Text>
524 if (j.status === 'running') {
525 return <Text key={p} color="warning">{p}: rodando há {Math.round((now - j.startedAt) / 1000)}s…</Text>
526 }
527 if (j.status === 'error') return <Text key={p} color="error">{p}: {j.problem}</Text>
528 const r = j.result!
529 return (
530 <Box key={p} flexDirection="column">
531 <Text color={r.failed > 0 ? 'error' : 'success'}>
532 {testSummary(r)} · {Math.round(r.durationSec)}s
533 </Text>
534 {r.failures.slice(0, 6).map(f => (
535 <Text key={f.name} dimColor>
536 {' '}✗ {f.name}: {f.message.split('\n')[0]}
537 </Text>
538 ))}
539 </Box>
540 )
541 })}
542 <Box flexDirection="row" columnGap={2}>
543 <Button key="run-edit" label="Rodar EditMode" onPress={() => startTests($, ['EditMode'])} />
544 <Button key="run-play" label="Rodar PlayMode" onPress={() => startTests($, ['PlayMode'])} />
545 {failures.length > 0 && (
546 <Button
547 key="send-failures"
548 label="Mandar falhas pro Claude"
549 variant="primary"
550 onPress={() =>
551 void $.prompt.fill({
552 text: `Os testes do Unity falharam. Investigue e corrija:\n${failures.map(r => failuresText(r)).join('\n')}`,
553 })
554 }
555 />
556 )}
557 </Box>
558 </Box>
559
560 <Box flexDirection="column">
561 <Text bold>Build do servidor (Linux, dedicado)</Text>
562 {!job && <Text dimColor>Nenhum build nesta sessão. Saída: {options.serverBuildPath}</Text>}
563 {job && (
564 <Text color={job.status === 'error' ? 'error' : job.status === 'done' ? 'success' : 'warning'}>
565 {job.status === 'running'
566 ? `Rodando há ${Math.round((now - job.startedAt) / 1000)}s…`
567 : job.status === 'done'
568 ? `✓ Pronto em ${Math.round(((job.endedAt ?? now) - job.startedAt) / 1000)}s`
569 : `✗ ${job.problem ?? 'Falhou'}`}
570 </Text>
571 )}
572 {job?.lines.slice(-12).map((line, i) => (
573 <Text key={`l${i}`} dimColor wrap="truncate">
574 {line}
575 </Text>
576 ))}
577 <Box flexDirection="row" columnGap={2}>
578 <Button key="build" label="Gerar build do servidor" onPress={() => void runBuild($)} />
579 </Box>
580 </Box>
581 </Box>
582 )
583 })
584}
585hooks/unity.ts 260 lines1import type { CompileError, TestCase, TestRun } from '../types'
2
3// ---------- caminhos ----------
4
5export const norm = (p: string) => p.replaceAll('\\', '/').replace(/\/+$/, '')
6
7export const join = (...parts: string[]) =>
8 parts
9 .filter(Boolean)
10 .map((p, i) => (i === 0 ? norm(p) : norm(p).replace(/^\/+/, '')))
11 .join('/')
12
13export const dirname = (p: string) => {
14 const n = norm(p)
15 const i = n.lastIndexOf('/')
16
17 return i <= 0 ? n.slice(0, i + 1) : n.slice(0, i)
18}
19
20export const isUnder = (root: string, path: string) =>
21 norm(path).toLowerCase().startsWith(`${norm(root).toLowerCase()}/`)
22
23/** Caminho relativo à raiz do projeto Unity, com `/`. */
24export const rel = (root: string, path: string) => (isUnder(root, path) ? norm(path).slice(norm(root).length + 1) : norm(path))
25
26/** As pastas de cima, da mais funda à raiz: candidatos a raiz do projeto. */
27export const ancestors = (dir: string): string[] => {
28 const out: string[] = []
29 let cur = norm(dir)
30 while (cur && !out.includes(cur)) {
31 out.push(cur)
32 const up = dirname(cur)
33 if (up === cur) break
34 cur = up
35 }
36
37 return out
38}
39
40/** `m_EditorVersion: 6000.6.3f1` → `6000.6.3f1`. */
41export const editorVersion = (projectVersionTxt: string) => /m_EditorVersion:\s*(\S+)/.exec(projectVersionTxt)?.[1]
42
43export const defaultEditorPath = (version: string, isWindows: boolean) =>
44 isWindows
45 ? `C:/Program Files/Unity/Hub/Editor/${version}/Editor/Unity.exe`
46 : `/Applications/Unity/Hub/Editor/${version}/Unity.app/Contents/MacOS/Unity`
47
48/** A pasta `PlaybackEngines` do editor (os módulos de plataforma instalados). */
49export const playbackEngines = (editorPath: string, isWindows: boolean) =>
50 isWindows
51 ? join(dirname(editorPath), 'Data/PlaybackEngines')
52 : join(dirname(dirname(dirname(dirname(editorPath)))), 'PlaybackEngines')
53
54// ---------- assemblies e compilação ----------
55
56/** Assembly padrão de um script sem .asmdef acima dele. */
57export const defaultAssembly = (relPath: string) =>
58 /(^|\/)Editor\//.test(relPath) ? 'Assembly-CSharp-Editor' : 'Assembly-CSharp'
59
60export const asmdefName = (json: string) => {
61 try {
62 const name = (JSON.parse(json) as { name?: unknown }).name
63 return typeof name === 'string' ? name : undefined
64 } catch {
65 return /"name"\s*:\s*"([^"]+)"/.exec(json)?.[1]
66 }
67}
68
69/** Se o .csproj gerado pelo Unity já lista o arquivo. */
70export const csprojHas = (csproj: string, relPath: string) => {
71 const a = relPath.replaceAll('\\', '/')
72
73 return csproj.includes(`"${a}"`) || csproj.includes(`"${a.replaceAll('/', '\\')}"`)
74}
75
76/** Os .csproj que este referencia (`<ProjectReference Include="X.csproj">`). */
77export const projectReferences = (csproj: string): string[] =>
78 [...csproj.matchAll(/<ProjectReference\s+Include="([^"]+)"/g)].map(m =>
79 (m[1] ?? '').replaceAll('\\', '/').split('/').pop()!.replace(/\.csproj$/, ''),
80 )
81
82/**
83 * Um .targets que o MSBuild importa no fim do .csproj do Unity
84 * (CustomAfterMicrosoftCommonTargets) para compilar arquivos novos que o
85 * Unity ainda não pôs na lista, sem escrever nada dentro do projeto.
86 */
87export const extraTargets = (absPaths: string[]) =>
88 `<Project>\n <ItemGroup>\n${absPaths
89 .map(p => ` <Compile Include="${p.replaceAll('&', '&').replaceAll('"', '"')}" />`)
90 .join('\n')}\n </ItemGroup>\n</Project>\n`
91
92const ERROR_LINE = /^(.+?)\((\d+),(\d+)\):\s*error\s+(CS\d+):\s*(.+?)(?:\s+\[[^\]]+\])?$/
93
94/** Erros do `dotnet build`, sem repetidos e sem "arquivo não encontrado" de scripts apagados. */
95export const parseCompileErrors = (output: string, root: string): CompileError[] => {
96 const seen = new Set<string>()
97 const out: CompileError[] = []
98 for (const line of output.split(/\r?\n/)) {
99 const m = ERROR_LINE.exec(line.trim())
100 if (!m || m[4] === 'CS2001') continue
101 const error: CompileError = {
102 file: rel(root, m[1] ?? ''),
103 line: Number(m[2]),
104 column: Number(m[3]),
105 code: m[4] ?? '',
106 message: m[5] ?? '',
107 }
108 const key = `${error.file}:${error.line}:${error.column}:${error.code}`
109 if (seen.has(key)) continue
110 seen.add(key)
111 out.push(error)
112 }
113
114 return out
115}
116
117export const formatErrors = (asm: string, errors: CompileError[], limit = 20) => {
118 const lines = errors
119 .slice(0, limit)
120 .map(e => `- ${e.file}(${e.line},${e.column}): ${e.code} ${e.message}`)
121 if (errors.length > limit) lines.push(`- … e mais ${errors.length - limit}`)
122
123 return (
124 `Compilação Unity (${asm}, via dotnet build): ${errors.length} erro${errors.length === 1 ? '' : 's'}.\n` +
125 `${lines.join('\n')}\n` +
126 'Se você ainda está no meio de uma mudança em vários arquivos, alguns podem ser transitórios.'
127 )
128}
129
130// ---------- testes (XML do NUnit que o Unity grava) ----------
131
132const attr = (tag: string, name: string) => new RegExp(`\\b${name}="([^"]*)"`).exec(tag)?.[1]
133
134const unescapeXml = (s: string) =>
135 s
136 .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
137 .replaceAll('<', '<')
138 .replaceAll('>', '>')
139 .replaceAll('"', '"')
140 .replaceAll(''', "'")
141 .replaceAll('&', '&')
142
143export const parseTestResults = (xml: string, platform: string): TestRun | undefined => {
144 const run = /<test-run\b[^>]*>/.exec(xml)?.[0]
145 if (!run) return undefined
146 const failures: TestCase[] = []
147 for (const m of xml.matchAll(/<test-case\b([^>]*)>([\s\S]*?)<\/test-case>/g)) {
148 const tag = m[1] ?? ''
149 if (attr(tag, 'result') !== 'Failed') continue
150 const body = m[2] ?? ''
151 const message = /<message>([\s\S]*?)<\/message>/.exec(body)?.[1]
152 const stack = /<stack-trace>([\s\S]*?)<\/stack-trace>/.exec(body)?.[1]
153 failures.push({
154 name: unescapeXml(attr(tag, 'fullname') ?? attr(tag, 'name') ?? '?'),
155 message: unescapeXml(message ?? '').trim(),
156 stack: unescapeXml(stack ?? '')
157 .trim()
158 .split(/\r?\n/)
159 .slice(0, 4)
160 .join('\n'),
161 })
162 }
163
164 return {
165 platform,
166 total: Number(attr(run, 'total') ?? 0),
167 passed: Number(attr(run, 'passed') ?? 0),
168 failed: Number(attr(run, 'failed') ?? 0),
169 skipped: Number(attr(run, 'skipped') ?? 0),
170 durationSec: Number(attr(run, 'duration') ?? 0),
171 failures,
172 }
173}
174
175/** O motivo de o Unity não ter rodado, lido do log. */
176export const unityLogProblem = (log: string): string | undefined => {
177 if (/another Unity instance is running|Multiple Unity instances cannot open the same project/i.test(log)) {
178 return 'O projeto está aberto no Unity Editor. Feche o editor para rodar em batch, ou use o Test Runner dentro dele.'
179 }
180 if (/No valid Unity Editor license found|Licen[sc]e.*(not|in)valid|User is not logged in/i.test(log)) {
181 return 'O Unity não achou uma licença válida. Abra o Unity Hub e entre na conta.'
182 }
183 if (/Scripts have compiler errors/i.test(log)) {
184 return 'Os scripts têm erros de compilação; corrija antes de rodar os testes.'
185 }
186
187 return undefined
188}
189
190export const testSummary = (r: TestRun) =>
191 `${r.platform} ${r.passed}/${r.total}${r.failed > 0 ? ` · ${r.failed} falha${r.failed === 1 ? '' : 's'}` : ' ✓'}`
192
193export const failuresText = (r: TestRun, limit = 10) =>
194 r.failures
195 .slice(0, limit)
196 .map(f => `- ${f.name}\n ${f.message.split('\n')[0] ?? ''}${f.stack ? `\n ${f.stack.split('\n')[0]}` : ''}`)
197 .join('\n')
198
199// ---------- guardas ----------
200
201export type GuardInput = {
202 tool: string
203 /** Arquivo que a ferramenta escreve. */
204 path?: string
205 /** O texto novo (Write: conteúdo; Edit: new_string). */
206 newText?: string
207 /** Se o arquivo já existe (para .meta). */
208 exists?: boolean
209 /** O comando do Bash. */
210 command?: string
211}
212
213export type GuardConfig = { root: string; coreDir: string; allowSceneEdits: boolean }
214
215const GENERATED = ['Library', 'Temp', 'Logs', 'obj', 'UserSettings']
216
217/** O motivo para recusar a chamada, ou undefined para deixar passar. */
218export const guard = (input: GuardInput, cfg: GuardConfig): string | undefined => {
219 if (input.command !== undefined) {
220 const deletes = /(^|[;&|]\s*|\s)(git\s+rm|rm|del|erase|Remove-Item|rd|rmdir)\s/i.test(` ${input.command}`)
221 if (deletes && /\.meta\b/i.test(input.command)) {
222 return 'unity-tools: não apague arquivos .meta pelo terminal. Apague o asset junto com o .meta, ou deixe o Unity cuidar disso.'
223 }
224 return undefined
225 }
226
227 if (!input.path || !isUnder(cfg.root, input.path)) return undefined
228 const r = rel(cfg.root, input.path)
229 const top = r.split('/')[0] ?? ''
230
231 if (GENERATED.includes(top)) {
232 return `unity-tools: ${top}/ é gerado pelo Unity e não deve ser editado (${r}).`
233 }
234 if (!cfg.allowSceneEdits && /\.(unity|prefab)$/i.test(r)) {
235 return (
236 `unity-tools: não reescreva cenas ou prefabs à mão (${r}). Crie ou altere por um script de editor ` +
237 'ou no próprio Unity.'
238 )
239 }
240 if (/\.meta$/i.test(r) && input.tool === 'Write' && input.exists === false) {
241 return `unity-tools: não crie .meta à mão (${r}); o Unity gera o .meta e o GUID quando importa o asset.`
242 }
243
244 const core = norm(cfg.coreDir)
245 if (core && (r === core || r.startsWith(`${core}/`))) {
246 const text = input.newText ?? ''
247 if (/\.cs$/i.test(r) && /\busing\s+(static\s+)?UnityEngine\b|\bUnityEngine\s*\./.test(text)) {
248 return (
249 `unity-tools: ${core} é o núcleo de regras sem engine (asmdef com noEngineReferences). ` +
250 'Não use UnityEngine aqui; ponha o código que depende da engine em Game e chame o núcleo de lá.'
251 )
252 }
253 if (/\.asmdef$/i.test(r) && /"noEngineReferences"\s*:\s*false/.test(text)) {
254 return `unity-tools: o asmdef do núcleo (${r}) deve manter "noEngineReferences": true.`
255 }
256 }
257
258 return undefined
259}
260types/index.d.ts 66 lines1export type CompileError = {
2 file: string
3 line: number
4 column: number
5 code: string
6 message: string
7}
8
9export type TestCase = { name: string; message: string; stack: string }
10
11export type TestRun = {
12 platform: string
13 total: number
14 passed: number
15 failed: number
16 skipped: number
17 durationSec: number
18 failures: TestCase[]
19}
20
21export type Platform = 'EditMode' | 'PlayMode'
22
23export type TestJob = {
24 platform: Platform
25 status: 'running' | 'done' | 'error'
26 startedAt: number
27 endedAt?: number
28 result?: TestRun
29 /** Por que não rodou (editor aberto, licença, erro de compilação...). */
30 problem?: string
31}
32
33export type BuildJob = {
34 status: 'running' | 'done' | 'error'
35 startedAt: number
36 endedAt?: number
37 /** As últimas linhas do log do Unity. */
38 lines: string[]
39 problem?: string
40}
41
42/** O resultado da última checagem de compilação de um assembly. */
43export type CompileState = { errors: CompileError[]; at: number }
44
45export type ProjectInfo = {
46 root: string
47 version?: string
48 editor?: string
49 hasEditor: boolean
50 isWindows: boolean
51 hasDotnet: boolean
52 /** O módulo Linux Dedicated Server do editor está instalado. */
53 hasLinuxServer: boolean
54}
55
56declare module 'claude-code' {
57 interface PluginState {
58 'unity-tools': {
59 project: ProjectInfo | null
60 compile: Record<string, CompileState>
61 tests: Partial<Record<Platform, TestJob>>
62 build: BuildJob | null
63 }
64 }
65}
66