SLOPSHOPPER

xcode-build-watch

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

newguardcommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · xcode-build-watch
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /build-status ⎿ xcode-build-watch: No background build yet this session. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

xcode-build-watch

Builds your Xcode project or Swift package in the background after Claude edits Swift files, so a broken build shows up right away.

What it does

  • Watches successful Edit/Write calls on .swift files inside the session folder, made by the main conversation.
  • When the turn ends, finds the nearest enclosing folder with an .xcworkspace, .xcodeproj, Package.swift or project.yml, and builds it once per turn:
  • Xcode: xcodebuild -configuration Debug -quiet CODE_SIGNING_ALLOWED=NO build, using the scheme named after the project, else the first scheme xcodebuild -list reports;
  • Swift package: swift build;
  • project.yml without an .xcodeproj: reports "run xcodegen generate first".
  • Status line: ⏳ building <name>…, then ✅ <name> builds (Ns) or ❌ <name>: build failed, N errors. A toast appears on failure.
  • If a build is already running for the same project, one more build is queued afterwards.

Commands

CommandEffect
/build-statusPrint the last result and up to 20 distinct error: lines.

Install

claude --plugin-dir /path/to/ModsTools/mods/xcode-build-watch

Limits

  • Only reacts to the main conversation's Edit/Write, not Bash, subagents or worktrees.
  • A background build can overlap a manual xcodebuild on the same DerivedData.
  • Build cut off after 10 minutes; scheme listing after 2 minutes.
  • Errors are matched on the text error: in the output.

Develop

claude plugin validate mods/xcode-build-watch
claude plugin test mods/xcode-build-watch   # 8 tests
Source 2 files
hooks/register.ts 162 lines
1import { 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}
162
types/index.d.ts 13 lines
1export 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