Checks Xcode String Catalogs (.xcstrings) after Claude edits them or Swift files beside them: missing translations, new or needs-review states and stale…

Keeps Xcode String Catalogs (.xcstrings) complete: after Claude edits a catalog or Swift code beside one, it shows which translations are still pending.
Edit/Write calls made by the main conversation on .xcstrings files, and on .swift files of a project that holds catalogs..git, project.yml, Package.swift or an Xcode project. Its catalogs are found once (hidden, build, node_modules, Pods, DerivedData… folders skipped) and all re-checked, 2 s after the last edit, never before the tool result is returned. One check at a time per project.languages setting):stringUnit for that language, also looked for inside variations (plural, device, nested) and substitutions;stringUnit in state new / needs_review;extractionState: "stale" (listed once, not counted per language).shouldTranslate: false are ignored. A file that is not valid JSON or not a catalog is reported unreadable.🌐 fr: 3 missing · 1 review, then · N stale, · N unreadable. Cleared when every catalog is complete.| Command | Effect |
|---|---|
/xcstrings | Opens the pane: catalog → language → keys (first 8 per list, with counts) |
/xcstrings check | Re-scans and re-checks the session project now |
/xcstrings off | Stops checking and clears the status line (kept across sessions) |
/xcstrings on | Resumes |
| Field | Default | Meaning |
|---|---|---|
languages | empty | Comma-separated languages every catalog must cover, e.g. fr,de |
claude --plugin-dir /path/to/ModsTools/mods/xcstrings-check
Edit/Write; catalogs changed by Xcode itself are seen at the next edit or /xcstrings check.one but no other) counts as translated.claude plugin validate mods/xcstrings-check
claude plugin test mods/xcstrings-check # 18 testshooks/register.tsx 214 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { CatalogReport } from '../types'
5import {
6 CATALOG_SUFFIX,
7 analyze,
8 ancestors,
9 catalogLines,
10 dirName,
11 isCatalog,
12 isProjectMarker,
13 isWalked,
14 join,
15 parseLanguages,
16 shown,
17 statusText,
18} from './catalog'
19
20const PANE = 'xcstrings-check'
21const TITLE = 'String Catalogs'
22const DEBOUNCE_MS = 2000
23const MAX_DEPTH = 8
24const MAX_DIRS = 2000
25
26const reports = atom({ plugin: 'xcstrings-check', key: 'reports' } as const, {})
27const projects = atom({ plugin: 'xcstrings-check', key: 'projects' } as const, [])
28
29// Edited files since the last flush, the debounce timer, catalogs found per project, checks in flight.
30const pending = new Set<string>()
31let timer: Timer | undefined
32const catalogsOf = new Map<string, string[]>()
33const running = new Set<string>()
34const again = new Set<string>()
35
36async function isEnabled($: EngineInterface): Promise<boolean> {
37 return (await $.store.get('enabled').catch(() => undefined)) !== false
38}
39
40// The closest folder at or above `dir` holding .git, project.yml, Package.swift or an Xcode project; else `dir`.
41async function projectRoot($: EngineInterface, dir: string): Promise<string> {
42 for (const folder of ancestors(dir)) {
43 const names = (await $.fs.list(folder).catch(() => [])).map(entry => entry.name)
44 if (names.some(isProjectMarker)) return folder
45 }
46
47 return dir
48}
49
50// The .xcstrings files under `root`, skipping hidden, build and dependency folders.
51async function findCatalogs($: EngineInterface, root: string): Promise<string[]> {
52 const found: string[] = []
53 let queue = [root]
54 let seen = 0
55 for (let depth = 0; depth <= MAX_DEPTH && queue.length > 0 && seen < MAX_DIRS; depth++) {
56 const next: string[] = []
57 for (const dir of queue) {
58 if (++seen > MAX_DIRS) break
59 for (const entry of await $.fs.list(dir).catch(() => [])) {
60 const path = join(dir, entry.name)
61 if (entry.kind === 'file' && isCatalog(entry.name)) found.push(path)
62 else if (entry.kind === 'dir' && isWalked(entry.name)) next.push(path)
63 }
64 }
65 queue = next
66 }
67
68 return found.sort()
69}
70
71async function catalogs($: EngineInterface, root: string, isFresh: boolean): Promise<string[]> {
72 const known = catalogsOf.get(root)
73 if (known !== undefined && !isFresh) return known
74 const found = await findCatalogs($, root)
75 catalogsOf.set(root, found)
76
77 return found
78}
79
80async function readCatalog($: EngineInterface, path: string, root: string, cwd: string, expected: readonly string[]): Promise<CatalogReport> {
81 const base = { path, shown: shown(path, cwd), root }
82 try {
83 return { ...base, ...analyze(await $.fs.read(path), expected) }
84 } catch {
85 return { ...base, sourceLanguage: null, total: 0, languages: [], stale: [], unreadable: 'cannot be read (missing, or over 4 MiB)' }
86 }
87}
88
89async function showStatus($: EngineInterface) {
90 $.ui.status(statusText(Object.values(await read($, reports))))
91}
92
93// One check at a time per project; a request meanwhile runs once more after it.
94async function checkProject($: EngineInterface, root: string, expected: readonly string[]): Promise<void> {
95 if (running.has(root)) {
96 again.add(root)
97 return
98 }
99 running.add(root)
100 try {
101 const cwd = await $.session.cwd().catch(() => '/')
102 const done: CatalogReport[] = []
103 for (const path of catalogsOf.get(root) ?? []) done.push(await readCatalog($, path, root, cwd, expected))
104 await update($, reports, all => ({
105 ...Object.fromEntries(Object.entries(all).filter(([, one]) => one.root !== root)),
106 ...Object.fromEntries(done.map(one => [one.path, one])),
107 }))
108 await update($, projects, list => (list.includes(root) ? list : [...list, root]))
109 await showStatus($)
110 } finally {
111 running.delete(root)
112 }
113 if (again.delete(root)) await checkProject($, root, expected)
114}
115
116async function flush($: EngineInterface, expected: readonly string[]): Promise<void> {
117 timer = undefined
118 const files = [...pending]
119 pending.clear()
120 if (files.length === 0 || !(await isEnabled($))) return
121 const roots = new Set<string>()
122 for (const file of files) {
123 const root = await projectRoot($, dirName(file))
124 const list = await catalogs($, root, false)
125 if (isCatalog(file) && !list.includes(file)) catalogsOf.set(root, [...list, file].sort())
126 if ((catalogsOf.get(root) ?? []).length > 0) roots.add(root)
127 }
128 for (const root of roots) await checkProject($, root, expected)
129}
130
131function schedule($: EngineInterface, expected: readonly string[]) {
132 timer?.cancel()
133 timer = $.clock.after(DEBOUNCE_MS, () => void flush($, expected))
134}
135
136async function summary($: EngineInterface): Promise<string> {
137 const all = Object.values(await read($, reports))
138 if (all.length === 0) return 'No String Catalog checked yet (/xcstrings check).'
139 const text = statusText(all)
140 const count = `${all.length} catalog${all.length === 1 ? '' : 's'}`
141
142 return text === undefined ? `${count}: every translation complete.` : `${count}: ${text}`
143}
144
145export const register: Register = (on, options) => {
146 const expected = parseLanguages(options?.languages)
147
148 on('session.start', async ($, e, next) => {
149 await $.command.register({
150 name: 'xcstrings',
151 description: 'String Catalog translations pending per language, in a pane (check | off | on)',
152 })
153
154 return next(e)
155 })
156
157 on('command.run', { command: 'xcstrings' }, async ($, e) => {
158 const arg = e.args.trim()
159 if (arg === 'off' || arg === 'on') {
160 await $.store.set('enabled', arg === 'on')
161 if (arg === 'off') {
162 timer?.cancel()
163 timer = undefined
164 pending.clear()
165 $.ui.status(undefined)
166 } else await showStatus($)
167 return { text: `xcstrings-check is ${arg}.` }
168 }
169 if (arg === 'check') {
170 let roots = await read($, projects)
171 if (roots.length === 0) roots = [await projectRoot($, await $.session.cwd().catch(() => '/'))]
172 for (const root of roots) {
173 await catalogs($, root, true)
174 await checkProject($, root, expected)
175 }
176 return { text: await summary($) }
177 }
178 await $.ui.open({ id: PANE, title: TITLE })
179
180 return { text: await summary($) }
181 })
182
183 on('tool.call', async ($, e, next) => {
184 const ran = await next(e)
185 if (e.agentId !== undefined || (e.tool !== 'Edit' && e.tool !== 'Write')) return ran
186 if (ran.deny !== undefined || ran.isError === true) return ran
187 if (!e.file_path.endsWith('.swift') && !e.file_path.endsWith(CATALOG_SUFFIX)) return ran
188 pending.add(e.file_path)
189 schedule($, expected)
190
191 return ran
192 })
193
194 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
195 const { Box, Text } = $.ui.resolve(e)
196 const all = Object.values(await read($, reports)).sort((a, b) => a.shown.localeCompare(b.shown))
197 const lines = all.flatMap(catalogLines)
198 const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
199 const color = { title: undefined, ok: 'green', pending: 'yellow', dim: undefined } as const
200
201 return (
202 <Box flexDirection="column">
203 {all.length === 0 && <Text dimColor>No String Catalog checked yet: /xcstrings check</Text>}
204 {lines.slice(0, room).map(line => (
205 <Text bold={line.tone === 'title'} color={color[line.tone]} dimColor={line.tone === 'dim'}>
206 {`${' '.repeat(line.depth)}${line.text}`}
207 </Text>
208 ))}
209 {lines.length > room && <Text dimColor>… {lines.length - room} more lines</Text>}
210 </Box>
211 )
212 })
213}
214hooks/catalog.ts 194 lines1// Pure logic of xcstrings-check: a String Catalog's text read into what is pending per
2// language, and the texts drawn from it. No `$` here: the hooks find and read the files.
3
4import type { CatalogReport, LanguageReport } from '../types'
5
6export const CATALOG_SUFFIX = '.xcstrings'
7
8// Below this depth, the walk inside a variation or substitution stops.
9const MAX_DEPTH = 8
10
11const record = (value: unknown): Record<string, unknown> | null =>
12 typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null
13
14/** Comma-separated language codes, trimmed, empty ones and duplicates dropped. */
15export function parseLanguages(text: unknown): string[] {
16 if (typeof text !== 'string') return []
17
18 return [...new Set(text.split(',').map(one => one.trim()).filter(Boolean))]
19}
20
21// Every stringUnit's state in a localization: its own, and those nested in
22// `variations` (plural, device, width…, nested in turn) and `substitutions`.
23function unitStates(node: unknown, depth = 0): string[] {
24 const one = record(node)
25 if (one === null || depth > MAX_DEPTH) return []
26 const states: string[] = []
27 const unit = record(one.stringUnit)
28 if (unit !== null) states.push(typeof unit.state === 'string' ? unit.state : 'translated')
29 for (const kind of Object.values(record(one.variations) ?? {})) {
30 for (const variant of Object.values(record(kind) ?? {})) states.push(...unitStates(variant, depth + 1))
31 }
32 for (const substitution of Object.values(record(one.substitutions) ?? {})) states.push(...unitStates(substitution, depth + 1))
33
34 return states
35}
36
37type Analysis = Pick<CatalogReport, 'sourceLanguage' | 'total' | 'languages' | 'stale' | 'unreadable'>
38
39/**
40 * What a catalog's text leaves to do: per language other than the source one (those in
41 * the catalog plus `expected`), the keys with no translation, in state `new`, or
42 * `needs_review`; and the keys whose extractionState is `stale`. Keys marked
43 * `shouldTranslate: false` are left out, and stale keys count only as stale.
44 */
45export function analyze(text: string, expected: readonly string[] = []): Analysis {
46 let json: unknown
47 try {
48 json = JSON.parse(text)
49 } catch {
50 return { sourceLanguage: null, total: 0, languages: [], stale: [], unreadable: 'not valid JSON' }
51 }
52 const root = record(json)
53 const strings = record(root?.strings)
54 if (root === null || strings === null) return { sourceLanguage: null, total: 0, languages: [], stale: [], unreadable: 'not a String Catalog (no "strings")' }
55 const source = typeof root.sourceLanguage === 'string' ? root.sourceLanguage : null
56
57 const entries = Object.entries(strings).flatMap(([key, value]) => {
58 const entry = record(value)
59 return entry === null || entry.shouldTranslate === false ? [] : [{ key, entry }]
60 })
61 const languages = new Set(expected)
62 for (const { entry } of entries) for (const language of Object.keys(record(entry.localizations) ?? {})) languages.add(language)
63 if (source !== null) languages.delete(source)
64
65 const stale = entries.filter(({ entry }) => entry.extractionState === 'stale').map(({ key }) => key)
66 const live = entries.filter(({ entry }) => entry.extractionState !== 'stale')
67 const reports: LanguageReport[] = [...languages].sort().map(language => {
68 const report: LanguageReport = { language, missing: [], new: [], review: [] }
69 for (const { key, entry } of live) {
70 const states = unitStates(record(entry.localizations)?.[language])
71 if (states.length === 0) report.missing.push(key)
72 else if (states.includes('new')) report.new.push(key)
73 else if (states.includes('needs_review')) report.review.push(key)
74 }
75 return report
76 })
77
78 return { sourceLanguage: source, total: entries.length, languages: reports, stale }
79}
80
81export const pendingCount = (one: LanguageReport) => one.missing.length + one.new.length + one.review.length
82
83/** `3 missing · 1 new · 1 review`, zero parts left out; empty when complete. */
84export function counts(one: LanguageReport): string {
85 return [
86 one.missing.length > 0 ? `${one.missing.length} missing` : '',
87 one.new.length > 0 ? `${one.new.length} new` : '',
88 one.review.length > 0 ? `${one.review.length} review` : '',
89 ]
90 .filter(Boolean)
91 .join(' · ')
92}
93
94/** All catalogs summed per language. */
95export function byLanguage(reports: readonly CatalogReport[]): LanguageReport[] {
96 const sum = new Map<string, LanguageReport>()
97 for (const report of reports) {
98 for (const one of report.languages) {
99 const into = sum.get(one.language) ?? { language: one.language, missing: [], new: [], review: [] }
100 into.missing.push(...one.missing)
101 into.new.push(...one.new)
102 into.review.push(...one.review)
103 sum.set(one.language, into)
104 }
105 }
106
107 return [...sum.values()].sort((a, b) => a.language.localeCompare(b.language))
108}
109
110/** `🌐 fr: 3 missing · 1 review, de: 2 missing · 1 stale`, or undefined when nothing is pending. */
111export function statusText(reports: readonly CatalogReport[]): string | undefined {
112 const languages = byLanguage(reports)
113 .filter(one => pendingCount(one) > 0)
114 .map(one => `${one.language}: ${counts(one)}`)
115 const stale = reports.reduce((sum, one) => sum + one.stale.length, 0)
116 const unreadable = reports.filter(one => one.unreadable !== undefined).length
117 const tail = [stale > 0 ? `${stale} stale` : '', unreadable > 0 ? `${unreadable} unreadable` : ''].filter(Boolean)
118 if (languages.length === 0 && tail.length === 0) return undefined
119 const head = languages.join(', ')
120
121 return `🌐 ${[head, ...tail].filter(Boolean).join(' · ')}`
122}
123
124export const MAX_KEYS = 8
125const MAX_KEY_LENGTH = 40
126
127const clip = (key: string) => (key.length > MAX_KEY_LENGTH ? `${key.slice(0, MAX_KEY_LENGTH - 1)}…` : key)
128
129/** `missing (12): a, b, … +4 more`: a label, the count and the first keys, each clipped. */
130export function keyList(label: string, keys: readonly string[]): string {
131 const first = keys.slice(0, MAX_KEYS).map(key => JSON.stringify(clip(key)))
132 const more = keys.length > MAX_KEYS ? ` +${keys.length - MAX_KEYS} more` : ''
133
134 return `${label} (${keys.length}): ${first.join(', ')}${more}`
135}
136
137/** The pane's lines for one catalog, with how deep each is indented. */
138export function catalogLines(report: CatalogReport): { text: string; depth: number; tone: 'title' | 'ok' | 'pending' | 'dim' }[] {
139 if (report.unreadable !== undefined) return [{ text: `${report.shown}: unreadable (${report.unreadable})`, depth: 0, tone: 'pending' }]
140 const lines: ReturnType<typeof catalogLines> = [
141 { text: `${report.shown} — ${report.total} keys, source ${report.sourceLanguage ?? '?'}`, depth: 0, tone: 'title' },
142 ]
143 if (report.languages.length === 0) lines.push({ text: 'no language besides the source one', depth: 1, tone: 'dim' })
144 for (const one of report.languages) {
145 if (pendingCount(one) === 0) {
146 lines.push({ text: `${one.language} ✓`, depth: 1, tone: 'ok' })
147 continue
148 }
149 lines.push({ text: `${one.language}: ${counts(one)}`, depth: 1, tone: 'pending' })
150 if (one.missing.length > 0) lines.push({ text: keyList('missing', one.missing), depth: 2, tone: 'dim' })
151 if (one.new.length > 0) lines.push({ text: keyList('new', one.new), depth: 2, tone: 'dim' })
152 if (one.review.length > 0) lines.push({ text: keyList('needs review', one.review), depth: 2, tone: 'dim' })
153 }
154 if (report.stale.length > 0) lines.push({ text: keyList('stale', report.stale), depth: 1, tone: 'pending' })
155
156 return lines
157}
158
159// Folders never walked when looking for catalogs.
160const SKIPPED = new Set(['node_modules', 'build', 'Build', 'DerivedData', 'Pods', 'Carthage', 'release', 'vendor'])
161const SKIPPED_SUFFIXES = ['.xcodeproj', '.xcworkspace', '.xcassets', '.app', '.framework', '.xcframework', '.lproj']
162
163export const isWalked = (name: string) => !name.startsWith('.') && !SKIPPED.has(name) && !SKIPPED_SUFFIXES.some(suffix => name.endsWith(suffix))
164
165export const isCatalog = (path: string) => path.endsWith(CATALOG_SUFFIX)
166
167// Files and folders that mark a project's root folder.
168export const isProjectMarker = (name: string) =>
169 name === '.git' || name === 'project.yml' || name === 'Package.swift' || name.endsWith('.xcodeproj') || name.endsWith('.xcworkspace')
170
171export const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1)
172
173export function dirName(path: string): string {
174 const cut = path.lastIndexOf('/')
175
176 return cut <= 0 ? '/' : path.slice(0, cut)
177}
178
179/** The folder and every folder above it, closest first, ending at `/`. */
180export function ancestors(dir: string): string[] {
181 const list = [dir]
182 for (let current = dir; current !== '/'; ) {
183 current = dirName(current)
184 list.push(current)
185 }
186
187 return list
188}
189
190export const join = (dir: string, name: string) => (dir === '/' ? `/${name}` : `${dir}/${name}`)
191
192/** `path` relative to `base` when inside it, else unchanged. */
193export const shown = (path: string, base: string) => (path.startsWith(`${base}/`) ? path.slice(base.length + 1) : path)
194types/index.d.ts 26 lines1export type LanguageReport = {
2 language: string
3 missing: string[]
4 new: string[]
5 review: string[]
6}
7
8export type CatalogReport = {
9 path: string
10 shown: string
11 root: string
12 sourceLanguage: string | null
13 total: number
14 languages: LanguageReport[]
15 stale: string[]
16 unreadable?: string
17}
18
19export type CatalogReports = Record<string, CatalogReport>
20
21declare module 'claude-code' {
22 interface PluginState {
23 'xcstrings-check': { reports: CatalogReports; projects: string[] }
24 }
25}
26