SLOPSHOPPER

xcodegen-sync

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

newbandguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · xcodegen-sync
› 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 › /xcodegen-sync ⎿ xcodegen-sync: xcodegen-sync is on; no folder waiting. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

xcodegen-sync

A band above the prompt reminding to regenerate the Xcode project with XcodeGen when its file set changed, with a button that runs it.

What it does

  • Watches successful main-conversation tool calls (subagents are ignored):
  • an Edit, Write or NotebookEdit of a project.yml;
  • a Write that creates a .swift file (the file did not exist before the call);
  • a 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.
  • Edits to existing .swift files do not count: XcodeGen only cares about the file set.
  • For each touched path it looks for the closest folder at or above it holding a project.yml. No such folder, nothing happens.
  • Each pending folder gets a row: XcodeGen: project.yml or Swift files changed in <folder> — run xcodegen generate, with:
  • Run xcodegen: runs 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;
  • Dismiss: clears the row.
  • A successful 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.

Commands

CommandEffect
/xcodegen-syncShows on/off and the folders waiting
/xcodegen-sync offStops tracking and clears the band (kept across sessions)
/xcodegen-sync onResumes tracking

Install

claude --plugin-dir /path/to/ModsTools/mods/xcodegen-sync

Limits

  • Relative paths in Bash are resolved against the session folder (plus any cd in the same command line), not a folder changed by an earlier command.
  • Files created by Bash (touch, cp, redirects), rm -r of a whole folder and mv of a folder are not seen.
  • The pending list is per session; a new session starts empty.
  • project.yml is the only spec name looked for (no --spec other.yml lookup when tracking).

Develop

claude plugin validate mods/xcodegen-sync
claude plugin test mods/xcodegen-sync   # 19 tests
Source 3 files
hooks/register.tsx 129 lines
1import { 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}
129
hooks/sync.ts 189 lines
1// 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))
189
types/index.d.ts 8 lines
1export type PendingFolders = string[]
2
3declare module 'claude-code' {
4  interface PluginState {
5    'xcodegen-sync': { pending: PendingFolders }
6  }
7}
8