feat(mods): add productivity/aitmpl, an aitmpl.com catalog browser above the prompt (#915)

This commit is contained in:
Daniel Avila
2026-09-17 18:32:56 -04:00
committed by GitHub
parent 5b1070a186
commit d400d5d494
20 changed files with 2941 additions and 1873 deletions
@@ -0,0 +1,33 @@
{
"name": "aitmpl",
"version": "0.1.1",
"description": "/aitmpl opens a component browser above the prompt: the aitmpl.com catalog (agents, commands, MCPs, settings, hooks, skills, loops, mods) read live from the site's public JSON through $.http.fetch. Pick a type or a trending component, filter, page, open a component and install it from there ($.process.run with the fixed CLI argv and a validated path, no shell), drop its install command into the prompt, or open its page in your browser. Hooks session.start, command.run and ui.render on AbovePrompt; browsing costs no tokens.",
"author": {
"name": "claude-code-templates",
"url": "https://www.aitmpl.com"
},
"repository": "https://github.com/davila7/claude-code-templates",
"license": "MIT",
"keywords": [
"mod",
"function-hooks",
"productivity",
"aitmpl",
"catalog",
"tui"
],
"userConfig": {
"siteUrl": {
"type": "string",
"title": "Site URL",
"description": "The site whose public catalog JSON is browsed (counts.json, components/{type}.json)",
"default": "https://www.aitmpl.com"
},
"pageSize": {
"type": "number",
"title": "Rows per page",
"description": "Components listed per page, capped by the rows the band has",
"default": 8
}
}
}
@@ -0,0 +1,94 @@
# aitmpl
`/aitmpl` opens a component browser above the Claude Code prompt: the
catalog of [aitmpl.com](https://www.aitmpl.com) (agents, commands, MCPs,
settings, hooks, skills, loops and mods), read live from the site's public
JSON (`counts.json`, `trending-data.json`, `components/{type}.json`) through
`$.http.fetch` and cached for the session. Browsing costs no tokens: nothing
the browser draws enters the transcript.
```
aitmpl.com 2.0k components · 1.3M downloads · 153k this week · 190 countries [refresh] [close]
Browse Trending this week
1: Agents 422 frontend-design skill 4.7k↓
2: Commands 287 frontend-developer agent 3.9k↓
3: MCPs 103 code-reviewer skill 3.2k↓
4: Settings 72 …
5: Hooks 62
6: Skills 876
7: Loops 18
8: Mods 24
search ▸ a name, a category, words of a description
1-8 open a type · click a trending row · Tab / Enter move and press · q closes
```
Three views:
- **home** — the types with their live counts on the left, the site's
trending components of the week on the right (a press opens the detail),
the global stats in the header, and a search field.
- **list** — the type's components sorted by downloads, a `filter` field
(every word must appear in the name, category or description), the type
row to switch types keeping the filter, `p`/`n` to page, digits `1``9`
to open a row. Templates are retired on the site and are not listed.
- **detail** — description, category, downloads, the install command, and
three actions: `i` **install here** runs the CLI on the host
(`$.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), `c` **put command in
prompt** writes an install request into the prompt box for Claude
(`$.prompt.fill`, nothing is submitted), and `o` **open on aitmpl.com**
opens the component's page in your browser (`open` on macOS, `xdg-open` on
Linux, `explorer.exe` on Windows, each an argv; the URL is http(s) only).
## Command
```
/aitmpl home
/aitmpl mods open a type (agents, commands, mcps, settings, hooks, skills, loops, mods)
/aitmpl react perf search (starts in agents; switch the type from the list)
/aitmpl stop close
```
Hotkeys press from an empty composer, or while a control of the band has the
focus; Tab moves between controls, Enter presses. The band pauses under a
survey and on the mobile surface (no `Input` element there yet).
## Hooks
| Event | What it does |
|---|---|
| `session.start` | registers `/aitmpl` (`$.command.register`) |
| `command.run` `{ command: "aitmpl" }` | opens the home, a type or a search; `stop` closes |
| `ui.render` `{ component: "AbovePrompt" }` | draws the view 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 `r` refresh, never by a repaint |
The mod calls `$.http.fetch` (the site's JSON), `$.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("OS")`,
`$.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 per page, capped by the rows the band has (default 8)
```
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:
```json
{ "pluginConfigs": { "aitmpl": { "options": { "pageSize": 12 } } } }
```
## Install
```sh
npx claude-code-templates@latest --mod productivity/aitmpl
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude
```
It is written to `.claude/skills/aitmpl/`, which Claude Code auto-loads as `aitmpl@skills-dir`. For one session with hot reload: `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir .claude/skills/aitmpl`. `claude plugin validate .claude/skills/aitmpl` prints every event it hooks and every `$` call it makes; `bun test tests/` runs the catalog helpers' tests.
**Early access.** Mods need Claude Code 2.1.259+ with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`; the `$` API may change between releases. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
@@ -0,0 +1,209 @@
// aitmpl.com catalog: pure helpers (no `$`), drawn by ../register.tsx and covered by ../tests.
export type TypeKey =
| 'agents'
| 'commands'
| 'mcps'
| 'settings'
| 'hooks'
| 'skills'
| 'loops'
| 'mods'
export type TypeInfo = {
key: TypeKey
label: string
/** the CLI flag that installs one (`--agent`) */
flag: string
/** the digit that opens it from the home view */
hotkey: string
}
// The browsable types, in the site's order; sandbox, plugins and the retired templates are not browsed.
export const TYPES: readonly TypeInfo[] = [
{ key: 'agents', label: 'Agents', flag: '--agent', hotkey: '1' },
{ key: 'commands', label: 'Commands', flag: '--command', hotkey: '2' },
{ key: 'mcps', label: 'MCPs', flag: '--mcp', hotkey: '3' },
{ key: 'settings', label: 'Settings', flag: '--setting', hotkey: '4' },
{ key: 'hooks', label: 'Hooks', flag: '--hook', hotkey: '5' },
{ key: 'skills', label: 'Skills', flag: '--skill', hotkey: '6' },
{ key: 'loops', label: 'Loops', flag: '--loop', hotkey: '7' },
{ key: 'mods', label: 'Mods', flag: '--mod', hotkey: '8' },
]
export function typeByKey(key: string): TypeInfo | undefined {
return TYPES.find(t => t.key === key || t.label.toLowerCase() === key)
}
export type Item = {
name: string
/** `category/name.md`, or `category/name` for a directory component */
path?: string
category: string
description: string
downloads: number
}
export type Trending = {
type: TypeInfo
name: string
category: string
downloadsWeek: number
}
export type GlobalStats = {
totalComponents?: number
totalDownloads?: number
weeklyDownloads?: number
totalCountries?: number
}
// `trending-data.json`: `trending.all[]` rows keyed `{singular}-{name}` (skill-frontend-design), plus globalStats
export function parseTrending(text: string, max = 6): { rows: Trending[]; stats: GlobalStats } {
const data: unknown = JSON.parse(text)
const d = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>
const stats: GlobalStats = {}
const gs = (d.globalStats && typeof d.globalStats === 'object' ? d.globalStats : {}) as Record<string, unknown>
for (const k of ['totalComponents', 'totalDownloads', 'weeklyDownloads', 'totalCountries'] as const) {
if (typeof gs[k] === 'number') stats[k] = gs[k] as number
}
const trending = (d.trending && typeof d.trending === 'object' ? d.trending : {}) as Record<string, unknown>
const all = Array.isArray(trending.all) ? trending.all : []
const rows: Trending[] = []
for (const row of all) {
if (!row || typeof row !== 'object') continue
const r = row as Record<string, unknown>
if (typeof r.id !== 'string' || typeof r.name !== 'string') continue
const prefix = r.id.slice(0, r.id.length - r.name.length - 1)
const type = TYPES.find(t => t.flag === `--${prefix}`)
if (!type) continue
rows.push({
type,
name: r.name,
category: typeof r.category === 'string' ? r.category : '',
downloadsWeek: typeof r.downloadsWeek === 'number' ? r.downloadsWeek : 0,
})
if (rows.length >= max) break
}
return { rows, stats }
}
// The catalog keeps some descriptions as the quoted frontmatter string they came from:
// literal `\n`, escaped quotes, and an <example> block the model reads but a list does not.
export function cleanDescription(raw: unknown): string {
if (typeof raw !== 'string') return ''
let s = raw.trim()
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) s = s.slice(1, -1)
s = s.replace(/\\n/g, ' ').replace(/\\"/g, '"').replace(/\\\\/g, '\\')
const example = s.search(/<example>|\bExamples?:/i)
if (example > 0) s = s.slice(0, example)
s = s.replace(/\s+/g, ' ').trim()
// a lead-in left dangling by the cut ("... integration. Specifically:") ends at its last full sentence
if (s.endsWith(':')) {
const lastStop = s.lastIndexOf('. ')
s = lastStop > 0 ? s.slice(0, lastStop + 1) : s.slice(0, -1)
}
return s
}
// One `components/{type}.json` body: an array of components; a bad row is skipped, a body that is
// not an array throws so the fetch's error path reports it instead of caching an empty catalog.
export function parseItems(text: string): Item[] {
const data: unknown = JSON.parse(text)
if (!Array.isArray(data)) throw new Error('catalog body is not an array')
const rows = data
const items: Item[] = []
for (const row of rows) {
if (!row || typeof row !== 'object') continue
const r = row as Record<string, unknown>
if (typeof r.name !== 'string' || !r.name) continue
items.push({
name: r.name,
path: typeof r.path === 'string' ? r.path : undefined,
category: typeof r.category === 'string' ? r.category : '',
description: cleanDescription(r.description),
downloads: typeof r.downloads === 'number' ? r.downloads : 0,
})
}
return items
}
// `counts.json`: `{ agents: 422, ... }`
export function parseCounts(text: string): Record<string, number> {
const data: unknown = JSON.parse(text)
const out: Record<string, number> = {}
if (data && typeof data === 'object') {
for (const [k, v] of Object.entries(data)) if (typeof v === 'number') out[k] = v
}
return out
}
// Every word of the query must appear in the name, category or description; no query keeps all.
export function filterItems(items: readonly Item[], query: string): Item[] {
const words = query.toLowerCase().split(/\s+/).filter(Boolean)
if (words.length === 0) return [...items]
return items.filter(it => {
const hay = `${it.name} ${it.category} ${it.description}`.toLowerCase()
return words.every(w => hay.includes(w))
})
}
export function sortByDownloads(items: readonly Item[]): Item[] {
return [...items].sort((a, b) => b.downloads - a.downloads || a.name.localeCompare(b.name))
}
export function pageOf<T>(items: readonly T[], page: number, size: number): { slice: T[]; page: number; pages: number } {
const pages = Math.max(1, Math.ceil(items.length / Math.max(1, size)))
const p = Math.min(Math.max(0, page), pages - 1)
return { slice: items.slice(p * size, p * size + size), page: p, pages }
}
// `category/name.md` -> `category/name`, what the CLI flag and the site's URL take
export function cleanPath(item: Item): string {
return item.path?.replace(/\.(md|json)$/, '') ?? item.name
}
// A component path the CLI may be handed: path segments of letters, digits, `.`, `_` and `-`, none
// empty, none starting with `-` (never an option) or `.` (never `..`). Catalog rows are live data.
const SAFE_SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/
export function isSafePath(path: string): boolean {
const segments = path.split('/')
return segments.length > 0 && segments.every(seg => SAFE_SEGMENT.test(seg))
}
// The argv `$.process.run` takes for the install: always the fixed CLI, the type's own flag and a
// validated path, never a command string from the catalog. `undefined` when the path is not safe.
export function installArgv(item: Item, type: TypeInfo): string[] | undefined {
const path = cleanPath(item)
if (!isSafePath(path)) return undefined
return ['npx', 'claude-code-templates@latest', type.flag, path, '--yes']
}
export function installCommandFor(item: Item, type: TypeInfo): string {
const argv = installArgv(item, type)
return argv ? argv.slice(0, -1).join(' ') : `(unsafe component path: ${JSON.stringify(cleanPath(item))})`
}
// The component's page on the site, only ever http(s); `undefined` otherwise so no opener runs.
export function webUrlFor(site: string, item: Item, type: TypeInfo): string | undefined {
const path = cleanPath(item)
if (!isSafePath(path)) return undefined
const base = site.replace(/\/+$/, '')
if (!/^https?:\/\/[^\s/?#]+$/i.test(base)) return undefined
return `${base}/component/${type.key}/${path}`
}
export function truncate(text: string, width: number): string {
if (width <= 0) return ''
if (text.length <= width) return text
return width <= 1 ? '…' : text.slice(0, width - 1) + '…'
}
export function formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n)
}
export function padRight(text: string, width: number): string {
return truncate(text, width).padEnd(width)
}
@@ -0,0 +1,6 @@
{
"description": "/aitmpl browses the aitmpl.com catalog above the prompt: session.start registers the command, command.run opens or closes it, ui.render on AbovePrompt draws the type list with trending, the filtered pages and the component detail with install, put-in-prompt and open-in-browser buttons; the catalog JSON is fetched live through $.http.fetch and cached for the session",
"modules": [
"./register.tsx"
]
}
@@ -0,0 +1,533 @@
/* @jsx h */
/**
* aitmpl — Claude Mod (EARLY ACCESS)
*
* `/aitmpl [query]` opens a component browser above the prompt: the catalog
* of aitmpl.com (agents, commands, MCPs, settings, hooks, skills, loops,
* mods) read live from the site's public JSON through `$.http.fetch`. Pick
* a type or a trending component, filter, page through the list, open a
* component, and install it from there (`$.process.run` with the CLI's
* argv, no shell), drop its install command into the prompt for Claude
* (`$.prompt.fill`) or open its page in the browser.
*
* Three hooks: `session.start` registers the command, `command.run` opens or
* closes the browser, `ui.render` on AbovePrompt draws it from the surface's
* own element table (Box, Text, Button, Input) with digit and letter
* hotkeys; the fetches run from the hooks' own closures. Browsing costs no
* tokens: nothing here enters the transcript.
*
* Needs CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (Claude Code >= 2.1.259). Typed
* against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
*
* Options:
* siteUrl: string the site whose catalog is browsed (default "https://www.aitmpl.com")
* pageSize: number rows per page, at most what the band allows (default 8)
*/
import type { Register, RenderElement } from 'claude-code'
import {
TYPES,
filterItems,
formatCount,
installArgv,
installCommandFor,
padRight,
pageOf,
parseCounts,
parseItems,
parseTrending,
sortByDownloads,
truncate,
typeByKey,
webUrlFor,
type GlobalStats,
type Item,
type Trending,
type TypeInfo,
type TypeKey,
} from './catalog.ts'
type View =
| { kind: 'home' }
| { kind: 'list'; type: TypeInfo; query: string; page: number }
| { kind: 'detail'; type: TypeInfo; item: Item; query: string; page: number }
// a trending row pressed before its type's catalog is in the cache
| { kind: 'pending'; type: TypeInfo; name: string }
let open = false
let view: View = { kind: 'home' }
let counts: Record<string, number> | undefined
let trending: { rows: Trending[]; stats: GlobalStats } | undefined
const cache = new Map<TypeKey, Item[]>()
const loading = new Set<string>()
// a fetch that failed is not retried by a repaint: only refresh (or a new /aitmpl) clears it
const failed = new Set<string>()
let error: string | undefined
// what the last action did (install, open), drawn under the detail
let notice: { text: string; tone: 'info' | 'ok' | 'bad' } | undefined
let installing = false
const singular = (type: TypeInfo) => type.label.toLowerCase().replace(/s$/, '')
export const register: Register = (on, options) => {
const siteUrl = typeof options.siteUrl === 'string' && options.siteUrl ? options.siteUrl : 'https://www.aitmpl.com'
const pageSize = typeof options.pageSize === 'number' && options.pageSize > 0 ? Math.floor(options.pageSize) : 8
const dataUrl = (file: string) => `${siteUrl.replace(/\/+$/, '')}/${file}`
on('session.start', async ($, e, next) => {
const r = await next(e)
await $.command
.register({
name: 'aitmpl',
description: 'Browse and install aitmpl.com components above the prompt (stop closes)',
argumentHint: '[query | type | stop]',
immediate: true,
})
.catch(err => $.ui.log(`aitmpl: /aitmpl not registered: ${err}`))
return r
})
on('command.run', { command: 'aitmpl' }, async ($, e) => {
const arg = e.args.trim()
if (/^(stop|close|quit)$/i.test(arg)) {
open = false
$.ui.invalidate('ui.render')
return { text: 'aitmpl browser closed' }
}
open = true
error = undefined
notice = undefined
failed.clear()
const type = arg ? typeByKey(arg.toLowerCase()) : undefined
if (type) view = { kind: 'list', type, query: '', page: 0 }
else if (arg) view = { kind: 'list', type: TYPES[0]!, query: arg, page: 0 }
else view = { kind: 'home' }
$.ui.invalidate('ui.render')
return {
text: 'aitmpl.com browser open above the prompt · digits and letters are hotkeys from an empty composer · /aitmpl stop closes',
}
})
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
// a survey owns the band; the mobile surface has no Input element yet
if (!open || e.props.hasSurvey || e.surface === 'mobile') return next(e)
const { Box, Text, Button, Input } = $.ui.resolve(e)
const cols = e.props.bodyColumns || (e.viewport?.columns ?? 80)
const rows = Math.max(6, e.props.maxRows - 1)
const repaint = () => $.ui.invalidate('ui.render')
// --- data: fetched from the hook's own closures, once per file, cached for the session
const loadCounts = () => {
if (counts || loading.has('counts') || failed.has('counts')) return
loading.add('counts')
$.http
.fetch(dataUrl('counts.json'))
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
counts = parseCounts(res.text)
})
.catch(err => {
failed.add('counts')
error = `counts.json: ${err instanceof Error ? err.message : String(err)} · refresh retries`
})
.finally(() => {
loading.delete('counts')
repaint()
})
}
const loadTrending = () => {
if (trending || loading.has('trending') || failed.has('trending')) return
loading.add('trending')
$.http
.fetch(dataUrl('trending-data.json'))
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
trending = parseTrending(res.text, 6)
})
.catch(err => {
// the home stands without its trending column
$.ui.log(`aitmpl: trending-data.json: ${err instanceof Error ? err.message : String(err)}`)
failed.add('trending')
trending = { rows: [], stats: {} }
})
.finally(() => {
loading.delete('trending')
repaint()
})
}
const loadType = (type: TypeInfo) => {
if (cache.has(type.key) || loading.has(type.key) || failed.has(type.key)) return
loading.add(type.key)
$.http
.fetch(dataUrl(`components/${type.key}.json`))
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
cache.set(type.key, sortByDownloads(parseItems(res.text)))
error = undefined
})
.catch(err => {
failed.add(type.key)
error = `${type.key}.json: ${err instanceof Error ? err.message : String(err)} · refresh retries`
})
.finally(() => {
loading.delete(type.key)
repaint()
})
}
// --- navigation
const close = () => {
open = false
repaint()
}
const goHome = () => {
view = { kind: 'home' }
notice = undefined
repaint()
}
const goList = (type: TypeInfo, query = '', page = 0) => {
view = { kind: 'list', type, query, page }
notice = undefined
loadType(type)
repaint()
}
// --- the frame every view shares: one header row, a dim context row, the body, a dim help row
const beneath = await next(e)
const badge = (
<Box flexShrink={0}>
<Text bold inverse color="cyan">{' aitmpl.com '}</Text>
</Box>
)
const frame = (args: { title: string; context?: string; nav: RenderElement[]; body: RenderElement; help: string }) => (
<Box flexDirection="column">
<Box flexDirection="row" columnGap={1}>
{badge}
<Box flexGrow={1} flexShrink={1}>
<Text bold wrap="truncate-end">{args.title}</Text>
</Box>
<Box flexShrink={0} flexDirection="row" columnGap={1}>
{args.nav}
</Box>
</Box>
{args.context !== undefined ? <Text dimColor wrap="truncate-end">{truncate(args.context, cols)}</Text> : null}
{error ? <Text color="red" wrap="truncate-end">{truncate(`${error}`, cols)}</Text> : null}
{args.body}
<Text dimColor wrap="truncate-end">{truncate(args.help, cols)}</Text>
{beneath}
</Box>
)
const navClose = <Button key="aitmpl:close" label="close" hotkey="q" dimColor onPress={close} />
const navHome = <Button key="aitmpl:home" label="home" hotkey="h" dimColor onPress={goHome} />
// ---------------------------------------------------------------- home
if (view.kind === 'home') {
loadCounts()
loadTrending()
const stats = trending?.stats ?? {}
const total = stats.totalComponents ?? (counts ? Object.values(counts).reduce((a, b) => a + b, 0) : undefined)
const facts = [
total !== undefined ? `${formatCount(total)} components` : undefined,
stats.totalDownloads !== undefined ? `${formatCount(stats.totalDownloads)} downloads` : undefined,
stats.weeklyDownloads !== undefined ? `${formatCount(stats.weeklyDownloads)} this week` : undefined,
stats.totalCountries !== undefined ? `${stats.totalCountries} countries` : undefined,
].filter((f): f is string => f !== undefined)
const twoColumns = cols >= 78
const leftWidth = 22
const rightWidth = twoColumns ? cols - leftWidth - 3 : cols
const trendRows = trending?.rows ?? []
const typesColumn = (
<Box flexDirection="column" width={leftWidth}>
<Text bold>Browse</Text>
{TYPES.map(t => {
const n = counts?.[t.key]
return (
<Button
key={`aitmpl:type:${t.key}`}
label={`${padRight(t.label, 10)}${n !== undefined ? String(n).padStart(5) : ' …'}`}
hotkey={t.hotkey}
plain
onPress={() => goList(t)}
/>
)
})}
</Box>
)
const nameWidth = Math.max(12, Math.min(28, rightWidth - 20))
const trendingColumn = (
<Box flexDirection="column" width={rightWidth}>
<Box flexDirection="row" columnGap={1}>
<Text bold>Trending this week</Text>
<Text dimColor>{loading.has('trending') ? 'loading…' : trendRows.length === 0 && trending ? 'unavailable' : ''}</Text>
</Box>
{trendRows.map((row, i) => (
<Button
key={`aitmpl:trend:${i}`}
label={truncate(`${padRight(row.name, nameWidth)} ${padRight(singular(row.type), 7)} ${formatCount(row.downloadsWeek).padStart(5)}`, rightWidth - 4)}
dimColor
onPress={() => {
view = { kind: 'pending', type: row.type, name: row.name }
loadType(row.type)
repaint()
}}
/>
))}
</Box>
)
return frame({
title: 'Component catalog',
context: facts.join(' · ') || (loading.has('counts') ? 'loading…' : ''),
nav: [
<Button key="aitmpl:refresh" label="refresh" hotkey="r" dimColor onPress={() => {
counts = undefined
trending = undefined
cache.clear()
failed.clear()
error = undefined
loadCounts()
loadTrending()
repaint()
}} />,
navClose,
],
body: (
<Box flexDirection="column">
{twoColumns ? (
<Box flexDirection="row" columnGap={3}>
{typesColumn}
{trendingColumn}
</Box>
) : (
<Box flexDirection="column">
{typesColumn}
{trendingColumn}
</Box>
)}
<Input
key="aitmpl:search-all"
label="search"
placeholder="a name, a category, words of a description"
submitLabel="search"
onSubmit={value => {
const q = value.trim()
if (q) goList(TYPES[0]!, q)
}}
/>
</Box>
),
help: '1-8 open a type · click a trending row · Tab / Enter move and press · q closes',
})
}
// ------------------------------------------------------------- pending
if (view.kind === 'pending') {
const { type, name } = view
const items = cache.get(type.key)
if (items) {
const item = items.find(it => it.name === name || it.path?.replace(/\.(md|json)$/, '') === name)
view = item ? { kind: 'detail', type, item, query: '', page: 0 } : { kind: 'list', type, query: name, page: 0 }
repaint()
} else if (failed.has(type.key)) {
view = { kind: 'list', type, query: name, page: 0 }
repaint()
} else if (!loading.has(type.key)) {
loadType(type)
}
return frame({
title: name,
context: `loading ${type.label.toLowerCase()}`,
nav: [navHome, navClose],
body: <Box />,
help: 'h home · q closes',
})
}
// ---------------------------------------------------------------- list
if (view.kind === 'list') {
const { type, query } = view
const items = cache.get(type.key)
if (!items) loadType(type)
const size = Math.min(pageSize, Math.max(3, rows - 6))
const matches = items ? filterItems(items, query) : []
const { slice, page, pages } = pageOf(matches, view.page, size)
const current = view
const nameWidth = Math.max(14, Math.min(30, Math.floor(cols / 4)))
const catWidth = cols >= 100 ? 16 : 0
const descWidth = Math.max(8, cols - 4 - nameWidth - (catWidth ? catWidth + 2 : 0) - 8 - 2)
const typeRow = (
<Box flexDirection="row" columnGap={1} flexWrap="wrap">
{TYPES.map(t => (
<Button
key={`aitmpl:type:${t.key}`}
label={t.key === type.key ? `[${t.label.toLowerCase()}]` : t.label.toLowerCase()}
dimColor={t.key !== type.key}
onPress={() => goList(t, query)}
/>
))}
</Box>
)
const tableHead = (
<Text dimColor wrap="truncate-end">
{` ${padRight('name', nameWidth)} ${catWidth ? padRight('category', catWidth) + ' ' : ''}${'↓'.padStart(6)} description`}
</Text>
)
const tableRows = slice.map((item, i) => {
const n = page * size + i + 1
const label = `${padRight(item.name, nameWidth)} ${catWidth ? padRight(item.category, catWidth) + ' ' : ''}${formatCount(item.downloads).padStart(6)} ${truncate(item.description, descWidth)}`
return (
<Button
key={`aitmpl:item:${n}`}
label={label}
hotkey={i < 9 ? String(i + 1) : undefined}
plain
onPress={() => {
view = { kind: 'detail', type, item, query, page }
notice = undefined
repaint()
}}
/>
)
})
return frame({
title: type.label,
context: items
? `${matches.length}${query ? ` of ${items.length} match "${query}"` : ' components'} · sorted by downloads · page ${page + 1}/${pages}`
: loading.has(type.key)
? 'loading…'
: '',
nav: [
<Button key="aitmpl:refresh" label="refresh" hotkey="r" dimColor onPress={() => {
cache.delete(type.key)
failed.delete(type.key)
error = undefined
loadType(type)
repaint()
}} />,
<Button key="aitmpl:prev" label="◀ prev" hotkey="p" dimColor onPress={() => {
view = { ...current, page: Math.max(0, page - 1) }
repaint()
}} />,
<Button key="aitmpl:next" label="next ▶" hotkey="n" dimColor onPress={() => {
view = { ...current, page: Math.min(pages - 1, page + 1) }
repaint()
}} />,
navHome,
navClose,
],
body: (
<Box flexDirection="column">
{typeRow}
<Input
key="aitmpl:search"
label="filter"
placeholder="a name, a category, words of a description"
value={query}
submitLabel="filter"
onSubmit={value => {
view = { ...current, query: value.trim(), page: 0 }
repaint()
}}
/>
{tableHead}
{items && matches.length === 0 ? <Text dimColor>{` nothing matches "${query}"`}</Text> : null}
{tableRows}
</Box>
),
help: '1-9 open a row · p / n page · click a type to switch · r reloads the type · h home · q closes',
})
}
// -------------------------------------------------------------- detail
const { type, item, query, page } = view
// the catalog is live data: the argv is the fixed CLI with a validated path, the URL http(s) only
const argv = installArgv(item, type)
const command = installCommandFor(item, type)
const url = webUrlFor(siteUrl, item, type)
const back = () => goList(type, query, page)
const say = (text: string, tone: 'info' | 'ok' | 'bad') => {
notice = { text, tone }
repaint()
}
const install = () => {
if (installing) return
if (!argv) return say('✗ this component has an unsafe path in the catalog; not installing it', 'bad')
installing = true
say(`installing ${item.name}`, 'info')
$.ui.status(`aitmpl: installing ${item.name}`)
$.process
.run(argv, { timeoutMs: 180_000 })
.then(res => {
if (res.exitCode === 0) {
say(`✓ installed ${item.name} into this project`, 'ok')
$.ui.toast(`aitmpl: installed ${item.name}`)
} else {
const tail = (res.stderr || res.stdout).trim().split('\n').pop() ?? ''
say(`✗ install failed (exit ${res.exitCode}) ${tail}`, 'bad')
}
})
.catch(err => say(`✗ install failed: ${err instanceof Error ? err.message : String(err)}`, 'bad'))
.finally(() => {
installing = false
$.ui.status(undefined)
repaint()
})
}
const toPrompt = () => {
$.prompt
.fill({ text: `Install the ${singular(type)} "${item.name}" from aitmpl.com by running: ${command}` })
.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'))
.catch(err => say(`✗ prompt.fill failed: ${err instanceof Error ? err.message : String(err)}`, 'bad'))
}
// the platform's URL opener, each an argv without a shell: `explorer.exe` on Windows (by its OS
// variable), else `open` (macOS) then `xdg-open` (Linux); the URL was validated as http(s) above
const openInBrowser = () => {
if (!url) return say('✗ this component has no valid page URL', 'bad')
say('opening in the browser…', 'info')
$.env
.get('OS')
.then(os => {
const openers: string[][] = /windows/i.test(os ?? '') ? [['explorer.exe', url]] : [['open', url], ['xdg-open', url]]
return openers.reduce<Promise<void>>(
(chain, argv) =>
chain.catch(() =>
$.process.run(argv, { timeoutMs: 10_000 }).then(r => {
if (r.exitCode !== 0) throw new Error(`${argv[0]} exited ${r.exitCode}`)
}),
),
Promise.reject(new Error('no opener')),
)
})
.then(() => say(`✓ opened ${url}`, 'ok'))
.catch(err => say(`✗ could not open a browser (${err instanceof Error ? err.message : String(err)}) · ${url}`, 'bad'))
}
const tone = notice?.tone === 'ok' ? 'green' : notice?.tone === 'bad' ? 'red' : 'yellow'
return frame({
title: item.name,
context: `${singular(type)} · ${item.category || 'uncategorized'} · ${formatCount(item.downloads)} downloads${url ? ` · ${url}` : ''}`,
nav: [<Button key="aitmpl:back" label="◀ back" hotkey="b" dimColor onPress={back} />, navHome, navClose],
body: (
<Box flexDirection="column">
<Box borderStyle="round" borderDimColor paddingX={1} width={Math.max(24, cols - 2)}>
<Text wrap="wrap">{item.description || 'no description'}</Text>
</Box>
<Box flexDirection="row" columnGap={1}>
<Text dimColor>$</Text>
<Text color="green" wrap="truncate-end">{command}</Text>
</Box>
<Box flexDirection="row" columnGap={2}>
<Button key="aitmpl:install" label={installing ? 'installing…' : 'install here'} hotkey="i" onPress={install} />
<Button key="aitmpl:prompt" label="put command in prompt" hotkey="c" onPress={toPrompt} />
{url ? <Button key="aitmpl:open" label="open on aitmpl.com" hotkey="o" onPress={openInBrowser} /> : null}
</Box>
{notice ? <Text color={tone} wrap="truncate-end">{truncate(notice.text, cols)}</Text> : null}
</Box>
),
help: 'i installs into this project · c writes the install request into the prompt · o opens the page in your browser · b back · q closes',
})
})
}
@@ -0,0 +1,116 @@
import { expect, test } from 'bun:test'
import {
TYPES,
cleanDescription,
filterItems,
installArgv,
installCommandFor,
isSafePath,
pageOf,
parseCounts,
parseItems,
parseTrending,
sortByDownloads,
truncate,
typeByKey,
webUrlFor,
} from '../hooks/catalog.ts'
const agents = TYPES[0]!
test('the eight browsable types have distinct digit hotkeys; templates are retired', () => {
expect(TYPES.length).toBe(8)
expect(new Set(TYPES.map(t => t.hotkey)).size).toBe(8)
expect(typeByKey('templates')).toBeUndefined()
expect(typeByKey('mods')?.flag).toBe('--mod')
expect(typeByKey('MCPs'.toLowerCase())?.key).toBe('mcps')
expect(typeByKey('sandbox')).toBeUndefined()
})
test('a quoted frontmatter description is unescaped and cut before its examples', () => {
const raw = '"Use this agent for audits.\\n\\n<example>\\nContext: something\\n</example>"'
expect(cleanDescription(raw)).toBe('Use this agent for audits.')
expect(cleanDescription('plain \\"quoted\\" text')).toBe('plain "quoted" text')
expect(cleanDescription('Builds apps across React and Vue. Specifically:\\n<example>x</example>')).toBe('Builds apps across React and Vue.')
expect(cleanDescription('Use when:')).toBe('Use when')
expect(cleanDescription(42)).toBe('')
})
test('parseItems keeps the fields the browser draws and skips bad rows', () => {
const items = parseItems(
JSON.stringify([
{ name: 'a', path: 'cat/a.md', category: 'cat', description: 'first', downloads: 3 },
{ name: '', path: 'x' },
'junk',
{ name: 'b', downloads: 1 },
]),
)
expect(items.map(i => i.name)).toEqual(['a', 'b'])
expect(items[1]!.category).toBe('')
expect(() => parseItems('not json')).toThrow()
expect(() => parseItems('{"components": []}')).toThrow()
expect(parseCounts('{"agents": 2, "x": "no"}')).toEqual({ agents: 2 })
})
test('filtering needs every word, in name, category or description', () => {
const items = parseItems(
JSON.stringify([
{ name: 'react-expert', category: 'frontend', description: 'React components', downloads: 5 },
{ name: 'db-admin', category: 'database', description: 'Postgres tuning', downloads: 9 },
]),
)
expect(filterItems(items, '').length).toBe(2)
expect(filterItems(items, 'react front').map(i => i.name)).toEqual(['react-expert'])
expect(filterItems(items, 'postgres react')).toEqual([])
expect(sortByDownloads(items).map(i => i.name)).toEqual(['db-admin', 'react-expert'])
})
test('pages clamp to the range', () => {
const list = [1, 2, 3, 4, 5]
expect(pageOf(list, 0, 2)).toEqual({ slice: [1, 2], page: 0, pages: 3 })
expect(pageOf(list, 9, 2)).toEqual({ slice: [5], page: 2, pages: 3 })
expect(pageOf([], 0, 2)).toEqual({ slice: [], page: 0, pages: 1 })
})
test('install argv is always the fixed CLI with a validated path; the shown command drops --yes', () => {
const item = parseItems(JSON.stringify([{ name: 'x', path: 'cat/x.md', downloads: 0 }]))[0]!
expect(installArgv(item, agents)).toEqual(['npx', 'claude-code-templates@latest', '--agent', 'cat/x', '--yes'])
expect(installCommandFor(item, agents)).toBe('npx claude-code-templates@latest --agent cat/x')
expect(webUrlFor('https://www.aitmpl.com/', item, agents)).toBe('https://www.aitmpl.com/component/agents/cat/x')
// live catalog rows are data: a path that is an option, a traversal or a shell fragment never reaches the CLI
for (const bad of ['--version', 'cat/../x', 'cat/x; rm -rf ~', 'a b', '', 'cat//x', '$(id)']) {
expect(isSafePath(bad)).toBe(false)
const row = parseItems(JSON.stringify([{ name: 'n', path: bad }]))[0]!
expect(installArgv(row, agents)).toBeUndefined()
expect(webUrlFor('https://www.aitmpl.com', row, agents)).toBeUndefined()
}
expect(isSafePath('web-data/bright-data-mcp')).toBe(true)
expect(webUrlFor('ftp://x', item, agents)).toBeUndefined()
expect(webUrlFor('https://evil.com/?q=', item, agents)).toBeUndefined()
})
test('trending rows resolve their type from the id prefix and skip retired types', () => {
const { rows, stats } = parseTrending(
JSON.stringify({
globalStats: { totalComponents: 2019, totalDownloads: 1340064, weeklyDownloads: 152849, totalCountries: 190, junk: 'x' },
trending: {
all: [
{ id: 'skill-frontend-design', name: 'frontend-design', category: 'creative-design', downloadsWeek: 4697 },
{ id: 'template-angular-app', name: 'angular-app', downloadsWeek: 9 },
{ id: 'mcp-web-data/brightdata', name: 'web-data/brightdata', category: 'web-data', downloadsWeek: 12 },
'junk',
],
},
}),
6,
)
expect(rows.map(r => `${r.type.key}:${r.name}`)).toEqual(['skills:frontend-design', 'mcps:web-data/brightdata'])
expect(stats).toEqual({ totalComponents: 2019, totalDownloads: 1340064, weeklyDownloads: 152849, totalCountries: 190 })
expect(parseTrending('{}').rows).toEqual([])
})
test('truncate keeps the width', () => {
expect(truncate('abcdef', 4)).toBe('abc…')
expect(truncate('abc', 4)).toBe('abc')
expect(truncate('abc', 0)).toBe('')
})
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"agents": 422, "commands": 287, "mcps": 103, "settings": 72, "hooks": 62, "sandbox": 11, "skills": 876, "loops": 18, "mods": 24, "templates": 14, "plugins": 0}
{"agents": 422, "commands": 287, "mcps": 103, "settings": 72, "hooks": 62, "sandbox": 11, "skills": 877, "loops": 18, "mods": 25, "templates": 14, "plugins": 0}
File diff suppressed because one or more lines are too long
+1938 -1862
View File
File diff suppressed because it is too large Load Diff