SLOPSHOPPER

unity-tools

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

newpaneguardcommandtoaststatus
★ 1v0.1.1MITupdated 2026-10-09kevinmedeiros/claude-mods/plugins/unity-tools
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · unity-tools
│ ┃ unity-tools ✕ › fix the failing auth test and add an audit log call │ ┃ Nenhum projeto Unity encontrado a partir da │ ┃ pasta desta sessão. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · unity-tools
Nenhum projeto Unity encontrado a partir da pasta desta sessão.
README

claude-mods

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.

License: MIT Claude Code plugins Mods Tests

English · Português

<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.

ModWhat it gives you
usage-limitsYour 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.
tarefasThe 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-toolsCompiler 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-previewA 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.

Install

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.


usage-limits

<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?

  • Status line and a band above the prompt with each limit's %, how many hours it lasts at the current pace, time to reset, and tokens and cost across all open sessions.
  • /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.
  • Every open session on the machine, with project, model, tokens, cost and share of the total, plus today's total.
  • Per-project spend for each window, so you know which project is burning the week.
  • Model advice. When the pace won't reach the reset, it recommends the least disruptive change, such as "use Opus to orchestrate and Haiku to execute", and shows how long the limit would last.
  • Economy mode (/economia on). The main thread keeps its model while new subagents run on Claude Haiku 5.5.
  • API credits. Optional, with a Console Admin API key: spend of the monthly credit by model, the daily pace, and when the credit runs out.
  • ↻ Refresh to pick up the newest reading any session on the machine has seen.
  • Toasts at 80%, at 95%, when a limit is exhausted, and when the projection says it ends before the reset.
OptionDefault
adminApiKey—sk-ant-admin… key of the Console organization that receives the plan's API credits. Kept in secure storage.
apiMonthlyCredit200Max 5x: 100 · Max 20x: 200
apiCycleDay1Day of the month the credit renews
  • 5-hour window: the pace of the last hour, or the window average when the window is young. Hours left = (100 − used) ÷ pace.
  • Weekly window: points the account spent in the last 24 hours, never less than one day of history, so a busy first morning isn't treated as the pace of the whole week. This includes other machines and overnight runs.
  • Hours of work (weekly, extra): the mod measures how many weekly points each 5-hour point costs, inside a single 5-hour window, and multiplies that by the 5-hour pace. It reacts within minutes when you switch models.
  • Model scenarios: spend is priced per model at API list prices from the last three active hours, and each scenario reprices it with a cheaper model. Subscription limits may weight models differently, so treat the numbers as estimates.
  • Freshness: a session learns the account's percentages only from its own API responses. Sessions on the same machine share the newest reading, and the band says "reading from 2h ago" when it is older than 10 minutes.

fluxo

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.
  • Automatic handoff (off by default). When autoHandoff is on, every turn that edits files refreshes the file list, so there's a record even if the session dies.
  • On the other machine, the next session in that project shows "↪ Handoff from MacBook (3h ago): …" above the prompt, with Continue from here, View and Dismiss buttons.
  • Long-turn alert: a toast, plus a chime on macOS, when a turn runs longer than 3 minutes. Optionally spoken.

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.

OptionDefault
longTurnMinutes3
sound / speaktrue / false
autoHandofffalse (turn on to refresh the file after every turn that edits)
handoffPath.claude/handoff.md

tarefas

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).

  • Above the prompt: "▶ Next: port the dodge to FishNet · 7 open" with Start (writes the prompt for you), ✓ Done and Tasks.
  • /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.
  • Claude keeps it current. It gets 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.
  • With fluxo, /handoff carries the open tasks to the other machine.
OptionDefault
tasksPathTASKS.md
showBandtrue
claudeCanEdittrue

unity-tools

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.

  • Compiler errors in Claude's context. After Claude edits a .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.
  • Guards refuse, with a reason Claude can act on:
  • editing Library/, Temp/, Logs/ or UserSettings/;
  • hand-writing .unity or .prefab files;
  • creating or deleting .meta files by hand;
  • UnityEngine inside an engine-free core folder.
OptionDefault
projectPathautoProject folder, if auto-detection misses
unityPathUnity Hub path for the project's version
coreDirAssets/Scripts/CoreEngine-free folder; empty turns the guard off
compileChecktrue
allowSceneEditsfalse
serverBuildPathBuilds/Server/Server.x86_64
buildMethod—e.g. MyGame.Editor.BuildServer.Build; empty uses -buildLinux64Player

Requirements:

  • Unity Hub with the project's editor version.
  • The .NET SDK (dotnet), for the compile check.
  • Generated project files: Preferences → External Tools → Regenerate project files.
  • Batch tests need the editor closed for that project.

blender-preview

Works with Blender connected to Claude Code through a Blender MCP server.

  • After every scene change Claude makes through Blender MCP, the mod renders a thumbnail and measures triangles per object, bones, materials, textures and actions against your budget. /blender opens the pane.
  • On every .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.
OptionDefault
maxTriangles20000
maxBones100
maxTextureSize2048
autoPreviewtrue
serverBlender

Privacy

Everything stays on your machine.

  • Local files. The mods write to ~/.claude/usage-limits/, to each plugin's own store, to .claude/handoff.md and TASKS.md in your project.
  • Network requests:
  • 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.
  • Nothing else leaves your machine.
  • Local processes: unity-tools runs dotnet and the Unity editor; blender-preview talks to your local Blender through MCP.

Development

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.

License

MIT © Kevin Medeiros. Not affiliated with Anthropic.

Source 3 files
hooks/register.tsx 585 lines
1import { 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}
585
hooks/unity.ts 260 lines
1import 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('&', '&amp;').replaceAll('"', '&quot;')}" />`)
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('&lt;', '<')
138    .replaceAll('&gt;', '>')
139    .replaceAll('&quot;', '"')
140    .replaceAll('&apos;', "'")
141    .replaceAll('&amp;', '&')
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}
260
types/index.d.ts 66 lines
1export 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