Builds the Xcode project or Swift package in the background after a turn edits Swift files, and shows the result in the status line

Builds your Xcode project or Swift package in the background after Claude edits Swift files, so a broken build shows up right away.
Edit/Write calls on .swift files inside the session folder, made by the main conversation..xcworkspace, .xcodeproj, Package.swift or project.yml, and builds it once per turn:xcodebuild -configuration Debug -quiet CODE_SIGNING_ALLOWED=NO build, using the scheme named after the project, else the first scheme xcodebuild -list reports;swift build;project.yml without an .xcodeproj: reports "run xcodegen generate first".⏳ building <name>…, then ✅ <name> builds (Ns) or ❌ <name>: build failed, N errors. A toast appears on failure.| Command | Effect |
|---|---|
/build-status | Print the last result and up to 20 distinct error: lines. |
claude --plugin-dir /path/to/ModsTools/mods/xcode-build-watch
xcodebuild on the same DerivedData.error: in the output.claude plugin validate mods/xcode-build-watch
claude plugin test mods/xcode-build-watch # 8 testshooks/register.ts 162 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BuildReport } from '../types'
5
6// A build that runs longer than this is cut off.
7const BUILD_TIMEOUT_MS = 10 * 60 * 1000
8// Listing schemes may resolve packages first.
9const LIST_TIMEOUT_MS = 2 * 60 * 1000
10const MAX_ERRORS = 20
11
12const last = atom({ plugin: 'xcode-build-watch', key: 'last' } as const, null)
13
14type Project =
15 | { root: string; kind: 'package' }
16 | { root: string; kind: 'xcode'; flag: '-workspace' | '-project'; path: string }
17 | { root: string; kind: 'xcodegen-only' }
18
19const parent = (path: string) => path.slice(0, Math.max(path.lastIndexOf('/'), 0)) || '/'
20const base = (path: string) => path.slice(path.lastIndexOf('/') + 1)
21
22// The one named after its folder (not a Finder copy like "App 2.xcodeproj"), else the first.
23const pick = (names: string[], suffix: string, folder: string) =>
24 names.find(name => name === folder + suffix) ?? names.find(name => name.endsWith(suffix))
25
26// The nearest folder above `file` that holds a workspace, an Xcode project, a Package.swift or a project.yml.
27async function findProject($: EngineInterface, file: string): Promise<Project | null> {
28 for (let dir = parent(file); dir !== '/' && dir !== ''; dir = parent(dir)) {
29 const names = (await $.fs.list(dir).catch(() => [])).map(entry => entry.name).sort()
30 const workspace = pick(names, '.xcworkspace', base(dir))
31 const project = pick(names, '.xcodeproj', base(dir))
32 if (workspace !== undefined) return { root: dir, kind: 'xcode', flag: '-workspace', path: `${dir}/${workspace}` }
33 if (project !== undefined) return { root: dir, kind: 'xcode', flag: '-project', path: `${dir}/${project}` }
34 if (names.includes('Package.swift')) return { root: dir, kind: 'package' }
35 if (names.includes('project.yml')) return { root: dir, kind: 'xcodegen-only' }
36 }
37
38 return null
39}
40
41// The scheme named like the project, else the first one xcodebuild lists.
42async function scheme($: EngineInterface, project: Extract<Project, { kind: 'xcode' }>): Promise<string | undefined> {
43 const listed = await $.process.run(['xcodebuild', '-list', '-json', project.flag, project.path], {
44 cwd: project.root,
45 timeoutMs: LIST_TIMEOUT_MS,
46 })
47 if (listed.exitCode !== 0) return undefined
48 try {
49 const json = JSON.parse(listed.stdout) as { project?: { schemes?: string[] }; workspace?: { schemes?: string[] } }
50 const schemes = json.project?.schemes ?? json.workspace?.schemes ?? []
51 const name = base(project.path).replace(/\.(xcodeproj|xcworkspace)$/, '')
52
53 return schemes.includes(name) ? name : schemes[0]
54 } catch {
55 return undefined
56 }
57}
58
59const errorLines = (output: string) =>
60 [...new Set(output.split('\n').filter(line => /\berror:/.test(line)).map(line => line.trim()))].slice(0, MAX_ERRORS)
61
62async function build($: EngineInterface, project: Project): Promise<BuildReport> {
63 const name = base(project.root)
64 if (project.kind === 'xcodegen-only') {
65 return { project: name, isOk: false, seconds: 0, errors: ['project.yml without .xcodeproj: run xcodegen generate first'] }
66 }
67 let argv: string[]
68 if (project.kind === 'package') {
69 argv = ['swift', 'build']
70 } else {
71 const chosen = await scheme($, project)
72 if (chosen === undefined) return { project: name, isOk: false, seconds: 0, errors: ['no scheme found (xcodebuild -list)'] }
73 argv = ['xcodebuild', project.flag, project.path, '-scheme', chosen, '-configuration', 'Debug', '-quiet', 'CODE_SIGNING_ALLOWED=NO', 'build']
74 }
75 const startedAt = await $.clock.now()
76 const ran = await $.process.run(argv, { cwd: project.root, timeoutMs: BUILD_TIMEOUT_MS })
77 const seconds = Math.round(((await $.clock.now()) - startedAt) / 1000)
78 const errors = ran.exitCode === 0 ? [] : errorLines(`${ran.stdout}\n${ran.stderr}`)
79 if (ran.exitCode !== 0 && errors.length === 0) errors.push(`${argv[0]} exited with ${ran.exitCode}`)
80
81 return { project: name, isOk: ran.exitCode === 0, seconds, errors }
82}
83
84function describe(report: BuildReport): string {
85 if (report.isOk) return `✅ ${report.project} builds (${report.seconds}s)`
86 const count = report.errors.length
87
88 return `❌ ${report.project}: build failed, ${count} error${count === 1 ? '' : 's'}`
89}
90
91// Projects whose Swift files the current turn edited, by root.
92const pending = new Map<string, Project>()
93const running = new Set<string>()
94const again = new Set<string>()
95
96async function run($: EngineInterface, project: Project): Promise<void> {
97 if (running.has(project.root)) {
98 again.add(project.root)
99 return
100 }
101 running.add(project.root)
102 $.ui.status(`⏳ building ${base(project.root)}…`)
103 try {
104 const report = await build($, project).catch(
105 (error: unknown): BuildReport => ({
106 project: base(project.root),
107 isOk: false,
108 seconds: 0,
109 errors: [`build did not finish: ${error instanceof Error ? error.message : String(error)}`],
110 }),
111 )
112 await update($, last, () => report)
113 $.ui.status(describe(report))
114 if (!report.isOk) $.ui.toast(`${describe(report)} — /build-status for details`)
115 } finally {
116 running.delete(project.root)
117 }
118 if (again.delete(project.root)) await run($, project)
119}
120
121export const register: Register = on => {
122 on('session.start', async ($, e, next) => {
123 await $.command.register({
124 name: 'build-status',
125 description: 'Show the last background Swift build result and its errors',
126 })
127
128 return next(e)
129 })
130
131 on('command.run', { command: 'build-status' }, async $ => {
132 const report = await read($, last)
133 if (report === null) return { text: 'No background build yet this session.' }
134
135 return { text: [describe(report), ...report.errors].join('\n') }
136 })
137
138 on('tool.call', async ($, e, next) => {
139 const ran = await next(e)
140 // A subagent's edits (often in a worktree) are its own to build: each worktree build costs a DerivedData folder.
141 const path = e.agentId === undefined && (e.tool === 'Edit' || e.tool === 'Write') ? e.file_path : undefined
142 if (path === undefined || !path.endsWith('.swift') || ran.deny !== undefined || ran.isError === true) return ran
143 if (!path.startsWith((await $.session.cwd()) + '/')) return ran
144 const project = await findProject($, path)
145 if (project !== null) pending.set(project.root, project)
146
147 return ran
148 })
149
150 on('turn.complete', ($, e, next) => {
151 if (e.agentId === undefined && pending.size > 0) {
152 const projects = [...pending.values()]
153 pending.clear()
154 $.clock.after(0, () => {
155 for (const project of projects) void run($, project)
156 })
157 }
158
159 return next(e)
160 })
161}
162types/index.d.ts 13 lines1export type BuildReport = {
2 project: string
3 isOk: boolean
4 seconds: number
5 errors: string[]
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'xcode-build-watch': { last: BuildReport | null }
11 }
12}
13