SLOPSHOPPER

XC Pane

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.

newpanerowsguardcommandtoast
★ 3v0.2.6MITupdated 2026-10-09griches/xcpane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · xcpane
│ ┃ Xcode build ✕ › fix the failing auth test and add an audit log call │ ┃ No builds yet. │ ┃ Ask Claude to run xcodebuild, swift build or ⏺ Read(src/auth.ts) │ ┃ swift test. ⎿ 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 │ │ › /xcpane │ ⎿ xcpane: Xcode build pane opened. No builds yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Xcode build
No builds yet. Ask Claude to run xcodebuild, swift build or swift test.
README

xcpane

GitHub stars CI License

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.

The pane showing a failed build

Why

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 modWith the mod
Raw log295 lines, 47 KBthe same
What Claude reads10,039 characters, none of them an error message570 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/.

What it does

Claude Code with the pane docked on the right

  • Reads Xcode's result bundle. Adds -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.
  • Condenses what Claude reads. The tool result becomes the verdict, every error as file:line:column: error: message, the failed tests, and a count of warnings per file. The transcript keeps the raw output.
  • Shows a pane. Errors grouped by file, failed tests, the slowest tests, line coverage when the run collected it, a toggle for warnings, a running timer while a build is in flight, and the last few builds.
  • Gives Claude a tool for the rest. The summary counts warnings without listing them, so the mod adds a tool, 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.
  • Follows builds through Xcode's MCP too. When Claude builds or tests through Apple's Xcode MCP server (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.
  • Sets the status line on a failure and shows a toast on a success.
  • Draws a compact transcript row. The verdict and the first three errors, in place of the raw log. This applies to a build drawn as its own row; in the fullscreen layout Claude Code folds shell commands into one line ("Ran 1 shell command"), and the row is not drawn there.

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.

Using it with Apple's Xcode MCP

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 MCPxcpane
CoversBuilds Claude runs through the MCP's build toolBuilds Claude runs as xcodebuild or swift build in Bash, which it condenses, and builds run through the MCP, which it shows
SetupEnable in Xcode; headless mode needs sudo and per-agent approvalTwo commands
ScopeBuilds, tests, previews, project navigation, documentationBuild and test results only
InterfaceNone in Claude CodeA 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.

When the MCP isn't an option

xcpane works without it:

  • No admin rights. Headless mode is turned on with sudo, and approving an agent or folder needs it too. Many company-managed Macs don't give developers admin access.
  • MCP servers restricted. An organisation can control which MCP servers Claude Code may use, and some block any that haven't been reviewed.
  • A broader grant. The MCP lets an agent drive Xcode itself. This mod adds one flag to an xcodebuild command Claude was already allowed to run, reads the result locally with Apple's xcresulttool, and sends nothing over the network.
  • Older Xcode. Before Xcode 27 the MCP needs Xcode open.

Why a live pane in the terminal

  • You see what Claude sees. The pane shows the same errors Claude was handed, so you can tell at a glance whether it is fixing the right thing.
  • No scrolling. Build output otherwise sits folded in the transcript. The verdict, error count and files stay visible while the conversation moves on.
  • Progress while you wait. A timer runs during the build, then the pane turns red or green.
  • History. The last few builds are listed, so you can watch a fix go from two errors to one to green.
  • Tests and warnings in the same place. Failed tests show their assertion message, and warnings are one keypress away.
  • No window switching. It sits beside the conversation, which matters most over SSH or when Xcode isn't open.

An MCP server returns data to the model and cannot draw interface in Claude Code. That part is specific to mods.

How this compares to xcsift and xcbeautify

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.

xcpanexcsiftxcbeautify
What it isA Claude Code modA command-line toolA command-line tool
Made forClaude CodeCoding agents and CIPeople and CI
How a build reaches itBy itself, when Claude runs xcodebuild or builds through Xcode's MCPThe command is piped through itThe command is piped through it
Where results come fromXcode's result bundle, the log as a fallbackThe build log, plus coverage filesThe build log
Live pane in Claude CodeYesNoNo
Other agents, CI, LinuxNoYesCI yes
CoverageA single line-coverage figureDetailed reportsNo

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.

Requirements

  • macOS with Xcode and its command line tools (xcodebuild, xcrun).
  • Claude Code with mod support. Built and tested on 2.1.288; check yours with claude --version and update with claude update.
  • For the result bundle, an Xcode whose xcresulttool has get build-results (tested on Xcode 27.1). Without it the mod falls back to reading the log.

Install

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.

From a clone instead

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"
  }
}

Check it loaded (optional)

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.

Use

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:

  • Claude reads the parsed errors in place of the raw log.
  • The status line shows a failure, and a toast shows a success.
  • The pane opens by itself on terminals at least 144 columns wide. On narrower terminals it stays closed until you type /xcpane, and then shows above the prompt.

The commands and keys are only for the pane:

WhatHow
Open the pane/xcpane
Forget the builds/xcpane clear
Show or hide warningsFocus the pane (ctrl+x then tab), press w
Clear from the paneFocus the pane, press c
Close the panectrl+x then x, or click its ✕

Options

Each option is a row in Claude Code's config menu (/config).

OptionDefaultMeaning
condensetrueReplace the raw log Claude reads with the parsed diagnostics
warningscountcount: Claude reads how many warnings each file has. list: every warning
resultBundletrueAdd -resultBundlePath to xcodebuild commands that name none
autoOpenalwaysOpen the pane always (when a build starts), on failure, or never
compactRowtrueDraw the verdict in the transcript instead of the raw log

What it does on your machine

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.

Permissions

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.

Update and uninstall

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.

Troubleshooting

  • /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.
  • The pane does not open by itself. Your terminal is narrower than 144 columns, or autoOpen is not always. Type /xcpane.
  • A build is not picked up. See Limits. claude --debug logs why a hook was skipped.
  • Claude still reads the raw log. The mod only condenses when it found errors or failed tests, or the build succeeded. A failure with no diagnostics passes through unchanged.

Limits

  • Only builds Claude runs through the Bash tool are seen. Builds you start in Xcode are not.
  • A build inside a script, make, fastlane, bash -c or $(...) is not recognised, and neither is one run in the background.
  • A command with several xcodebuild invocations is read from its log only.
  • When a failing build yields no diagnostics, Claude reads the raw log unchanged.
  • swift build has no result bundle, so a long failing log that Claude Code cuts in the middle can still lose its errors.
  • The mod API is early access and may change between Claude Code releases.

Develop

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.

License

MIT

Source 7 files
hooks/register.tsx 593 lines
1import { 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}
593
hooks/format.ts 203 lines
1import 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}
203
hooks/log.ts 140 lines
1import 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}
140
hooks/mcp.ts 133 lines
1import 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
133
hooks/shell.ts 379 lines
1export 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)
379
hooks/xcresult.ts 171 lines
1import 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}
171
types/index.d.ts 59 lines
1export 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