A band reminding to run xcodegen generate after project.yml changes or Swift files are added, removed or moved, with a button that runs it

A band above the prompt reminding to regenerate the Xcode project with XcodeGen when its file set changed, with a button that runs it.
Edit, Write or NotebookEdit of a project.yml;Write that creates a .swift file (the file did not exist before the call);Bash command whose rm, git rm, mv or git mv names .swift files (globs such as Sources/*.swift included). rtk, sudo, env, VAR=value prefixes are stripped, cd X && and git -C X are followed..swift files do not count: XcodeGen only cares about the file set.project.yml. No such folder, nothing happens.XcodeGen: project.yml or Swift files changed in <folder> — run xcodegen generate, with:xcodegen generate in that folder (60 s timeout) and toasts xcodegen: generated <folder> or the first line the tool wrote on failure; success clears the row;Bash run of xcodegen generate (or bare xcodegen, optionally rtk- or cd x &&-prefixed, --spec path/project.yml honoured) clears the row of that folder by itself. Success is the tool's error flag (exit code), never its output text.| Command | Effect |
|---|---|
/xcodegen-sync | Shows on/off and the folders waiting |
/xcodegen-sync off | Stops tracking and clears the band (kept across sessions) |
/xcodegen-sync on | Resumes tracking |
claude --plugin-dir /path/to/ModsTools/mods/xcodegen-sync
Bash are resolved against the session folder (plus any cd in the same command line), not a folder changed by an earlier command.Bash (touch, cp, redirects), rm -r of a whole folder and mv of a folder are not seen.project.yml is the only spec name looked for (no --spec other.yml lookup when tracking).claude plugin validate mods/xcodegen-sync
claude plugin test mods/xcodegen-sync # 19 testshooks/register.tsx 129 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5 SPEC,
6 addFolder,
7 ancestors,
8 bandText,
9 dirName,
10 display,
11 generatedFolders,
12 isSpec,
13 isSwift,
14 removeFolders,
15 resolve,
16 runToast,
17 swiftTargets,
18} from './sync'
19
20const RUN_MS = 60_000
21const pending = atom({ plugin: 'xcodegen-sync', key: 'pending' } as const, [])
22
23async function isEnabled($: EngineInterface): Promise<boolean> {
24 return (await $.store.get('enabled').catch(() => undefined)) !== false
25}
26
27async function homeOf($: EngineInterface): Promise<string | null> {
28 const home = await $.env.get('HOME').catch(() => undefined)
29
30 return home === undefined || !home.startsWith('/') ? null : home.replace(/\/$/, '')
31}
32
33// The closest folder at or above `dir` holding a project.yml, or null.
34async function projectRoot($: EngineInterface, dir: string): Promise<string | null> {
35 for (const folder of ancestors(dir)) {
36 const spec = folder === '/' ? `/${SPEC}` : `${folder}/${SPEC}`
37 if (await $.fs.exists(spec).catch(() => false)) return folder
38 }
39
40 return null
41}
42
43async function track($: EngineInterface, dirs: string[]) {
44 const roots: string[] = []
45 for (const dir of new Set(dirs)) {
46 const root = await projectRoot($, dir)
47 if (root !== null && !roots.includes(root)) roots.push(root)
48 }
49 if (roots.length > 0) await update($, pending, list => roots.reduce(addFolder, list))
50}
51
52async function runXcodegen($: EngineInterface, folder: string) {
53 const home = await homeOf($)
54 try {
55 const ran = await $.process.run(['xcodegen', 'generate'], { cwd: folder, timeoutMs: RUN_MS })
56 if (ran.exitCode === 0) await update($, pending, list => removeFolders(list, [folder]))
57 $.ui.toast(runToast(folder, home, ran.exitCode, ran.stderr, ran.stdout))
58 } catch {
59 $.ui.toast(`xcodegen could not run in ${display(folder, home)} (not installed, or over 60s)`)
60 }
61}
62
63export const register: Register = on => {
64 on('session.start', async ($, e, next) => {
65 await $.command.register({
66 name: 'xcodegen-sync',
67 description: 'Folders waiting for xcodegen generate (off | on | status)',
68 })
69
70 return next(e)
71 })
72
73 on('command.run', { command: 'xcodegen-sync' }, async ($, e) => {
74 const arg = e.args.trim()
75 if (arg === 'off' || arg === 'on') {
76 await $.store.set('enabled', arg === 'on')
77 if (arg === 'off') await update($, pending, () => [])
78 }
79 const state = (await isEnabled($)) ? 'on' : 'off'
80 const home = await homeOf($)
81 const list = await read($, pending)
82 const folders = list.length === 0 ? 'no folder waiting' : `waiting: ${list.map(one => display(one, home)).join(', ')}`
83
84 return { text: `xcodegen-sync is ${state}; ${folders}.` }
85 })
86
87 on('tool.call', async ($, e, next) => {
88 if (e.tool !== 'Bash' && e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit') return next(e)
89 const cwd = await $.session.cwd()
90 const home = await homeOf($)
91 const raw = e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : e.tool === 'NotebookEdit' ? e.notebook_path : null
92 const path = raw === null ? null : resolve(cwd, raw, home)
93 // A Write creates a Swift file only when it did not exist before the call.
94 const isNewSwift = e.tool === 'Write' && path !== null && isSwift(path) && !(await $.fs.exists(path).catch(() => true))
95
96 const ran = await next(e)
97 if (ran.deny !== undefined || ran.isError === true) return ran
98 if (e.tool === 'Bash') {
99 const done = generatedFolders(e.command, cwd, home)
100 if (done.length > 0) await update($, pending, list => removeFolders(list, done))
101 }
102 if (e.agentId !== undefined || !(await isEnabled($))) return ran
103
104 if (e.tool === 'Bash') await track($, swiftTargets(e.command, cwd, home).map(dirName))
105 else if (path !== null && (isSpec(path) || isNewSwift)) await track($, [dirName(path)])
106
107 return ran
108 })
109
110 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
111 const list = await read($, pending)
112 if (list.length === 0 || e.props.hasSurvey) return next(e)
113 const { Box, Button, Text } = $.ui.resolve(e)
114 const home = await homeOf($)
115
116 return (
117 <Box flexDirection="column">
118 {list.map(folder => (
119 <Box>
120 <Text color="yellow">{bandText(folder, home)} </Text>
121 <Button key={`run:${folder}`} label="Run xcodegen" onPress={() => runXcodegen($, folder)} />
122 <Button key={`dismiss:${folder}`} label="Dismiss" onPress={() => update($, pending, all => removeFolders(all, [folder]))} />
123 </Box>
124 ))}
125 </Box>
126 )
127 })
128}
129hooks/sync.ts 189 lines1// Pure logic of xcodegen-sync: which paths a tool call touched, and which folders a
2// command regenerated. No `$` here: the hooks resolve folders on disk.
3
4export const SPEC = 'project.yml'
5
6// Wrappers that run the command after them; the value is their options that take an argument.
7const WRAPPERS: Record<string, Set<string>> = {
8 rtk: new Set(),
9 sudo: new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-U']),
10 env: new Set(['-u', '-C', '-S']),
11 command: new Set(),
12 nohup: new Set(),
13 time: new Set(),
14 nice: new Set(['-n']),
15}
16
17const unquote = (word: string) => word.replace(/^['"]+|['"]+$/g, '')
18
19const isAssignment = (word: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)
20
21// The command a segment runs, wrappers, their options and env assignments dropped.
22function strip(words: string[]): string[] {
23 let i = 0
24 for (;;) {
25 while (words[i] !== undefined && isAssignment(words[i]!)) i++
26 const takesArg = WRAPPERS[words[i] ?? '']
27 if (takesArg === undefined) break
28 i++
29 while (words[i]?.startsWith('-')) i += takesArg.has(words[i]!) ? 2 : 1
30 }
31
32 return words.slice(i)
33}
34
35// Each simple command of a shell line as unquoted words, wrappers dropped, in order.
36export function segments(command: string): string[][] {
37 return command
38 .split(/&&|\|\||[;|&\n]/)
39 .map(part => strip(part.trim().split(/\s+/).filter(Boolean).map(unquote)))
40 .filter(words => words.length > 0)
41}
42
43export const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1)
44
45export function dirName(path: string): string {
46 const cut = path.lastIndexOf('/')
47
48 return cut <= 0 ? '/' : path.slice(0, cut)
49}
50
51/** `path` made absolute against `cwd`, `.` and `..` folded; `~` paths against `home` when known. */
52export function resolve(cwd: string, path: string, home: string | null = null): string | null {
53 let full = path
54 if (path === '~' || path.startsWith('~/')) {
55 if (home === null) return null
56 full = home + path.slice(1)
57 } else if (!path.startsWith('/')) full = `${cwd}/${path}`
58 const parts: string[] = []
59 for (const part of full.split('/')) {
60 if (part === '' || part === '.') continue
61 if (part === '..') parts.pop()
62 else parts.push(part)
63 }
64
65 return `/${parts.join('/')}`
66}
67
68/** The folder and every folder above it, closest first, ending at `/`. */
69export function ancestors(dir: string): string[] {
70 const list = [dir]
71 let current = dir
72 while (current !== '/') {
73 current = dirName(current)
74 list.push(current)
75 }
76
77 return list
78}
79
80export const isSwift = (path: string) => path.endsWith('.swift')
81
82export const isSpec = (path: string) => baseName(path) === SPEC
83
84// The words after `git` and its global options, with the folder `-C` points at.
85function gitArgs(words: string[], cwd: string, home: string | null): { args: string[]; cwd: string } | null {
86 if (baseName(words[0] ?? '') !== 'git') return null
87 let i = 1
88 let dir = cwd
89 while (words[i]?.startsWith('-')) {
90 if (words[i] === '-C' && words[i + 1] !== undefined) dir = resolve(dir, words[i + 1]!, home) ?? dir
91 i += words[i] === '-C' || words[i] === '-c' ? 2 : 1
92 }
93
94 return { args: words.slice(i), cwd: dir }
95}
96
97// Walks a shell line, following `cd`, and hands each segment its working folder.
98function walk(command: string, cwd: string, home: string | null, visit: (words: string[], cwd: string) => void) {
99 let dir = cwd
100 for (const words of segments(command)) {
101 if (words[0] === 'cd') {
102 const target = words[1] === undefined ? home : resolve(dir, words[1], home)
103 if (target !== null) dir = target
104 continue
105 }
106 visit(words, dir)
107 }
108}
109
110/**
111 * The `.swift` paths (absolute, globs kept as written) that `rm`, `git rm`, `mv` or
112 * `git mv` in the line delete or move; empty when the line touches no Swift file.
113 */
114export function swiftTargets(command: string, cwd: string, home: string | null = null): string[] {
115 const found: string[] = []
116 walk(command, cwd, home, (words, dir) => {
117 let args: string[] | null = null
118 let base = dir
119 const name = baseName(words[0] ?? '')
120 if (name === 'rm' || name === 'mv') args = words.slice(1)
121 const git = gitArgs(words, dir, home)
122 if (git !== null && (git.args[0] === 'rm' || git.args[0] === 'mv')) {
123 args = git.args.slice(1)
124 base = git.cwd
125 }
126 if (args === null) return
127 for (const arg of args) {
128 if (arg.startsWith('-') || !isSwift(arg)) continue
129 const path = resolve(base, arg, home)
130 if (path !== null) found.push(path)
131 }
132 })
133
134 return found
135}
136
137/**
138 * The folders whose `xcodegen generate` (or bare `xcodegen`) the line runs: the folder
139 * of `--spec`/`-s` when given, else the segment's working folder.
140 */
141export function generatedFolders(command: string, cwd: string, home: string | null = null): string[] {
142 const found: string[] = []
143 walk(command, cwd, home, (words, dir) => {
144 if (baseName(words[0] ?? '') !== 'xcodegen') return
145 const sub = words[1]
146 if (sub !== undefined && sub !== 'generate' && !sub.startsWith('-')) return
147 const at = words.findIndex(word => word === '--spec' || word === '-s')
148 const inline = words.find(word => word.startsWith('--spec='))
149 const spec = at >= 0 ? words[at + 1] : inline?.slice('--spec='.length)
150 const path = spec === undefined ? dir : resolve(dir, spec, home)
151 if (path !== null) found.push(spec === undefined ? path : dirName(path))
152 })
153
154 return found
155}
156
157/** `path` with the home folder shown as `~`. */
158export function display(path: string, home: string | null): string {
159 if (home === null || home === '/' || home === '') return path
160 if (path === home) return '~'
161
162 return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
163}
164
165export const bandText = (folder: string, home: string | null) =>
166 `XcodeGen: project.yml or Swift files changed in ${display(folder, home)} — run xcodegen generate`
167
168/** The first non-empty line of a program's output, trimmed. */
169export function firstLine(text: string): string {
170 return (
171 text
172 .split('\n')
173 .map(line => line.trim())
174 .find(line => line !== '') ?? ''
175 )
176}
177
178/** The toast after the Run button: ok, or the first line the program wrote. */
179export function runToast(folder: string, home: string | null, exitCode: number, stderr: string, stdout: string): string {
180 if (exitCode === 0) return `xcodegen: generated ${display(folder, home)}`
181 const line = firstLine(stderr) || firstLine(stdout) || `exit code ${exitCode}`
182
183 return `xcodegen failed in ${display(folder, home)}: ${line}`
184}
185
186export const addFolder = (list: string[], folder: string) => (list.includes(folder) ? list : [...list, folder])
187
188export const removeFolders = (list: string[], folders: string[]) => list.filter(one => !folders.includes(one))
189types/index.d.ts 8 lines1export type PendingFolders = string[]
2
3declare module 'claude-code' {
4 interface PluginState {
5 'xcodegen-sync': { pending: PendingFolders }
6 }
7}
8