SLOPSHOPPER

The Tree of Mods

/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…

newpanecommandtoastprocess
v0.1.0Apache-2.0updated 2026-10-04hacksurvivor/claude-mods/mods/tree-of-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tree-of-mods
│ ┃ The Tree of Mods ✕ › fix the failing auth test and add an audit log call │ ┃ Yours Installed Updates │ ┃ ⏺ Read(src/auth.ts) │ ┃ Nothing read yet. ⎿ 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 │ │ › /mods │ ⎿ tree-of-mods: The Tree of Mods is open on the side. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · The Tree of Mods
Yours Installed Updates Nothing read yet.
README

The Tree of Mods

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

The Tree of Mods: the panel beside the chat with the repo's clones, page views, and your mods with their status

  • Yours: the mods you make, published or only on your computer, with their tests and what each has done. Your repo's clones over 14 days and the page views of each published mod, from GitHub. Click a mod for its details and Publish, which asks Claude to prepare it and stops before committing or pushing.
  • Installed: the plugins you installed from marketplaces, with versions and updates.
  • Updates: what Updates found, with Update all, Update per row, Review for adapted copies, and Copy command for ones that need your password.
  • On and Off switch any of them: installed plugins with 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.

Where your mods are

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" }

Requirements

  • Claude Code 2.1.286 or later (mods). The panel docks beside the chat in the Claude desktop app.
  • Python 3, and the GitHub CLI (gh) signed in with access to your repo's traffic, for the numbers.
  • Updates, for the Updates tab.

Install

In Claude Code:

/plugin marketplace add hacksurvivor/claude-mods
/plugin install tree-of-mods@hacksurvivor
/reload-plugins

What it runs, reads and writes

  • Runs 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.
  • Writes only when you ask: a switch changes ~/.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.
  • Sends nothing to anyone but GitHub (the traffic requests) and the services an update you start talks to.
  • Keeps its cache in ~/.claude/tree-of-mods/.

See PRIVACY.md.

License

Apache-2.0

Source 3 files
hooks/register.tsx 414 lines
1import { 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}
414
hooks/shelf.ts 167 lines
1import 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}
167
types/index.d.ts 73 lines
1export 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