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.

<img src=".claude-plugin/icon.png" width="72" align="right" alt="">
It tells you when a mod you installed has a new version, and what changed.
⬆ 2 mod updates when any are waiting./mod-updates lists each installed mod with:Update runs claude plugin update <id> for one mod; Update all does them all. Reload afterwards.
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.| Setting | Default | |
|---|---|---|
everyHours | 6 | How often it fetches. 0 checks only when you open /mod-updates. |
hooks/register.tsx 312 lines1// 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}
312hooks/bands.ts 73 lines1// 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}
73hooks/updates.ts 84 lines1// 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}
84types/index.d.ts 28 lines1// 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