SLOPSHOPPER

mod-updates

Spots new versions of your installed mods, shows the commits since you installed each one, and updates one or all in a click. /mod-updates opens it.

newpanebandcommandtoastprocess
v0.1.0MITupdated 2026-10-23THEROCKSSS/claude-mods/mods/mod-updates
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-updates
│ ┃ Mod Updates ✕ › fix the failing auth test and add an audit log call │ ┃ ⬆ Mod Updates checking… ✕ │ ┃ Checking your marketplaces… ⏺ 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 │ │ › /mod-updates │ ⎿ mod-updates: Mod Updates: checking your marketplaces… │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mod Updates
⬆ Mod Updates checking… ✕ Checking your marketplaces…
README

<img src=".claude-plugin/icon.png" width="72" align="right" alt="">

mod-updates

It tells you when a mod you installed has a new version, and what changed.

What it does

  • A bar line shows ⬆ 2 mod updates when any are waiting.
  • /mod-updates lists each installed mod with:
  • your version and the newest
  • the commits since your install that touched that mod's folder

Update runs claude plugin update <id> for one mod; Update all does them all. Reload afterwards.

  • Git marketplaces: it runs git fetch in the marketplace clone, then reads the mod's plugin.json at the fetched ref and the git log for its folder. Nothing is checked out until you update.
  • Folder marketplaces (a local path) compare against the folder's current version.
  • It first checks 20 s after start, then on the schedule.

Settings

SettingDefault
everyHours6How often it fetches. 0 checks only when you open /mod-updates.
Source 4 files
hooks/register.tsx 312 lines
1// Mod Updates: which installed mods have a newer version, what changed, and one click to update.
2//   - A marketplace cloned from git is fetched (git fetch only: nothing it holds is reset) and each
3//     of its mods compared: the version listed upstream, and the commits that touched the mod's
4//     folder since the commit it was installed from.
5//   - A folder marketplace is read live, so its mods already run what the folder holds; an update
6//     there records the folder's version.
7//   - /mod-updates lists them; Update runs the session's own `claude plugin update`, then a reload.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register, Timer } from 'claude-code'
10
11import type { Row } from '../types'
12import { bandsFile, parseOrder, stackBands } from './bands'
13import { isNewer, parseLog, readCatalog, readInstalled, readMarkets, sortRows } from './updates'
14
15const NAME = 'mod-updates'
16const PANE = 'mod-updates'
17const FIRST_CHECK_MS = 20_000 // after the session settles, so the fetch never slows its start
18const MAX_COMMITS = 30
19
20const rows = atom({ plugin: 'mod-updates', key: 'rows' } as const, [] as Row[])
21const checkedAt = atom({ plugin: 'mod-updates', key: 'checkedAt' } as const, 0)
22const checking = atom({ plugin: 'mod-updates', key: 'checking' } as const, false)
23const updating = atom({ plugin: 'mod-updates', key: 'updating' } as const, '')
24const updated = atom({ plugin: 'mod-updates', key: 'updated' } as const, [] as string[])
25const showAll = atom({ plugin: 'mod-updates', key: 'showAll' } as const, false)
26const error = atom({ plugin: 'mod-updates', key: 'error' } as const, '')
27
28let everyMs = 6 * 3_600_000
29let timer: Timer | undefined
30let bandOrder: Promise<string[]> | undefined
31let lastAnnounced = ''
32
33export const register: Register = (on, options) => {
34  const hours = typeof options.everyHours === 'number' && options.everyHours >= 0 ? options.everyHours : 6
35  everyMs = hours * 3_600_000
36  bandOrder = undefined
37
38  on('session.start', async ($, e, next) => {
39    const r = await next(e)
40    await $.command.register({ name: 'mod-updates', description: 'New versions of your mods: what changed, and update in one click' }).catch(() => {})
41    timer?.cancel()
42    if (everyMs > 0) {
43      $.clock.after(FIRST_CHECK_MS, () => void check($, true).catch(() => {}))
44      timer = $.clock.every(everyMs, () => {
45        void check($, true).catch(() => {})
46      })
47    }
48    return r
49  })
50
51  on('command.run', { command: 'mod-updates' }, async $ => {
52    await $.ui.open({ id: PANE, title: 'Mod Updates', focus: true })
53    void check($, true).catch(() => {})
54    return { text: 'Mod Updates: checking your marketplaces…' }
55  })
56
57  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($, e))
58
59  // A line above the prompt only while there is something to do.
60  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
61    const rest = await next(e)
62    const list = await read($, rows)
63    const done = await read($, updated)
64    const waiting = list.filter(r => r.hasUpdate).length
65    if (e.props.hasSurvey || (waiting === 0 && done.length === 0)) return rest
66    const { Box, Text, Button } = $.ui.resolve(e)
67    const mine = (
68      <Box flexDirection="row" columnGap={1} alignItems="center" paddingX={1}>
69        {waiting > 0 && <Text color="suggestion" weight="semibold">{`⬆ ${waiting} mod update${waiting === 1 ? '' : 's'}`}</Text>}
70        {waiting > 0 && <Button key="review" label="Review" plain onPress={() => $.ui.open({ id: PANE, title: 'Mod Updates', focus: true })} />}
71        {done.length > 0 && <Button key="reload" label={`↻ Reload to apply ${done.length}`} variant="primary" onPress={() => reload($)} />}
72      </Box>
73    )
74    return stackBands(rest, NAME, mine, 'above', await (bandOrder ??= readBandOrder($).catch(() => [])))
75  })
76}
77
78// ── Checking ──────────────────────────────────────────────────────────────
79
80const slash = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
81
82async function configDir($: EngineInterface): Promise<string> {
83  const config = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)
84  if (config) return slash(config)
85  const home = (await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined)) ?? ''
86  return `${slash(home)}/.claude`
87}
88
89async function readJson($: EngineInterface, path: string): Promise<unknown> {
90  const text = await $.fs.read(path).catch(() => '')
91  try {
92    return JSON.parse(typeof text === 'string' ? text : '')
93  } catch {
94    return null
95  }
96}
97
98async function git($: EngineInterface, dir: string, args: string[], timeoutMs = 30_000) {
99  const r = await $.process.run(['git', '-C', dir, ...args], { timeoutMs }).catch(() => null)
100  return r && r.exitCode === 0 ? r.stdout : null
101}
102
103// The branch upstream: origin/HEAD, else origin/main, else origin/master.
104async function upstream($: EngineInterface, dir: string): Promise<string | null> {
105  const head = (await git($, dir, ['rev-parse', '--abbrev-ref', 'origin/HEAD']))?.trim()
106  if (head && head !== 'origin/HEAD') return head
107  for (const ref of ['origin/main', 'origin/master']) if ((await git($, dir, ['rev-parse', '--verify', '--quiet', ref])) !== null) return ref
108  return null
109}
110
111async function check($: EngineInterface, isFetch: boolean) {
112  if (await read($, checking)) return
113  await update($, checking, () => true)
114  await update($, error, () => '')
115  try {
116    const dir = await configDir($)
117    const installed = readInstalled(await readJson($, `${dir}/plugins/installed_plugins.json`))
118    const marketsJson = (await readJson($, `${dir}/plugins/known_marketplaces.json`)) as Record<string, { installLocation?: string }> | null
119    const gitDirs = new Set<string>()
120    for (const m of Object.values(marketsJson ?? {})) {
121      const loc = m?.installLocation ? slash(m.installLocation) : ''
122      if (loc && (await $.fs.exists(`${loc}/.git`).catch(() => false))) gitDirs.add(loc)
123    }
124    const markets = readMarkets(marketsJson, d => gitDirs.has(d))
125    const out: Row[] = []
126    for (const [market, source] of markets) {
127      const mine = installed.filter(p => p.market === market)
128      if (mine.length === 0) continue
129      if (source.kind === 'git') {
130        if (isFetch) await git($, source.dir, ['fetch', '--quiet', 'origin'], 90_000)
131        const ref = await upstream($, source.dir)
132        const catalog = ref ? readCatalog(safeJson(await git($, source.dir, ['show', `${ref}:.claude-plugin/marketplace.json`]))) : new Map()
133        for (const p of mine) {
134          const entry = catalog.get(p.name)
135          if (!ref || !entry) {
136            out.push({ id: p.id, name: p.name, market, from: p.version, to: p.version, commits: [], kind: 'git', hasUpdate: false, note: ref ? 'no longer listed in its marketplace' : 'could not read the marketplace' })
137            continue
138          }
139          const manifest = safeJson(await git($, source.dir, ['show', `${ref}:${entry.path ? `${entry.path}/` : ''}.claude-plugin/plugin.json`])) as { version?: unknown } | null
140          const to = entry.version || (typeof manifest?.version === 'string' ? manifest.version : '') || p.version
141          const log = p.sha ? await git($, source.dir, ['log', `--max-count=${MAX_COMMITS}`, '--format=%h%x09%s', `${p.sha}..${ref}`, '--', entry.path || '.']) : null
142          const commits = log ? parseLog(log) : []
143          out.push({ id: p.id, name: p.name, market, from: p.version, to, commits, kind: 'git', hasUpdate: commits.length > 0 || isNewer(to, p.version), note: '' })
144        }
145      } else if (source.kind === 'folder') {
146        const catalog = readCatalog(await readJson($, `${source.dir}/.claude-plugin/marketplace.json`))
147        for (const p of mine) {
148          const entry = catalog.get(p.name)
149          const manifest = entry ? ((await readJson($, `${source.dir}/${entry.path ? `${entry.path}/` : ''}.claude-plugin/plugin.json`)) as { version?: unknown } | null) : null
150          const to = (typeof manifest?.version === 'string' ? manifest.version : '') || entry?.version || p.version
151          out.push({ id: p.id, name: p.name, market, from: p.version, to, commits: [], kind: 'folder', hasUpdate: to !== p.version, note: 'read live from its folder; Update records the new version' })
152        }
153      } else {
154        for (const p of mine) out.push({ id: p.id, name: p.name, market, from: p.version, to: p.version, commits: [], kind: 'other', hasUpdate: false, note: 'not a git clone: Update asks the marketplace directly' })
155      }
156    }
157    const sorted = sortRows(out)
158    await update($, rows, () => sorted)
159    await update($, checkedAt, () => Date.now())
160    const waiting = sorted.filter(r => r.hasUpdate)
161    const key = waiting.map(r => `${r.id}:${r.to}:${r.commits[0]?.sha ?? ''}`).join(',')
162    if (waiting.length && key !== lastAnnounced) $.ui.toast(`${waiting.length} mod update${waiting.length === 1 ? '' : 's'}: ${waiting.slice(0, 3).map(r => r.name).join(', ')}. /mod-updates`)
163    lastAnnounced = key
164  } catch (err) {
165    await update($, error, () => (err instanceof Error ? err.message : String(err)))
166  } finally {
167    await update($, checking, () => false)
168  }
169}
170
171function safeJson(text: string | null): unknown {
172  try {
173    return text ? JSON.parse(text) : null
174  } catch {
175    return null
176  }
177}
178
179async function readBandOrder($: EngineInterface): Promise<string[]> {
180  const home = (await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined))?.replace(/\\/g, '/')
181  const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), home)
182  return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
183}
184
185// ── Updating ──────────────────────────────────────────────────────────────
186
187async function updateOne($: EngineInterface, id: string): Promise<boolean> {
188  await update($, updating, () => id)
189  const exe = (await $.env.get('CLAUDE_CODE_EXECPATH').catch(() => undefined)) || 'claude'
190  const r = await $.process.run([exe, 'plugin', 'update', id], { timeoutMs: 180_000 }).catch(() => null)
191  await update($, updating, () => '')
192  if (!r || r.exitCode !== 0) {
193    await update($, error, () => `Could not update ${id}: ${(r?.stderr || r?.stdout || 'claude did not run').trim().split(/\r?\n/).slice(-1)[0]}`)
194    return false
195  }
196  await update($, updated, list => [...new Set([...list, id])])
197  await update($, rows, list => list.map(x => (x.id === id ? { ...x, hasUpdate: false, from: x.to, commits: [] } : x)))
198  return true
199}
200
201async function updateAll($: EngineInterface) {
202  const waiting = (await read($, rows)).filter(r => r.hasUpdate)
203  let ok = 0
204  for (const r of waiting) if (await updateOne($, r.id)) ok++
205  $.ui.toast(`Updated ${ok} of ${waiting.length}. Reload to apply`)
206}
207
208async function reload($: EngineInterface) {
209  await $.command.run({ command: 'reload-plugins' }).catch(() => $.ui.toast('Run /reload-plugins to apply the updates'))
210  await update($, updated, () => [])
211}
212
213// ── The pane ──────────────────────────────────────────────────────────────
214
215export function ago(at: number, now: number) {
216  const s = Math.max(0, Math.round((now - at) / 1000))
217  return s < 60 ? `${s} s ago` : s < 3600 ? `${Math.round(s / 60)} min ago` : `${Math.round(s / 3600)} h ago`
218}
219
220async function drawPane($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0]) {
221  const { Box, Text, Button } = $.ui.resolve(e)
222  const list = await read($, rows)
223  const at = await read($, checkedAt)
224  const isChecking = await read($, checking)
225  const busyId = await read($, updating)
226  const done = await read($, updated)
227  const isAll = await read($, showAll)
228  const problem = await read($, error)
229  const now = Date.now()
230  const waiting = list.filter(r => r.hasUpdate)
231  const current = list.filter(r => !r.hasUpdate)
232  const pill = (key: string, text: string, bg: string) => (
233    <Text key={key} backgroundColor={bg} color="inverseText" weight="semibold" size="caption">
234      {` ${text} `}
235    </Text>
236  )
237
238  return (
239    <Box flexDirection="column" gap={1}>
240      <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2} flexWrap="wrap">
241        <Box flexDirection="column">
242          <Text size="title" weight="bold">
243            <Text color="claude">{'⬆ '}</Text>
244            {'Mod Updates'}
245          </Text>
246          <Text size="footnote" dimColor>
247            {isChecking ? 'Checking your marketplaces…' : at ? `${waiting.length ? `${waiting.length} update${waiting.length === 1 ? '' : 's'}` : 'Everything is up to date'} · checked ${ago(at, now)}` : 'Not checked yet'}
248          </Text>
249        </Box>
250        <Box flexDirection="row" columnGap={1} alignItems="center">
251          {done.length > 0 && <Button key="reload" label={`↻ Reload to apply ${done.length}`} variant="primary" onPress={() => reload($)} />}
252          {waiting.length > 1 && !busyId && <Button key="update-all" label={`⬆ Update all ${waiting.length}`} variant="primary" onPress={() => updateAll($)} />}
253          <Button key="check" label={isChecking ? 'checking…' : '↻ Check now'} plain={isChecking ? true : undefined} onPress={() => check($, true)} />
254          <Button key="close" label="✕" plain dimColor role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
255        </Box>
256      </Box>
257
258      {problem !== '' && (
259        <Box borderStyle="round" borderColor="error" paddingX={1}>
260          <Text color="error" wrap="wrap">
261            {problem}
262          </Text>
263        </Box>
264      )}
265
266      {at > 0 && waiting.length === 0 && (
267        <Box flexDirection="column" alignItems="center" borderStyle="round" borderColor="success" paddingY={1}>
268          <Text color="success" weight="semibold">
269            ✓ All {list.length} mods are on their latest version
270          </Text>
271        </Box>
272      )}
273
274      {waiting.map(r => (
275        <Box key={`u-${r.id}`} flexDirection="column" borderStyle="round" borderColor="suggestion" hover={{ borderColor: 'claude' }} paddingX={1}>
276          <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={1}>
277            <Box flexDirection="row" columnGap={1} alignItems="center">
278              <Text size="heading" weight="semibold">
279                {r.name}
280              </Text>
281              <Text mono size="footnote" dimColor>{`v${r.from}`}</Text>
282              <Text dimColor>→</Text>
283              {pill(`to-${r.id}`, r.to === r.from ? `${r.commits.length} new commit${r.commits.length === 1 ? '' : 's'}` : `v${r.to}`, 'suggestion')}
284            </Box>
285            {busyId === r.id ? <Text color="suggestion">updating…</Text> : <Button key={`update-${r.id}`} label="⬆ Update" variant="primary" onPress={() => updateOne($, r.id)} />}
286          </Box>
287          <Text size="caption" dimColor>{`@${r.market}${r.note ? ` · ${r.note}` : ''}`}</Text>
288          {r.commits.slice(0, 6).map(c => (
289            <Text key={`c-${r.id}-${c.sha}`} size="footnote" wrap="truncate-end">
290              <Text mono dimColor>{`${c.sha}  `}</Text>
291              {c.subject}
292            </Text>
293          ))}
294          {r.commits.length > 6 && <Text size="caption" dimColor>{`… and ${r.commits.length - 6} more`}</Text>}
295        </Box>
296      ))}
297
298      {current.length > 0 && (
299        <Box flexDirection="column">
300          <Button key="show-all" label={isAll ? `▾ Up to date (${current.length})` : `▸ Up to date (${current.length})`} plain dimColor onPress={() => update($, showAll, v => !v)} />
301          {isAll &&
302            current.map(r => (
303              <Text key={`ok-${r.id}`} size="footnote" dimColor wrap="truncate-end">
304                {`  ${r.name}  v${r.from}  @${r.market}${r.note ? `  · ${r.note}` : ''}${done.includes(r.id) ? '  · updated, reload to apply' : ''}`}
305              </Text>
306            ))}
307        </Box>
308      )}
309    </Box>
310  )
311}
312
hooks/bands.ts 73 lines
1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2//   The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3//   (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4//   `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5//   column, adds its own band and sorts again, so the last one to draw sorts them all.
6//   Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7//   Reading the file needs $, which Claude Code follows only within one file, so each mod
8//   reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16  const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17  return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22  try {
23    const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24    return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25  } catch {
26    return []
27  }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38  const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39  const others = unpack(rest)
40  const items = at === 'above' ? [own, ...others] : [...others, own]
41  return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46  if (!rest) return []
47  if (!isColumn(rest)) return [rest]
48  const kids = rest.children ?? []
49  const owner = rest.part?.plugin
50  // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51  return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55  return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59  if (typeof node === 'string' || node.type !== 'Box') return undefined
60  const key = node.props?.key
61  return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65  const rank = (n: RenderNode) => {
66    const name = bandName(n)
67    if (name === undefined) return -2 // not a band: first
68    const i = order.indexOf(name)
69    return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70  }
71  return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73
hooks/updates.ts 84 lines
1// What mod-updates compares. Pure: no $, so the tests call it directly.
2
3export type Installed = { id: string; name: string; market: string; version: string; sha: string }
4export type MarketSource = { kind: 'git'; dir: string } | { kind: 'folder'; dir: string } | { kind: 'other' }
5export type Commit = { sha: string; subject: string }
6export type Row = {
7  id: string
8  name: string
9  market: string
10  from: string
11  to: string
12  commits: Commit[]
13  kind: 'git' | 'folder' | 'other'
14  hasUpdate: boolean
15  note: string // why it could not be checked, or what an update does here
16}
17
18const slash = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
19
20// installed_plugins.json (version 2) as one row per plugin id.
21export function readInstalled(json: unknown): Installed[] {
22  const plugins = ((json as { plugins?: Record<string, unknown> } | null)?.plugins ?? {}) as Record<string, unknown>
23  return Object.entries(plugins).flatMap(([id, rows]) => {
24    const row = (Array.isArray(rows) ? rows[0] : undefined) as Record<string, unknown> | undefined
25    if (!row) return []
26    const at = id.lastIndexOf('@')
27    return [{ id, name: at > 0 ? id.slice(0, at) : id, market: at > 0 ? id.slice(at + 1) : '', version: String(row.version ?? ''), sha: typeof row.gitCommitSha === 'string' ? row.gitCommitSha : '' }]
28  })
29}
30
31// known_marketplaces.json as how each marketplace can be checked: a git clone, a folder, or neither.
32export function readMarkets(json: unknown, isGit: (dir: string) => boolean): Map<string, MarketSource> {
33  const out = new Map<string, MarketSource>()
34  for (const [name, raw] of Object.entries((json ?? {}) as Record<string, { source?: { source?: string; path?: string }; installLocation?: string }>)) {
35    const dir = raw?.installLocation ? slash(raw.installLocation) : ''
36    if (raw?.source?.source === 'directory') out.set(name, { kind: 'folder', dir: slash(raw.source.path ?? dir) })
37    else if (dir && isGit(dir)) out.set(name, { kind: 'git', dir })
38    else out.set(name, { kind: 'other' })
39  }
40  return out
41}
42
43// A marketplace.json's entries: plugin name → its folder in the repo (no ./) and its listed version.
44export function readCatalog(json: unknown): Map<string, { path: string; version: string }> {
45  const out = new Map<string, { path: string; version: string }>()
46  for (const p of ((json as { plugins?: unknown[] } | null)?.plugins ?? []) as { name?: unknown; source?: unknown; version?: unknown }[]) {
47    if (typeof p.name !== 'string') continue
48    const source = typeof p.source === 'string' ? p.source.replace(/^\.\/?/, '').replace(/\/+$/, '') : ''
49    out.set(p.name, { path: source, version: typeof p.version === 'string' ? p.version : '' })
50  }
51  return out
52}
53
54// `git log --format=%h%x09%s` lines as commits.
55export function parseLog(stdout: string): Commit[] {
56  return stdout
57    .split(/\r?\n/)
58    .filter(l => l.includes('\t'))
59    .map(l => ({ sha: l.slice(0, l.indexOf('\t')), subject: l.slice(l.indexOf('\t') + 1).trim() }))
60}
61
62// Newest first: those with updates, then the rest by name.
63export function sortRows(rows: Row[]): Row[] {
64  return [...rows].sort((a, b) => Number(b.hasUpdate) - Number(a.hasUpdate) || a.name.localeCompare(b.name))
65}
66
67// Whether a version string is ahead of another (1.2.10 > 1.2.9; a suffix like -win.2 counts after).
68export function isNewer(to: string, from: string): boolean {
69  if (!to || to === from) return false
70  const parts = (v: string) => v.split(/[.-]/).map(x => (/^\d+$/.test(x) ? Number(x) : x))
71  const a = parts(to)
72  const b = parts(from)
73  for (let i = 0; i < Math.max(a.length, b.length); i++) {
74    const x = a[i]
75    const y = b[i]
76    if (x === undefined) return false
77    if (y === undefined) return true
78    if (x === y) continue
79    if (typeof x === 'number' && typeof y === 'number') return x > y
80    return String(x) > String(y)
81  }
82  return false
83}
84
types/index.d.ts 28 lines
1// One installed mod as the check found it; hooks/updates.ts holds the same shape.
2export type Commit = { sha: string; subject: string }
3export type Row = {
4  id: string
5  name: string
6  market: string
7  from: string
8  to: string
9  commits: Commit[]
10  kind: 'git' | 'folder' | 'other'
11  hasUpdate: boolean
12  note: string
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'mod-updates': {
18      rows: Row[]
19      checkedAt: number // 0 before the first check
20      checking: boolean
21      updating: string // the plugin id updating now, '' when none
22      updated: string[] // ids updated this session: a reload applies them
23      showAll: boolean // the pane lists up-to-date mods too
24      error: string
25    }
26  }
27}
28