SLOPSHOPPER

aitmpl

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

newpanecommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aitmpl
│ ┃ aitmpl.com ✕ › fix the failing auth test and add an audit log call │ ┃ aitmpl.com [ refresh │ ┃ ⚠ mods.json: HTTP 0 · refresh retries ● aitmpl: aitmpl: trending-data.json: HTTP 0 │ ┃ ● aitmpl: aitmpl: counts.json: HTTP 0 │ ┃ Search components on aitmpl.com ⏎ search ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ BROWSE ⏺ Update(src/auth.ts) │ ┃ ▌ ✦All ⎿ Added 2 lines, removed 1 line │ ┃ ◆Skills ⏺ Bash(bun test) │ ┃ ◉Agents ⎿ 3 pass, 1 fail │ ┃ ❯Commands │ ┃ ⚙Settings ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ↪Hooks │ ┃ ⬡MCPs ✻ Worked for 42s · done 4:20 PM │ ┃ ▣Mods │ ┃ ↻Loops › /aitmpl │ ┃ ⎿ aitmpl: aitmpl.com pane open beside the transcript · click a row │ ┃ ──────────────────────────────────────────── │ ┃ ▾ INSTALLED │ ┃ │ ┃ ──────────────────────────────────────────── │ ┃ ▾ POPULAR │ ┃ │ ┃ Nothing to show │ ┃ │ ┃ Installed lists agents, commands, skills and ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · aitmpl.com
aitmpl.com [ refresh ] [ close ] ⚠ mods.json: HTTP 0 · refresh retries Search components on aitmpl.com ⏎ search BROWSE ▌ ✦All 0 ◆Skills … ◉Agents … ❯Commands … ⚙Settings … ↪Hooks … ⬡MCPs … ▣Mods … ↻Loops … ─────────────────────────────────────────────────────── ▾ INSTALLED ─────────────────────────────────────────────────────── ▾ POPULAR Nothing to show Installed lists agents, commands, skills and mods found in .claude/ and ~/.claude/. Downloads are totals.
README

aitmpl

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

  • Search filters as you type (every word must appear in the name, category or description); it spans every type, or only the type chosen in Browse.
  • Browse is the site's menu: one line per type (Skills, Agents, Commands, Settings, Hooks, MCPs, Mods, Loops) plus All, each with its count from counts.json; the chosen type narrows both sections.
  • Installed lists what is already on disk, with the project's .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.
  • Rows show the type, the description, the category, downloads and two actions: 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 come from the 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.

Command

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

Hooks

EventWhat it does
session.startregisters /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.completerescans what is installed (Claude may have installed something)
ui.closenotes 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.

Options

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

Install

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

Source 2 files
hooks/register.tsx 632 lines
1/* @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}
632
hooks/catalog.ts 286 lines
1// 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