In Xcode projects only: one line with the last build and test result and the booted simulator; flags 0-test runs; blocks a second xcodebuild while one runs

Function-hook mods for Claude Code. Each folder is one plugin.
| Mod | What it does | ||
|---|---|---|---|
context-watch | One-line gauge above the prompt for context fill, cost and hot rate limits; toasts at 60/80/90%; compaction keeps your instructions and edited files; /ctx opens a context breakdown pane | ||
live-spinner | Spinner shows what is running: the Bash description or a trimmed command, the ssh host, file being read or edited, search, subagent, MCP tool; a tool's own elapsed time after 15s; "Waiting for signing approval" when a signed git commit or tag sits quiet | ||
pr-watch | Tracks PRs from gh pr / gt submit / git push output, shows checks above the prompt, toasts on green/red/merged, blocks foreground CI polling loops; /prs | ||
house-style | No em dashes: system prompt rule, reminder to the model after a write that adds one, toast when a reply uses one | ||
handoff | /carryover [focus] writes a state note from the conversation and clears; the next prompt carries it. /carryover save, show, drop | ||
agent-jobs | /jobs opens a pane of running subagents and background shells with elapsed time and a Stop button; toasts when background work finishes | ||
zsh-safe | Quotes glob flags zsh would expand (--include=*.ts, find -name *.ts) and keeps a grep that finds nothing from failing a cd ... && grep chain | ||
pm-guard | Blocks npm/npx/yarn where the lockfile says bun or pnpm, and steers to bun where there is no lockfile | ||
remote-guard | Asks before a destructive command (restart, rm, kill, reboot, pct/qm stop, zfs destroy) runs over ssh | ||
xcode-status | Only in a folder with an .xcodeproj/.xcworkspace: one line with the last build or test result (flags runs that executed 0 tests) and the booted simulator; blocks a second xcodebuild while one is running; re-checks on /cd and keeps each project's last result | ||
agent-models | Pins the model each subagent type runs on (Explore, general-purpose, Plan, any other, plus custom type=model pairs), set in /config or with /agent-models <type> <model>; a model the caller names wins unless "Override explicit models" is on | ||
worktrees | /worktrees pane: each git worktree with branch, uncommitted changes, ahead/behind and PR state; Remove (after asking) on clean worktrees whose PR is merged or closed, plus "Remove N merged" to clear every merged one at once | ||
pr-rules | Blocks gh pr create / gt submit when the repo keeps an [Unreleased] changelog and the branch adds no entry (skip with --label no-changelog); where the repo's CLAUDE.md says to label every PR (or requireLabels is on), blocks a PR with no --label and lists the repo's labels. Repos with neither are untouched; `/pr-rules on | off | auto` overrides per repo (kept across sessions) |
claude plugin marketplace add narrowstacks/claude-code-mods
claude plugin install context-watch@claude-code-mods
Or load from a checkout without installing:
claude --plugin-dir ./context-watch --plugin-dir ./live-spinner
claude plugin validate ./context-watch
claude plugin test ./context-watch
tsc -p ./context-watch works once Claude Code has loaded the mod and written .claude-plugin/types/.
hooks/register.tsx 233 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BuildResult } from '../types'
5
6const project = atom({ plugin: 'xcode-status', key: 'project' } as const, null as string | null)
7const root = atom({ plugin: 'xcode-status', key: 'root' } as const, null as string | null)
8// Last result per project root, so moving away and back keeps it.
9const results = atom({ plugin: 'xcode-status', key: 'results' } as const, {} as Record<string, BuildResult>)
10const simulator = atom({ plugin: 'xcode-status', key: 'simulator' } as const, null as string | null)
11const tick = atom({ plugin: 'xcode-status', key: 'tick' } as const, 0)
12
13const POLL_MS = 30_000
14const SKIP_DIRS = new Set(['Pods', 'node_modules', 'DerivedData', '.build', 'build', 'Carthage', '.git'])
15const XCODE_CONTAINER = /\.(xcodeproj|xcworkspace)$/
16
17type Entry = { name: string; kind: 'file' | 'dir' | 'other' }
18
19// The project's name when `entries` (the root) or one folder below holds an
20// .xcodeproj or .xcworkspace; null otherwise. A workspace wins over a project.
21export const findXcodeProject = (root: readonly Entry[], children: Record<string, readonly Entry[]> = {}) => {
22 const pick = (entries: readonly Entry[]) => {
23 const found = entries.filter(entry => entry.kind === 'dir' && XCODE_CONTAINER.test(entry.name))
24 const best = found.find(entry => entry.name.endsWith('.xcworkspace')) ?? found[0]
25
26 return best?.name.replace(XCODE_CONTAINER, '') ?? null
27 }
28
29 return pick(root) ?? Object.values(children).map(pick).find(name => name !== null) ?? null
30}
31
32// What a run of xcodebuild (or a script wrapping it) printed: null when the
33// output carries none of xcodebuild's own result markers.
34export const parseResult = (output: string, command: string): Omit<BuildResult, 'at' | 'durationMs'> | null => {
35 const marker = output.match(/\*\* (BUILD|TEST|ANALYZE|ARCHIVE|CLEAN) (SUCCEEDED|FAILED|INTERRUPTED) \*\*/g)?.at(-1)
36 if (marker === undefined) {
37 return null
38 }
39
40 const kind = marker.includes('TEST') ? 'test' : 'build'
41 const ok = marker.includes('SUCCEEDED')
42 const errors = new Set(output.match(/^.*:\d+:\d+: error: .*$/gm) ?? []).size
43 const scheme = command.match(/-scheme\s+("[^"]+"|'[^']+'|\S+)/)?.[1]?.replace(/^["']|["']$/g, '')
44
45 // XCTest: "Executed 42 tests, with 1 failure". Swift Testing: "Test run with 42 tests ... passed/failed ... 3 issues".
46 const xctest = [...output.matchAll(/Executed (\d+) tests?, with (\d+) failures?/g)].at(-1)
47 const swiftTesting = [...output.matchAll(/Test run with (\d+) tests?[^\n]*?(passed|failed)(?:[^\n]*?with (\d+) issues?)?/g)].at(-1)
48 let tests: number | undefined
49 let failures: number | undefined
50 if (xctest !== undefined) {
51 tests = Number(xctest[1])
52 failures = Number(xctest[2])
53 }
54 if (swiftTesting !== undefined) {
55 tests = (tests ?? 0) + Number(swiftTesting[1])
56 failures = (failures ?? 0) + (swiftTesting[2] === 'failed' ? Number(swiftTesting[3] ?? 1) : 0)
57 }
58 if (kind === 'test' && tests === undefined && /Executed 0 tests|Test run with 0 tests/.test(output)) {
59 tests = 0
60 }
61
62 return { kind, ok, errors, scheme, tests, failures }
63}
64
65// "iPhone 16 Pro (iOS 18.2)" for each booted device in `simctl list -j`.
66export const parseBooted = (json: string): string | null => {
67 const data = JSON.parse(json) as { devices: Record<string, { name: string; state: string }[]> }
68 const booted = Object.entries(data.devices).flatMap(([runtime, devices]) =>
69 devices
70 .filter(device => device.state === 'Booted')
71 .map(device => {
72 const os = runtime.match(/SimRuntime\.(\w+)-(\d+)-(\d+)/)
73
74 return os === null ? device.name : `${device.name} (${os[1]} ${os[2]}.${os[3]})`
75 }),
76 )
77
78 return booted.length === 0 ? null : booted.length === 1 ? (booted[0] ?? null) : `${booted[0]} +${booted.length - 1}`
79}
80
81export const ago = (ms: number) => {
82 const minutes = Math.floor(ms / 60_000)
83 if (minutes < 1) return 'just now'
84 if (minutes < 60) return `${minutes}m ago`
85
86 return `${Math.floor(minutes / 60)}h ago`
87}
88
89const detect = async ($: EngineInterface) => {
90 const dir = await $.session.root()
91 const entries = await $.fs.list(dir)
92 const children: Record<string, Entry[]> = {}
93 if (findXcodeProject(entries) === null) {
94 for (const entry of entries) {
95 if (entry.kind === 'dir' && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
96 children[entry.name] = await $.fs.list(`${dir}/${entry.name}`).catch(() => [])
97 }
98 }
99 }
100 const name = findXcodeProject(entries, children)
101 await update($, project, () => name)
102 await update($, root, () => (name === null ? null : dir))
103
104 return name
105}
106
107const pollSimulator = async ($: EngineInterface) => {
108 if ((await read($, project)) === null) {
109 return
110 }
111 const run = await $.process.run(['xcrun', 'simctl', 'list', 'devices', 'booted', '-j'], { timeoutMs: 10_000 })
112 const booted = run.exitCode === 0 ? parseBooted(run.stdout) : null
113 await update($, simulator, () => booted)
114 await update($, tick, n => n + 1)
115}
116
117const isXcodebuild = (command: string) => /(^|[\s;&|(])xcodebuild\s/.test(command)
118
119export const register: Register = on => {
120 on('session.start', async ($, e, next) => {
121 const started = await next(e)
122 if ((await detect($).catch(() => null)) !== null) {
123 pollSimulator($).catch(() => undefined)
124 }
125 $.clock.every(POLL_MS, () => {
126 pollSimulator($).catch(() => undefined)
127 })
128
129 return started
130 })
131
132 // The root moves with /cd or a worktree switch: look again before each prompt.
133 on('prompt.submit', async ($, e, next) => {
134 const before = await read($, project)
135 const name = await detect($).catch(() => before)
136 if (name !== null && name !== before) {
137 pollSimulator($).catch(() => undefined)
138 }
139
140 return next(e)
141 })
142
143 on('command.run', { command: 'cd' }, async ($, e, next) => {
144 const moved = await next(e)
145 const before = await read($, project)
146 const name = await detect($).catch(() => before)
147 if (name !== null && name !== before) {
148 pollSimulator($).catch(() => undefined)
149 }
150
151 return moved
152 })
153
154 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
155 if ((await read($, project)) === null) {
156 return next(e)
157 }
158
159 // Parallel builds and simulators once froze the Mac for 30+ minutes.
160 if (isXcodebuild(e.command)) {
161 const running = await $.process.run(['pgrep', '-x', 'xcodebuild']).catch(() => undefined)
162 if (running?.exitCode === 0) {
163 return {
164 deny: 'xcode-status: another xcodebuild is already running (a subagent or a background run). Builds and tests must be serialized: wait for it to finish, then run this.',
165 }
166 }
167 }
168
169 const startedAt = await $.clock.now()
170 const ran = await next(e)
171 if (ran.deny !== undefined) {
172 return ran
173 }
174
175 const result = parseResult(ran.text ?? '', e.command)
176 const dir = await read($, root)
177 if (result !== null && dir !== null) {
178 const at = await $.clock.now()
179 await update($, results, all => ({ ...all, [dir]: { ...result, at, durationMs: at - startedAt } }))
180 }
181 if (/\bsimctl\s+(boot|shutdown|erase)\b/.test(e.command)) {
182 await pollSimulator($)
183 }
184
185 return ran
186 })
187
188 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
189 const below = await next(e)
190 const name = await read($, project)
191 if (name === null || e.props.hasSurvey) {
192 return below
193 }
194
195 await read($, tick)
196 const dir = await read($, root)
197 const build = dir === null ? null : ((await read($, results))[dir] ?? null)
198 const sim = await read($, simulator)
199 if (build === null && sim === null) {
200 return below
201 }
202
203 const { Box, Text } = $.ui.resolve(e)
204 const now = await $.clock.now()
205 const isZeroTests = build?.kind === 'test' && build.tests === 0
206 const color = build === null ? undefined : !build.ok ? 'red' : isZeroTests ? 'yellow' : 'green'
207 const outcome =
208 build === null
209 ? ''
210 : build.kind === 'test'
211 ? build.tests === undefined
212 ? `tests ${build.ok ? 'passed' : 'failed'}`
213 : isZeroTests
214 ? 'ran 0 tests'
215 : `${build.tests} tests${build.failures ? `, ${build.failures} failed` : ''}`
216 : build.ok
217 ? 'built'
218 : `build failed${build.errors > 0 ? ` (${build.errors} error${build.errors === 1 ? '' : 's'})` : ''}`
219
220 return (
221 <Box flexDirection="column">
222 <Box>
223 <Text dimColor>{build?.scheme ?? name} </Text>
224 {build !== null && <Text color={color}>{build.ok && !isZeroTests ? '✓' : isZeroTests ? '⚠' : '✗'} {outcome}</Text>}
225 {build !== null && <Text dimColor> {ago(now - build.at)}</Text>}
226 {sim !== null && <Text dimColor>{build !== null ? ' · ' : ''}◉ {sim}</Text>}
227 </Box>
228 {below}
229 </Box>
230 )
231 })
232}
233types/index.d.ts 17 lines1export type BuildResult = {
2 kind: 'build' | 'test'
3 ok: boolean
4 at: number
5 durationMs: number
6 scheme?: string
7 errors: number
8 tests?: number
9 failures?: number
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'xcode-status': { project: string | null; root: string | null; results: Record<string, BuildResult>; simulator: string | null; tick: number }
15 }
16}
17