/aitmpl opens a side pane for the aitmpl.com catalog: search box, the site's Browse menu with an icon, colour and count per type, an Installed section (agents…

/aitmpl opens a side pane laid out like VS Code's Extensions view for the catalog of aitmpl.com: a search box, a Browse menu like the site's sidebar (one line per type, with its count), an Installed section, a Popular list (or Results while you search), the download count on every row and a button that opens the component's page. In the fullscreen layout (/tui fullscreen) Claude Code docks it beside the transcript; otherwise it sits above the prompt. Claude Desktop and VS Code draw it in their own pane.
aitmpl.com refresh close
2.2k components · 1.6M downloads
[ Search components on aitmpl.com ] [search]
BROWSE
▌✦ All [ 1919 ]
◆ Skills [ 889 ]
◉ Agents [ 422 ]
❯ Commands [ 288 ]
⚙ Settings [ 72 ]
↪ Hooks [ 62 ]
⬡ MCPs [ 104 ]
▣ Mods [ 39 ]
↻ Loops [ 18 ]
────────────────────────────────────────────────
▾ INSTALLED [ 3 ]
◉ frontend-developer agent
Frontend specialist for React apps…
↓ 3.9k · development-team view ↗ ✓
Each type has its own icon and ANSI colour (skills magenta, agents blue, commands green, settings yellow, hooks red, MCPs cyan, mods bright magenta, loops bright yellow), used by the menu, the rows and the detail; counts are pills, downloads are yellow, install is the primary button and a ✓ marks what is on disk. Colours are ANSI names, so the terminal's theme chooses the shade. Sections collapse when you press their heading.
counts.json; the chosen type narrows both sections..claude/ winning over ~/.claude/: agents (agents/*.md), commands (commands/**.md), skills and mods (skills/{name}/; a directory with .claude-plugin/plugin.json is a mod). Hooks, settings, MCPs and loops are merged into JSON files, so they cannot be told apart by name and are not listed. A name found on disk that the catalog does not know is shown as local. The list is rescanned when the pane opens, after an install and at the end of every turn.view opens the component's page on aitmpl.com in your browser (open on macOS, xdg-open on Linux, explorer.exe on Windows, each an argv; the URL is http(s) only) and install (installed once it is on disk). A row's name opens its detail: description, the install command, install here ($.process.run(["npx", "claude-code-templates@latest", "--agent", "…", "--yes"]): always the fixed CLI, the type's own flag and a validated path, never a command string from the catalog, never a shell), open on aitmpl.com and put command in prompt ($.prompt.fill, nothing is submitted).downloads field of the site's components/{type}.json: the total per component the catalog generator publishes (the header totals come from trending-data.json). The catalog is fetched live through $.http.fetch and cached for the session; refresh reloads it.Browsing costs no tokens: nothing the pane draws enters the transcript.
/aitmpl open the pane
/aitmpl skills open it filtered to a type (agents, commands, mcps, settings, hooks, skills, loops, mods)
/aitmpl react perf open it with a search
/aitmpl stop close
The search box takes the keyboard when the pane opens; Esc returns it, and then Tab / Enter (or a click) press the buttons, and r refreshes, b goes back from a detail, i / o / c install, open and copy the command.
Every cell that has to line up is a fixed-width box and every run of spaces in a label is a no-break space off the terminal, because Claude Desktop sizes a cell to its text and collapses spaces. The mobile surface has no Input element yet and draws the engine's own pane instead.
| Event | What it does |
|---|---|
session.start | registers /aitmpl ($.command.register) |
command.run { command: "aitmpl" } | scans what is installed, opens the pane ($.ui.open, focused, docked at columns); stop closes it |
ui.render { component: "Pane", requestId: "aitmpl" } | draws the pane from $.ui.resolve(e) (Box, Text, Button, Input); the fetches start from its closures and $.ui.invalidate("ui.render") repaints when they land; a fetch that failed is retried only by refresh, never by a repaint |
turn.complete | rescans what is installed (Claude may have installed something) |
ui.close | notes the pane is closed |
The mod calls $.http.fetch (the site's JSON), $.fs.list and $.fs.exists (the installed scan, read-only), $.process.run (from the install button, with the fixed CLI argv, and from the open-in-browser button, with the platform's URL opener), $.prompt.fill, $.env.get (HOME, USERPROFILE, OS), $.ui.open, $.ui.close, $.ui.toast, $.ui.status and $.ui.log. An admin-capability-lockdown mod that withholds http or process refuses it at plugin.register, by design.
siteUrl: string the site whose catalog is browsed (default "https://www.aitmpl.com")
pageSize: number rows shown per section before "show more" (default 8)
columns: number width asked for the docked pane, 28-120 (default 48)
Declared in .claude-plugin/plugin.json (userConfig). Set them in /config, in user settings (~/.claude/settings.json, not project settings), with --settings <file> or in managed settings:
{ "pluginConfigs": { "aitmpl@skills-dir": { "options": { "pageSize": 12 } } } }
npx claude-code-templates@latest --mod productivity/aitmpl
claude
It is written to .claude/skills/aitmpl/, which Claude Code auto-loads as aitmpl@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/aitmpl. claude plugin validate .claude/skills/aitmpl prints every event it hooks and every $ call it makes; claude plugin test .claude/skills/aitmpl runs the tests (catalog helpers and the pane on the terminal and desktop surfaces). A project mod loads only once the workspace trust prompt is accepted; ~/.claude/skills/aitmpl/ always loads.
Requirements. Mods are on by default in Claude Code 2.1.287+. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
hooks/register.tsx 632 lines1/* @jsx h */
2/**
3 * aitmpl — Claude Mod
4 *
5 * `/aitmpl [query | type | stop]` opens a side pane laid out like VS Code's
6 * Extensions view: a search box, quick filters for the component types of
7 * aitmpl.com, an Installed section (what is already under `.claude/` and
8 * `~/.claude/`), and a Popular / Results section. Every row carries its
9 * download count and a button that opens the component's page in the
10 * browser; a row's name opens its detail, with install, put-the-command-in-
11 * the-prompt and open-on-aitmpl.com.
12 *
13 * The catalog is read live from the site's public JSON (`trending-data.json`,
14 * `components/{type}.json`) through `$.http.fetch` and cached for the session;
15 * `downloads` is the per-component total the catalog generator publishes. In
16 * the fullscreen terminal layout the engine docks the pane beside the
17 * transcript, otherwise it sits above the prompt; Claude Desktop and VS Code
18 * draw it in their own pane. Browsing costs no tokens.
19 *
20 * Installed detection reads agents (`agents/*.md`), commands (`commands/**.md`),
21 * skills and mods (`skills/{name}/`; a directory holding
22 * `.claude-plugin/plugin.json` is a mod) in `.claude/` and `~/.claude/`. Hooks,
23 * settings, MCPs and loops are merged into JSON files, so they are not listed.
24 *
25 * Layout: every cell that must line up is a fixed-width Box and every run of
26 * spaces inside a label is a no-break space off the terminal, since HTML
27 * surfaces size a cell to its text and collapse spaces.
28 *
29 * Needs Claude Code >= 2.1.287. Typed
30 * against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
31 *
32 * Options:
33 * siteUrl: string the site whose catalog is browsed (default "https://www.aitmpl.com")
34 * pageSize: number rows shown per section before "show more" (default 8)
35 * columns: number width asked for the docked pane (default 48)
36 */
37import type { Register, RenderElement } from 'claude-code'
38import {
39 TYPES,
40 catalogItemFor,
41 dedupeInstalled,
42 filterInstalled,
43 formatCount,
44 installArgv,
45 installCommandFor,
46 isInstalled,
47 itemKey,
48 nbsp,
49 parseCounts,
50 parseItems,
51 parseTrending,
52 searchEntries,
53 sortByDownloads,
54 stripExt,
55 truncate,
56 TYPE_STYLE,
57 typeByKey,
58 webUrlFor,
59 type GlobalStats,
60 type Installed,
61 type Item,
62 type Scope,
63 type TypeInfo,
64 type TypeKey,
65} from './catalog.ts'
66
67const PANE = 'aitmpl'
68// the order of the site's Browse menu
69const MENU: readonly TypeKey[] = ['skills', 'agents', 'commands', 'settings', 'hooks', 'mcps', 'mods', 'loops']
70
71type View = { kind: 'browse' } | { kind: 'detail'; type: TypeInfo; item: Item }
72type Entry = { name: string; kind: 'file' | 'dir' | 'other' }
73
74let isOpen = false
75let view: View = { kind: 'browse' }
76let filter: TypeKey | 'all' = 'all'
77let query = ''
78let installed: Installed[] = []
79let stats: GlobalStats | undefined
80let counts: Record<string, number> | undefined
81const cache = new Map<TypeKey, Item[]>()
82const loading = new Set<string>()
83// a fetch that failed is not retried by a repaint: only refresh (or a new /aitmpl) clears it
84const failed = new Set<string>()
85let error: string | undefined
86// what the last action did (install, open), drawn under the header
87let notice: { text: string; tone: 'info' | 'ok' | 'bad' } | undefined
88let installing = false
89let homeDir: string | undefined
90const collapsed = { installed: false, list: false }
91let shown = 0
92
93const singular = (type: TypeInfo) => type.label.toLowerCase().replace(/s$/, '')
94const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
95
96// What is under `.claude/` of the project and of the user, by argv-free file listing.
97async function scanInstalled(
98 list: (path: string) => Promise<Entry[]>,
99 exists: (path: string) => Promise<boolean>,
100 home: string | undefined,
101): Promise<Installed[]> {
102 const roots: { root: string; scope: Scope }[] = [{ root: '.claude', scope: 'project' }]
103 if (home) roots.push({ root: `${home.replace(/[\\/]+$/, '')}/.claude`, scope: 'user' })
104 const out: Installed[] = []
105 const safe = (path: string) => list(path).catch((): Entry[] => [])
106
107 for (const { root, scope } of roots) {
108 // agents and commands are `.md` files, possibly one directory deep (commands/{category}/x.md)
109 for (const kind of ['agents', 'commands'] as const) {
110 const dir = `${root}/${kind}`
111 for (const entry of await safe(dir)) {
112 if (entry.kind === 'file' && entry.name.endsWith('.md')) out.push({ type: kind, name: stripExt(entry.name), scope })
113 else if (entry.kind === 'dir') {
114 for (const inner of await safe(`${dir}/${entry.name}`)) {
115 if (inner.kind === 'file' && inner.name.endsWith('.md')) out.push({ type: kind, name: stripExt(inner.name), scope })
116 }
117 }
118 }
119 }
120 // skills and mods share `skills/`: a plugin manifest makes it a mod
121 for (const entry of await safe(`${root}/skills`)) {
122 if (entry.kind === 'file') continue
123 const isMod = await exists(`${root}/skills/${entry.name}/.claude-plugin/plugin.json`).catch(() => false)
124 out.push({ type: isMod ? 'mods' : 'skills', name: entry.name, scope })
125 }
126 }
127 return dedupeInstalled(out)
128}
129
130export const register: Register = (on, options) => {
131 const siteUrl = typeof options.siteUrl === 'string' && options.siteUrl ? options.siteUrl : 'https://www.aitmpl.com'
132 const pageSize = typeof options.pageSize === 'number' && options.pageSize > 0 ? Math.floor(options.pageSize) : 8
133 const columns =
134 typeof options.columns === 'number' && options.columns >= 28 && options.columns <= 120 ? Math.floor(options.columns) : 48
135 const dataUrl = (file: string) => `${siteUrl.replace(/\/+$/, '')}/${file}`
136
137 on('session.start', async ($, e, next) => {
138 const r = await next(e)
139 await $.command
140 .register({
141 name: 'aitmpl',
142 description: 'aitmpl.com components in a side pane: search, filter by type, installed, downloads (stop closes)',
143 argumentHint: '[query | type | stop]',
144 immediate: true,
145 })
146 .catch(err => $.ui.log(`aitmpl: /aitmpl not registered: ${err}`))
147 return r
148 })
149
150 on('command.run', { command: 'aitmpl' }, async ($, e) => {
151 const arg = e.args.trim()
152 if (/^(stop|close|quit)$/i.test(arg)) {
153 await $.ui.close({ id: PANE }).catch(() => undefined)
154 isOpen = false
155 return { text: 'aitmpl pane closed' }
156 }
157 isOpen = true
158 error = undefined
159 notice = undefined
160 failed.clear()
161 view = { kind: 'browse' }
162 shown = 0
163 const type = arg ? typeByKey(arg.toLowerCase()) : undefined
164 filter = type ? type.key : 'all'
165 query = arg && !type ? arg : ''
166 homeDir = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
167 installed = await scanInstalled(
168 path => $.fs.list(path),
169 path => $.fs.exists(path),
170 homeDir,
171 ).catch(() => [])
172 await $.ui.open({ id: PANE, title: 'aitmpl.com', focus: true, columns })
173 $.ui.invalidate('ui.render')
174 return {
175 text: e.presentation.isFullscreen
176 ? 'aitmpl.com pane open beside the transcript · click a row, or Tab and Enter · /aitmpl stop closes'
177 : 'aitmpl.com pane open above the prompt; /tui fullscreen docks it beside the transcript · /aitmpl stop closes',
178 }
179 })
180
181 on('ui.close', async ($, e, next) => {
182 if (e.id !== PANE) return next(e)
183 const r = await next(e)
184 isOpen = false
185 return r
186 })
187
188 // Claude may have installed something during the turn
189 on('turn.complete', async ($, e, next) => {
190 const r = await next(e)
191 if (!isOpen || e.agentId) return r
192 installed = await scanInstalled(
193 path => $.fs.list(path),
194 path => $.fs.exists(path),
195 homeDir,
196 ).catch(() => installed)
197 $.ui.invalidate('ui.render')
198 return r
199 })
200
201 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
202 // the mobile surface has no Input element yet
203 if (e.surface === 'mobile') return next(e)
204 const { Box, Text, Button, Input } = $.ui.resolve(e)
205 const W = Math.max(24, e.props.bodyColumns - 1)
206 const pad = (text: string) => nbsp(text, e.surface)
207 const repaint = () => $.ui.invalidate('ui.render')
208
209 // --- data: fetched from the hook's own closures, once per file, cached for the session
210 const loadTrending = () => {
211 if (stats || loading.has('trending') || failed.has('trending')) return
212 loading.add('trending')
213 $.http
214 .fetch(dataUrl('trending-data.json'))
215 .then(res => {
216 if (!res.ok) throw new Error(`HTTP ${res.status}`)
217 stats = parseTrending(res.text, 0).stats
218 })
219 .catch(err => {
220 // the header stands without its totals
221 $.ui.log(`aitmpl: trending-data.json: ${message(err)}`)
222 failed.add('trending')
223 stats = {}
224 })
225 .finally(() => {
226 loading.delete('trending')
227 repaint()
228 })
229 }
230 const loadCounts = () => {
231 if (counts || loading.has('counts') || failed.has('counts')) return
232 loading.add('counts')
233 $.http
234 .fetch(dataUrl('counts.json'))
235 .then(res => {
236 if (!res.ok) throw new Error(`HTTP ${res.status}`)
237 counts = parseCounts(res.text)
238 })
239 .catch(err => {
240 // the menu stands without its counts
241 $.ui.log(`aitmpl: counts.json: ${message(err)}`)
242 failed.add('counts')
243 counts = {}
244 })
245 .finally(() => {
246 loading.delete('counts')
247 repaint()
248 })
249 }
250 const loadType = (type: TypeInfo) => {
251 if (cache.has(type.key) || loading.has(type.key) || failed.has(type.key)) return
252 loading.add(type.key)
253 $.http
254 .fetch(dataUrl(`components/${type.key}.json`))
255 .then(res => {
256 if (!res.ok) throw new Error(`HTTP ${res.status}`)
257 cache.set(type.key, sortByDownloads(parseItems(res.text)))
258 })
259 .catch(err => {
260 failed.add(type.key)
261 error = `${type.key}.json: ${message(err)} · refresh retries`
262 })
263 .finally(() => {
264 loading.delete(type.key)
265 repaint()
266 })
267 }
268 const rescan = () =>
269 scanInstalled(
270 path => $.fs.list(path),
271 path => $.fs.exists(path),
272 homeDir,
273 )
274 .then(rows => {
275 installed = rows
276 })
277 .catch(() => undefined)
278 .finally(repaint)
279 const say = (text: string, tone: 'info' | 'ok' | 'bad') => {
280 notice = { text, tone }
281 repaint()
282 }
283
284 // --- actions
285 const install = (type: TypeInfo, item: Item) => {
286 if (installing) return
287 const argv = installArgv(item, type)
288 if (!argv) return say('This component has an unsafe path in the catalog; not installing it', 'bad')
289 installing = true
290 say(`Installing ${item.name}`, 'info')
291 $.ui.status(`aitmpl: installing ${item.name}`)
292 $.process
293 .run(argv, { timeoutMs: 180_000 })
294 .then(res => {
295 if (res.exitCode === 0) {
296 say(`✓ Installed ${item.name} into this project`, 'ok')
297 $.ui.toast(`aitmpl: installed ${item.name}`)
298 return rescan()
299 }
300 const tail = (res.stderr || res.stdout).trim().split('\n').pop() ?? ''
301 say(`Install failed (exit ${res.exitCode}) ${tail}`, 'bad')
302 })
303 .catch(err => say(`Install failed: ${message(err)}`, 'bad'))
304 .finally(() => {
305 installing = false
306 $.ui.status(undefined)
307 repaint()
308 })
309 }
310 // the platform's URL opener, each an argv without a shell: `explorer.exe` on Windows (by its OS
311 // variable), else `open` (macOS) then `xdg-open` (Linux); the URL was validated as http(s)
312 const openUrl = (url: string) => {
313 say('Opening in the browser', 'info')
314 $.env
315 .get('OS')
316 .then(os => {
317 const openers: string[][] = /windows/i.test(os ?? '') ? [['explorer.exe', url]] : [['open', url], ['xdg-open', url]]
318 return openers.reduce<Promise<void>>(
319 (chain, argv) =>
320 chain.catch(() =>
321 $.process.run(argv, { timeoutMs: 10_000 }).then(r => {
322 if (r.exitCode !== 0) throw new Error(`${argv[0]} exited ${r.exitCode}`)
323 }),
324 ),
325 Promise.reject(new Error('no opener')),
326 )
327 })
328 .then(() => say(`Opened ${url}`, 'ok'))
329 .catch(err => say(`Could not open a browser (${message(err)}) · ${url}`, 'bad'))
330 }
331 const toPrompt = (type: TypeInfo, item: Item) => {
332 $.prompt
333 .fill({ text: `Install the ${singular(type)} "${item.name}" from aitmpl.com by running: ${installCommandFor(item, type)}` })
334 .then(r => say(r.isFilled ? 'Install request written into the prompt: Enter sends it' : 'the prompt box is busy, try again', r.isFilled ? 'ok' : 'bad'))
335 .catch(err => say(`prompt.fill failed: ${message(err)}`, 'bad'))
336 }
337 const refresh = () => {
338 stats = undefined
339 counts = undefined
340 cache.clear()
341 failed.clear()
342 error = undefined
343 notice = undefined
344 loadTrending()
345 loadCounts()
346 void rescan()
347 repaint()
348 }
349 const setFilter = (to: TypeKey | 'all') => {
350 filter = to
351 shown = 0
352 repaint()
353 }
354
355 // Design: one accent (cyan), one colour and icon per type (TYPE_STYLE), count pills, a rule between
356 // rows. Every aligned cell is a fixed-width Box and every colour is an ANSI name, so the terminal's
357 // theme picks the shade and Desktop draws the same grid.
358 const ACCENT = 'cyan'
359 const rule = (key: string) => (
360 <Box key={key} width={W}>
361 <Text dimColor wrap="truncate-end">{'─'.repeat(W)}</Text>
362 </Box>
363 )
364 const pill = (text: string, color = ACCENT) => <Text bold inverse color={color}>{pad(` ${text} `)}</Text>
365 const facts = [
366 stats?.totalComponents !== undefined ? `${formatCount(stats.totalComponents)} components` : undefined,
367 stats?.totalDownloads !== undefined ? `${formatCount(stats.totalDownloads)} downloads` : undefined,
368 ].filter((f): f is string => f !== undefined)
369 loadTrending()
370 loadCounts()
371 const toneColor = notice?.tone === 'ok' ? 'green' : notice?.tone === 'bad' ? 'red' : 'yellow'
372 const header = (
373 <Box key="header" flexDirection="column" width={W}>
374 <Box key="title-row" flexDirection="row" width={W}>
375 <Box key="title" flexGrow={1} flexShrink={1}>
376 <Text bold color={ACCENT} wrap="truncate-end">{'aitmpl.com'}</Text>
377 </Box>
378 <Box key="tools" flexShrink={0} flexDirection="row" columnGap={1}>
379 <Button key="aitmpl:refresh" label="refresh" hotkey="r" dimColor onPress={refresh} />
380 <Button key="aitmpl:close" label="close" dimColor onPress={() => void $.ui.close({ id: PANE })} />
381 </Box>
382 </Box>
383 {facts.length > 0 ? <Text dimColor wrap="truncate-end">{truncate(facts.join(' · '), W)}</Text> : null}
384 {error ? <Text color="red" wrap="truncate-end">{truncate(`⚠ ${error}`, W)}</Text> : null}
385 {notice ? <Text color={toneColor} wrap="wrap">{notice.text}</Text> : null}
386 </Box>
387 )
388
389 // ------------------------------------------------------------- detail
390 if (view.kind === 'detail') {
391 const { type, item } = view
392 const style = TYPE_STYLE[type.key]
393 const url = webUrlFor(siteUrl, item, type)
394 const isHere = isInstalled(installed, type, item)
395 return (
396 <Box flexDirection="column" width={W}>
397 {header}
398 <Box key="back" marginTop={1}>
399 <Button key="aitmpl:back" label="◀ back" hotkey="b" dimColor onPress={() => {
400 view = { kind: 'browse' }
401 notice = undefined
402 repaint()
403 }} />
404 </Box>
405 <Box key="name" marginTop={1} flexDirection="row" columnGap={1} width={W}>
406 <Box key="icon" flexShrink={0} width={1}>
407 <Text color={style.color}>{style.icon}</Text>
408 </Box>
409 <Box key="text" flexGrow={1} flexShrink={1}>
410 <Text bold wrap="wrap">{item.name}</Text>
411 </Box>
412 </Box>
413 <Box key="meta" flexDirection="row" columnGap={1} width={W}>
414 <Text color={style.color}>{singular(type)}</Text>
415 <Text dimColor wrap="truncate-end">{truncate(`· ${item.category || 'uncategorized'}`, W - 12)}</Text>
416 </Box>
417 <Box key="stats" flexDirection="row" columnGap={1} width={W}>
418 <Text color="yellow">{`↓ ${formatCount(item.downloads)}`}</Text>
419 <Text dimColor>{'downloads'}</Text>
420 {isHere ? <Text bold color="green">{'✓ installed'}</Text> : null}
421 </Box>
422 <Box key="desc" marginTop={1} borderStyle="round" borderColor={style.color} paddingX={1} width={W}>
423 <Text wrap="wrap">{item.description || 'No description'}</Text>
424 </Box>
425 <Box key="cmd" marginTop={1} flexDirection="row" columnGap={1} width={W}>
426 <Box key="cmd-prompt" flexShrink={0} width={1}>
427 <Text dimColor>{'$'}</Text>
428 </Box>
429 <Box key="cmd-text" flexGrow={1} flexShrink={1}>
430 <Text color="green" wrap="wrap">{installCommandFor(item, type)}</Text>
431 </Box>
432 </Box>
433 <Box key="actions" marginTop={1} flexDirection="column">
434 <Button key="aitmpl:install" variant="primary" label={installing ? 'installing…' : isHere ? 'reinstall here' : 'install here'} hotkey="i" onPress={() => install(type, item)} />
435 {url ? <Button key="aitmpl:open" label="open on aitmpl.com ↗" hotkey="o" onPress={() => openUrl(url)} /> : null}
436 <Button key="aitmpl:prompt" label="put command in prompt" hotkey="c" onPress={() => toPrompt(type, item)} />
437 </Box>
438 </Box>
439 )
440 }
441
442 // ------------------------------------------------------------- browse
443 const activeTypes = filter === 'all' ? TYPES : [typeByKey(filter) ?? TYPES[0]!]
444 // the catalogs behind the list, and behind the names found on disk
445 for (const t of activeTypes) loadType(t)
446 for (const row of installed) {
447 const t = typeByKey(row.type)
448 if (t) loadType(t)
449 }
450 const pending = activeTypes.filter(t => loading.has(t.key)).length
451
452 const installedRows = filterInstalled(installed, filter, query)
453 const entries = searchEntries(cache, activeTypes, query)
454 const visible = entries.slice(0, shown || pageSize)
455
456 // the site's Browse menu: icon, label and a count pill per type; the chosen line carries a bar
457 const menuItems: { key: TypeKey | 'all'; label: string; n: number | undefined }[] = [
458 { key: 'all', label: 'All', n: counts ? Object.values(counts).reduce((a, b) => a + b, 0) : undefined },
459 ...MENU.map(k => ({ key: k, label: typeByKey(k)!.label, n: counts?.[k] })),
460 ]
461 const menu = (
462 <Box key="menu" flexDirection="column" width={W}>
463 <Text bold dimColor>{'BROWSE'}</Text>
464 {menuItems.map(m => {
465 const style = m.key === 'all' ? { icon: '✦', color: ACCENT } : TYPE_STYLE[m.key]
466 const chosen = filter === m.key
467 return (
468 <Box key={`menu:${m.key}`} flexDirection="row" width={W}>
469 <Box key="bar" flexShrink={0} width={1}>
470 <Text color={ACCENT}>{chosen ? '▌' : ' '}</Text>
471 </Box>
472 <Box key="icon" flexShrink={0} width={2} marginLeft={1}>
473 <Text color={style.color}>{style.icon}</Text>
474 </Box>
475 <Box key="label" flexGrow={1} flexShrink={1}>
476 <Button key={`aitmpl:chip:${m.key}`} plain label={pad(m.label)} onPress={() => setFilter(m.key)} />
477 </Box>
478 <Box key="n" flexShrink={0} width={7} justifyContent="flex-end">
479 {m.n !== undefined ? pill(String(m.n), chosen ? ACCENT : 'gray') : <Text dimColor>{'…'}</Text>}
480 </Box>
481 </Box>
482 )
483 })}
484 </Box>
485 )
486
487 // One component: icon + name + type tag, description, downloads + actions, then a rule.
488 const row = (section: string, type: TypeInfo, name: string, item: Item | undefined, scope?: Scope) => {
489 const id = `${section}:${type.key}:${name}`
490 const style = TYPE_STYLE[type.key]
491 const isHere = scope !== undefined || (item ? isInstalled(installed, type, item) : false)
492 const url = item ? webUrlFor(siteUrl, item, type) : undefined
493 const open = () => {
494 if (!item) return
495 view = { kind: 'detail', type, item }
496 notice = undefined
497 repaint()
498 }
499 return (
500 <Box key={`row:${id}`} flexDirection="column" width={W} marginTop={1}>
501 <Box key="l1" flexDirection="row" width={W}>
502 <Box key="icon" flexShrink={0} width={2}>
503 <Text color={style.color}>{style.icon}</Text>
504 </Box>
505 <Box key="name" flexGrow={1} flexShrink={1}>
506 {item ? (
507 <Button key={`aitmpl:open:${id}`} plain label={pad(truncate(name, W - 13))} onPress={open} />
508 ) : (
509 <Text bold wrap="truncate-end">{truncate(name, W - 13)}</Text>
510 )}
511 </Box>
512 <Box key="type" flexShrink={0} width={9} justifyContent="flex-end">
513 <Text color={style.color}>{singular(type)}</Text>
514 </Box>
515 </Box>
516 {item && item.description ? (
517 <Box key="l2" width={W} paddingLeft={2}>
518 <Text dimColor wrap="truncate-end">{truncate(item.description, W - 2)}</Text>
519 </Box>
520 ) : null}
521 <Box key="l3" flexDirection="row" width={W} paddingLeft={2} columnGap={1}>
522 <Box key="meta" flexGrow={1} flexShrink={1}>
523 {item ? (
524 <Text wrap="truncate-end">
525 <Text color="yellow">{`↓ ${formatCount(item.downloads)}`}</Text>
526 <Text dimColor>{item.category ? ` · ${item.category}` : ''}</Text>
527 </Text>
528 ) : (
529 <Text dimColor wrap="truncate-end">{`${scope ?? 'local'} · not in the catalog`}</Text>
530 )}
531 </Box>
532 <Box key="act" flexShrink={0} flexDirection="row" columnGap={1}>
533 {url ? <Button key={`aitmpl:view:${id}`} label="view ↗" dimColor onPress={() => openUrl(url)} /> : null}
534 {isHere ? (
535 <Text bold color="green">{'✓'}</Text>
536 ) : item ? (
537 <Button key={`aitmpl:install:${id}`} variant="primary" label={installing ? '…' : 'install'} onPress={() => install(type, item)} />
538 ) : null}
539 </Box>
540 </Box>
541 </Box>
542 )
543 }
544
545 const section = (key: 'installed' | 'list', title: string, count: number, body: RenderElement[]) => (
546 <Box key={`sec:${key}`} flexDirection="column" width={W} marginTop={1}>
547 {rule(`rule:${key}`)}
548 <Box key="head" flexDirection="row" width={W}>
549 <Box key="title" flexGrow={1} flexShrink={1}>
550 <Button
551 key={`aitmpl:sec:${key}`}
552 plain
553 label={pad(`${collapsed[key] ? '▸' : '▾'} ${title.toUpperCase()}`)}
554 onPress={() => {
555 collapsed[key] = !collapsed[key]
556 repaint()
557 }}
558 />
559 </Box>
560 <Box key="count" flexShrink={0} minWidth={7} justifyContent="flex-end">
561 {pill(String(count))}
562 </Box>
563 </Box>
564 {collapsed[key] ? null : body}
565 </Box>
566 )
567
568 const installedBody = installedRows.map(r => {
569 const type = typeByKey(r.type)!
570 return row('inst', type, r.name, catalogItemFor(cache.get(r.type), r), r.scope)
571 })
572 const listBody: RenderElement[] = visible.map(en => row('list', en.type, en.item.name, en.item))
573 if (entries.length > visible.length) {
574 listBody.push(
575 <Box key="more" marginTop={1}>
576 <Button
577 key="aitmpl:more"
578 label={`show ${Math.min(pageSize, entries.length - visible.length)} more of ${entries.length - visible.length}`}
579 dimColor
580 onPress={() => {
581 shown = (shown || pageSize) + pageSize
582 repaint()
583 }}
584 />
585 </Box>,
586 )
587 }
588 if (entries.length === 0) {
589 listBody.push(
590 <Box key="none" marginTop={1}>
591 <Text dimColor>{pending > 0 ? 'loading…' : query ? `Nothing matches "${query}"` : 'Nothing to show'}</Text>
592 </Box>,
593 )
594 }
595
596 return (
597 <Box flexDirection="column" width={W}>
598 {header}
599 <Box key="search" marginTop={1} width={W}>
600 <Input
601 key="aitmpl:search"
602 placeholder="Search components on aitmpl.com"
603 value={query}
604 autoFocus
605 submitLabel="search"
606 onInput={value => {
607 query = value
608 shown = 0
609 repaint()
610 }}
611 onSubmit={value => {
612 query = value.trim()
613 shown = 0
614 repaint()
615 }}
616 />
617 </Box>
618 <Box key="menu-wrap" marginTop={1}>
619 {menu}
620 </Box>
621 {section('installed', 'Installed', installedRows.length, installedBody)}
622 {section('list', query ? 'Results' : 'Popular', entries.length, listBody)}
623 <Box key="foot" marginTop={1} width={W}>
624 <Text dimColor wrap="wrap">
625 {'Installed lists agents, commands, skills and mods found in .claude/ and ~/.claude/. Downloads are totals.'}
626 </Text>
627 </Box>
628 </Box>
629 )
630 })
631}
632hooks/catalog.ts 286 lines1// aitmpl.com catalog: pure helpers (no `$`), drawn by ../register.tsx and covered by ../tests.
2
3export type TypeKey =
4 | 'agents'
5 | 'commands'
6 | 'mcps'
7 | 'settings'
8 | 'hooks'
9 | 'skills'
10 | 'loops'
11 | 'mods'
12
13export type TypeInfo = {
14 key: TypeKey
15 label: string
16 /** the CLI flag that installs one (`--agent`) */
17 flag: string
18 /** the digit that opens it from the home view */
19 hotkey: string
20}
21
22// The browsable types, in the site's order; sandbox, plugins and the retired templates are not browsed.
23export const TYPES: readonly TypeInfo[] = [
24 { key: 'agents', label: 'Agents', flag: '--agent', hotkey: '1' },
25 { key: 'commands', label: 'Commands', flag: '--command', hotkey: '2' },
26 { key: 'mcps', label: 'MCPs', flag: '--mcp', hotkey: '3' },
27 { key: 'settings', label: 'Settings', flag: '--setting', hotkey: '4' },
28 { key: 'hooks', label: 'Hooks', flag: '--hook', hotkey: '5' },
29 { key: 'skills', label: 'Skills', flag: '--skill', hotkey: '6' },
30 { key: 'loops', label: 'Loops', flag: '--loop', hotkey: '7' },
31 { key: 'mods', label: 'Mods', flag: '--mod', hotkey: '8' },
32]
33
34export function typeByKey(key: string): TypeInfo | undefined {
35 return TYPES.find(t => t.key === key || t.label.toLowerCase() === key)
36}
37
38export type Item = {
39 name: string
40 /** `category/name.md`, or `category/name` for a directory component */
41 path?: string
42 category: string
43 description: string
44 downloads: number
45}
46
47export type Trending = {
48 type: TypeInfo
49 name: string
50 category: string
51 downloadsWeek: number
52}
53
54export type GlobalStats = {
55 totalComponents?: number
56 totalDownloads?: number
57 weeklyDownloads?: number
58 totalCountries?: number
59}
60
61// `trending-data.json`: `trending.all[]` rows keyed `{singular}-{name}` (skill-frontend-design), plus globalStats
62export function parseTrending(text: string, max = 6): { rows: Trending[]; stats: GlobalStats } {
63 const data: unknown = JSON.parse(text)
64 const d = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>
65 const stats: GlobalStats = {}
66 const gs = (d.globalStats && typeof d.globalStats === 'object' ? d.globalStats : {}) as Record<string, unknown>
67 for (const k of ['totalComponents', 'totalDownloads', 'weeklyDownloads', 'totalCountries'] as const) {
68 if (typeof gs[k] === 'number') stats[k] = gs[k] as number
69 }
70 const trending = (d.trending && typeof d.trending === 'object' ? d.trending : {}) as Record<string, unknown>
71 const all = Array.isArray(trending.all) ? trending.all : []
72 const rows: Trending[] = []
73 for (const row of all) {
74 if (!row || typeof row !== 'object') continue
75 const r = row as Record<string, unknown>
76 if (typeof r.id !== 'string' || typeof r.name !== 'string') continue
77 const prefix = r.id.slice(0, r.id.length - r.name.length - 1)
78 const type = TYPES.find(t => t.flag === `--${prefix}`)
79 if (!type) continue
80 rows.push({
81 type,
82 name: r.name,
83 category: typeof r.category === 'string' ? r.category : '',
84 downloadsWeek: typeof r.downloadsWeek === 'number' ? r.downloadsWeek : 0,
85 })
86 if (rows.length >= max) break
87 }
88 return { rows, stats }
89}
90
91// The catalog keeps some descriptions as the quoted frontmatter string they came from:
92// literal `\n`, escaped quotes, and an <example> block the model reads but a list does not.
93export function cleanDescription(raw: unknown): string {
94 if (typeof raw !== 'string') return ''
95 let s = raw.trim()
96 if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) s = s.slice(1, -1)
97 s = s.replace(/\\n/g, ' ').replace(/\\"/g, '"').replace(/\\\\/g, '\\')
98 const example = s.search(/<example>|\bExamples?:/i)
99 if (example > 0) s = s.slice(0, example)
100 s = s.replace(/\s+/g, ' ').trim()
101 // a lead-in left dangling by the cut ("... integration. Specifically:") ends at its last full sentence
102 if (s.endsWith(':')) {
103 const lastStop = s.lastIndexOf('. ')
104 s = lastStop > 0 ? s.slice(0, lastStop + 1) : s.slice(0, -1)
105 }
106 return s
107}
108
109// One `components/{type}.json` body: an array of components; a bad row is skipped, a body that is
110// not an array throws so the fetch's error path reports it instead of caching an empty catalog.
111export function parseItems(text: string): Item[] {
112 const data: unknown = JSON.parse(text)
113 if (!Array.isArray(data)) throw new Error('catalog body is not an array')
114 const rows = data
115 const items: Item[] = []
116 for (const row of rows) {
117 if (!row || typeof row !== 'object') continue
118 const r = row as Record<string, unknown>
119 if (typeof r.name !== 'string' || !r.name) continue
120 items.push({
121 name: r.name,
122 path: typeof r.path === 'string' ? r.path : undefined,
123 category: typeof r.category === 'string' ? r.category : '',
124 description: cleanDescription(r.description),
125 downloads: typeof r.downloads === 'number' ? r.downloads : 0,
126 })
127 }
128 return items
129}
130
131// `counts.json`: `{ agents: 422, ... }`
132export function parseCounts(text: string): Record<string, number> {
133 const data: unknown = JSON.parse(text)
134 const out: Record<string, number> = {}
135 if (data && typeof data === 'object') {
136 for (const [k, v] of Object.entries(data)) if (typeof v === 'number') out[k] = v
137 }
138 return out
139}
140
141// Every word of the query must appear in the name, category or description; no query keeps all.
142export function filterItems(items: readonly Item[], query: string): Item[] {
143 const words = query.toLowerCase().split(/\s+/).filter(Boolean)
144 if (words.length === 0) return [...items]
145 return items.filter(it => {
146 const hay = `${it.name} ${it.category} ${it.description}`.toLowerCase()
147 return words.every(w => hay.includes(w))
148 })
149}
150
151export function sortByDownloads(items: readonly Item[]): Item[] {
152 return [...items].sort((a, b) => b.downloads - a.downloads || a.name.localeCompare(b.name))
153}
154
155export function pageOf<T>(items: readonly T[], page: number, size: number): { slice: T[]; page: number; pages: number } {
156 const pages = Math.max(1, Math.ceil(items.length / Math.max(1, size)))
157 const p = Math.min(Math.max(0, page), pages - 1)
158 return { slice: items.slice(p * size, p * size + size), page: p, pages }
159}
160
161// `category/name.md` -> `category/name`, what the CLI flag and the site's URL take
162export function cleanPath(item: Item): string {
163 return item.path?.replace(/\.(md|json)$/, '') ?? item.name
164}
165
166// A component path the CLI may be handed: path segments of letters, digits, `.`, `_` and `-`, none
167// empty, none starting with `-` (never an option) or `.` (never `..`). Catalog rows are live data.
168const SAFE_SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/
169export function isSafePath(path: string): boolean {
170 const segments = path.split('/')
171 return segments.length > 0 && segments.every(seg => SAFE_SEGMENT.test(seg))
172}
173
174// The argv `$.process.run` takes for the install: always the fixed CLI, the type's own flag and a
175// validated path, never a command string from the catalog. `undefined` when the path is not safe.
176export function installArgv(item: Item, type: TypeInfo): string[] | undefined {
177 const path = cleanPath(item)
178 if (!isSafePath(path)) return undefined
179 return ['npx', 'claude-code-templates@latest', type.flag, path, '--yes']
180}
181
182export function installCommandFor(item: Item, type: TypeInfo): string {
183 const argv = installArgv(item, type)
184 return argv ? argv.slice(0, -1).join(' ') : `(unsafe component path: ${JSON.stringify(cleanPath(item))})`
185}
186
187// The component's page on the site, only ever http(s); `undefined` otherwise so no opener runs.
188export function webUrlFor(site: string, item: Item, type: TypeInfo): string | undefined {
189 const path = cleanPath(item)
190 if (!isSafePath(path)) return undefined
191 const base = site.replace(/\/+$/, '')
192 if (!/^https?:\/\/[^\s/?#]+$/i.test(base)) return undefined
193 return `${base}/component/${type.key}/${path}`
194}
195
196export function truncate(text: string, width: number): string {
197 if (width <= 0) return ''
198 if (text.length <= width) return text
199 return width <= 1 ? '…' : text.slice(0, width - 1) + '…'
200}
201
202export function formatCount(n: number): string {
203 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
204 return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n)
205}
206
207export function padRight(text: string, width: number): string {
208 return truncate(text, width).padEnd(width)
209}
210
211// --- sidebar: what is installed, and one list across types -------------------------------------
212
213export type Scope = 'project' | 'user'
214
215/** A component found on disk under `.claude/` (project) or `~/.claude/` (user). */
216export type Installed = { type: TypeKey; name: string; scope: Scope }
217
218/** A catalog row with the type it belongs to, for lists that span types. */
219export type Entry = { type: TypeInfo; item: Item }
220
221// The name the CLI writes the component under: the last segment of its path (`security/audit` -> `audit`)
222export function itemKey(item: Item): string {
223 const path = cleanPath(item)
224 return path.slice(path.lastIndexOf('/') + 1)
225}
226
227export function stripExt(file: string): string {
228 return file.replace(/\.(md|json)$/, '')
229}
230
231// Installed rows deduplicated by type and name (project wins over user), sorted by name.
232export function dedupeInstalled(rows: readonly Installed[]): Installed[] {
233 const seen = new Map<string, Installed>()
234 for (const r of rows) {
235 const k = `${r.type}:${r.name}`
236 if (!seen.has(k) || (seen.get(k)!.scope === 'user' && r.scope === 'project')) seen.set(k, r)
237 }
238 return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name))
239}
240
241export function isInstalled(installed: readonly Installed[], type: TypeInfo, item: Item): boolean {
242 const key = itemKey(item)
243 return installed.some(i => i.type === type.key && i.name === key)
244}
245
246// The catalog row behind an installed name, when that type's catalog is loaded.
247export function catalogItemFor(items: readonly Item[] | undefined, row: Installed): Item | undefined {
248 return items?.find(it => itemKey(it) === row.name)
249}
250
251export function filterInstalled(rows: readonly Installed[], filter: TypeKey | 'all', query: string): Installed[] {
252 const words = query.toLowerCase().split(/\s+/).filter(Boolean)
253 return rows.filter(r => (filter === 'all' || r.type === filter) && words.every(w => r.name.toLowerCase().includes(w)))
254}
255
256// Catalog rows across the given types that match the query, most downloaded first.
257export function searchEntries(
258 byType: ReadonlyMap<TypeKey, readonly Item[]>,
259 types: readonly TypeInfo[],
260 query: string,
261): Entry[] {
262 const out: Entry[] = []
263 for (const type of types) {
264 for (const item of filterItems(byType.get(type.key) ?? [], query)) out.push({ type, item })
265 }
266 return out.sort((a, b) => b.item.downloads - a.item.downloads || a.item.name.localeCompare(b.item.name))
267}
268
269// HTML collapses runs of spaces and trims a text's ends; a no-break space keeps them.
270export function nbsp(text: string, surface: string): string {
271 return surface === 'terminal' ? text : text.replace(/ /g, ' ')
272}
273
274// One icon and one terminal colour per type, shared by the menu, the rows and the detail. ANSI names
275// follow the person's terminal theme; every icon is a single BMP glyph.
276export const TYPE_STYLE: Record<TypeKey, { icon: string; color: string }> = {
277 skills: { icon: '◆', color: 'magenta' },
278 agents: { icon: '◉', color: 'blue' },
279 commands: { icon: '❯', color: 'green' },
280 settings: { icon: '⚙', color: 'yellow' },
281 hooks: { icon: '↪', color: 'red' },
282 mcps: { icon: '⬡', color: 'cyan' },
283 mods: { icon: '▣', color: 'magentaBright' },
284 loops: { icon: '↻', color: 'yellowBright' },
285}
286