/mods opens a panel beside the chat with the mods you made and the plugins you installed: on or off, tests, updates you can apply, and your repo's GitHub…

A Claude Code mod that puts all your mods in one panel beside the chat. Type /mods.

claude plugin enable and disable, your own mods in your settings' CLAUDE_CODE_PLUGIN_DIRS.Install counts aren't shown: Claude Code doesn't report them to authors. GitHub's clones count the whole repository and include your own.
By default, the folder this mod sits in and the GitHub remote of its repository. To point it elsewhere, write ~/.claude/tree-of-mods/config.json:
{ "modsDir": "~/code/my-mods/mods", "repo": "you/my-mods" }
gh) signed in with access to your repo's traffic, for the numbers.In Claude Code:
/plugin marketplace add hacksurvivor/claude-mods
/plugin install tree-of-mods@hacksurvivor
/reload-plugins
bin/inventory.py, which reads your mods folder and its git state, your installed plugins, the Updates report, and GitHub's traffic numbers for your repo (gh api, GET only, cached for an hour). It runs each mod's tests with claude plugin test when its files changed.~/.claude/settings.json (backed up first) or runs claude plugin enable or disable; an update runs that update's steps; Publish sends Claude a prompt.~/.claude/tree-of-mods/.See PRIVACY.md.
Apache-2.0
hooks/register.tsx 414 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Installed, Inventory, Mine, Tab, Update } from '../types'
5import {
6 FOLDED,
7 applyResult,
8 clonesCard,
9 details,
10 parse,
11 publishPrompt,
12 readyOf,
13 restLine,
14 rowStat,
15 summary,
16 tabLabel,
17 updateLine,
18 updateVersions,
19 viewsCard,
20} from './shelf'
21
22// /mods opens a panel beside the chat: the mods you made (on or off, published
23// or only on this Mac, tests, what each has done), the plugins you installed
24// from marketplaces, the updates the Updates mod found, and the repo's GitHub numbers.
25// bin/inventory.py reads all of it; it writes only when a switch is flipped.
26
27const PANE_ID = 'mods'
28const LOAD_TIMEOUT_MS = 60_000
29const TESTS_TIMEOUT_MS = 600_000
30const SWITCH_TIMEOUT_MS = 60_000
31
32const inventory = atom({ plugin: 'tree-of-mods', key: 'inventory' } as const, null as Inventory | null)
33const error = atom({ plugin: 'tree-of-mods', key: 'error' } as const, null as string | null)
34const tab = atom({ plugin: 'tree-of-mods', key: 'tab' } as const, 'yours' as Tab)
35const isLoading = atom({ plugin: 'tree-of-mods', key: 'isLoading' } as const, false)
36const changed = atom({ plugin: 'tree-of-mods', key: 'changed' } as const, [] as string[])
37const showsAll = atom({ plugin: 'tree-of-mods', key: 'showsAll' } as const, false)
38const opened = atom({ plugin: 'tree-of-mods', key: 'opened' } as const, null as string | null)
39const updating = atom({ plugin: 'tree-of-mods', key: 'updating' } as const, [] as string[])
40const failures = atom({ plugin: 'tree-of-mods', key: 'failures' } as const, {} as Record<string, string>)
41const needsReload = atom({ plugin: 'tree-of-mods', key: 'needsReload' } as const, false)
42// The host allows a command 10 minutes at most; each step inside gets 4.
43const APPLY_TIMEOUT_MS = 600_000
44
45function helper($: EngineInterface, args: string[], timeoutMs: number) {
46 return $.process.run(['/bin/zsh', '-lc', '"$@"', 'mods', 'python3', `${$.plugin.root}/bin/inventory.py`, ...args], { timeoutMs })
47}
48
49async function load($: EngineInterface, args: string[]): Promise<void> {
50 try {
51 const ran = await helper($, args, args.includes('--tests') ? TESTS_TIMEOUT_MS : LOAD_TIMEOUT_MS)
52 const found = parse(ran.stdout)
53
54 if ('error' in found) {
55 await update($, error, () => found.error)
56
57 return
58 }
59
60 await update($, inventory, () => found.inventory)
61 await update($, error, () => null)
62 } catch (failure) {
63 await update($, error, () => (failure instanceof Error ? failure.message : String(failure)).slice(0, 160))
64 }
65}
66
67// The list at once from the cache, then the tests of mods that changed.
68async function refresh($: EngineInterface): Promise<void> {
69 await update($, isLoading, () => true)
70 await load($, [])
71 await update($, isLoading, () => false)
72 void load($, ['--tests'])
73}
74
75async function switchMine($: EngineInterface, mod: Mine): Promise<void> {
76 const ran = await helper($, ['toggle', mod.dir, mod.isOn ? 'off' : 'on'], SWITCH_TIMEOUT_MS)
77
78 if (ran.exitCode !== 0) {
79 $.ui.toast(`Couldn't switch ${mod.name}: ${(ran.stdout + ran.stderr).trim().slice(0, 120)}`, { timeoutMs: 8_000 })
80
81 return
82 }
83
84 await update($, inventory, now => now && { ...now, yours: now.yours.map(item => (item.id === mod.id ? { ...item, isOn: !mod.isOn } : item)) })
85 await update($, changed, list => (list.includes(mod.id) ? list : [...list, mod.id]))
86}
87
88async function switchInstalled($: EngineInterface, plugin: Installed): Promise<void> {
89 const ran = await $.process.run(['/bin/zsh', '-lc', '"$@"', 'mods', 'claude', 'plugin', plugin.isOn ? 'disable' : 'enable', plugin.id], {
90 timeoutMs: SWITCH_TIMEOUT_MS,
91 })
92
93 if (ran.exitCode !== 0) {
94 $.ui.toast(`Couldn't switch ${plugin.name}: ${(ran.stderr || ran.stdout).trim().slice(0, 120)}`, { timeoutMs: 8_000 })
95
96 return
97 }
98
99 await update($, inventory, now => now && { ...now, installed: now.installed.map(item => (item.id === plugin.id ? { ...item, isOn: !plugin.isOn } : item)) })
100 await update($, changed, list => (list.includes(plugin.id) ? list : [...list, plugin.id]))
101}
102
103async function publish($: EngineInterface, mod: Mine): Promise<void> {
104 await $.ui.close({ id: PANE_ID })
105 await $.prompt.submit({ text: publishPrompt(mod, (await read($, inventory))?.repo), asUser: true })
106}
107
108// Runs the chosen updates through the helper, then reads the list again.
109async function applyUpdates($: EngineInterface, ids: string[]): Promise<void> {
110 if ((await read($, updating)).length > 0 || ids.length === 0) {
111 return
112 }
113
114 await update($, updating, () => ids)
115 await update($, failures, now => Object.fromEntries(Object.entries(now).filter(([id]) => !ids.includes(id))))
116
117 try {
118 const ran = await helper($, ['apply', ...ids], APPLY_TIMEOUT_MS)
119 const result = applyResult(ran.stdout)
120
121 if (result === undefined) {
122 $.ui.toast(`Couldn't update: ${(ran.stderr || ran.stdout).trim().slice(0, 120)}`, { timeoutMs: 8_000 })
123
124 return
125 }
126
127 await update($, failures, now => ({ ...now, ...result.failed }))
128
129 if (result.reload) {
130 await update($, needsReload, () => true)
131 }
132
133 const failed = Object.keys(result.failed).length
134 $.ui.toast(
135 failed === 0
136 ? `Updated ${result.done.length}.${result.reload ? ' Reload plugins to finish.' : ''}`
137 : `Updated ${result.done.length}, ${failed} failed. The Updates tab says why.`,
138 { timeoutMs: 8_000 },
139 )
140 } finally {
141 await update($, updating, () => [])
142 await load($, [])
143 }
144}
145
146async function review($: EngineInterface, item: Update): Promise<void> {
147 await $.ui.close({ id: PANE_ID })
148 await $.prompt.submit({ text: item.review ?? `Help me update ${item.name} from ${item.from} to ${item.to}.`, asUser: true })
149}
150
151async function copyCommand($: EngineInterface, item: Update, surface: Parameters<EngineInterface['ui']['copy']>[0]['surface']) {
152 const copied = await $.ui.copy({ text: item.command ?? '', surface })
153 $.ui.toast(copied.isCopied ? 'Copied. Paste it in a terminal; it asks for your password.' : `Couldn't copy: ${item.command}`, {
154 timeoutMs: 8_000,
155 })
156}
157
158export const register: Register = on => {
159 on('session.start', async ($, e, next) => {
160 await $.command.register({ name: 'mods', description: 'Open The Tree of Mods: your mods, installed plugins, updates', immediate: true })
161
162 return next(e)
163 })
164
165 on('command.run', { command: 'mods' }, async $ => {
166 await $.ui.open({ id: PANE_ID, title: 'The Tree of Mods', closeOnEscape: true })
167 void refresh($)
168
169 return { text: 'The Tree of Mods is open on the side.' }
170 })
171
172 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
173 if (e.props.title !== 'The Tree of Mods') {
174 return next(e)
175 }
176
177 const found = await read($, inventory)
178 const failed = await read($, error)
179 const current = await read($, tab)
180 const switched = await read($, changed)
181 const isAll = await read($, showsAll)
182 const loading = await read($, isLoading)
183 const open = await read($, opened)
184 const running = await read($, updating)
185 const failedNow = await read($, failures)
186 const reloadNow = await read($, needsReload)
187 const { Box, Button, Text } = $.ui.resolve(e)
188 const labels = tabLabel(found)
189
190 const onOff = (key: string, isOn: boolean, press: () => void) => (
191 <Button key={key} label={isOn ? 'On' : 'Off'} plain dimColor={!isOn} onPress={press} />
192 )
193
194 const row = (key: string, name: string, meta: string | null, right: JSX.Element[]) => (
195 <Box key={key} flexDirection="row" columnGap={2} paddingY={0}>
196 <Box flexGrow={1} flexShrink={1}>
197 <Text wrap="truncate">{name}</Text>
198 </Box>
199 {meta === null ? null : <Text dimColor wrap="truncate">{meta}</Text>}
200 {right}
201 </Box>
202 )
203
204 // A card on top: the number big, its label and a note dim under it.
205 const card = (key: string, value: string, label: string, lines: string[]) => (
206 <Box key={key} flexDirection="column" flexGrow={1} width="50%" borderStyle="round" borderDimColor paddingX={1}>
207 <Text bold>{value}</Text>
208 <Text dimColor wrap="truncate">{label}</Text>
209 {lines.map((line, at) => (
210 <Text key={`${key}-${at}`} dimColor wrap="truncate">
211 {line}
212 </Text>
213 ))}
214 </Box>
215 )
216
217 const mineRow = (mod: Mine) => {
218 const stat = rowStat(mod)
219 const isOpen = open === mod.id
220
221 return (
222 <Box key={`mine-${mod.id}`} flexDirection="column">
223 <Box flexDirection="row" columnGap={1}>
224 <Text color={mod.isOn ? 'success' : undefined} dimColor={!mod.isOn}>
225 ●
226 </Text>
227 <Box flexGrow={1} flexShrink={1}>
228 <Button key={`open-${mod.id}`} label={mod.name} plain onPress={() => update($, opened, now => (now === mod.id ? null : mod.id))} />
229 </Box>
230 <Text color={stat.tone} dimColor={stat.tone === undefined} wrap="truncate">
231 {stat.text}
232 </Text>
233 {onOff(`switch-${mod.id}`, mod.isOn, () => switchMine($, mod))}
234 </Box>
235 {isOpen ? (
236 <Box key={`details-${mod.id}`} flexDirection="column" marginLeft={2} marginBottom={1}>
237 <Text dimColor wrap="wrap">
238 {mod.description}
239 </Text>
240 <Box flexDirection="row" columnGap={2}>
241 <Box flexGrow={1}>
242 <Text dimColor>{details(mod, found?.stats.modViews?.[mod.id])}</Text>
243 </Box>
244 {mod.isPublished ? null : <Button key={`publish-${mod.id}`} label="Publish" variant="secondary" onPress={() => publish($, mod)} />}
245 </Box>
246 </Box>
247 ) : null}
248 </Box>
249 )
250 }
251
252 const yoursTab = () => {
253 const yours = found?.yours ?? []
254 const published = yours.filter(mod => mod.isPublished)
255 // Published first, then the ones that did something, then the rest.
256 const ordered = [...published, ...yours.filter(mod => !mod.isPublished && mod.usage !== null), ...yours.filter(mod => !mod.isPublished && mod.usage === null)]
257 const visible = isAll ? ordered : ordered.slice(0, FOLDED)
258 const rest = ordered.slice(visible.length)
259 const clones = clonesCard(found?.stats ?? {})
260 const views = viewsCard(found?.stats ?? {}, published)
261 const head = summary(yours)
262
263 return (
264 <Box flexDirection="column" rowGap={1}>
265 {typeof clones === 'string' ? (
266 <Text dimColor>{clones}</Text>
267 ) : (
268 <Box flexDirection="row" columnGap={1}>
269 {card('clones', clones.value, clones.label, [clones.spark, clones.note])}
270 {card('views', views.value, views.label, ['', views.note])}
271 </Box>
272 )}
273 <Box flexDirection="column">
274 <Box flexDirection="row" columnGap={2}>
275 <Box flexGrow={1}>
276 <Text dimColor>{head.left}</Text>
277 </Box>
278 <Text dimColor>{head.right}</Text>
279 </Box>
280 {visible.map(mineRow)}
281 {rest.length > 0 || isAll ? (
282 <Box flexDirection="row" columnGap={1}>
283 <Text dimColor>●</Text>
284 <Box flexGrow={1}>
285 <Text dimColor wrap="truncate">{isAll ? '' : restLine(rest.map(mod => mod.name))}</Text>
286 </Box>
287 <Button key="show-all" label={isAll ? 'Show less' : 'Show'} plain dimColor onPress={() => update($, showsAll, all => !all)} />
288 </Box>
289 ) : null}
290 </Box>
291 </Box>
292 )
293 }
294
295 const installedTab = () => (
296 <Box flexDirection="column">
297 {(found?.installed ?? []).map(plugin =>
298 row(`plugin-${plugin.id}`, plugin.name, plugin.update === null ? plugin.version : `${plugin.update} ready`, [
299 onOff(`switch-${plugin.id}`, plugin.isOn, () => switchInstalled($, plugin)),
300 ]),
301 )}
302 </Box>
303 )
304
305 const updateRow = (item: Update) => {
306 const isRunning = running.includes(item.id)
307 const reason = failedNow[item.id]
308 const action =
309 item.kind === 'ready' ? (
310 <Button key={`apply-${item.id}`} label={isRunning ? 'Updating…' : 'Update'} plain onPress={() => applyUpdates($, [item.id])} />
311 ) : item.kind === 'review' ? (
312 <Button key={`review-${item.id}`} label="Review" plain onPress={() => review($, item)} />
313 ) : (
314 <Button key={`copy-${item.id}`} label="Copy command" plain onPress={press => copyCommand($, item, press.surface)} />
315 )
316
317 return (
318 <Box key={`update-${item.id}`} flexDirection="column">
319 <Box flexDirection="row" columnGap={1}>
320 <Box flexGrow={1} flexShrink={1}>
321 <Text bold wrap="truncate">
322 {item.name}
323 </Text>
324 </Box>
325 <Text dimColor>{updateVersions(item)}</Text>
326 {action}
327 </Box>
328 <Text dimColor wrap="truncate">
329 {updateLine(item)}
330 </Text>
331 {reason === undefined ? null : (
332 <Text color="error" wrap="wrap">
333 {reason}
334 </Text>
335 )}
336 </Box>
337 )
338 }
339
340 const updatesTab = () => {
341 const items = found?.updates ?? []
342 const readyNow = readyOf(found)
343 const reviews = items.filter(item => item.kind === 'review')
344 const manual = items.filter(item => item.kind === 'manual')
345
346 if (items.length === 0) {
347 return <Text dimColor>Everything is up to date. Updates checks once a week; /updates checks now.</Text>
348 }
349
350 return (
351 <Box flexDirection="column" rowGap={1}>
352 {readyNow.length > 0 ? (
353 <Box flexDirection="column" rowGap={1}>
354 <Box flexDirection="row" columnGap={2} alignItems="center">
355 <Box flexGrow={1}>
356 <Text dimColor>{running.length > 0 ? `Updating ${running.length}…` : `${readyNow.length} ready`}</Text>
357 </Box>
358 {running.length > 0 ? null : (
359 <Button key="update-all" label="Update all" variant="primary" onPress={() => applyUpdates($, readyNow.map(item => item.id))} />
360 )}
361 </Box>
362 {readyNow.map(updateRow)}
363 </Box>
364 ) : (
365 <Text dimColor>Nothing updates by itself right now.</Text>
366 )}
367 {reviews.length > 0 ? (
368 <Box flexDirection="column" rowGap={1}>
369 <Text dimColor>Changed upstream · yours to merge</Text>
370 {reviews.map(updateRow)}
371 </Box>
372 ) : null}
373 {manual.length > 0 ? (
374 <Box flexDirection="column" rowGap={1}>
375 <Text dimColor>Needs you</Text>
376 {manual.map(updateRow)}
377 </Box>
378 ) : null}
379 </Box>
380 )
381 }
382
383 return (
384 <Box flexDirection="column" paddingX={1} rowGap={1}>
385 <Box flexDirection="row" columnGap={2}>
386 {(['yours', 'installed', 'updates'] as const).map(name => (
387 <Button key={`tab-${name}`} label={labels[name]} plain dimColor={current !== name} onPress={() => update($, tab, () => name)} />
388 ))}
389 </Box>
390 {failed === null ? null : <Text color="error" wrap="wrap">{`Couldn't read your mods: ${failed}`}</Text>}
391 {found === null ? (
392 <Text dimColor>{loading ? 'Reading your mods…' : 'Nothing read yet.'}</Text>
393 ) : current === 'yours' ? (
394 yoursTab()
395 ) : current === 'installed' ? (
396 installedTab()
397 ) : (
398 updatesTab()
399 )}
400 {switched.length === 0 && !reloadNow ? null : (
401 <Box flexDirection="column">
402 <Text dimColor wrap="wrap">
403 {switched.length === 0 ? 'Plugins were updated. Reload them to finish.' : `Switched ${switched.length}. Installed plugins switch on a plugin reload; your own mods in new sessions.`}
404 </Text>
405 <Box flexDirection="row">
406 <Button key="reload" label="Reload plugins" plain onPress={() => { void update($, needsReload, () => false); void $.prompt.fill({ text: '/reload-plugins', mode: 'replace' }) }} />
407 </Box>
408 </Box>
409 )}
410 </Box>
411 )
412 })
413}
414hooks/shelf.ts 167 lines1import type { Inventory, Mine, Stats } from '../types'
2
3export const FOLDED = 4
4const BARS = '▁▂▃▄▅▆▇█'
5const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
6
7export function parse(stdout: string): { inventory: Inventory } | { error: string } {
8 const line = stdout.trim().split('\n').at(-1) ?? ''
9
10 try {
11 const found = JSON.parse(line) as Partial<Inventory> & { error?: string }
12
13 if (typeof found.error === 'string') {
14 return { error: found.error }
15 }
16
17 return { inventory: { yours: found.yours ?? [], installed: found.installed ?? [], updates: found.updates ?? [], stats: found.stats ?? {}, repo: found.repo ?? null } }
18 } catch {
19 return { error: line === '' ? 'the helper printed nothing' : `the helper printed something else: ${line.slice(0, 100)}` }
20 }
21}
22
23const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`
24
25// ▁▁▁█ from daily counts; an empty day is the lowest bar.
26export function sparkline(daily: number[]): string {
27 const top = Math.max(...daily, 0)
28
29 return daily.map(n => (top === 0 || n === 0 ? BARS[0] : BARS[Math.max(1, Math.round((n / top) * (BARS.length - 1)))])).join('')
30}
31
32const shortDay = (iso: string): string => {
33 const [, m, d] = iso.split('-').map(Number)
34
35 return m === undefined || d === undefined ? iso : `${MONTHS[m - 1]} ${d}`
36}
37
38// The clones card: the number, the line under it, the sparkline.
39export function clonesCard(stats: Stats): { value: string; label: string; spark: string; note: string } | string {
40 if (stats.clones === undefined) {
41 return stats.error === undefined ? 'GitHub numbers load with the list' : `Couldn't read GitHub: ${stats.error}`
42 }
43
44 const daily = stats.daily ?? []
45 const active = daily.filter(n => n > 0).length
46 const note = stats.clones > 0 && active === 1 && stats.peak ? `all on ${shortDay(stats.peak)}` : `${plural(stats.cloners ?? 0, 'source', 'sources')}`
47
48 return { value: String(stats.clones), label: 'clones · 14 days', spark: sparkline(daily), note }
49}
50
51// The views card: the published mods' page views, most viewed first.
52export function viewsCard(stats: Stats, published: Mine[]): { value: string; label: string; note: string } {
53 const views = published.map(mod => ({ mod, n: stats.modViews?.[mod.id] ?? 0 })).sort((a, b) => b.n - a.n)
54 const total = views.reduce((sum, entry) => sum + entry.n, 0)
55 const [top, ...rest] = views
56
57 if (top === undefined) {
58 return { value: '0', label: 'page views', note: 'Nothing published yet' }
59 }
60
61 return {
62 value: String(total),
63 label: `${total === 1 ? 'page view' : 'page views'}${top.n > 0 ? ` · ${top.mod.name}` : ''}`,
64 note: rest.length === 0 ? '14 days' : rest.map(entry => `${entry.n} on ${entry.mod.name}`).join(' · '),
65 }
66}
67
68// The line over the list: how many are on, how the tests stand.
69export function summary(yours: Mine[]): { left: string; right: string } {
70 const on = yours.filter(mod => mod.isOn).length
71 const pass = yours.reduce((sum, mod) => sum + (mod.tests?.pass ?? 0), 0)
72 const fail = yours.reduce((sum, mod) => sum + (mod.tests?.fail ?? 0), 0)
73
74 return {
75 left: `${plural(yours.length, 'mod', 'mods')} · ${on} on`,
76 right: fail > 0 ? `${plural(fail, 'test fails', 'tests fail')}` : `${pass} tests pass`,
77 }
78}
79
80// What a row says on the right: published, else what the mod did, else its tests.
81export function rowStat(mod: Mine): { text: string; tone?: 'success' | 'error' } {
82 if (mod.tests !== null && mod.tests.fail > 0) {
83 return { text: plural(mod.tests.fail, 'test fails', 'tests fail'), tone: 'error' }
84 }
85
86 if (mod.isPublished) {
87 return { text: 'Published', tone: 'success' }
88 }
89
90 if (mod.usage !== null) {
91 return { text: mod.usage }
92 }
93
94 return { text: mod.tests === null ? mod.version : `${mod.tests.pass} tests pass` }
95}
96
97// The opened row's line under its name.
98export function details(mod: Mine, views: number | undefined): string {
99 const parts = [mod.version]
100
101 if (mod.tests !== null) {
102 parts.push(mod.tests.fail > 0 ? plural(mod.tests.fail, 'test fails', 'tests fail') : `${mod.tests.pass} tests pass`)
103 }
104
105 if (mod.isPublished) {
106 parts.push(plural(views ?? 0, 'page view', 'page views'))
107 }
108
109 return parts.join(' · ')
110}
111
112export const tabLabel = (inventory: Inventory | null) => ({
113 yours: `Yours ${inventory?.yours.length ?? ''}`.trim(),
114 installed: `Installed ${inventory?.installed.length ?? ''}`.trim(),
115 updates: `Updates ${inventory === null ? '' : readyOf(inventory).length || ''}`.trim(),
116})
117
118export function restLine(names: string[]): string {
119 if (names.length === 1) {
120 return names[0] ?? ''
121 }
122
123 return `${names[0]} and ${names.length - 1} more`
124}
125
126export function publishPrompt(mod: Mine, repo: string | null | undefined): string {
127 return [
128 `Get my ${mod.name} mod (${mod.dir}) ready to publish${repo ? ` on ${repo}` : ''}, the way my published mods are:`,
129 'check it for personal paths, names, client details and secrets first and tell me what you find;',
130 'then a README, a privacy policy, the license, full plugin metadata, a marketplace entry and a row in the repo README,',
131 'with its tests passing. Stop and ask me before committing or pushing.',
132 ].join(' ')
133}
134
135export const readyOf = (inventory: Inventory | null) => inventory?.updates.filter(item => item.kind === 'ready') ?? []
136
137export function updateVersions(item: { from: string; to: string }): string {
138 return item.from === '' || item.from === item.to ? item.to : `${item.from} → ${item.to}`
139}
140
141// The row's line when the check wrote none.
142export function updateLine(item: { kind: string; line?: string; commits?: number; from: string; to: string }): string {
143 if (item.line !== undefined && item.line !== '') {
144 return item.line
145 }
146
147 if (item.kind === 'review') {
148 return item.commits === undefined ? 'Changed upstream' : plural(item.commits, 'commit', 'commits') + ' upstream'
149 }
150
151 if (item.kind === 'manual') {
152 return 'Needs your password'
153 }
154
155 return item.from === item.to ? 'New commits, same version' : 'New version'
156}
157
158export function applyResult(stdout: string): { done: string[]; failed: Record<string, string>; reload: boolean } | undefined {
159 try {
160 const found = JSON.parse(stdout.trim().split('\n').at(-1) ?? '') as { done?: string[]; failed?: Record<string, string>; reload?: boolean }
161
162 return { done: found.done ?? [], failed: found.failed ?? {}, reload: found.reload === true }
163 } catch {
164 return undefined
165 }
166}
167types/index.d.ts 73 lines1export type Tests = { pass: number; fail: number; stale?: boolean }
2
3// A mod in the mods folder.
4export type Mine = {
5 id: string
6 name: string
7 version: string
8 description: string
9 dir: string
10 isPublished: boolean
11 isOn: boolean
12 // What it has done, from its own store ("1 link judged"), when it keeps such a count.
13 usage: string | null
14 tests: Tests | null
15}
16
17// A plugin installed from a marketplace.
18export type Installed = { id: string; name: string; market: string; version: string; isOn: boolean; update: string | null }
19
20// One thing with a newer upstream, from the Updates mod's report.
21export type Update = {
22 kind: 'ready' | 'review' | 'manual'
23 id: string
24 name: string
25 from: string
26 to: string
27 line?: string
28 // review: the prompt that starts the merge with Claude.
29 review?: string
30 // manual: the command the person runs.
31 command?: string
32 reload?: boolean
33 commits?: number
34}
35
36export type Stats = {
37 cloners?: number
38 clones?: number
39 // Clones per day, oldest first, the last 14 days.
40 daily?: number[]
41 // The day with the most clones, YYYY-MM-DD.
42 peak?: string
43 visitors?: number
44 stars?: number
45 // Page views of each mod's folder on GitHub, 14 days, by mod folder name.
46 modViews?: Record<string, number>
47 error?: string
48}
49
50export type Inventory = { yours: Mine[]; installed: Installed[]; updates: Update[]; stats: Stats; repo?: string | null }
51
52export type Tab = 'yours' | 'installed' | 'updates'
53
54declare module 'claude-code' {
55 interface PluginState {
56 'tree-of-mods': {
57 inventory: Inventory | null
58 error: string | null
59 tab: Tab
60 isLoading: boolean
61 // Switched this session: they apply on a reload or in new sessions.
62 changed: string[]
63 showsAll: boolean
64 // The mod whose details are open.
65 opened: string | null
66 // Updates running now, and why the failed ones failed.
67 updating: string[]
68 failures: Record<string, string>
69 needsReload: boolean
70 }
71 }
72}
73