Turns xcodebuild and swift build output into a live pane of errors grouped by file, and hands Claude the diagnostics instead of the raw log.

A Claude Code mod that turns xcodebuild and swift build output into a live pane of errors grouped by file, and hands Claude the diagnostics instead of the raw log.

When a Bash command fails with long output, Claude Code gives the model the start and the end and cuts out the middle. A failing xcodebuild puts the compiler errors in the middle, between hundreds of lines of SwiftCompile and Copy steps. Claude learns that the build failed and which files failed, but not why.
Measured on Xcode 27.1 with a five-file Swift package and three compile errors:
| Without the mod | With the mod | |
|---|---|---|
| Raw log | 295 lines, 47 KB | the same |
| What Claude reads | 10,039 characters, none of them an error message | 570 characters, every reported error with its file, line and column |
Repeated ten times, the result without the mod had no error message in it in eight runs. The method, the demo project and a script to reproduce it are in Benchmarks/.

-resultBundlePath to xcodebuild commands that name none, then reads errors, warnings and test failures from the bundle with xcresulttool. The bundle goes to the temporary folder and is deleted once read.file:line:column: error: message, the failed tests, and a count of warnings per file. The transcript keeps the raw output.mcp__xcpane__details, that Claude can call for every warning, error and failed test of the last build. The summary tells Claude it is there.BuildProject, RunAllTests, RunSomeTests), the result is left as it is and shown in the same pane, status line and toast. The MCP's build result lists errors only, so the mod also asks its build log for the warnings and shows those.It also reads swift build and swift test from their log output, and a build piped through tail, xcbeautify or xcpretty is still read from the result bundle.
Xcode ships its own MCP server (xcrun mcpbridge) that lets Claude build, test and read parsed results through Xcode's tools. From Xcode 27 it can run headless, with Xcode closed, after a one-time sudo xcrun mcp-server enable.
The two work together. When Claude builds or tests through the MCP, xcpane leaves the result as it is, since it is already structured, and shows it in the pane, status line and toast. When Claude runs xcodebuild in the shell instead, xcpane also replaces the raw log with the errors. You can have both set up and get the pane either way.
How they differ:
| Apple's Xcode MCP | xcpane | |
|---|---|---|
| Covers | Builds Claude runs through the MCP's build tool | Builds Claude runs as xcodebuild or swift build in Bash, which it condenses, and builds run through the MCP, which it shows |
| Setup | Enable in Xcode; headless mode needs sudo and per-agent approval | Two commands |
| Scope | Builds, tests, previews, project navigation, documentation | Build and test results only |
| Interface | None in Claude Code | A live pane, status line and toast in the terminal |
If you have the MCP set up, keep using it and add xcpane for the pane. If you don't, xcpane alone covers the builds Claude runs in the shell, which it often does.
xcpane works without it:
sudo, and approving an agent or folder needs it too. Many company-managed Macs don't give developers admin access.xcodebuild command Claude was already allowed to run, reads the result locally with Apple's xcresulttool, and sends nothing over the network.An MCP server returns data to the model and cannot draw interface in Claude Code. That part is specific to mods.
xcsift and xcbeautify are command-line tools you pipe xcodebuild output through. xcsift is built for coding agents and does more than this mod in several places; this mod's difference is that it lives inside Claude Code.
| xcpane | xcsift | xcbeautify | |
|---|---|---|---|
| What it is | A Claude Code mod | A command-line tool | A command-line tool |
| Made for | Claude Code | Coding agents and CI | People and CI |
| How a build reaches it | By itself, when Claude runs xcodebuild or builds through Xcode's MCP | The command is piped through it | The command is piped through it |
| Where results come from | Xcode's result bundle, the log as a fallback | The build log, plus coverage files | The build log |
| Live pane in Claude Code | Yes | No | No |
| Other agents, CI, Linux | No | Yes | CI yes |
| Coverage | A single line-coverage figure | Detailed reports | No |
If you use several agents, or want the same output in CI, xcsift is the better fit. If you work in Claude Code and want the errors in front of you as well as in front of Claude, use this.
xcodebuild, xcrun).claude --version and update with claude update.xcresulttool has get build-results (tested on Xcode 27.1). Without it the mod falls back to reading the log.Two commands, then start a new Claude Code session:
claude plugin marketplace add griches/xcpane
claude plugin install xcpane@griches
The mod then loads in every session.
Use this to try it for one session, or to work on the mod:
git clone https://github.com/griches/xcpane.git ~/.claude/mods/xcpane
cd /path/to/your/xcode/project
claude --plugin-dir ~/.claude/mods/xcpane
To load a clone in every session, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. The path must be absolute; ~ is allowed. If the variable already names other folders, separate them with :.
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/xcpane"
}
}
Type /xcpane in a session. If the mod is loaded, the pane opens and says "No builds yet." You only need to do this once, to confirm the install.
There is nothing to switch on. Once the mod is loaded it works by itself whenever Claude runs xcodebuild, swift build or swift test, however you ask:
Build the app and fix any errors.
Each time Claude builds:
/xcpane, and then shows above the prompt.The commands and keys are only for the pane:
| What | How |
|---|---|
| Open the pane | /xcpane |
| Forget the builds | /xcpane clear |
| Show or hide warnings | Focus the pane (ctrl+x then tab), press w |
| Clear from the pane | Focus the pane, press c |
| Close the pane | ctrl+x then x, or click its ✕ |
Each option is a row in Claude Code's config menu (/config).
| Option | Default | Meaning |
|---|---|---|
condense | true | Replace the raw log Claude reads with the parsed diagnostics |
warnings | count | count: Claude reads how many warnings each file has. list: every warning |
resultBundle | true | Add -resultBundlePath to xcodebuild commands that name none |
autoOpen | always | Open the pane always (when a build starts), on failure, or never |
compactRow | true | Draw the verdict in the transcript instead of the raw log |
xcpane is a mod: code that runs inside Claude Code. This is everything it does.
It watches Bash commands. It hooks the Bash tool. A command that runs xcodebuild, swift build or swift test runs as Claude wrote it, except that one flag, -resultBundlePath, is added to an xcodebuild command that names none (turn this off with resultBundle). Other commands are passed on untouched.
It runs three local programs, each by a fixed command: xcrun xcresulttool and xcrun xccov to read the result bundle, and /bin/rm -rf on the one temporary bundle it asked for, at <temporary folder>/xcpane/<id>.xcresult.
It replaces what Claude reads of the build's output. It hooks the row Claude Code stores for the tool's result and swaps the raw log for the parsed errors, warning counts and failed tests. Your transcript keeps the raw log, and the details tool hands the rest back. A line that also prints something else, such as xcodebuild build && cat config.json, keeps its whole output.
It watches builds run through Xcode's MCP server, and asks that same server for the build's warnings. It changes nothing in those calls.
It reads one kind of file: when Claude Code has saved a long log to a file of its own, xcpane reads that file to see the whole log.
It makes no network requests, calls no model and keeps nothing between sessions.
It adds the /xcpane command, a pane, a status line, a toast, a compact transcript row, and one tool for the model, details, which lists stored results and runs no build.
The mod changes the command Claude runs by appending one flag: -resultBundlePath '<temporary folder>/xcpane/<id>.xcresult'. An allow rule such as Bash(xcodebuild:*) still matches. A rule that names one exact command will no longer match and Claude Code will ask; set resultBundle to false to leave commands untouched.
claude plugin marketplace update griches
claude plugin update xcpane@griches
claude plugin uninstall xcpane@griches
claude plugin marketplace remove griches
For a clone, git pull in the folder to update. To uninstall, remove the folder from CLAUDE_CODE_PLUGIN_DIRS and delete it.
/xcpane is not a command. The mod did not load. Check claude plugin list shows xcpane@griches as enabled, start a new session, and check claude --version. For a clone, run claude plugin validate on the folder and check the path in your settings.autoOpen is not always. Type /xcpane.claude --debug logs why a hook was skipped.make, fastlane, bash -c or $(...) is not recognised, and neither is one run in the background.xcodebuild invocations is read from its log only.swift build has no result bundle, so a long failing log that Claude Code cuts in the middle can still lose its errors.claude plugin validate .
claude plugin test .
hooks/register.tsx holds the hooks; shell.ts finds builds in a command, xcresult.ts, log.ts and mcp.ts read results, and format.ts words them. The fixtures in tests/fixtures.ts are excerpts of real Xcode output.
hooks/register.tsx 593 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Build, Issue, Tests } from '../types'
5import {
6 basename,
7 byFile,
8 condense,
9 details,
10 location,
11 percent,
12 plural,
13 seconds,
14 subject,
15 tally,
16 verdict,
17} from './format'
18import type { Detail } from './format'
19import { parseLog } from './log'
20import { parseMcpBuild, parseMcpBuildLog, parseMcpTests, structuredOf } from './mcp'
21import { findInvocations, mixedWith, withResultBundle } from './shell'
22import { parseBuildResults, parseCoverage, parseTestDetails, parseTestSummary } from './xcresult'
23
24const PANE = 'xcpane'
25const TITLE = 'Xcode build'
26const COMMAND = 'xcpane'
27const DETAILS_TOOL = 'mcp__xcpane__details'
28const KEPT_BUILDS = 20
29const KEPT_ISSUES = 300
30const PANE_ISSUES = 60
31const ROW_ERRORS = 3
32
33type AutoOpen = 'always' | 'failure' | 'never'
34
35const builds = atom({ plugin: 'xcpane', key: 'builds' } as const, [])
36const isShowingWarnings = atom({ plugin: 'xcpane', key: 'isShowingWarnings' } as const, false)
37const now = atom({ plugin: 'xcpane', key: 'now' } as const, 0)
38
39const GLYPH = { running: '●', succeeded: '✓', failed: '✗', cancelled: '◌' } as const
40const TONE = { running: 'warning', succeeded: 'success', failed: 'error', cancelled: undefined } as const
41
42const isError = (issue: Issue) => issue.severity === 'error'
43
44const bare = (name: string) => name.replace(/\(\)$/, '')
45
46const run = async ($: EngineInterface, argv: readonly string[]) => {
47 const ran = await $.process.run(argv, { timeoutMs: 20_000 })
48
49 return ran.exitCode === 0 && !ran.isStdoutTruncated ? ran.stdout : ''
50}
51
52const xcresult = ($: EngineInterface, path: string, query: readonly string[]) =>
53 run($, ['xcrun', 'xcresulttool', 'get', ...query, '--path', path, '--compact'])
54
55/** What a result bundle says of its tests: the summary, with locations, the slowest tests and coverage added. */
56const readTests = async ($: EngineInterface, path: string, summary: Tests | null) => {
57 const found = parseTestDetails(await xcresult($, path, ['test-results', 'tests']))
58 const coverage = parseCoverage(await run($, ['xcrun', 'xccov', 'view', '--report', '--only-targets', '--json', path]))
59 const tests =
60 summary === null || found === null
61 ? summary
62 : {
63 ...summary,
64 slowest: found.slowest,
65 failures: summary.failures.map(failure => ({ ...failure, ...found.locations.get(failure.name) })),
66 }
67
68 return { tests, coverage }
69}
70
71const readBundle = async ($: EngineInterface, path: string, hasTests: boolean) => {
72 try {
73 if (!(await $.fs.exists(path))) {
74 return null
75 }
76
77 const built = parseBuildResults(await xcresult($, path, ['build-results']))
78 const summary = hasTests ? parseTestSummary(await xcresult($, path, ['test-results', 'summary'])) : null
79
80 return built === null ? null : { built, ...(await readTests($, path, summary)) }
81 } catch {
82 return null
83 }
84}
85
86/**
87 * The warnings of the build an Xcode MCP server just ran. Its `BuildProject`
88 * result lists errors only, so they are asked for from its build log.
89 */
90const mcpWarnings = async ($: EngineInterface, tool: string, workspace: unknown): Promise<Issue[]> => {
91 try {
92 const server = tool.split('__')[1] ?? ''
93 const args = typeof workspace === 'string' ? { severity: 'warning', workspaceIdentifier: workspace } : { severity: 'warning' }
94 const log = structuredOf({ result: await $.mcp.call(server, 'GetBuildLog', args) })
95
96 return (log === null ? null : parseMcpBuildLog(log))?.filter(one => !isError(one)) ?? []
97 } catch {
98 return []
99 }
100}
101
102const openPane = ($: EngineInterface) => {
103 void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
104}
105
106const store = ($: EngineInterface, build: Build) =>
107 update($, builds, list => [...list.filter(one => one.id !== build.id), build].slice(-KEPT_BUILDS))
108
109const drop = ($: EngineInterface, id: string) => update($, builds, list => list.filter(one => one.id !== id))
110
111/** Moves the pane's clock on, so a running timer redraws. */
112const tick = async ($: EngineInterface) => {
113 const at = await $.clock.now()
114 await update($, now, () => at)
115}
116
117/** Shows `running` in the pane with a ticking timer for as long as `work` takes. */
118async function track<T>($: EngineInterface, running: Build, autoOpen: AutoOpen, work: () => Promise<T>): Promise<T> {
119 await update($, now, () => running.startedAt)
120 await store($, running)
121
122 if (autoOpen === 'always') {
123 openPane($)
124 }
125
126 const ticker = $.clock.every(1000, () => {
127 void tick($).catch(() => undefined)
128 })
129
130 try {
131 return await work()
132 } catch (error) {
133 await drop($, running.id)
134 throw error
135 } finally {
136 ticker.cancel()
137 }
138}
139
140/** Stores a finished build and says how it went: the status line on a failure, a toast on a success. */
141const announce = async ($: EngineInterface, finished: Build, autoOpen: AutoOpen) => {
142 await store($, finished)
143
144 if (finished.status === 'failed') {
145 $.ui.status(`${GLYPH.failed} ${finished.scheme ?? (finished.tool === 'xcode' ? 'Xcode' : finished.tool)}: ${tally(finished)}`)
146
147 if (autoOpen === 'failure') {
148 openPane($)
149 }
150 } else {
151 $.ui.status(undefined)
152 }
153
154 if (finished.status === 'succeeded') {
155 $.ui.toast(`${GLYPH.succeeded} ${verdict(finished)} · ${tally(finished)} · ${seconds(finished.durationMs ?? 0)}`)
156 }
157}
158
159const started = (id: string, startedAt: number, facts: Pick<Build, 'tool' | 'action' | 'hasTests' | 'scheme'>): Build => ({
160 ...facts,
161 id,
162 status: 'running',
163 startedAt,
164 durationMs: null,
165 errorCount: 0,
166 warningCount: 0,
167 issues: [],
168 tests: null,
169 coverage: null,
170 logPath: null,
171 failedCommands: [],
172 source: 'none',
173 logLines: 0,
174 isCondensed: false,
175})
176
177const sorted = (issues: readonly Issue[]) =>
178 [...issues.filter(isError), ...issues.filter(one => !isError(one))].slice(0, KEPT_ISSUES)
179
180export const register: Register = (on, options) => {
181 const wantsCondense = options.condense !== false
182 const wantsBundle = options.resultBundle !== false
183 const wantsCompactRow = options.compactRow !== false
184 const warnings = options.warnings === 'list' ? 'list' : 'count'
185 const autoOpen: AutoOpen = options.autoOpen === 'failure' || options.autoOpen === 'never' ? options.autoOpen : 'always'
186 const condensed = new Map<string, string>()
187 /** Calls whose line printed more than a build, so their transcript row is left as it is. */
188 const mixed = new Set<string>()
189
190 on('session.start', async ($, e, next) => {
191 await $.command.register({
192 name: COMMAND,
193 description: 'Show the Xcode build pane: errors by file, warnings, failed tests (clear: forget the builds)',
194 })
195 await $.tool.register({
196 name: 'details',
197 description:
198 'Lists what the most recent xcodebuild, swift build or Xcode MCP build reported, in full: every error and warning with its file, line and message, the failed and slowest tests, and line coverage. Call it when a build summary counted warnings without listing them. It reads stored results and runs no build.',
199 inputSchema: {
200 type: 'object',
201 properties: {
202 show: {
203 type: 'string',
204 enum: ['all', 'errors', 'warnings', 'tests'],
205 description: 'Which part to list; all by default.',
206 },
207 },
208 },
209 })
210
211 return next(e)
212 })
213
214 on('command.run', { command: COMMAND }, async ($, e) => {
215 if (e.args.trim() === 'clear') {
216 await update($, builds, () => [])
217 $.ui.status(undefined)
218
219 return { text: 'Xcode build history cleared.' }
220 }
221
222 await $.ui.open({ id: PANE, title: TITLE })
223 const latest = (await read($, builds)).at(-1)
224
225 return {
226 text:
227 latest === undefined
228 ? 'Xcode build pane opened. No builds yet.'
229 : `Xcode build pane opened. Last: ${subject(latest)}: ${verdict(latest)} · ${tally(latest)}`,
230 }
231 })
232
233 on('tool.call', { tool: /^mcp__xcpane__details$/ }, async ($, e) => {
234 const latest = (await read($, builds)).findLast(one => one.status !== 'running')
235 const asked = (e as { show?: unknown }).show
236 const show: Detail = asked === 'errors' || asked === 'warnings' || asked === 'tests' ? asked : 'all'
237
238 return { result: latest === undefined ? 'No build has finished in this session yet.' : details(latest, show) }
239 })
240
241 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
242 const found = findInvocations(e.command).filter(one => !one.isInfoOnly)
243 const invocation = found[0]
244
245 if (invocation === undefined || e.run_in_background === true) {
246 return next(e)
247 }
248
249 const id = e.tool_use_id
250 const isAlone = found.length === 1 && invocation.tool === 'xcodebuild'
251 let command = e.command
252 let bundle: string | null = null
253 let isOwnBundle = false
254
255 if (wantsBundle && isAlone && invocation.resultBundlePath !== null) {
256 const isStale = await $.fs.exists(invocation.resultBundlePath).catch(() => true)
257 bundle = isStale ? null : invocation.resultBundlePath
258 } else if (wantsBundle && isAlone && !invocation.hasResultBundleFlag && invocation.action !== 'clean') {
259 const temporary = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')
260 bundle = `${temporary}/xcpane/${id.replace(/[^A-Za-z0-9_-]/g, '')}.xcresult`
261 command = withResultBundle(e.command, invocation, bundle)
262 isOwnBundle = true
263 }
264
265 const startedAt = await $.clock.now()
266 const running = started(id, startedAt, {
267 tool: invocation.tool,
268 action: invocation.action,
269 hasTests: invocation.hasTests,
270 scheme: invocation.scheme,
271 })
272 const ran = await track($, running, autoOpen, () => next(command === e.command ? e : { ...e, command }))
273
274 if (ran.deny !== undefined) {
275 await drop($, id)
276
277 return ran
278 }
279
280 const shown = ran.text ?? ''
281 let raw = shown
282 let logPath: string | null = null
283 let isStopped = false
284
285 if (ran.isError === true) {
286 raw = typeof ran.result === 'string' ? ran.result : shown
287 } else {
288 const persisted = ran.result.persistedOutputPath
289 raw = [ran.result.stdout, ran.result.stderr].filter(Boolean).join('\n')
290 isStopped = ran.result.interrupted || ran.result.backgroundTaskId !== undefined
291
292 if (persisted !== undefined) {
293 logPath = persisted
294 raw = await $.fs.read(persisted).catch(() => raw)
295 }
296 }
297
298 const log = parseLog(raw)
299 const bundled = bundle === null || isStopped ? null : await readBundle($, bundle, invocation.hasTests)
300 const located = new Map(log.failures.map(failure => [bare(failure.name), failure]))
301 const logTests = log.passedTests + log.failedTests + log.skippedTests
302 const tests: Tests | null =
303 bundled?.tests ??
304 (logTests === 0
305 ? null
306 : {
307 total: logTests,
308 passed: log.passedTests,
309 failed: log.failedTests,
310 skipped: log.skippedTests,
311 failures: log.failures,
312 slowest: [],
313 })
314 const issues = bundled?.built.issues ?? log.issues
315 const errorCount = bundled?.built.errorCount ?? issues.filter(isError).length
316 const hasFailed =
317 ran.isError === true ||
318 bundled?.built.status === 'failed' ||
319 log.marker === 'failed' ||
320 errorCount > 0 ||
321 (tests?.failed ?? 0) > 0
322 const hasEvidence = bundled !== null || log.marker !== null || log.issues.length > 0 || log.failures.length > 0
323 const finished: Build = {
324 ...running,
325 status: isStopped ? 'cancelled' : hasFailed ? 'failed' : 'succeeded',
326 durationMs: (await $.clock.now()) - startedAt,
327 errorCount,
328 warningCount: bundled?.built.warningCount ?? issues.filter(one => !isError(one)).length,
329 issues: sorted(issues),
330 tests:
331 tests === null
332 ? null
333 : {
334 ...tests,
335 failures: tests.failures.map(failure => {
336 const where = failure.file === null ? located.get(bare(failure.name)) : undefined
337
338 return where === undefined ? failure : { ...failure, file: where.file, line: where.line }
339 }),
340 },
341 coverage: bundled?.coverage ?? null,
342 logPath,
343 failedCommands: log.failedCommands.slice(0, 10),
344 source: bundled !== null ? 'xcresult' : hasEvidence ? 'log' : 'none',
345 logLines: log.lines,
346 }
347 const hasFindings = finished.errorCount > 0 || (finished.tests?.failed ?? 0) > 0
348 const isReadable = finished.source !== 'none' && (finished.status === 'succeeded' || hasFindings)
349
350 // A line that also prints something else (a file, a listing) keeps its output: only the build's part could be summed up.
351 const isMixed = mixedWith(e.command).length > 0
352
353 if (isMixed) {
354 mixed.add(id)
355 }
356
357 if (wantsCondense && isReadable && !isStopped && !isMixed && ran.text !== undefined) {
358 const exit = /^Exit code (\d+)/.exec(shown)
359 const summary = condense(finished, {
360 warnings,
361 exitCode: exit === null ? null : Number(exit[1]),
362 isLogCut: log.isCut || (ran.isError === true && log.marker === null),
363 detailsTool: DETAILS_TOOL,
364 })
365
366 if (summary.length < ran.text.length) {
367 condensed.set(id, summary)
368 finished.isCondensed = true
369 }
370 }
371
372 await announce($, finished, autoOpen)
373
374 if (isOwnBundle && bundle !== null) {
375 void $.process.run(['/bin/rm', '-rf', bundle]).catch(() => undefined)
376 }
377
378 return ran
379 })
380
381 // A build Claude runs through Xcode's own MCP server already comes back
382 // structured, so it is left as it is and only shown: pane, status, toast.
383 on('tool.call', { tool: /^mcp__.+__(BuildProject|RunAllTests|RunSomeTests)$/ }, async ($, e, next) => {
384 const id = e.tool_use_id
385 const hasTests = !e.tool.endsWith('__BuildProject')
386 const startedAt = await $.clock.now()
387 const running = started(id, startedAt, { tool: 'xcode', action: hasTests ? 'test' : 'build', hasTests, scheme: null })
388 const ran = await track($, running, autoOpen, () => next(e))
389
390 if (ran.deny !== undefined) {
391 await drop($, id)
392
393 return ran
394 }
395
396 const data = structuredOf(ran)
397 const report = data === null ? null : hasTests ? parseMcpTests(data) : parseMcpBuild(data)
398 const elapsed = (await $.clock.now()) - startedAt
399
400 if (report === null) {
401 await announce($, { ...running, status: 'failed', durationMs: elapsed }, autoOpen)
402
403 return ran
404 }
405
406 const bundled =
407 report.bundlePath === null ? null : await readTests($, report.bundlePath, report.tests).catch(() => null)
408 const issues = hasTests
409 ? report.issues
410 : [...report.issues, ...(await mcpWarnings($, e.tool, (e as { workspaceIdentifier?: unknown }).workspaceIdentifier))]
411
412 await announce(
413 $,
414 {
415 ...running,
416 scheme: report.scheme,
417 status: report.status,
418 durationMs: report.durationMs ?? elapsed,
419 errorCount: issues.filter(isError).length,
420 warningCount: issues.filter(one => !isError(one)).length,
421 issues: sorted(issues),
422 tests: bundled?.tests ?? report.tests,
423 coverage: bundled?.coverage ?? null,
424 logPath: report.logPath,
425 source: 'xcresult',
426 },
427 autoOpen,
428 )
429
430 return ran
431 })
432
433 on('session.append', { door: 'tool-result' }, ($, e, next) => {
434 let isRewritten = false
435 const content = e.message.content.map(block => {
436 const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : ''
437 const summary = block.type === 'tool_result' ? condensed.get(id) : undefined
438
439 if (summary === undefined) {
440 return block
441 }
442
443 condensed.delete(id)
444 isRewritten = true
445
446 return { ...block, content: typeof block.content === 'string' ? summary : [{ type: 'text', text: summary }] }
447 })
448
449 return isRewritten ? next({ ...e, message: { ...e.message, content } }) : next(e)
450 })
451
452 on('ui.render', { component: 'ToolResult', props: { tool: 'Bash' } }, async ($, e, next) => {
453 const build = wantsCompactRow ? (await read($, builds)).find(one => one.id === e.requestId) : undefined
454
455 if (build === undefined || build.status === 'running' || build.source === 'none' || mixed.has(build.id)) {
456 return next(e)
457 }
458
459 const { Box, Text } = $.ui.resolve(e)
460 const errors = build.issues.filter(isError)
461 const failures = build.tests?.failures ?? []
462 const facts = [tally(build), seconds(build.durationMs ?? 0), `${plural(build.logLines, 'log line')} folded`]
463
464 return (
465 <Box flexDirection="column">
466 <Box flexDirection="row">
467 <Text dimColor>{' ⎿ '}</Text>
468 <Text bold color={TONE[build.status]}>{`${GLYPH[build.status]} ${verdict(build)}`}</Text>
469 <Text dimColor>{` · ${facts.join(' · ')}`}</Text>
470 </Box>
471 {errors.slice(0, ROW_ERRORS).map(issue => (
472 <Text wrap="truncate-end">
473 {` ${[issue.file === null ? null : basename(issue.file), location(issue)].filter(Boolean).join(':')} ${issue.message}`}
474 </Text>
475 ))}
476 {errors.length === 0 &&
477 failures
478 .slice(0, ROW_ERRORS)
479 .map(failure => <Text wrap="truncate-end">{` ${failure.name} ${failure.message}`}</Text>)}
480 {errors.length > ROW_ERRORS && (
481 <Text dimColor>{` +${plural(build.errorCount - ROW_ERRORS, 'more error')} · /${COMMAND} shows them all`}</Text>
482 )}
483 </Box>
484 )
485 })
486
487 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
488 const { Box, Button, Text } = $.ui.resolve(e)
489 const list = await read($, builds)
490 const latest = list.at(-1)
491
492 if (latest === undefined) {
493 return (
494 <Box flexDirection="column">
495 <Text dimColor>No builds yet.</Text>
496 <Text dimColor>Ask Claude to run xcodebuild, swift build or swift test.</Text>
497 </Box>
498 )
499 }
500
501 const showsWarnings = await read($, isShowingWarnings)
502 const elapsed =
503 latest.status === 'running' ? Math.max(0, (await read($, now)) - latest.startedAt) : (latest.durationMs ?? 0)
504 const visible = latest.issues.filter(one => showsWarnings || isError(one))
505 const groups = byFile(visible.slice(0, PANE_ISSUES))
506 const failures = latest.tests?.failures ?? []
507 const slowest = latest.tests?.slowest ?? []
508 const earlier = list.slice(0, -1).slice(-5).reverse()
509 const hasNothingToShow = latest.status === 'failed' && latest.errorCount === 0 && failures.length === 0
510
511 return (
512 <Box flexDirection="column">
513 <Box flexDirection="row">
514 <Text bold color={TONE[latest.status]}>{`${GLYPH[latest.status]} ${verdict(latest)}`}</Text>
515 <Text dimColor>{` ${seconds(elapsed)}`}</Text>
516 </Box>
517 <Text dimColor>{subject(latest)}</Text>
518 {latest.status !== 'running' && <Text>{tally(latest)}</Text>}
519 {latest.coverage !== null && <Text dimColor>{`Line coverage ${percent(latest.coverage)}`}</Text>}
520 {groups.map(group => (
521 <Box flexDirection="column" marginTop={1}>
522 <Box flexDirection="row">
523 <Text bold>{group.file === null ? 'Project' : basename(group.file)}</Text>
524 <Text dimColor>{` ${plural(group.issues.length, 'issue')}`}</Text>
525 </Box>
526 {group.issues.map(issue => (
527 <Box flexDirection="row">
528 <Box width={9} flexShrink={0}>
529 <Text color={isError(issue) ? 'error' : 'warning'}>{` ${location(issue) || (isError(issue) ? 'error' : 'warn')}`}</Text>
530 </Box>
531 <Box flexGrow={1} flexShrink={1}>
532 <Text>{issue.message}</Text>
533 </Box>
534 </Box>
535 ))}
536 </Box>
537 ))}
538 {visible.length > PANE_ISSUES && <Text dimColor>{`+${plural(visible.length - PANE_ISSUES, 'more issue')}`}</Text>}
539 {failures.length > 0 && (
540 <Box flexDirection="column" marginTop={1}>
541 <Text bold>Failed tests</Text>
542 {failures.slice(0, PANE_ISSUES).map(failure => (
543 <Box flexDirection="column">
544 <Text color="error">{` ${GLYPH.failed} ${failure.name}`}</Text>
545 <Text>{` ${failure.message}`}</Text>
546 </Box>
547 ))}
548 </Box>
549 )}
550 {slowest.length > 0 && (
551 <Box flexDirection="column" marginTop={1}>
552 <Text bold>Slowest tests</Text>
553 {slowest.map(test => (
554 <Text dimColor>{` ${test.seconds.toFixed(2)}s ${test.name}`}</Text>
555 ))}
556 </Box>
557 )}
558 {hasNothingToShow && (
559 <Box flexDirection="column" marginTop={1}>
560 <Text dimColor>No diagnostics found in the output; Claude read the raw log.</Text>
561 {latest.failedCommands.slice(0, 5).map(failed => (
562 <Text wrap="truncate-end">{` ${failed}`}</Text>
563 ))}
564 </Box>
565 )}
566 <Box flexDirection="row" gap={2} marginTop={1}>
567 {latest.warningCount > 0 && (
568 <Button
569 key="warnings"
570 hotkey="w"
571 plain
572 label={showsWarnings ? 'Hide warnings' : `Show ${plural(latest.warningCount, 'warning')}`}
573 onPress={() => update($, isShowingWarnings, shows => !shows)}
574 />
575 )}
576 <Button key="clear" hotkey="c" plain label="Clear" onPress={() => update($, builds, () => [])} />
577 </Box>
578 {earlier.length > 0 && (
579 <Box flexDirection="column" marginTop={1}>
580 <Text dimColor>Earlier</Text>
581 {earlier.map(build => (
582 <Box flexDirection="row">
583 <Text color={TONE[build.status]}>{` ${GLYPH[build.status]} `}</Text>
584 <Text dimColor>{`${build.action} · ${tally(build)} · ${seconds(build.durationMs ?? 0)}`}</Text>
585 </Box>
586 ))}
587 </Box>
588 )}
589 </Box>
590 )
591 })
592}
593hooks/format.ts 203 lines1import type { Build, Issue } from '../types'
2
3export type FileGroup = { file: string | null; issues: Issue[] }
4
5export type CondenseSettings = {
6 warnings: 'count' | 'list'
7 exitCode: number | null
8 isLogCut: boolean
9 /** The tool the model can call for what the summary leaves out, when there is one. */
10 detailsTool: string | null
11}
12
13export type Detail = 'all' | 'errors' | 'warnings' | 'tests'
14
15const LISTED = 50
16
17export const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`
18
19export const basename = (path: string) => path.slice(path.lastIndexOf('/') + 1)
20
21export const seconds = (ms: number) => {
22 if (ms < 10_000) {
23 return `${(ms / 1000).toFixed(1)}s`
24 }
25
26 const whole = Math.round(ms / 1000)
27
28 return whole < 60 ? `${whole}s` : `${Math.floor(whole / 60)}m ${whole % 60}s`
29}
30
31const noun = (build: Build) => {
32 if (build.hasTests) {
33 return 'TEST'
34 }
35
36 if (build.action.includes('archive')) {
37 return 'ARCHIVE'
38 }
39
40 if (build.action.includes('analyze')) {
41 return 'ANALYZE'
42 }
43
44 return build.action === 'clean' ? 'CLEAN' : 'BUILD'
45}
46
47/** `BUILD FAILED`, `TEST SUCCEEDED`: the verdict as xcodebuild words it. */
48export const verdict = (build: Build) => `${noun(build)} ${build.status.toUpperCase()}`
49
50/** `xcodebuild test, scheme Demo`: what ran. */
51export const subject = (build: Build) => {
52 const command = { swift: `swift ${build.action}`, xcodebuild: `xcodebuild ${build.action}`, xcode: `Xcode ${build.action} (MCP)` }[
53 build.tool
54 ]
55
56 return build.scheme === null ? command : `${command}, scheme ${build.scheme}`
57}
58
59/** `1 error · 3 warnings · 12 tests, 1 failed`. */
60export const tally = (build: Build) => {
61 const parts = [plural(build.errorCount, 'error'), plural(build.warningCount, 'warning')]
62
63 if (build.tests !== null) {
64 parts.push(`${plural(build.tests.total, 'test')}, ${build.tests.failed} failed`)
65 }
66
67 return parts.join(' · ')
68}
69
70export const location = (issue: Pick<Issue, 'line' | 'column'>) => {
71 if (issue.line === null) {
72 return ''
73 }
74
75 return issue.column === null ? `${issue.line}` : `${issue.line}:${issue.column}`
76}
77
78/** The issues grouped by file in first-seen order, errors ahead of warnings in each. */
79export const byFile = (issues: readonly Issue[]): FileGroup[] => {
80 const groups = new Map<string | null, Issue[]>()
81
82 for (const issue of issues) {
83 groups.set(issue.file, [...(groups.get(issue.file) ?? []), issue])
84 }
85
86 return [...groups].map(([file, own]) => ({
87 file,
88 issues: [...own.filter(one => one.severity === 'error'), ...own.filter(one => one.severity === 'warning')],
89 }))
90}
91
92export const percent = (fraction: number) => `${Math.round(fraction * 100)}%`
93
94const diagnostic = (issue: Issue) => {
95 const where = issue.file === null ? '' : `${[issue.file, location(issue)].filter(Boolean).join(':')}: `
96
97 return `${where}${issue.severity}: ${issue.message}`
98}
99
100const failedTests = (build: Build, limit: number) =>
101 (build.tests?.failures ?? []).slice(0, limit).map(failure => {
102 const where = failure.file === null ? '' : `${[failure.file, failure.line].filter(Boolean).join(':')}: `
103
104 return `${where}${failure.name}: ${failure.message}`
105 })
106
107const listed = (lines: string[], total: number, word: string) =>
108 total > lines.length ? [...lines, `(+${plural(total - lines.length, `more ${word}`)})`] : lines
109
110/**
111 * The build as the model reads it in place of the raw log: the verdict, every
112 * error in `file:line:column: error: message` form, warnings counted or listed,
113 * and the failed tests.
114 */
115export const condense = (build: Build, settings: CondenseSettings): string => {
116 const errors = build.issues.filter(one => one.severity === 'error')
117 const warnings = build.issues.filter(one => one.severity === 'warning')
118 const took = build.durationMs === null ? '' : ` in ${seconds(build.durationMs)}`
119 const exit = settings.exitCode === null ? '' : ` (exit code ${settings.exitCode})`
120 const blocks: string[][] = [[`${subject(build)}: ${verdict(build)}${took}${exit}`, tally(build)]]
121
122 if (errors.length > 0) {
123 blocks.push(listed(errors.slice(0, LISTED).map(diagnostic), build.errorCount, 'error'))
124 }
125
126 if (build.tests !== null && build.tests.failures.length > 0) {
127 blocks.push(['Failed tests:', ...listed(failedTests(build, LISTED), build.tests.failed, 'failed test')])
128 }
129
130 if (build.coverage !== null) {
131 blocks.push([`Line coverage: ${percent(build.coverage)}`])
132 }
133
134 if (build.warningCount > 0 && settings.warnings === 'list') {
135 blocks.push(listed(warnings.slice(0, LISTED).map(diagnostic), build.warningCount, 'warning'))
136 } else if (build.warningCount > 0) {
137 const files = byFile(warnings)
138 .slice(0, 8)
139 .map(group => `${group.file === null ? 'no file' : basename(group.file)} (${group.issues.length})`)
140 const hint = settings.detailsTool === null ? '' : ` Call ${settings.detailsTool} to list them.`
141 blocks.push([`${plural(build.warningCount, 'warning')} not listed: ${files.join(', ')}.${hint}`])
142 }
143
144 const from = build.source === 'xcresult' ? "Xcode's result bundle" : 'the build log'
145 const notes = [`[xcpane: summarised from ${from}; ${plural(build.logLines, 'line')} of raw log omitted.`]
146
147 if (settings.isLogCut && build.source === 'log') {
148 notes.push('The captured log was cut short, so later diagnostics may be missing.')
149 }
150
151 if (build.logPath !== null) {
152 notes.push(`Full log: ${build.logPath}`)
153 }
154
155 blocks.push([`${notes.join(' ')}]`])
156
157 return blocks.map(block => block.join('\n')).join('\n\n')
158}
159
160/**
161 * A build in full, for the details tool: every stored error and warning with
162 * its location, the failed and slowest tests, and coverage.
163 */
164export const details = (build: Build, show: Detail): string => {
165 const wants = (one: Detail) => show === 'all' || show === one
166 const of = (severity: Issue['severity']) => build.issues.filter(one => one.severity === severity).map(diagnostic)
167 const took = build.durationMs === null ? '' : ` in ${seconds(build.durationMs)}`
168 const blocks: string[][] = [[`${subject(build)}: ${verdict(build)}${took}`, tally(build)]]
169
170 if (wants('errors')) {
171 blocks.push(build.errorCount === 0 ? ['No errors.'] : listed(of('error'), build.errorCount, 'error'))
172 }
173
174 if (wants('warnings')) {
175 blocks.push(build.warningCount === 0 ? ['No warnings.'] : listed(of('warning'), build.warningCount, 'warning'))
176 }
177
178 if (wants('tests') && build.tests !== null) {
179 const { tests } = build
180 const slowest = tests.slowest.map(test => ` ${test.name}: ${test.seconds.toFixed(2)}s`)
181 blocks.push([
182 `${plural(tests.total, 'test')}: ${tests.passed} passed, ${tests.failed} failed, ${tests.skipped} skipped`,
183 ...listed(failedTests(build, tests.failures.length), tests.failed, 'failed test'),
184 ])
185
186 if (slowest.length > 0) {
187 blocks.push(['Slowest tests:', ...slowest])
188 }
189 } else if (show === 'tests') {
190 blocks.push(['This build ran no tests.'])
191 }
192
193 if (show === 'all' && build.coverage !== null) {
194 blocks.push([`Line coverage: ${percent(build.coverage)}`])
195 }
196
197 if (show === 'all' && build.logPath !== null) {
198 blocks.push([`Full log: ${build.logPath}`])
199 }
200
201 return blocks.map(block => block.join('\n')).join('\n\n')
202}
203hooks/log.ts 140 lines1import type { Issue, TestFailure } from '../types'
2
3export type LogReport = {
4 issues: Issue[]
5 failures: TestFailure[]
6 passedTests: number
7 failedTests: number
8 skippedTests: number
9 marker: 'succeeded' | 'failed' | null
10 failedCommands: string[]
11 lines: number
12 isCut: boolean
13}
14
15const ESCAPES = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g
16const LOCATED = /^([^\s:][^:]*\.[A-Za-z0-9+]+):(\d+)(?::(\d+))?: (error|warning|fatal error): (.*)$/
17const UNLOCATED = /^(?:([A-Za-z0-9_+.-]+): )?(error|warning|fatal error): (.+)$/
18const NOISE = /failed with a nonzero exit code|^Build failed$|^fatalError$|command failed due to signal|^(?:emit-module|compile) command failed/
19const XCTEST_FAILURE = /^-\[(\S+) (\S+)\] : (.*)$/
20const XCTEST_CASE = /^Test [Cc]ase '(.+?)' (passed|failed|skipped)\b/
21const SWIFT_TESTING_ISSUE = /^\S+ +Test (?:"(.+?)"|(\S+)) recorded an issue at (.+?):(\d+):\d+: (.*)$/
22const SWIFT_TESTING_CASE = /^\S+ +Test (?!run\b|Case\b|Suite\b)(?:"(.+?)"|(\S+)) (passed|failed|skipped) after /
23const MARKER = /^\*\* [A-Z ]+? (SUCCEEDED|FAILED) \*\*/
24const UNDEFINED_SYMBOLS = /^Undefined symbols?(?: for architecture \w+)?:/
25const TAG = /\s*\[#\w+\]$/
26const CUT = /\[\d+ (?:characters|lines) truncated\]|^<persisted-output>/m
27
28const testName = (suite: string, test: string) => `${suite.slice(suite.lastIndexOf('.') + 1)}/${test}`
29
30const caseName = (name: string) => {
31 const objc = /^-\[(\S+) (\S+)\]$/.exec(name)
32
33 return objc === null ? name : testName(objc[1] ?? '', objc[2] ?? '')
34}
35
36/**
37 * Reads what xcodebuild, swift build and swift test print: compiler and linker
38 * diagnostics, test results, the closing verdict and the commands that failed.
39 */
40export const parseLog = (text: string): LogReport => {
41 const lines = text.replace(ESCAPES, '').split(/\r?\n/)
42 const issues = new Map<string, Issue>()
43 const failures = new Map<string, TestFailure>()
44 const outcomes = new Map<string, string>()
45 const failedCommands: string[] = []
46 let marker: LogReport['marker'] = null
47 let isListingFailedCommands = false
48
49 const report = (issue: Issue) => {
50 issues.set([issue.severity, issue.file, issue.line, issue.column, issue.message].join('|'), issue)
51 }
52
53 for (const [index, line] of lines.entries()) {
54 const located = LOCATED.exec(line)
55 const unlocated = located === null ? UNLOCATED.exec(line) : null
56 const verdict = MARKER.exec(line)
57 const xctestCase = XCTEST_CASE.exec(line)
58 const swiftCase = SWIFT_TESTING_CASE.exec(line)
59 const swiftIssue = SWIFT_TESTING_ISSUE.exec(line)
60
61 if (isListingFailedCommands) {
62 isListingFailedCommands = /^\s+\S/.test(line)
63
64 if (isListingFailedCommands) {
65 failedCommands.push(line.trim())
66 }
67 } else if (located !== null) {
68 const file = located[1] ?? ''
69 const row = Number(located[2])
70 const message = (located[5] ?? '').replace(TAG, '')
71 const failed = XCTEST_FAILURE.exec(message)
72
73 if (failed === null) {
74 report({
75 severity: located[4] === 'warning' ? 'warning' : 'error',
76 file,
77 line: row,
78 column: located[3] === undefined ? null : Number(located[3]),
79 message,
80 })
81 } else {
82 const name = testName(failed[1] ?? '', failed[2] ?? '')
83 failures.set(name, { name, message: failed[3] ?? '', file, line: row })
84 }
85 } else if (unlocated !== null) {
86 const message = unlocated[3] ?? ''
87
88 if (message === 'Build failed') {
89 marker = 'failed'
90 }
91
92 if (!NOISE.test(message)) {
93 report({
94 severity: unlocated[2] === 'warning' ? 'warning' : 'error',
95 file: null,
96 line: null,
97 column: null,
98 message: unlocated[1] === undefined ? message : `${unlocated[1]}: ${message}`,
99 })
100 }
101 } else if (UNDEFINED_SYMBOLS.test(line)) {
102 const symbols = lines.slice(index + 1, index + 9).filter(one => /^\s+\S/.test(one))
103 report({ severity: 'error', file: null, line: null, column: null, message: [line, ...symbols].join('\n') })
104 } else if (verdict !== null) {
105 marker = verdict[1] === 'FAILED' || marker === 'failed' ? 'failed' : 'succeeded'
106 } else if (line.startsWith('Build complete!')) {
107 marker ??= 'succeeded'
108 } else if (line.startsWith('The following build commands failed:')) {
109 isListingFailedCommands = true
110 } else if (xctestCase !== null) {
111 outcomes.set(caseName(xctestCase[1] ?? ''), xctestCase[2] ?? '')
112 } else if (swiftCase !== null) {
113 outcomes.set(swiftCase[1] ?? swiftCase[2] ?? '', swiftCase[3] ?? '')
114 } else if (swiftIssue !== null) {
115 const name = swiftIssue[1] ?? swiftIssue[2] ?? ''
116 failures.set(name, { name, message: swiftIssue[5] ?? '', file: swiftIssue[3] ?? null, line: Number(swiftIssue[4]) })
117 }
118 }
119
120 for (const [name, outcome] of outcomes) {
121 if (outcome === 'failed' && !failures.has(name)) {
122 failures.set(name, { name, message: 'failed', file: null, line: null })
123 }
124 }
125
126 const outcome = (wanted: string) => [...outcomes.values()].filter(one => one === wanted).length
127
128 return {
129 issues: [...issues.values()],
130 failures: [...failures.values()],
131 passedTests: outcome('passed'),
132 failedTests: Math.max(outcome('failed'), failures.size),
133 skippedTests: outcome('skipped'),
134 marker,
135 failedCommands,
136 lines: lines.length,
137 isCut: CUT.test(text),
138 }
139}
140hooks/mcp.ts 133 lines1import type { Issue, Tests } from '../types'
2
3export type McpReport = {
4 status: 'succeeded' | 'failed'
5 scheme: string | null
6 issues: Issue[]
7 tests: Tests | null
8 logPath: string | null
9 bundlePath: string | null
10 durationMs: number | null
11}
12
13type Json = Record<string, unknown>
14
15const object = (value: unknown): Json | null =>
16 typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Json) : null
17
18const objects = (value: unknown): Json[] => (Array.isArray(value) ? value.map(object).filter(one => one !== null) : [])
19
20const text = (value: unknown) => (typeof value === 'string' ? value : null)
21
22const count = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
23
24const parsed = (json: unknown): Json | null => {
25 try {
26 return typeof json === 'string' ? object(JSON.parse(json)) : null
27 } catch {
28 return null
29 }
30}
31
32/**
33 * The structured result of an Xcode MCP tool call, wherever the engine put it:
34 * `structuredContent`, the first text block, or the text the model read.
35 */
36export const structuredOf = (ran: { result?: unknown; text?: string }): Json | null => {
37 const result = object(ran.result)
38 const block = objects(result?.content).find(one => typeof one.text === 'string')
39
40 return object(result?.structuredContent) ?? parsed(block?.text) ?? parsed(ran.result) ?? parsed(ran.text) ?? result
41}
42
43/** Reads the result of Xcode MCP's `BuildProject`. */
44export const parseMcpBuild = (data: Json): McpReport | null => {
45 if (!('buildResult' in data) && !('errors' in data)) {
46 return null
47 }
48
49 const issues = objects(data.errors).map(
50 (entry): Issue => ({
51 severity: entry.classification === 'warning' ? 'warning' : 'error',
52 file: text(entry.filePath),
53 line: typeof entry.lineNumber === 'number' ? entry.lineNumber : null,
54 column: null,
55 message: text(entry.message) ?? 'Unknown issue',
56 }),
57 )
58 const hasFailed = issues.some(one => one.severity === 'error') || /fail/i.test(text(data.buildResult) ?? '')
59
60 return {
61 status: hasFailed ? 'failed' : 'succeeded',
62 scheme: null,
63 issues,
64 tests: null,
65 logPath: text(data.fullLogPath),
66 bundlePath: null,
67 durationMs: typeof data.elapsedTime === 'number' ? Math.round(data.elapsedTime * 1000) : null,
68 }
69}
70
71const FAILURE = /^(\S+?):(\d+) \S+: (.*)$/s
72
73/** Reads the result of Xcode MCP's `RunAllTests` and `RunSomeTests`. */
74export const parseMcpTests = (data: Json): McpReport | null => {
75 const counts = object(data.counts)
76
77 if (counts === null) {
78 return null
79 }
80
81 const failures = objects(data.results)
82 .filter(one => one.state === 'Failed')
83 .map(one => {
84 const message = (Array.isArray(one.errorMessages) ? one.errorMessages : []).filter(each => typeof each === 'string')
85 const located = FAILURE.exec(message[0] ?? '')
86
87 return {
88 name: text(one.identifier) ?? text(one.displayName) ?? 'Unknown test',
89 message: located?.[3] ?? (message.join('; ') || 'failed'),
90 file: located?.[1] ?? null,
91 line: located === null ? null : Number(located[2]),
92 }
93 })
94
95 return {
96 status: count(counts.failed) > 0 ? 'failed' : 'succeeded',
97 scheme: text(data.schemeName),
98 issues: [],
99 tests: {
100 total: count(counts.total),
101 passed: count(counts.passed),
102 failed: count(counts.failed),
103 skipped: count(counts.skipped),
104 failures,
105 slowest: [],
106 },
107 logPath: text(data.fullSummaryPath),
108 bundlePath: text(data.xcresultBundlePath),
109 durationMs: null,
110 }
111}
112
113/**
114 * Reads the result of Xcode MCP's `GetBuildLog`: the issues its build tasks
115 * emitted. `BuildProject` reports errors only, so warnings come from here.
116 */
117export const parseMcpBuildLog = (data: Json): Issue[] | null =>
118 Array.isArray(data.buildLogEntries)
119 ? objects(data.buildLogEntries).flatMap(entry =>
120 objects(entry.emittedIssues)
121 .filter(one => one.severity === 'warning' || one.severity === 'error')
122 .map(
123 (one): Issue => ({
124 severity: one.severity === 'warning' ? 'warning' : 'error',
125 file: text(one.path),
126 line: typeof one.line === 'number' ? one.line : null,
127 column: null,
128 message: text(one.message) ?? 'Unknown issue',
129 }),
130 ),
131 )
132 : null
133hooks/shell.ts 379 lines1export type Invocation = {
2 tool: 'xcodebuild' | 'swift'
3 action: string
4 hasTests: boolean
5 scheme: string | null
6 isInfoOnly: boolean
7 hasResultBundleFlag: boolean
8 resultBundlePath: string | null
9 insertAt: number
10}
11
12type Word = {
13 text: string
14 start: number
15 end: number
16 isRedirect: boolean
17 isDynamic: boolean
18}
19
20const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
21const WRAPPERS = new Set([
22 'time',
23 'command',
24 'exec',
25 'env',
26 'nohup',
27 'xcrun',
28 'arch',
29 'caffeinate',
30 '{',
31 '!',
32 'if',
33 'then',
34 'else',
35 'do',
36 'while',
37])
38const VALUED_WRAPPER_FLAGS = new Set(['-sdk', '--sdk', '-toolchain', '--toolchain'])
39const ACTIONS = new Set([
40 'build',
41 'build-for-testing',
42 'analyze',
43 'archive',
44 'test',
45 'test-without-building',
46 'docbuild',
47 'install',
48 'installsrc',
49 'clean',
50])
51const TEST_ACTIONS = new Set(['test', 'test-without-building'])
52const BOOLEAN_FLAGS = new Set([
53 '-quiet',
54 '-verbose',
55 '-json',
56 '-alltargets',
57 '-allowProvisioningUpdates',
58 '-allowProvisioningDeviceRegistration',
59 '-hideShellScriptEnvironment',
60 '-showBuildTimingSummary',
61 '-skipPackagePluginValidation',
62 '-skipMacroValidation',
63 '-skipPackageUpdates',
64 '-skipUnavailableActions',
65 '-disableAutomaticPackageResolution',
66 '-onlyUsePackageVersionsFromResolvedFile',
67 '-retry-tests-on-failure',
68 '-run-tests-until-failure',
69])
70const INFO_FLAGS = new Set([
71 '-version',
72 '-usage',
73 '-help',
74 '-license',
75 '-list',
76 '-showsdks',
77 '-showdestinations',
78 '-showBuildSettings',
79 '-showTestPlans',
80 '-showComponent',
81 '-checkFirstLaunchStatus',
82 '-runFirstLaunch',
83 '-downloadPlatform',
84 '-downloadAllPlatforms',
85 '-downloadComponent',
86 '-importPlatform',
87 '-exportArchive',
88 '-exportLocalizations',
89 '-importLocalizations',
90 '-exportNotarizedApp',
91 '-resolvePackageDependencies',
92 '-create-xcframework',
93 '-find-executable',
94 '-find-library',
95 '-enumerate-tests',
96 '-dry-run',
97 '-n',
98])
99
100const split = (command: string): Word[][] => {
101 const segments: Word[][] = []
102 let words: Word[] = []
103 let word: Word | null = null
104 const open = (at: number): Word => {
105 word ??= { text: '', start: at, end: at, isRedirect: false, isDynamic: false }
106
107 return word
108 }
109 const push = (at: number) => {
110 if (word !== null) {
111 word.end = at
112 words.push(word)
113 word = null
114 }
115 }
116 const cut = (at: number) => {
117 push(at)
118
119 if (words.length > 0) {
120 segments.push(words)
121 }
122
123 words = []
124 }
125 const size = command.length
126 let i = 0
127
128 while (i < size) {
129 const c = command.charAt(i)
130 const following = command.charAt(i + 1)
131
132 if (c === '\\') {
133 if (following !== '\n') {
134 open(i).text += following
135 }
136
137 i += 2
138 } else if (c === "'") {
139 const close = command.indexOf("'", i + 1)
140 const stop = close < 0 ? size : close
141 open(i).text += command.slice(i + 1, stop)
142 i = stop + 1
143 } else if (c === '"') {
144 const quoted = open(i)
145 i += 1
146
147 while (i < size && command.charAt(i) !== '"') {
148 const inner = command.charAt(i)
149 const escaped = command.charAt(i + 1)
150
151 if (inner === '\\' && '\\"$`\n'.includes(escaped) && escaped !== '') {
152 quoted.text += escaped === '\n' ? '' : escaped
153 i += 2
154 } else {
155 quoted.isDynamic ||= inner === '$' || inner === '`'
156 quoted.text += inner
157 i += 1
158 }
159 }
160
161 i += 1
162 } else if (c === '$' && following === '(') {
163 const substituted = open(i)
164 let depth = 0
165 let stop = i + 1
166
167 for (; stop < size; stop += 1) {
168 const inner = command.charAt(stop)
169 depth += inner === '(' ? 1 : inner === ')' ? -1 : 0
170
171 if (depth === 0) {
172 break
173 }
174 }
175
176 substituted.isDynamic = true
177 substituted.text += command.slice(i, stop + 1)
178 i = stop + 1
179 } else if (c === '`') {
180 const close = command.indexOf('`', i + 1)
181 const stop = close < 0 ? size : close
182 const substituted = open(i)
183 substituted.isDynamic = true
184 substituted.text += command.slice(i, stop + 1)
185 i = stop + 1
186 } else if (c === '#' && word === null) {
187 const newline = command.indexOf('\n', i)
188 i = newline < 0 ? size : newline
189 } else if (c === ' ' || c === '\t') {
190 push(i)
191 i += 1
192 } else if (c === '>' || c === '<') {
193 const redirect = open(i)
194 redirect.isRedirect = true
195 redirect.text += c
196 i += 1
197 } else if (c === '&' && ('<>'.includes(command.charAt(i - 1) || ' ') || following === '>')) {
198 const redirect = open(i)
199 redirect.isRedirect = true
200 redirect.text += c
201 i += 1
202 } else if (';\n|&()'.includes(c)) {
203 cut(i)
204 i += 1
205 } else {
206 const plain = open(i)
207 plain.isDynamic ||= c === '$'
208 plain.text += c
209 i += 1
210 }
211 }
212
213 cut(size)
214
215 return segments
216}
217
218const xcodebuild = (args: Word[], insertAt: number): Invocation => {
219 const actions: string[] = []
220 let scheme: string | null = null
221 let target: string | null = null
222 let isInfoOnly = false
223 let hasResultBundleFlag = false
224 let resultBundlePath: string | null = null
225
226 for (let k = 0; k < args.length; k += 1) {
227 const text = args[k]?.text ?? ''
228 const value = args[k + 1]
229
230 if (!text.startsWith('-')) {
231 if (!text.includes('=') && ACTIONS.has(text)) {
232 actions.push(text)
233 }
234 } else if (INFO_FLAGS.has(text)) {
235 isInfoOnly = true
236 } else if (!BOOLEAN_FLAGS.has(text)) {
237 hasResultBundleFlag ||= text === '-resultBundlePath'
238
239 if (value !== undefined && !value.text.startsWith('-')) {
240 scheme = text === '-scheme' ? value.text : scheme
241 target = text === '-target' ? value.text : target
242
243 if (text === '-resultBundlePath' && !value.isDynamic && value.text.startsWith('/')) {
244 resultBundlePath = value.text
245 }
246
247 k += 1
248 }
249 }
250 }
251
252 return {
253 tool: 'xcodebuild',
254 action: actions.length > 0 ? actions.join(' ') : 'build',
255 hasTests: actions.some(action => TEST_ACTIONS.has(action)),
256 scheme: scheme ?? target,
257 isInfoOnly,
258 hasResultBundleFlag,
259 resultBundlePath,
260 insertAt,
261 }
262}
263
264const analyse = (words: Word[]): Invocation | null => {
265 let i = 0
266
267 while (i < words.length) {
268 const text = words[i]?.text ?? ''
269
270 if (ASSIGNMENT.test(text)) {
271 i += 1
272 } else if (WRAPPERS.has(text)) {
273 i += 1
274
275 while (words[i]?.text.startsWith('-') === true) {
276 i += VALUED_WRAPPER_FLAGS.has(words[i]?.text ?? '') ? 2 : 1
277 }
278 } else {
279 break
280 }
281 }
282
283 const head = words[i]
284
285 if (head === undefined || head.isRedirect || head.isDynamic) {
286 return null
287 }
288
289 const rest = words.slice(i + 1)
290 const redirect = rest.findIndex(one => one.isRedirect)
291 const args = redirect < 0 ? rest : rest.slice(0, redirect)
292 const insertAt = (args.at(-1) ?? head).end
293 const name = head.text.slice(head.text.lastIndexOf('/') + 1)
294 const verb = args[0]?.text
295
296 if (name === 'xcodebuild') {
297 return xcodebuild(args, insertAt)
298 }
299
300 if (name === 'swift' && (verb === 'build' || verb === 'test')) {
301 return {
302 tool: 'swift',
303 action: verb,
304 hasTests: verb === 'test',
305 scheme: null,
306 isInfoOnly: args.some(one => one.text === '--help' || one.text === '-h'),
307 hasResultBundleFlag: false,
308 resultBundlePath: null,
309 insertAt,
310 }
311 }
312
313 return null
314}
315
316/**
317 * The xcodebuild and `swift build|test` invocations a Bash command runs, in order.
318 *
319 * Only a command standing at a command position counts: one inside a quoted
320 * string, a `$(...)` or a here-document is text, not a build this mod can read.
321 */
322export const findInvocations = (command: string): Invocation[] =>
323 command.includes('<<')
324 ? []
325 : split(command)
326 .map(analyse)
327 .filter(one => one !== null)
328
329export const withResultBundle = (command: string, invocation: Invocation, path: string): string => {
330 const quoted = `'${path.replaceAll("'", "'\\''")}'`
331
332 return `${command.slice(0, invocation.insertAt)} -resultBundlePath ${quoted}${command.slice(invocation.insertAt)}`
333}
334
335/** Commands that print nothing of their own, or only pass on what the build printed. */
336const PASSIVE = new Set([
337 'cd', 'pushd', 'popd', 'export', 'unset', 'set', 'source', '.', 'true', ':', 'mkdir', 'touch', 'rm', 'sleep', 'wait',
338 'tail', 'head', 'grep', 'egrep', 'rg', 'tee', 'sort', 'uniq', 'cut', 'awk', 'wc', 'tr', 'less', 'more', 'column',
339 'xcbeautify', 'xcpretty', 'xcsift',
340])
341
342/** The name of the command a segment runs, the assignments and wrappers before it passed over. */
343const headOf = (words: Word[]): { name: string; args: Word[] } | null => {
344 let i = 0
345
346 while (i < words.length) {
347 const text = words[i]?.text ?? ''
348
349 if (ASSIGNMENT.test(text)) {
350 i += 1
351 } else if (WRAPPERS.has(text)) {
352 i += 1
353
354 while (words[i]?.text.startsWith('-') === true) {
355 i += VALUED_WRAPPER_FLAGS.has(words[i]?.text ?? '') ? 2 : 1
356 }
357 } else {
358 break
359 }
360 }
361
362 const head = words[i]
363
364 return head === undefined || head.isRedirect ? null : { name: head.text.slice(head.text.lastIndexOf('/') + 1), args: words.slice(i + 1).filter(one => !one.isRedirect) }
365}
366
367/**
368 * The other commands of a line whose own output Claude may be after:
369 * `xcodebuild build && cat config.json` prints a file as well as a build, so
370 * that line's output is not replaced by a summary.
371 */
372export const mixedWith = (command: string): string[] =>
373 split(command)
374 .filter(words => analyse(words) === null)
375 .map(headOf)
376 .filter(one => one !== null)
377 .filter(one => !PASSIVE.has(one.name) && !((one.name === 'cat' && one.args.every(arg => arg.text.startsWith('-'))) || one.name === 'sed'))
378 .map(one => one.name)
379hooks/xcresult.ts 171 lines1import type { Issue, SlowTest, Tests } from '../types'
2
3export type BundleReport = {
4 status: 'succeeded' | 'failed' | null
5 issues: Issue[]
6 errorCount: number
7 warningCount: number
8}
9
10type Json = Record<string, unknown>
11
12const object = (value: unknown): Json | null =>
13 typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Json) : null
14
15const objects = (value: unknown): Json[] => (Array.isArray(value) ? value.map(object).filter(one => one !== null) : [])
16
17const count = (value: unknown, fallback: number) => (typeof value === 'number' && Number.isFinite(value) ? value : fallback)
18
19const parse = (json: string): Json | null => {
20 try {
21 return object(JSON.parse(json))
22 } catch {
23 return null
24 }
25}
26
27const decoded = (path: string) => {
28 try {
29 return decodeURIComponent(path)
30 } catch {
31 return path
32 }
33}
34
35const oneBased = (fragment: string, name: string) => {
36 const found = new RegExp(`(?:^|&)${name}=(\\d+)`).exec(fragment)
37
38 return found === null ? null : Number(found[1]) + 1
39}
40
41const issue = (severity: Issue['severity'], entry: Json): Issue => {
42 const url = typeof entry.sourceURL === 'string' && entry.sourceURL.startsWith('file://') ? entry.sourceURL : null
43 const hash = url?.indexOf('#') ?? -1
44 const fragment = url === null || hash < 0 ? '' : url.slice(hash + 1)
45
46 return {
47 severity,
48 file: url === null ? null : decoded(url.slice('file://'.length, hash < 0 ? undefined : hash)),
49 line: oneBased(fragment, 'StartingLineNumber'),
50 column: oneBased(fragment, 'StartingColumnNumber'),
51 message: typeof entry.message === 'string' ? entry.message : String(entry.issueType ?? 'Unknown issue'),
52 }
53}
54
55/**
56 * Reads `xcrun xcresulttool get build-results`: the build's verdict and its
57 * errors and warnings, whose line and column the bundle counts from zero.
58 *
59 * Null for the bundle xcodebuild leaves when it stopped before building
60 * (status `notRequested`, no issues): the log has what went wrong.
61 */
62export const parseBuildResults = (json: string): BundleReport | null => {
63 const root = parse(json)
64
65 if (root === null || !('errors' in root || 'status' in root)) {
66 return null
67 }
68
69 const errors = objects(root.errors).map(entry => issue('error', entry))
70 const warnings = [...objects(root.warnings), ...objects(root.analyzerWarnings)].map(entry => issue('warning', entry))
71 const errorCount = count(root.errorCount, errors.length)
72 const status = root.status === 'failed' || errorCount > 0 ? 'failed' : root.status === 'succeeded' ? 'succeeded' : null
73
74 if (status === null && warnings.length === 0) {
75 return null
76 }
77
78 return {
79 status,
80 issues: [...errors, ...warnings],
81 errorCount,
82 warningCount: count(root.warningCount, 0) + count(root.analyzerWarningCount, 0),
83 }
84}
85
86/**
87 * Reads `xcrun xcresulttool get test-results summary`; null for a bundle that
88 * ran no tests.
89 */
90export const parseTestSummary = (json: string): Tests | null => {
91 const root = parse(json)
92 const total = count(root?.totalTestCount, 0)
93
94 if (root === null || total === 0) {
95 return null
96 }
97
98 return {
99 total,
100 passed: count(root.passedTests, 0),
101 failed: count(root.failedTests, 0),
102 skipped: count(root.skippedTests, 0),
103 failures: objects(root.testFailures).map(entry => ({
104 name: String(entry.testIdentifierString ?? entry.testName ?? 'Unknown test'),
105 message: String(entry.failureText ?? 'failed'),
106 file: null,
107 line: null,
108 })),
109 slowest: [],
110 }
111}
112
113export type TestDetails = {
114 slowest: SlowTest[]
115 locations: Map<string, { file: string; line: number | null }>
116}
117
118const SLOWEST = 3
119
120/**
121 * Reads `xcrun xcresulttool get test-results tests`: the slowest tests, and
122 * where each failed test failed, keyed by the test's identifier.
123 */
124export const parseTestDetails = (json: string): TestDetails | null => {
125 const root = parse(json)
126
127 if (root === null) {
128 return null
129 }
130
131 const timed: SlowTest[] = []
132 const locations: TestDetails['locations'] = new Map()
133 const walk = (node: Json) => {
134 const id = typeof node.nodeIdentifier === 'string' ? node.nodeIdentifier : null
135
136 if (id !== null && typeof node.durationInSeconds === 'number') {
137 timed.push({ name: id, seconds: node.durationInSeconds })
138 }
139
140 for (const child of objects(node.children)) {
141 const where = object(child.sourceLocation)
142
143 if (id !== null && where !== null && typeof where.filePath === 'string' && !locations.has(id)) {
144 locations.set(id, { file: where.filePath, line: typeof where.lineNumber === 'number' ? where.lineNumber : null })
145 }
146
147 walk(child)
148 }
149 }
150
151 objects(root.testNodes).forEach(walk)
152
153 return { slowest: timed.sort((a, b) => b.seconds - a.seconds).slice(0, SLOWEST), locations }
154}
155
156/**
157 * Reads `xcrun xccov view --report --only-targets --json`: line coverage from
158 * 0 to 1 across the targets, test bundles left out.
159 */
160export const parseCoverage = (json: string): number | null => {
161 try {
162 const targets = objects(JSON.parse(json)).filter(target => !String(target.buildProductPath ?? '').includes('.xctest'))
163 const executable = targets.reduce((sum, target) => sum + count(target.executableLines, 0), 0)
164 const covered = targets.reduce((sum, target) => sum + count(target.coveredLines, 0), 0)
165
166 return executable === 0 ? null : covered / executable
167 } catch {
168 return null
169 }
170}
171types/index.d.ts 59 lines1export type Issue = {
2 severity: 'error' | 'warning'
3 file: string | null
4 line: number | null
5 column: number | null
6 message: string
7}
8
9export type TestFailure = {
10 name: string
11 message: string
12 file: string | null
13 line: number | null
14}
15
16export type SlowTest = { name: string; seconds: number }
17
18export type Tests = {
19 total: number
20 passed: number
21 failed: number
22 skipped: number
23 failures: TestFailure[]
24 slowest: SlowTest[]
25}
26
27export type Build = {
28 id: string
29 /** `xcode` is a build run through Xcode's MCP server. */
30 tool: 'xcodebuild' | 'swift' | 'xcode'
31 action: string
32 hasTests: boolean
33 scheme: string | null
34 status: 'running' | 'succeeded' | 'failed' | 'cancelled'
35 startedAt: number
36 durationMs: number | null
37 errorCount: number
38 warningCount: number
39 issues: Issue[]
40 tests: Tests | null
41 /** Line coverage from 0 to 1, when the run collected it. */
42 coverage: number | null
43 logPath: string | null
44 failedCommands: string[]
45 source: 'xcresult' | 'log' | 'none'
46 logLines: number
47 isCondensed: boolean
48}
49
50declare module 'claude-code' {
51 interface PluginState {
52 'xcpane': {
53 builds: Build[]
54 isShowingWarnings: boolean
55 now: number
56 }
57 }
58}
59