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

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

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).
claude plugin install, never with -y, so a marketplace's own commands are never auto-accepted.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.| Key | Where | Does |
|---|---|---|
i, then y | a mod's page | Install, then confirm |
b | a mod's page | Back to the list |
| Tab / arrows, Enter | anywhere | Move between items, open one |
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.
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 609 lines1import { 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}
609types/index.d.ts 41 lines1/** 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