SLOPSHOPPER

mod-store

/mods: browse, search and install community Claude Code mods from a pane, with what each one can access shown before you install

newpaneguardcommandtoasttool
★ 2v0.1.0MITupdated 2026-10-07hellosverre/mod-store
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-store
│ ┃ Mod Store ✕ › fix the failing auth test and add an audit log call │ ┃ Search 2,700 mods… spotify, usage, git, pet │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ ✦ Ask Claude ] [ All ] One-click Display o ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Loading the catalogue… Most stars ▾ ⎿ 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 │ ⎿ mod-store: Mod Store opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mod Store
Search 2,700 mods… spotify, usage, git, pet ⏎ search [ ✦ Ask Claude ] [ All ] One-click Display only Panes Bands Loading the catalogue… Most stars ▾
README

Mod Store for Claude Code

An app store for Claude Code mods, inside Claude Code. Type /mods, search about 2,700 community mods, see what each one can access, and install it with one click. Or just ask Claude: "find me a mod that shows my Spotify song".

Mod Store demo

Install

claude plugin install mod-store --marketplace hellosverre/mod-store

Then run /reload-plugins and type /mods. Needs a Claude Code version with mods (tested on 2.1.292).

What it does

  • Browse and search every public mod in a side pane, sorted by stars, newest, or A to Z. Big repos with dozens of mods don't flood the top: the star sort shows each repo's best mod first.
  • Filter to one-click installs, display-only mods, side panes, or bands above the prompt.
  • See the access before you install. Every mod gets a badge, from green Display only to red Network, and its page lists what it can read, run and see, and where it shows up.
  • Install safely. Install shows the exact command and the mod's permissions, and waits for you to confirm. Mods are installed with claude plugin install, never with -y, so a marketplace's own commands are never auto-accepted.
  • Ask Claude. The mod gives Claude two tools, search_mods and show_mod. Describe what you want and Claude searches, compares a few, explains what each can access, and opens the best one. Claude can't install anything: you press Install yourself.

Keys

KeyWhereDoes
i, then ya mod's pageInstall, then confirm
ba mod's pageBack to the list
Tab / arrows, EnteranywhereMove between items, open one

How it works

The catalogue comes from mods.aidojo.si, which indexes public mods on GitHub, checks them with claude plugin validate, and works out what each one can reach. Mod Store downloads it in pieces (it's 7.5 MB), keeps a slimmed copy for six hours, and checks claude plugin list to mark what you already have.

Mod Store itself uses the network (to fetch the catalogue) and runs claude plugin list and claude plugin install. It reads nothing else on your machine.

Develop

claude plugin validate .
claude plugin test .

License

MIT

Source 2 files
hooks/register.tsx 609 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Filter, Mod, Sort } from '../types'
5
6const PANE = 'mod-store'
7const CATALOG_URL = 'https://mods.aidojo.si/mods.json'
8const CACHE_KEY = 'catalog-v1'
9const MAX_AGE_MS = 6 * 60 * 60 * 1000
10/** The host cuts long bodies short, so the 7.5 MB catalogue comes in ranges. */
11const CHUNK_BYTES = 2_000_000
12const PER_PAGE = 12
13
14const query = atom({ plugin: 'mod-store', key: 'query' } as const, '')
15const sort = atom({ plugin: 'mod-store', key: 'sort' } as const, 'stars')
16const filter = atom({ plugin: 'mod-store', key: 'filter' } as const, 'all')
17const page = atom({ plugin: 'mod-store', key: 'page' } as const, 0)
18const selected = atom({ plugin: 'mod-store', key: 'selected' } as const, null)
19const isConfirming = atom({ plugin: 'mod-store', key: 'isConfirming' } as const, false)
20const isBusy = atom({ plugin: 'mod-store', key: 'isBusy' } as const, false)
21const status = atom({ plugin: 'mod-store', key: 'status' } as const, '')
22const loadedAt = atom({ plugin: 'mod-store', key: 'loadedAt' } as const, 0)
23const installed = atom({ plugin: 'mod-store', key: 'installed' } as const, [])
24
25/** Access levels, least to most: what the badge says and its color. */
26const LEVELS = [
27  { badge: 'Display only', color: 'green' },
28  { badge: 'Reads', color: 'cyan' },
29  { badge: 'Writes & runs', color: 'yellow' },
30  { badge: 'Network', color: 'red' },
31]
32
33const SORTS = [
34  { value: 'stars', label: 'Most stars' },
35  { value: 'new', label: 'Newest' },
36  { value: 'name', label: 'A to Z' },
37]
38const CHIPS: { value: Filter; label: string }[] = [
39  { value: 'all', label: 'All' },
40  { value: 'installable', label: 'One-click' },
41  { value: 'safe', label: 'Display only' },
42  { value: 'pane', label: 'Panes' },
43  { value: 'band', label: 'Bands' },
44]
45
46/** Where a mod shows up, in words. */
47const PLACES: Record<string, string> = {
48  Pane: 'a side pane',
49  AbovePrompt: 'a band above the prompt',
50  PromptHint: 'the prompt hint',
51  SessionMode: 'the session mode line',
52  AssistantMessage: "Claude's replies",
53  UserMessage: 'your messages',
54  Spinner: 'the spinner',
55  ToolUse: 'tool rows',
56  ToolResult: 'tool results',
57  CommandOutput: 'command output',
58}
59
60/** The catalogue, slimmed. A module variable: rebuilt from the store on reload. */
61let catalog: Mod[] = []
62
63/** Turns one catalogue record into the fields the pane draws. */
64export function slim(record: any): Mod | null {
65  if (record?.kind !== 'mod' || record.archived) return null
66  const root = (record.marketplaces ?? []).find(
67    (m: any) => m.path === '.claude-plugin/marketplace.json' && m.status !== 'failed',
68  )
69  return {
70    id: String(record.id),
71    name: String(record.name),
72    repo: String(record.repo),
73    description: String(record.description ?? '').slice(0, 240),
74    author: String(record.author ?? record.repo.split('/')[0]),
75    url: String(record.url ?? `https://github.com/${record.repo}`),
76    stars: Number(record.stars ?? 0),
77    createdAt: String(record.createdAt ?? ''),
78    level: Number(record.reach?.level ?? 2),
79    levelName: String(record.reach?.name ?? 'unknown'),
80    labels: (record.reach?.labels ?? []).filter((l: string) => !l.startsWith('other:')),
81    sees: record.sees ?? [],
82    draws: record.draws ?? [],
83    validate: String(record.validate?.status ?? 'unknown'),
84    marketplace: root ? String(root.name) : null,
85  }
86}
87
88/** The mods a query, filter and sort select, in order. */
89export function select(mods: Mod[], q: string, f: Filter, s: Sort): Mod[] {
90  const words = q.toLowerCase().split(/\s+/).filter(Boolean)
91  const kept = mods.filter(mod => {
92    if (f === 'installable' && !mod.marketplace) return false
93    if (f === 'safe' && mod.level > 0) return false
94    if (f === 'pane' && !mod.draws.includes('Pane')) return false
95    if (f === 'band' && !mod.draws.includes('AbovePrompt')) return false
96    const hay = `${mod.name} ${mod.description} ${mod.author} ${mod.repo}`.toLowerCase()
97    return words.every(w => hay.includes(w))
98  })
99  const order: Record<Sort, (a: Mod, b: Mod) => number> = {
100    stars: (a, b) => b.stars - a.stars || a.name.localeCompare(b.name),
101    new: (a, b) => b.createdAt.localeCompare(a.createdAt),
102    name: (a, b) => a.name.localeCompare(b.name),
103  }
104  const sorted = kept.sort(order[s])
105  if (s !== 'stars') return sorted
106  // Stars belong to a repo, not a mod: one big repo with 45 mods would fill
107  // every page. Take each repo's best first, then each one's second, and so on.
108  const seen = new Map<string, number>()
109  const turn = new Map(sorted.map(mod => {
110    const n = seen.get(mod.repo) ?? 0
111    seen.set(mod.repo, n + 1)
112    return [mod, n] as const
113  }))
114  return sorted.sort((a, b) => turn.get(a)! - turn.get(b)! || order.stars(a, b))
115}
116
117/** The id `claude plugin list` names an installed mod by. */
118const pluginId = (mod: Mod) => `${mod.name}@${mod.marketplace}`
119
120const level = (mod: Mod) => LEVELS[mod.level] ?? LEVELS[2]!
121
122const plural = (n: number, word: string) => `${n.toLocaleString('en-US')} ${word}${n === 1 ? '' : 's'}`
123
124const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text)
125
126/** Reads the whole catalogue a range at a time, since one body is cut short. */
127async function fetchCatalogText($: EngineInterface): Promise<string> {
128  let text = ''
129  let total = Infinity
130  for (let start = 0; start < total; start += CHUNK_BYTES) {
131    const res = await $.http.fetch(CATALOG_URL, {
132      headers: { Range: `bytes=${start}-${start + CHUNK_BYTES - 1}`, 'Accept-Encoding': 'identity' },
133    })
134    if (res.status === 200) return res.text
135    if (res.status !== 206) throw new Error(`HTTP ${res.status}`)
136    const size = /\/(\d+)\s*$/.exec(res.headers['content-range'] ?? '')
137    if (!size) throw new Error('the server sent no Content-Range')
138    total = Number(size[1])
139    text += res.text
140  }
141  return text
142}
143
144async function loadCatalog($: EngineInterface, force = false) {
145  const cached = (await $.store.get(CACHE_KEY)) as { at: number; mods: Mod[] } | undefined
146  if (cached && catalog.length === 0) {
147    catalog = cached.mods
148    await update($, loadedAt, () => cached.at)
149  }
150  const now = await $.clock.now()
151  if (!force && cached && now - cached.at < MAX_AGE_MS) return
152
153  await update($, status, () => (catalog.length ? 'Refreshing…' : ''))
154  try {
155    const data = JSON.parse(await fetchCatalogText($))
156    const mods: Mod[] = []
157    const seen = new Set<string>()
158    for (const record of data.mods ?? []) {
159      const mod = slim(record)
160      if (!mod || seen.has(mod.id)) continue
161      seen.add(mod.id)
162      mods.push(mod)
163    }
164    catalog = mods
165    await $.store.set(CACHE_KEY, { at: now, mods })
166    await update($, loadedAt, () => now)
167    await update($, status, () => '')
168  } catch (error) {
169    const why = error instanceof Error ? error.message : String(error)
170    await update($, status, () => `Couldn't load the catalogue (${why})`)
171  }
172}
173
174async function loadInstalled($: EngineInterface) {
175  try {
176    const { exitCode, stdout } = await $.process.run(['claude', 'plugin', 'list', '--json'])
177    if (exitCode !== 0) return
178    const ids = (JSON.parse(stdout) as { id: string }[]).map(p => p.id)
179    await update($, installed, () => ids)
180  } catch {
181    // Not fatal: the pane just shows no "installed" marks.
182  }
183}
184
185async function install($: EngineInterface, mod: Mod) {
186  if (!mod.marketplace) return
187  await update($, isBusy, () => true)
188  await update($, status, () => '')
189  try {
190    const { exitCode, stdout, stderr } = await $.process.run(
191      ['claude', 'plugin', 'install', mod.name, '--marketplace', mod.repo],
192      { timeoutMs: 120_000 },
193    )
194    if (exitCode === 0) {
195      $.ui.toast(`Installed ${mod.name}. Run /reload-plugins to load it.`)
196      await loadInstalled($)
197    } else {
198      const why = (stderr || stdout).trim().split('\n').slice(-3).join(' ')
199      await update($, status, () => `Install failed: ${why}`)
200    }
201  } catch (error) {
202    await update($, status, () => `Install failed: ${error instanceof Error ? error.message : error}`)
203  }
204  await update($, isConfirming, () => false)
205  await update($, isBusy, () => false)
206}
207
208/** One mod as Claude reads it in a tool result. */
209function describe(mod: Mod, owned: Set<string>): string {
210  const tier = level(mod)
211  const how = !mod.marketplace ? 'manual install' : owned.has(pluginId(mod)) ? 'installed' : 'one-click install'
212  return [
213    `- ${mod.name} (id ${mod.id}) by ${mod.author}, ★${mod.stars} for ${mod.repo}, ${how}`,
214    `  access: ${tier.badge}${mod.labels.length ? ` (${mod.labels.join(', ')})` : ''}${mod.draws.length ? `; shows in ${mod.draws.map(d => PLACES[d] ?? d).join(', ')}` : ''}`,
215    `  ${mod.description || 'No description.'}`,
216  ].join('\n')
217}
218
219export const register: Register = on => {
220  on('session.start', async ($, e, next) => {
221    await $.command.register({
222      name: 'mods',
223      description: 'Browse and install community Claude Code mods',
224      argumentHint: '[search]',
225    })
226    await $.tool.register({
227      name: 'search_mods',
228      description:
229        'Search the community catalogue of Claude Code mods (plugins that add panes, bands, commands and hooks). ' +
230        'Use it when the person wants a mod for something, or asks what mods exist. Every word of the query must match ' +
231        "a mod's name, description, author or repo, so search with one or two short keywords and try synonyms. " +
232        "Results list each mod's id, stars (the whole repo's, shared by its mods), what it can access, and whether it installs in one click. " +
233        'Then call show_mod with the best fit, so the person can read it and press Install themselves.',
234      inputSchema: {
235        type: 'object',
236        properties: {
237          query: { type: 'string', description: 'Keywords, e.g. "spotify" or "git branch". Empty lists the most starred.' },
238          filter: {
239            type: 'string',
240            enum: ['all', 'installable', 'safe', 'pane', 'band'],
241            description: 'installable: one-click only; safe: display-only mods; pane: side panes; band: bands above the prompt',
242          },
243          limit: { type: 'number', description: 'How many to return, 1 to 25 (default 10)' },
244        },
245        required: ['query'],
246      },
247    })
248    await $.tool.register({
249      name: 'show_mod',
250      description:
251        "Opens a mod's page in the Mod Store pane, where the person sees what it can access and installs it with a button. " +
252        'Takes an id from search_mods. It never installs anything.',
253      inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
254    })
255    void loadCatalog($)
256
257    return next(e)
258  })
259
260  on('tool.call', { tool: 'mcp__mod-store__search_mods' }, async ($, e) => {
261    const input = e as unknown as { query?: string; filter?: Filter; limit?: number }
262    await loadCatalog($)
263    if (catalog.length === 0) return { deny: (await read($, status)) || 'The mod catalogue is not loaded yet.' }
264    const limit = Math.min(25, Math.max(1, Math.round(input.limit ?? 10)))
265    const found = select(catalog, input.query ?? '', input.filter ?? 'all', 'stars')
266    const owned = new Set(await read($, installed))
267    const text = found.length
268      ? `${found.length} match${found.length === 1 ? '' : 'es'}, best ${Math.min(limit, found.length)}:\n` +
269        found.slice(0, limit).map(mod => describe(mod, owned)).join('\n')
270      : 'No mods match. Try one shorter keyword or a synonym.'
271    return { result: text }
272  })
273
274  on('tool.call', { tool: 'mcp__mod-store__show_mod' }, async ($, e) => {
275    const { id } = e as unknown as { id: string }
276    await loadCatalog($)
277    const mod = catalog.find(m => m.id === id) ?? catalog.find(m => m.name === id)
278    if (!mod) return { deny: `No mod with id ${id}. Use an id from search_mods.` }
279    await update($, isConfirming, () => false)
280    await update($, selected, () => mod.id)
281    await $.ui.open({ id: PANE, title: 'Mod Store', columns: 96, rows: 32 })
282    return { result: `Opened ${mod.name} in the Mod Store pane. The person installs it from there.` }
283  })
284
285  on('command.run', { command: 'mods' }, async ($, e) => {
286    const q = (e.args ?? '').trim()
287    await update($, query, () => q)
288    await update($, page, () => 0)
289    await update($, selected, () => null)
290    await $.ui.open({ id: PANE, title: 'Mod Store', focus: true, columns: 96, rows: 32 })
291    void loadCatalog($)
292    void loadInstalled($)
293
294    return { text: q ? `Mod Store: searching "${q}".` : 'Mod Store opened.' }
295  })
296
297  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
298    const elements = $.ui.resolve(e)
299    const { Box, Text, Button, Link } = elements
300    // The mobile app draws no fields yet: it gets the list and paging alone.
301    const Input = 'Input' in elements ? elements.Input : undefined
302    const Select = 'Select' in elements ? elements.Select : undefined
303
304    await read($, loadedAt)
305    const note = await read($, status)
306    const isTerminal = e.surface === 'terminal'
307    const width = Math.max(30, e.props.bodyColumns ?? e.viewport?.columns ?? 60)
308    const owned = new Set(await read($, installed))
309    const pickedId = await read($, selected)
310    const picked = pickedId ? catalog.find(m => m.id === pickedId) : undefined
311
312    // ── Details ──────────────────────────────────────────────────────────
313    if (picked) {
314      const tier = level(picked)
315      const isOwned = picked.marketplace !== null && owned.has(pluginId(picked))
316      const confirming = await read($, isConfirming)
317      const busy = await read($, isBusy)
318      const places = picked.draws.map(d => PLACES[d] ?? d)
319
320      return (
321        <Box flexDirection="column" gap={1}>
322          <Button
323            key="back"
324            label="← All mods"
325            plain
326            dimColor
327            hotkey="b"
328            onPress={async () => {
329              await update($, isConfirming, () => false)
330              await update($, selected, () => null)
331              await update($, status, () => '')
332            }}
333          />
334
335          <Box flexDirection="column">
336            <Text bold>{picked.name}</Text>
337            <Text dimColor>
338              {picked.author} · <Text color="yellow">★ {picked.stars.toLocaleString('en-US')}</Text>
339              {picked.createdAt ? ` · ${picked.createdAt.slice(0, 10)}` : ''}
340            </Text>
341          </Box>
342
343          <Text wrap="wrap">{picked.description}</Text>
344
345          <Box gap={2} alignItems="center">
346            {isOwned ? (
347              <Text color="green" bold>
348                ✓ Installed
349              </Text>
350            ) : !picked.marketplace ? (
351              <Text dimColor>Manual install: see its README</Text>
352            ) : busy ? (
353              <Text color="cyan">Installing…</Text>
354            ) : confirming ? (
355              <Button key="confirm" label="Confirm install" variant="primary" hotkey="y" autoFocus onPress={() => install($, picked)} />
356            ) : (
357              <Button key="install" label="Install" variant="primary" hotkey="i" onPress={() => update($, isConfirming, () => true)} />
358            )}
359            {confirming && !busy && (
360              <Button key="cancel" label="Cancel" plain dimColor onPress={() => update($, isConfirming, () => false)} />
361            )}
362            <Link href={picked.url} label="View source ↗" />
363          </Box>
364
365          {confirming && (
366            <Box flexDirection="column" borderStyle="round" borderColor={tier.color} paddingX={1}>
367              <Text bold>Install {picked.name}?</Text>
368              <Text dimColor wrap="wrap">
369                Runs: claude plugin install {picked.name} --marketplace {picked.repo}
370              </Text>
371              <Text wrap="wrap">
372                It will be able to: <Text color={tier.color}>{picked.labels.join(', ') || 'draw only'}</Text>
373              </Text>
374            </Box>
375          )}
376
377          {note !== '' && (
378            <Text color="red" wrap="wrap">
379              {note}
380            </Text>
381          )}
382
383          <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
384            <Box justifyContent="space-between">
385              <Text bold>What it can access</Text>
386              <Text color={tier.color} bold>
387                ● {tier.badge}
388              </Text>
389            </Box>
390            {picked.labels.length === 0 && <Text dimColor>Nothing beyond drawing.</Text>}
391            {picked.labels.map(label => (
392              <Text key={`can-${label}`} dimColor>
393                {'  '}• {label}
394              </Text>
395            ))}
396            {picked.sees.length > 0 && (
397              <Text dimColor wrap="wrap">
398                {'  '}• sees {picked.sees.join(', ')}
399              </Text>
400            )}
401            {places.length > 0 && (
402              <Text dimColor wrap="wrap">
403                {'  '}• shows up in {places.join(', ')}
404              </Text>
405            )}
406            <Text dimColor>
407              {'  '}• validation{' '}
408              <Text color={picked.validate === 'passed' ? 'green' : 'yellow'}>{picked.validate}</Text>
409            </Text>
410          </Box>
411        </Box>
412      )
413    }
414
415    // ── List ─────────────────────────────────────────────────────────────
416    const q = await read($, query)
417    const f = (await read($, filter)) as Filter
418    const s = (await read($, sort)) as Sort
419    const matches = select(catalog, q, f, s)
420    const pages = Math.max(1, Math.ceil(matches.length / PER_PAGE))
421    const at = Math.min(await read($, page), pages - 1)
422    const shown = matches.slice(at * PER_PAGE, (at + 1) * PER_PAGE)
423    const isLoading = catalog.length === 0 && note === ''
424    // A card's inside: the pane less its border and padding. Labels are padded to
425    // it so the whole card, not just the words, is a click target.
426    const inner = width - 4
427    const fill = (text: string, cells: number) => (isTerminal ? text.padEnd(cells, '\u00a0') : text)
428
429    return (
430      <Box flexDirection="column" gap={1}>
431        {Input && (
432          <Box flexDirection="column" width="100%">
433            <Input
434              key="search"
435              placeholder="Search 2,700 mods…  spotify, usage, git, pet"
436              value={q}
437              submitLabel="search"
438              autoFocus
439              onInput={async value => {
440                await update($, query, () => value)
441                await update($, page, () => 0)
442              }}
443              onSubmit={value => update($, query, () => value)}
444            />
445          </Box>
446        )}
447
448        <Box flexWrap="wrap" columnGap={1}>
449          <Button
450            key="ask"
451            label="✦ Ask Claude"
452            onPress={() =>
453              q.trim()
454                ? $.prompt.submit({
455                    text: `Help me find a Claude Code mod for: ${q.trim()}. Search the mod catalogue, compare the best few (what each does and can access), then open your top pick.`,
456                  })
457                : $.prompt.fill({ text: 'Find me a Claude Code mod that ', mode: 'replace' })
458            }
459          />
460          {CHIPS.map(chip =>
461            chip.value === f ? (
462              <Button key={`chip-${chip.value}`} label={chip.label} variant="primary" onPress={() => {}} />
463            ) : (
464              <Button
465                key={`chip-${chip.value}`}
466                label={chip.label}
467                plain
468                dimColor
469                onPress={async () => {
470                  await update($, filter, () => chip.value)
471                  await update($, page, () => 0)
472                }}
473              />
474            ),
475          )}
476        </Box>
477
478        <Box justifyContent="space-between" alignItems="center">
479          <Text dimColor>
480            {isLoading ? 'Loading the catalogue…' : catalog.length === 0 ? '' : plural(matches.length, 'mod')}
481          </Text>
482          {Select && (
483            <Select
484              key="sort"
485              options={SORTS}
486              value={s}
487              onSelect={async v => {
488                await update($, sort, () => v as Sort)
489                await update($, page, () => 0)
490              }}
491            />
492          )}
493        </Box>
494
495        {note !== '' && (
496          <Box gap={2}>
497            <Text color={catalog.length ? 'yellow' : 'red'} wrap="wrap">
498              {note}
499            </Text>
500            {catalog.length === 0 && <Button key="retry" label="Retry" onPress={() => loadCatalog($, true)} />}
501          </Box>
502        )}
503
504        {!isLoading && catalog.length > 0 && matches.length === 0 && (
505          <Text dimColor>No mods match. Try fewer words or another filter.</Text>
506        )}
507
508        <Box flexDirection="column" gap={0}>
509          {shown.map(mod => {
510            const tier = level(mod)
511            const isOwned = mod.marketplace !== null && owned.has(pluginId(mod))
512            const open = () => update($, selected, () => mod.id)
513            const stars = `★ ${mod.stars.toLocaleString('en-US')}`
514            const badge = `● ${tier.badge}   `
515            const owns = isOwned ? '✓ Installed   ' : ''
516            const footRoom = inner - badge.length - owns.length
517            return (
518              <Box
519                key={`card-${mod.id}`}
520                flexDirection="column"
521                borderStyle="round"
522                borderDimColor
523                paddingX={1}
524                hover={{ borderColor: 'cyan', borderDimColor: false }}
525              >
526                {!isTerminal ? (
527                  <>
528                    <Box justifyContent="space-between">
529                      <Button key={`open-${mod.id}`} label={clip(mod.name, 40)} plain onPress={open} />
530                      <Text color="yellow">{stars}</Text>
531                    </Box>
532                    <Text dimColor wrap="wrap">
533                      {clip(mod.description || 'No description.', 140)}
534                    </Text>
535                    <Box justifyContent="space-between">
536                      <Box columnGap={2}>
537                        <Text color={tier.color}>● {tier.badge}</Text>
538                        <Text dimColor>{clip(mod.author, 22)}</Text>
539                        {isOwned && <Text color="green">✓ Installed</Text>}
540                      </Box>
541                      <Button key={`more-${mod.id}`} label="Details ›" plain dimColor onPress={open} />
542                    </Box>
543                  </>
544                ) : (
545                  <>
546                <Box>
547                  <Button key={`open-${mod.id}`} label={fill(clip(mod.name, inner - stars.length - 2), inner - stars.length)} plain onPress={open} />
548                  <Text color="yellow">{stars}</Text>
549                </Box>
550                <Button
551                  key={`desc-${mod.id}`}
552                  label={fill(clip(mod.description || 'No description.', inner), inner)}
553                  plain
554                  dimColor
555                  onPress={open}
556                />
557                <Box>
558                  <Text color={tier.color}>{badge}</Text>
559                  {isOwned && <Text color="green">{owns}</Text>}
560                  <Button
561                    key={`more-${mod.id}`}
562                    label={fill(clip(mod.author, Math.max(4, footRoom - 12)), footRoom - 9) + 'Details ›'}
563                    plain
564                    dimColor
565                    onPress={open}
566                  />
567                </Box>
568                  </>
569                )}
570              </Box>
571            )
572          })}
573        </Box>
574
575        {pages > 1 && (
576          <Box justifyContent="space-between" alignItems="center">
577            <Button
578              key="prev"
579              label="‹ Prev"
580              plain
581              dimColor={at === 0}
582              onPress={() => update($, page, n => Math.max(0, n - 1))}
583            />
584            <Text dimColor>
585              {at * PER_PAGE + 1}–{Math.min(matches.length, (at + 1) * PER_PAGE)} of {matches.length.toLocaleString('en-US')}
586            </Text>
587            <Button
588              key="next"
589              label="Next ›"
590              plain
591              dimColor={at >= pages - 1}
592              onPress={() => update($, page, n => Math.min(pages - 1, n + 1))}
593            />
594          </Box>
595        )}
596
597        {catalog.length > 0 && (
598          <Box justifyContent="space-between">
599            <Text dimColor>
600              Catalogue by <Link href="https://mods.aidojo.si" label="mods.aidojo.si" />
601            </Text>
602            <Button key="refresh" label="↻ Refresh" plain dimColor onPress={() => loadCatalog($, true)} />
603          </Box>
604        )}
605      </Box>
606    )
607  })
608}
609
types/index.d.ts 41 lines
1/** One mod, slimmed from the catalogue's record. */
2export type Mod = {
3  id: string
4  name: string
5  repo: string
6  description: string
7  author: string
8  url: string
9  stars: number
10  createdAt: string
11  /** 0 draws and remembers, 1 reads, 2 writes or runs, 3 network */
12  level: number
13  levelName: string
14  labels: string[]
15  sees: string[]
16  draws: string[]
17  validate: string
18  /** The marketplace name to install from, when the repo root has one. */
19  marketplace: string | null
20}
21
22export type Sort = 'stars' | 'new' | 'name'
23export type Filter = 'all' | 'installable' | 'safe' | 'pane' | 'band'
24
25declare module 'claude-code' {
26  interface PluginState {
27    'mod-store': {
28      query: string
29      sort: Sort
30      filter: Filter
31      page: number
32      selected: string | null
33      isConfirming: boolean
34      isBusy: boolean
35      status: string
36      loadedAt: number
37      installed: string[]
38    }
39  }
40}
41