SLOPSHOPPER

xcstrings-check

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

newpaneguardcommandstatustimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · xcstrings-check
│ ┃ String Catalogs ✕ › fix the failing auth test and add an audit log call │ ┃ No String Catalog checked yet: /xcstrings │ ┃ check ⏺ 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 │ │ › /xcstrings │ ⎿ xcstrings-check: No String Catalog checked yet (/xcstrings check │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · String Catalogs
No String Catalog checked yet: /xcstrings check
README

xcstrings-check

Keeps Xcode String Catalogs (.xcstrings) complete: after Claude edits a catalog or Swift code beside one, it shows which translations are still pending.

What it does

  • Watches successful Edit/Write calls made by the main conversation on .xcstrings files, and on .swift files of a project that holds catalogs.
  • The project is the closest folder with .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.
  • For each catalog and each language other than the source one (those in the catalog plus the languages setting):
  • missing: no stringUnit for that language, also looked for inside variations (plural, device, nested) and substitutions;
  • new / needs review: a stringUnit in state new / needs_review;
  • stale: entries with extractionState: "stale" (listed once, not counted per language).
  • Keys with shouldTranslate: false are ignored. A file that is not valid JSON or not a catalog is reported unreadable.
  • Status line while anything is pending: 🌐 fr: 3 missing · 1 review, then · N stale, · N unreadable. Cleared when every catalog is complete.

Commands

CommandEffect
/xcstringsOpens the pane: catalog → language → keys (first 8 per list, with counts)
/xcstrings checkRe-scans and re-checks the session project now
/xcstrings offStops checking and clears the status line (kept across sessions)
/xcstrings onResumes

Settings

FieldDefaultMeaning
languagesemptyComma-separated languages every catalog must cover, e.g. fr,de

Install

claude --plugin-dir /path/to/ModsTools/mods/xcstrings-check

Limits

  • Only the main conversation's Edit/Write; catalogs changed by Xcode itself are seen at the next edit or /xcstrings check.
  • A plural with some categories missing (e.g. one but no other) counts as translated.
  • Catalogs over 4 MiB are reported unreadable; the search stops 8 folders deep or after 2000 folders.

Develop

claude plugin validate mods/xcstrings-check
claude plugin test mods/xcstrings-check   # 18 tests
Source 3 files
hooks/register.tsx 214 lines
1import { 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}
214
hooks/catalog.ts 194 lines
1// 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)
194
types/index.d.ts 26 lines
1export 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