A pane, opened with /catalog, that lists every skill, command, agent, mod, plugin, settings hook, MCP server and tool by category: descriptions, usage over 7…

<h1 align="center">Claude Code Mods</h1>
<a href="https://github.com/T-AI-Labs/claude-code-mods/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/T-AI-Labs/claude-code-mods/ci.yml?branch=main&style=flat-square&label=ci" alt="CI"></a> <a href="https://github.com/T-AI-Labs/claude-code-mods/releases"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FT-AI-Labs%2Fclaude-code-mods%2Fmain%2Fcontext-usage%2F.claude-plugin%2Fplugin.json&query=%24.version&prefix=v&label=context-usage&style=flat-square" alt="context-usage version"></a> <a href="https://github.com/T-AI-Labs/claude-code-mods/releases"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FT-AI-Labs%2Fclaude-code-mods%2Fmain%2Fcache-meter%2F.claude-plugin%2Fplugin.json&query=%24.version&prefix=v&label=cache-meter&style=flat-square" alt="cache-meter version"></a> <a href="https://github.com/T-AI-Labs/claude-code-mods/releases"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FT-AI-Labs%2Fclaude-code-mods%2Fmain%2Fcatalog-pane%2F.claude-plugin%2Fplugin.json&query=%24.version&prefix=v&label=catalog-pane&style=flat-square" alt="catalog-pane version"></a> <a href="https://github.com/T-AI-Labs/claude-code-mods/releases"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FT-AI-Labs%2Fclaude-code-mods%2Fmain%2Fcontext-meter%2F.claude-plugin%2Fplugin.json&query=%24.version&prefix=v&label=context-meter&style=flat-square" alt="context-meter version"></a> <a href="https://github.com/T-AI-Labs/claude-code-mods/releases"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FT-AI-Labs%2Fclaude-code-mods%2Fmain%2Fusage-limits%2F.claude-plugin%2Fplugin.json&query=%24.version&prefix=v&label=usage-limits&style=flat-square" alt="usage-limits version"></a> <a href="LICENSE"><img src="https://img.shields.io/github/license/T-AI-Labs/claude-code-mods?style=flat-square" alt="License"></a>
Add the marketplace once, then install the mods you want with the first command in their row of the Mods table. To update, run the second lines: the marketplace first, then each mod. The commands work in any shell, or inside Claude Code with /plugin in place of claude plugin. New sessions load the changes.
claude plugin marketplace add T-AI-Labs/claude-code-mods
claude plugin marketplace update T-AI-Labs
The mods use Claude Code's function-hook plugin API, which is in early access and may change between releases. They are built and tested against Claude Code 2.1.295, the engine in the Claude desktop app 2.31226.1.
context-usage already contains context-meter and usage-limits: install either it or those two, not both.
<table> <tr><th>Mod</th><th>Description</th><th width="35%">Preview</th></tr> <tr><td><a href="#context-usage">context‑usage</a></td><td>
Start here. The context fill with the auto-compact threshold, Compact and Clear, and the 5-hour and weekly limits beneath it.
claude plugin install context-usage@T-AI-Labs
claude plugin update context-usage@T-AI-Labs
</td><td><img src=".docs/context-usage.svg" alt="The context-usage rows" width="320"></td></tr> <tr><td><a href="#context-meter">context‑meter</a></td><td>
The context row of context-usage on its own.
claude plugin install context-meter@T-AI-Labs
claude plugin update context-meter@T-AI-Labs
</td><td><img src=".docs/context-meter.svg" alt="The context-meter row" width="320"></td></tr> <tr><td><a href="#usage-limits">usage‑limits</a></td><td>
The usage row of context-usage on its own.
claude plugin install usage-limits@T-AI-Labs
claude plugin update usage-limits@T-AI-Labs
</td><td><img src=".docs/usage-limits.svg" alt="The usage-limits row" width="320"></td></tr> <tr><td><a href="#cache-meter">cache‑meter</a></td><td>
How much of each turn the prompt cache served and how long it stays warm, with a switch that keeps it warm while you are away.
claude plugin install cache-meter@T-AI-Labs
claude plugin update cache-meter@T-AI-Labs
</td><td><img src=".docs/cache-meter.svg" alt="The cache-meter row" width="320"></td></tr> <tr><td><a href="#catalog-pane">catalog‑pane</a></td><td>
A pane listing every skill, command, agent, mod, plugin, settings hook, MCP server and tool, with usage, token cost and buttons to use or manage each one.
claude plugin install catalog-pane@T-AI-Labs
claude plugin update catalog-pane@T-AI-Labs
</td><td><img src=".docs/catalog-pane.svg" alt="The catalog pane" width="320"></td></tr> </table>
context-meter and usage-limits in one mod: the context row and the usage row beneath it. Both work as described under context-meter and usage-limits. To switch from the two separate mods, install context-usage, uninstall context-meter and usage-limits, and pick the threshold again.
The rows sit in the band above the prompt and cost no tokens: they only read figures Claude Code already has. In the terminal, ctrl+x tab moves into the band: Tab walks the controls, Enter presses a button, the arrow keys pick a threshold. ctrl+x ctrl+a or the band's [-] mark collapses it.
| Option | Values | Default | Effect |
|---|---|---|---|
display | gauge, text | gauge | gauge draws a five-segment gauge before each percentage; text leaves it out. |
details | true, false | true | Shows the figures in parentheses. |
usagePosition | below, above | below | Puts the usage row under or on top of the context row. |
'{"display":"text","usagePosition":"above"}' | claude plugin configure context-usage@T-AI-Labs --values-stdin
That is PowerShell; in cmd, use echo {"display":"text"} | …. Options you leave out keep their value, the command without --values-stdin shows what is set, and a new session picks up the change. --config display=text sets an option while installing, and in a terminal session the options are rows of the /config menu.
| Element | What it shows |
|---|---|
| Context | Fill of the context window, with tokens used and window size. Yellow within 15 points of the threshold, red at it; with Auto off, the same against 100 %. |
| Auto | The fill at which the mod compacts on its own: Off, 97, 90, 70, 60 or 50 %. Off leaves it to you and to Claude Code's built-in auto-compaction. Kept across sessions. |
| Compact | Runs /compact now. |
| Clear | Runs /clear. |
The row sits in the band above the prompt and costs no tokens: it only reads figures Claude Code already has. In the desktop app it is two lines high so the app's taller buttons fit. In the terminal, ctrl+x tab moves into the band: Tab walks the controls, Enter presses a button, the arrow keys pick a threshold. ctrl+x ctrl+a or the band's [-] mark collapses it.
Until the first response of a session, and again after a clear, the fill reads 0%: the engine counts the system prompt and tools only once a response arrives.
| Option | Values | Default | Effect |
|---|---|---|---|
display | gauge, text | gauge | gauge draws a five-segment gauge before the percentage; text leaves it out. |
details | true, false | true | Shows the figures in parentheses. |
'{"display":"text","details":"false"}' | claude plugin configure context-meter@T-AI-Labs --values-stdin
Set them as for context-usage. The row needs about 62 columns with the defaults; on a narrower window the buttons wrap to a second line. details off saves about 11 columns, display set to text about 6:
After each turn the mod compares the fill with the threshold and runs /compact once it is reached. It fires again only after the fill has dropped below the threshold, so a compaction that lands above it does not loop. Claude Code's built-in auto-compaction still runs at its own limit, so a threshold above that limit never fires.
Every compaction costs tokens, whether you press Compact or the threshold triggers it: /compact is a model request that summarizes the conversation, and the next turn starts on a cold cache. To keep usage down, leave the threshold high and compact by hand when it suits you.
A gauge for each rate-limit window, with the percentage used and the time until it resets: 5h for the 5-hour limit, Week for the weekly one. Yellow from 75 %, red from 90 %.
The row sits in the band above the prompt and costs no tokens. ctrl+x ctrl+a or the band's [-] mark collapses it in the terminal.
Claude Code reports the limits only on a subscription plan, with each response. Until the first one, and right after a /clear, the row shows the last reading, with a window that has reset since at 0 %. A window that never had a reading shows –.
Sessions running side by side share their readings through a file on your machine, once a minute and at no token cost, so an idle session still shows what the others used. context-usage and usage-limits keep separate stores and do not share with each other.
| Option | Values | Default | Effect |
|---|---|---|---|
display | gauge, text | gauge | gauge draws a five-segment gauge before each percentage; text leaves it out. |
details | true, false | true | Shows the reset times in parentheses. |
'{"display":"text"}' | claude plugin configure usage-limits@T-AI-Labs --values-stdin
Set them as for context-usage. Give the row mods the same display and details to keep their columns aligned.
| Element | What it shows |
|---|---|
| Cache | Share of the last turn's input tokens served from the prompt cache, with tokens read and total input. Yellow below 40 %; – until the first response and after a clear. |
| warm / cold | How long the cache stays valid: five minutes after the last request starts, or an hour once the session has used the hour-long cache. cold means the next turn writes the whole context again at full price, the cheapest moment to compact or clear. |
| Keep warm | Off by default. Refreshes the cache shortly before it lapses, for up to keepWarmMinutes after the last turn, and counts the refreshes. Stops when a refresh finds the cache already gone. |
The row sits in the band above the prompt and costs no tokens; only Keep warm's refreshes are model requests. Keep warm is a text link in the desktop app and a button elsewhere. In the terminal, ctrl+x tab moves into the band, Tab walks the controls and Enter presses one. ctrl+x ctrl+a or the band's [-] mark collapses it.
Before 0.4.0 cache-meter also showed the context fill, threshold, Compact and Clear. They moved to context-meter, which an update does not install: install it and pick the threshold again.
| Option | Values | Default | Effect |
|---|---|---|---|
display | gauge, text | gauge | gauge draws a five-segment gauge before the percentage; text leaves it out. |
details | true, false | true | Shows the figures in parentheses. |
keepWarmMinutes | 15, 30, 60, 120 | 60 | How long after the last turn Keep warm goes on refreshing. |
'{"keepWarmMinutes":"120"}' | claude plugin configure cache-meter@T-AI-Labs --values-stdin
Set them as for context-usage.
Every turn sends the whole conversation again. Cached tokens cost about a tenth of the input price, a twentieth on Claude Opus 5.5; writing them to the cache costs 1.25 times the input price. The cache lapses after a pause, on a model switch, and when something early in the context changes, such as CLAUDE.md or the tools.
Each refresh reads the cached context once at the cache price, plus a one-word reply. With 200k tokens on Claude Opus 5.5 that is about $0.04, against about $1.00 for writing the context again after it went cold. At one refresh every four and a half minutes, an hour costs about half a rewrite: worth it if you come back, lost if you don't. On a subscription the refreshes count against the usage limits.
A pane with one tab per category: Skills, Commands, Agents, Mods, Plugins, Hooks (from the settings files), MCP servers and built-in Tools.
/catalog opens the pane, in the desktop app and in the terminal; /catalog mods opens a category, /catalog <text> a search. Each row shows the entry's origin, how often it was used in total and in the last 30 days, and for plugin parts the tokens it adds to every session. Usage is counted from the install of the mod on. On the desktop you can filter by text and origin and sort:
| Sort | Order |
|---|---|
| Most used, Recently used, Name | By usage, or alphabetically. |
| Clean-up | Costly and unused entries first. |
| GitHub stars, Trending, GitHub forks, Latest GitHub push | By the plugin's repository; Trending is stars gained per month. |
| Installs, Newest installed | By the official directory's install count, or by install date. |
Pressing an entry opens its detail: usage over 7 and 30 days, token cost, version and install dates, what a mod hooks and calls, the repository and the full description. Explain with Haiku sums up in three sentences what the entry is for. The buttons depend on the kind: insert or run a skill or command, insert an agent, MCP server or tool into the prompt, update, disable or uninstall a plugin or mod, remove a local MCP server. Uninstalling and removing ask first.
| Option | Values | Default | Effect |
|---|---|---|---|
language | any language name | English | The language Explain with Haiku answers in. |
githubStats | true, false | true | Fetches the GitHub stats of each plugin's repository once a day. |
'{"language":"German"}' | claude plugin configure catalog-pane@T-AI-Labs --values-stdin
Set them as for context-usage.
githubStats.claude plugin details once per plugin version for its token cost, claude plugin validate once per mod version, and update, disable, enable or uninstall when you press those buttons.Everything else comes from Claude Code and the configuration files, and is kept in the mod's own store.
hooks/register.tsx 1645 lines1import { atom, read, update } from 'claude-code'
2import type {
3 CommandInfo,
4 Elements,
5 EngineInterface,
6 FsEntry,
7 On,
8 Register,
9 ToolInfo,
10} from 'claude-code'
11
12import type {
13 Cost,
14 Daily,
15 Entry,
16 Kind,
17 KnownAgent,
18 Mark,
19 PluginInfo,
20 RepoStats,
21 Scan,
22 Sort,
23 Stat,
24 Usage,
25 View,
26} from '../types'
27
28const KINDS: readonly { kind: Kind; label: string; noun: string }[] = [
29 { kind: 'skill', label: 'Skills', noun: 'skill' },
30 { kind: 'command', label: 'Commands', noun: 'slash command' },
31 { kind: 'agent', label: 'Agents', noun: 'subagent type' },
32 { kind: 'mod', label: 'Mods', noun: 'mod (a plugin with function hooks)' },
33 { kind: 'plugin', label: 'Plugins', noun: 'plugin' },
34 { kind: 'hook', label: 'Hooks', noun: 'settings hook (a shell command run on an event)' },
35 { kind: 'mcp', label: 'MCP', noun: 'MCP server' },
36 { kind: 'tool', label: 'Tools', noun: 'built-in tool' },
37]
38
39const EVERYWHERE = '*'
40
41const USAGE_KEY = 'usage'
42const DAILY_KEY = 'daily'
43const COSTS_KEY = 'costs'
44const REPOS_KEY = 'repos'
45const SCANS_KEY = 'scans'
46const SKILLS_KEY = 'skills'
47const AGENTS_KEY = 'agents'
48const EXPLANATIONS_KEY = 'explanations'
49const FLUSH_MS = 10_000
50const DAY_MS = 86_400_000
51const KEPT_DAYS = 31
52const REPO_TTL_MS = DAY_MS
53const KEPT_STAR_POINTS = 120
54const MONTH_DAYS = 30
55const CLI_TIMEOUT_MS = 120_000
56const MCP_PREVIEW = 8
57
58const entries = atom({ plugin: 'catalog-pane', key: 'entries' } as const, [])
59const isLoading = atom({ plugin: 'catalog-pane', key: 'isLoading' } as const, false)
60const usage = atom({ plugin: 'catalog-pane', key: 'usage' } as const, {})
61const pending = atom({ plugin: 'catalog-pane', key: 'pending' } as const, {})
62const daily = atom({ plugin: 'catalog-pane', key: 'daily' } as const, {})
63const costs = atom({ plugin: 'catalog-pane', key: 'costs' } as const, {})
64const repos = atom({ plugin: 'catalog-pane', key: 'repos' } as const, {})
65const scans = atom({ plugin: 'catalog-pane', key: 'scans' } as const, {})
66const explanations = atom({ plugin: 'catalog-pane', key: 'explanations' } as const, {})
67const explaining = atom({ plugin: 'catalog-pane', key: 'explaining' } as const, [])
68const confirming = atom({ plugin: 'catalog-pane', key: 'confirming' } as const, null)
69const working = atom({ plugin: 'catalog-pane', key: 'working' } as const, null)
70
71type Installed = {
72 plugins?: Record<
73 string,
74 { installPath: string; version?: string; installedAt?: string; lastUpdated?: string }[]
75 >
76}
77
78type Marketplaces = Record<
79 string,
80 { source?: { source?: string; repo?: string }; installLocation?: string }
81>
82
83type MarketplaceFile = {
84 plugins?: { name: string; version?: string; source?: unknown }[]
85}
86
87type Manifest = { name?: string; description?: string; version?: string; repository?: unknown }
88
89type HookGroups = Record<string, { matcher?: string; hooks?: { type?: string; command?: string }[] }[]>
90
91type HooksFile = { modules?: string[]; hooks?: HookGroups }
92
93type PluginDir = { name: string; root: string; info: PluginInfo }
94
95const SETTINGS_SOURCES = ['user', 'project', 'local', 'policy'] as const
96
97type MeasuredPlugin = { total: Cost; parts: Record<string, Cost> }
98
99type DetailTags = Pick<
100 Elements['terminal'],
101 'Box' | 'Text' | 'Button' | 'Markdown' | 'Link'
102>
103
104type DetailProps = {
105 entry: Entry
106 list: readonly Entry[]
107 stats: Record<string, Stat>
108 repos: Record<string, RepoStats>
109 scans: Record<string, Scan>
110 now: number
111 confirming: string | null
112 working: string | null
113 explanation: string | undefined
114 isExplaining: boolean
115 onOpen: (id: string) => void
116 onExplain: () => void
117}
118
119let isFlushing = false
120let skills = new Set<string>()
121let agents: Record<string, KnownAgent> = {}
122let showsGithub = true
123let enriching: Promise<void> = Promise.resolve()
124
125const idOf = (kind: Kind, name: string) => `${kind}:${name}`
126
127const labelOf = (kind: Kind) =>
128 KINDS.find(one => one.kind === kind)?.label ?? kind
129
130const nounOf = (kind: Kind) => KINDS.find(one => one.kind === kind)?.noun ?? kind
131
132const explanationKey = (language: string, id: string) => `${language}|${id}`
133
134const dayOf = (at: number) => new Date(at).toISOString().slice(0, 10)
135
136const dateOf = (value: string | number) => new Date(value).toISOString().slice(0, 10)
137
138const shortNumber = (count: number) =>
139 count >= 1000 ? `${(count / 1000).toFixed(1).replace(/\.0$/, '')}k` : `${count}`
140
141const addMark = (marks: Usage, id: string, mark: Mark): Usage => {
142 const seen = marks[id]
143
144 return {
145 ...marks,
146 [id]: {
147 count: (seen?.count ?? 0) + mark.count,
148 lastAt: Math.max(seen?.lastAt ?? 0, mark.lastAt),
149 },
150 }
151}
152
153const merge = (base: Usage, extra: Usage): Usage =>
154 Object.entries(extra).reduce((all, [id, mark]) => addMark(all, id, mark), base)
155
156const subtract = (marks: Usage, flushed: Usage): Usage => {
157 const left: Usage = {}
158
159 for (const [id, mark] of Object.entries(marks)) {
160 const count = mark.count - (flushed[id]?.count ?? 0)
161
162 if (count > 0) {
163 left[id] = { count, lastAt: mark.lastAt }
164 }
165 }
166
167 return left
168}
169
170const addDay = (days: Daily, day: string, delta: Usage, now: number): Daily => {
171 const oldest = dayOf(now - KEPT_DAYS * DAY_MS)
172 const kept: Daily = Object.fromEntries(Object.entries(days).filter(([one]) => one >= oldest))
173 const counts = { ...(kept[day] ?? {}) }
174
175 for (const [id, mark] of Object.entries(delta)) {
176 counts[id] = (counts[id] ?? 0) + mark.count
177 }
178
179 return { ...kept, [day]: counts }
180}
181
182const sinceDays = (days: Daily, id: string, span: number, now: number) => {
183 const oldest = dayOf(now - (span - 1) * DAY_MS)
184
185 return Object.entries(days)
186 .filter(([day]) => day >= oldest)
187 .reduce((sum, [, counts]) => sum + (counts[id] ?? 0), 0)
188}
189
190const statsOf = (
191 list: readonly Entry[],
192 totals: Usage,
193 waiting: Usage,
194 days: Daily,
195 measured: Record<string, Cost>,
196 now: number,
197): Record<string, Stat> => {
198 const stats: Record<string, Stat> = {}
199
200 for (const one of list.filter(entry => !isContainer(entry.kind))) {
201 const mark = totals[one.id] ?? { count: 0, lastAt: 0 }
202 const fresh = waiting[one.id]?.count ?? 0
203 stats[one.id] = {
204 ...mark,
205 week: sinceDays(days, one.id, 7, now) + fresh,
206 month: sinceDays(days, one.id, 30, now) + fresh,
207 cost: measured[one.id],
208 }
209 }
210
211 for (const one of list.filter(entry => isContainer(entry.kind))) {
212 const items = list.filter(item => !isContainer(item.kind) && item.origin === one.name)
213 stats[one.id] = items.reduce<Stat>(
214 (sum, item) => {
215 const stat = stats[item.id]
216
217 return stat === undefined
218 ? sum
219 : {
220 ...sum,
221 count: sum.count + stat.count,
222 lastAt: Math.max(sum.lastAt, stat.lastAt),
223 week: sum.week + stat.week,
224 month: sum.month + stat.month,
225 }
226 },
227 { count: 0, lastAt: 0, week: 0, month: 0, cost: measured[one.id] },
228 )
229 }
230
231 return stats
232}
233
234const isContainer = (kind: Kind) => kind === 'plugin' || kind === 'mod'
235
236const statOf = (stats: Record<string, Stat>, id: string): Stat =>
237 stats[id] ?? { count: 0, lastAt: 0, week: 0, month: 0 }
238
239const ownerOf = (entry: Entry, list: readonly Entry[]) =>
240 entry.plugin === undefined ? list.find(one => isContainer(one.kind) && one.name === entry.origin) : entry
241
242const infoOf = (entry: Entry, list: readonly Entry[]) => ownerOf(entry, list)?.plugin
243
244const repoOf = (entry: Entry, list: readonly Entry[], known: Record<string, RepoStats>) => {
245 const repo = infoOf(entry, list)?.repo
246
247 return repo === undefined ? undefined : known[repo]
248}
249
250// Stars gained per month: measured from the stored history once it spans a day, else averaged over the repo's age.
251const trendOf = (repo: RepoStats, now: number) => {
252 const oldest = dayOf(now - MONTH_DAYS * DAY_MS)
253 const base = [...(repo.history ?? [])].reverse().find(point => point.day <= oldest) ?? (repo.history ?? [])[0]
254 const span = base === undefined ? 0 : (now - Date.parse(base.day)) / DAY_MS
255
256 if (base !== undefined && span >= 1) {
257 return { perMonth: ((repo.stars - base.stars) / span) * MONTH_DAYS, isMeasured: true }
258 }
259
260 const months = Math.max(1, (now - Date.parse(repo.createdAt || '1970-01-01')) / DAY_MS / MONTH_DAYS)
261
262 return { perMonth: repo.stars / months, isMeasured: false }
263}
264
265const timeOf = (value: string | undefined) => {
266 const at = value === undefined ? Number.NaN : Date.parse(value)
267
268 return Number.isNaN(at) ? undefined : at
269}
270
271const SORTS: readonly { sort: Sort; label: string }[] = [
272 { sort: 'usage', label: 'Most used' },
273 { sort: 'recent', label: 'Recently used' },
274 { sort: 'name', label: 'Name' },
275 { sort: 'cleanup', label: 'Clean-up: costly and unused first' },
276 { sort: 'stars', label: 'GitHub stars' },
277 { sort: 'trending', label: 'Trending: stars gained per month' },
278 { sort: 'forks', label: 'GitHub forks' },
279 { sort: 'pushed', label: 'Latest GitHub push' },
280 { sort: 'installs', label: 'Installs in the official directory' },
281 { sort: 'installed', label: 'Newest installed' },
282]
283
284const metricOf = (
285 sort: Sort,
286 entry: Entry,
287 list: readonly Entry[],
288 stat: Stat,
289 known: Record<string, RepoStats>,
290 now: number,
291) => {
292 const repo = repoOf(entry, list, known)
293 const info = infoOf(entry, list)
294
295 switch (sort) {
296 case 'recent':
297 return stat.lastAt === 0 ? undefined : stat.lastAt
298 case 'stars':
299 return repo?.stars
300 case 'trending':
301 return repo === undefined ? undefined : trendOf(repo, now).perMonth
302 case 'forks':
303 return repo?.forks
304 case 'pushed':
305 return timeOf(repo?.pushedAt)
306 case 'installs':
307 return info?.installs
308 case 'installed':
309 return timeOf(info?.installedAt)
310 default:
311 return undefined
312 }
313}
314
315// What the active sort ranks by, for the row; undefined where the row already says it.
316const metricText = (
317 sort: Sort,
318 entry: Entry,
319 list: readonly Entry[],
320 stat: Stat,
321 known: Record<string, RepoStats>,
322 now: number,
323) => {
324 const repo = repoOf(entry, list, known)
325 const info = infoOf(entry, list)
326
327 switch (sort) {
328 case 'trending':
329 return repo === undefined ? undefined : trendText(repo, now)
330 case 'forks':
331 return repo === undefined ? undefined : `${shortNumber(repo.forks)} forks`
332 case 'pushed':
333 return repo === undefined || repo.pushedAt === '' ? undefined : `pushed ${dateOf(repo.pushedAt)}`
334 case 'installs':
335 return info?.installs === undefined ? undefined : `${shortNumber(info.installs)} installs`
336 case 'installed':
337 return info?.installedAt === undefined ? undefined : `installed ${dateOf(info.installedAt)}`
338 case 'recent':
339 return stat.lastAt === 0 ? undefined : `last ${dateOf(stat.lastAt)}`
340 case 'stars':
341 return entry.plugin === undefined && repo !== undefined ? `★ ${shortNumber(repo.stars)}` : undefined
342 default:
343 return undefined
344 }
345}
346
347const visible = (
348 list: readonly Entry[],
349 stats: Record<string, Stat>,
350 filter: { kind: Kind; query: string; origin: string; sort: Sort },
351 known: Record<string, RepoStats> = {},
352 now = 0,
353) => {
354 const query = filter.query.trim().toLowerCase()
355 const order = (a: Entry, b: Entry) => {
356 const one = statOf(stats, a.id)
357 const two = statOf(stats, b.id)
358 const first = metricOf(filter.sort, a, list, one, known, now)
359 const second = metricOf(filter.sort, b, list, two, known, now)
360
361 if (first !== second) {
362 if (first === undefined || second === undefined) {
363 return first === undefined ? 1 : -1
364 }
365
366 return second - first
367 }
368
369 if (filter.sort === 'usage' && one.count !== two.count) {
370 return two.count - one.count
371 }
372
373 if (filter.sort === 'cleanup') {
374 const unused = Number(two.month === 0) - Number(one.month === 0)
375 const weight = (two.cost?.alwaysOn ?? 0) - (one.cost?.alwaysOn ?? 0)
376
377 if (unused !== 0 || weight !== 0 || one.count !== two.count) {
378 return unused || weight || one.count - two.count
379 }
380 }
381
382 return a.name.localeCompare(b.name)
383 }
384
385 return list
386 .filter(one => one.kind === filter.kind)
387 .filter(one => filter.origin === EVERYWHERE || one.origin === filter.origin)
388 .filter(
389 one =>
390 query === '' ||
391 one.name.toLowerCase().includes(query) ||
392 one.description.toLowerCase().includes(query),
393 )
394 .sort(order)
395}
396
397const originsOf = (list: readonly Entry[], kind: Kind) =>
398 [...new Set(list.filter(one => one.kind === kind).map(one => one.origin))].sort()
399
400const firstLine = (text: string) => text.split(/\r?\n/).find(line => line.trim() !== '') ?? ''
401
402const rowText = (entry: Entry, stat: Stat, known: Record<string, RepoStats> = {}) =>
403 [
404 entry.origin,
405 stat.count === 0 ? 'never used' : `${stat.count}× (${stat.month} in 30d)`,
406 stat.cost === undefined ? undefined : `~${shortNumber(stat.cost.alwaysOn)} tok/session`,
407 entry.plugin?.repo === undefined || known[entry.plugin.repo] === undefined
408 ? undefined
409 : `★ ${shortNumber(known[entry.plugin.repo]?.stars ?? 0)}`,
410 entry.plugin?.latest !== undefined && entry.plugin.latest !== entry.plugin.version ? 'update' : undefined,
411 ]
412 .filter(Boolean)
413 .join(' · ')
414
415const firstParagraph = (text: string) => text.trim().split(/\r?\n\s*\r?\n/)[0] ?? ''
416
417const frontmatter = (text: string) => {
418 const head = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? ''
419 const field = (key: string) =>
420 new RegExp(`^${key}:\\s*(.*)$`, 'm')
421 .exec(head)?.[1]
422 ?.trim()
423 .replace(/^(["'])(.*)\1$/, '$2')
424 .replaceAll('\\n', '\n')
425
426 return { name: field('name'), description: field('description') }
427}
428
429const githubRepo = (value: unknown) => {
430 const url =
431 typeof value === 'string'
432 ? value
433 : typeof value === 'object' && value !== null && 'url' in value
434 ? String(value.url)
435 : ''
436 const match = /github\.com[/:]([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:[/#?]|$)/.exec(url)
437
438 return match === null ? undefined : `${match[1]}/${match[2]}`
439}
440
441const sourceRepo = (source: unknown) =>
442 typeof source === 'object' && source !== null && 'repo' in source && typeof source.repo === 'string'
443 ? source.repo
444 : undefined
445
446const tokenCount = (text: string) => {
447 const match = /^~?([\d.,]+)(k?)$/.exec(text.trim())
448
449 if (match === null) {
450 return undefined
451 }
452
453 const count = Number((match[1] ?? '').replaceAll(',', ''))
454
455 return Math.round(match[2] === 'k' ? count * 1000 : count)
456}
457
458const parseDetails = (text: string): MeasuredPlugin => {
459 const parts: Record<string, Cost> = {}
460 const table = text.split(/Per-component[^\n]*\n/)[1] ?? ''
461
462 for (const line of table.split(/\r?\n/)) {
463 const match = /^\s+(\S+)\s+(\S+)\s+(\S+)\s*$/.exec(line)
464 const alwaysOn = match === null ? undefined : tokenCount(match[2] ?? '')
465
466 if (match?.[1] !== undefined && alwaysOn !== undefined) {
467 parts[match[1]] = { alwaysOn, onInvoke: tokenCount(match[3] ?? '') }
468 }
469 }
470
471 const total = /Always-on:\s+(\S+)\s+tok/.exec(text)?.[1]
472 const sum = Object.values(parts).reduce((all, part) => all + part.alwaysOn, 0)
473
474 return { total: { alwaysOn: (total === undefined ? undefined : tokenCount(total)) ?? sum }, parts }
475}
476
477async function configDir($: EngineInterface) {
478 const own = await $.env.get('CLAUDE_CONFIG_DIR')
479
480 if (own !== undefined && own !== '') {
481 return own
482 }
483
484 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
485
486 return `${home}/.claude`
487}
488
489async function readJson<T>($: EngineInterface, path: string) {
490 try {
491 return JSON.parse(await $.fs.read(path)) as T
492 } catch {
493 return undefined
494 }
495}
496
497async function namesIn(
498 $: EngineInterface,
499 dir: string,
500 isWanted: (entry: FsEntry) => boolean,
501) {
502 const entries = await $.fs.list(dir).catch(() => [] as FsEntry[])
503
504 return entries.filter(isWanted).map(entry => entry.name)
505}
506
507const isFolder = (entry: FsEntry) => entry.kind === 'dir' || entry.isLink
508
509const isMarkdown = (entry: FsEntry) => entry.kind !== 'dir' && entry.name.endsWith('.md')
510
511async function agentsIn(
512 $: EngineInterface,
513 dir: string,
514 source: string,
515 prefix?: string,
516): Promise<KnownAgent[]> {
517 const files = await namesIn($, dir, isMarkdown)
518
519 return Promise.all(
520 files.map(async file => {
521 const text = await $.fs.read(`${dir}/${file}`).catch(() => '')
522 const { name = file.replace(/\.md$/, ''), description = '' } = frontmatter(text)
523
524 return { name: prefix === undefined ? name : `${prefix}:${name}`, description, source }
525 }),
526 )
527}
528
529async function latestOf($: EngineInterface, marketplace: Marketplaces[string] | undefined, name: string) {
530 const location = marketplace?.installLocation
531
532 if (location === undefined) {
533 return {}
534 }
535
536 const listed = (await readJson<MarketplaceFile>($, `${location}/.claude-plugin/marketplace.json`))?.plugins?.find(
537 one => one.name === name,
538 )
539
540 if (listed === undefined) {
541 return {}
542 }
543
544 const local =
545 typeof listed.source === 'string'
546 ? await readJson<Manifest>($, `${location}/${listed.source}/.claude-plugin/plugin.json`)
547 : undefined
548
549 return { version: listed.version ?? local?.version, repo: sourceRepo(listed.source) }
550}
551
552async function pluginDirs(
553 $: EngineInterface,
554 config: string,
555): Promise<(PluginDir & { manifest?: Manifest; hooksFile?: HooksFile })[]> {
556 const installed = await readJson<Installed>($, `${config}/plugins/installed_plugins.json`)
557 const marketplaces = (await readJson<Marketplaces>($, `${config}/plugins/known_marketplaces.json`)) ?? {}
558 const catalog = await readJson<{ catalog?: { plugins?: Record<string, { unique_installs?: number }> } }>(
559 $,
560 `${config}/plugins/plugin-catalog-cache.json`,
561 )
562 const settings = await $.settings.read()
563 const enabled = (settings.enabledPlugins ?? {}) as Record<string, unknown>
564
565 const dirs = await Promise.all(
566 Object.entries(installed?.plugins ?? {}).map(async ([key, installs]) => {
567 const [name = key, marketplace = ''] = key.split('@')
568 const first = installs[0]
569
570 if (first === undefined) {
571 return undefined
572 }
573
574 const manifest = await readJson<Manifest>($, `${first.installPath}/.claude-plugin/plugin.json`)
575 const hooksFile = await readJson<HooksFile>($, `${first.installPath}/hooks/hooks.json`)
576 const latest = await latestOf($, marketplaces[marketplace], name)
577 const info: PluginInfo = {
578 key,
579 version: first.version,
580 latest: latest.version,
581 isEnabled: enabled[key] === true,
582 installedAt: first.installedAt,
583 updatedAt: first.lastUpdated,
584 repo:
585 latest.repo ??
586 githubRepo(manifest?.repository) ??
587 sourceRepo(marketplaces[marketplace]?.source),
588 installs: catalog?.catalog?.plugins?.[key]?.unique_installs,
589 }
590
591 return { name, root: first.installPath, info, manifest, hooksFile }
592 }),
593 )
594
595 return dirs.filter(one => one !== undefined)
596}
597
598const agentOrigin = (agent: KnownAgent) => {
599 const prefix = agent.name.includes(':') ? agent.name.split(':')[0] : undefined
600
601 if (prefix !== undefined) {
602 return prefix
603 }
604
605 if (agent.source === 'built-in') {
606 return 'built-in'
607 }
608
609 return /project/i.test(agent.source) ? 'project' : 'user'
610}
611
612const commandOrigin = (command: CommandInfo, skillOrigin: string | undefined) => {
613 if (command.plugin !== undefined) {
614 return command.plugin
615 }
616
617 if (skillOrigin !== undefined) {
618 return skillOrigin
619 }
620
621 return command.source === 'builtin' ? 'built-in' : command.source
622}
623
624// claude.ai connectors and plugin-provided servers are not managed by `claude mcp`.
625const isRemovableServer = (server: string) =>
626 !server.startsWith('claude_ai') && !server.startsWith('plugin_') && !/^[0-9a-f]{8}-/.test(server)
627
628const mcpEntries = (tools: readonly ToolInfo[]): Entry[] => {
629 const servers = new Map<string, string[]>()
630
631 for (const tool of tools) {
632 const [, server, name] = tool.name.split('__')
633
634 if (tool.name.startsWith('mcp__') && server !== undefined) {
635 servers.set(server, [...(servers.get(server) ?? []), name ?? tool.name])
636 }
637 }
638
639 return [...servers].map(([server, names]) => ({
640 id: idOf('mcp', server),
641 kind: 'mcp',
642 name: server,
643 origin: 'mcp',
644 description:
645 `${names.length} tools: ${names.slice(0, MCP_PREVIEW).join(', ')}` +
646 (names.length > MCP_PREVIEW ? ', …' : ''),
647 detail: names.join(', '),
648 isRemovable: isRemovableServer(server),
649 }))
650}
651
652const isMod = (hooksFile: HooksFile | undefined) => (hooksFile?.modules?.length ?? 0) > 0
653
654const hookEntries = (groups: HookGroups | undefined, origin: string, place: string): Entry[] =>
655 Object.entries(groups ?? {}).flatMap(([event, list]) =>
656 list.flatMap((group, at) =>
657 (group.hooks ?? []).map((hook, index) => ({
658 id: idOf('hook', `${origin}:${event}:${at}:${index}`),
659 kind: 'hook' as const,
660 name: group.matcher === undefined || group.matcher === '' ? event : `${event} · ${group.matcher}`,
661 description: hook.command === undefined ? `A ${hook.type ?? 'hook'} hook.` : `\`${hook.command}\``,
662 origin,
663 detail: `${hook.type ?? 'command'} hook from ${place}`,
664 })),
665 ),
666 )
667
668// CLAUDE_CODE_PLUGIN_DIRS uses the platform's path-list separator.
669const folderList = (value: string) =>
670 value
671 .split(/^[A-Za-z]:[\\/]/.test(value) || value.includes(';') ? ';' : ':')
672 .map(one => one.trim())
673 .filter(one => one !== '')
674
675async function folderPlugins($: EngineInterface): Promise<Entry[]> {
676 const folders = folderList((await $.env.get('CLAUDE_CODE_PLUGIN_DIRS')) ?? '')
677
678 const found = await Promise.all(
679 folders.map(async root => {
680 const manifest = await readJson<Manifest>($, `${root}/.claude-plugin/plugin.json`)
681
682 if (manifest?.name === undefined) {
683 return []
684 }
685
686 const hooksFile = await readJson<HooksFile>($, `${root}/hooks/hooks.json`)
687 const kind: Kind = isMod(hooksFile) ? 'mod' : 'plugin'
688 const version = manifest.version === undefined ? '' : `, v${manifest.version}`
689 const entry: Entry = {
690 id: idOf(kind, manifest.name),
691 kind,
692 name: manifest.name,
693 description: manifest.description ?? '',
694 origin: 'folder',
695 detail: `Loaded from ${root} through CLAUDE_CODE_PLUGIN_DIRS${version}.`,
696 root,
697 }
698
699 return [entry, ...hookEntries(hooksFile?.hooks, manifest.name, `the plugin ${manifest.name}`)]
700 }),
701 )
702
703 return found.flat()
704}
705
706async function settingsHooks($: EngineInterface): Promise<Entry[]> {
707 const found = await Promise.all(
708 SETTINGS_SOURCES.map(async source => {
709 const settings = await $.settings.read({ source }).catch(() => ({}) as Record<string, unknown>)
710
711 return hookEntries(settings.hooks as HookGroups | undefined, source, `the ${source} settings`)
712 }),
713 )
714
715 return found.flat()
716}
717
718async function collect($: EngineInterface): Promise<Entry[]> {
719 const config = await configDir($)
720 const [commands, tools, plugins] = await Promise.all([
721 $.command.list(),
722 $.tool.list(),
723 pluginDirs($, config),
724 ])
725 const seenSkills = ((await $.store.get(SKILLS_KEY)) ?? []) as string[]
726 const offered = ((await $.store.get(AGENTS_KEY)) ?? {}) as Record<string, KnownAgent>
727
728 const skillOrigins = new Map<string, string>()
729
730 for (const name of seenSkills) {
731 skillOrigins.set(name, name.includes(':') ? (name.split(':')[0] ?? 'user') : 'user')
732 }
733
734 for (const name of await namesIn($, `${config}/skills`, isFolder)) {
735 skillOrigins.set(name, 'user')
736 }
737
738 for (const name of await namesIn($, '.claude/skills', isFolder)) {
739 skillOrigins.set(name, 'project')
740 }
741
742 const agentsFound = new Map<string, KnownAgent>()
743 const found = [
744 ...(await agentsIn($, `${config}/agents`, 'userSettings')),
745 ...(await agentsIn($, '.claude/agents', 'projectSettings')),
746 ]
747
748 for (const plugin of plugins) {
749 for (const name of await namesIn($, `${plugin.root}/skills`, isFolder)) {
750 skillOrigins.set(`${plugin.name}:${name}`, plugin.name)
751 }
752
753 found.push(...(await agentsIn($, `${plugin.root}/agents`, 'plugin', plugin.name)))
754 }
755
756 for (const agent of [...found, ...Object.values(offered)]) {
757 agentsFound.set(agent.name, agent)
758 }
759
760 const commandEntries: Entry[] = commands.map(command => {
761 const skillOrigin = skillOrigins.get(command.name)
762 const kind: Kind = skillOrigin === undefined ? 'command' : 'skill'
763
764 return {
765 id: idOf(kind, command.name),
766 kind,
767 name: command.name,
768 description: command.description,
769 origin: commandOrigin(command, skillOrigin),
770 }
771 })
772
773 const folders = await folderPlugins($)
774 const loaded = new Set(commands.flatMap(command => command.plugin ?? []))
775
776 for (const entry of folders) {
777 loaded.delete(entry.name)
778 }
779
780 const pluginEntries: Entry[] = plugins.map(plugin => {
781 const kind: Kind = isMod(plugin.hooksFile) ? 'mod' : 'plugin'
782 loaded.delete(plugin.name)
783
784 return {
785 id: idOf(kind, plugin.name),
786 kind,
787 name: plugin.name,
788 description: plugin.manifest?.description ?? '',
789 origin: plugin.info.key.split('@')[1] ?? '',
790 plugin: plugin.info,
791 root: plugin.root,
792 }
793 })
794
795 for (const name of loaded) {
796 pluginEntries.push({
797 id: idOf('plugin', name),
798 kind: 'plugin',
799 name,
800 description: '',
801 origin: 'session',
802 detail: 'Loaded for this session from a folder, not installed.',
803 })
804 }
805
806 const agentEntries: Entry[] = [...agentsFound.values()].map(agent => ({
807 id: idOf('agent', agent.name),
808 kind: 'agent',
809 name: agent.name,
810 description: agent.description,
811 origin: agentOrigin(agent),
812 }))
813
814 const toolEntries: Entry[] = tools
815 .filter(tool => !tool.name.startsWith('mcp__'))
816 .map(tool => ({
817 id: idOf('tool', tool.name),
818 kind: 'tool',
819 name: tool.name,
820 description: firstParagraph(tool.description),
821 origin: 'built-in',
822 }))
823
824 const pluginHooks = plugins.flatMap(plugin =>
825 hookEntries(plugin.hooksFile?.hooks, plugin.name, `the plugin ${plugin.name}`),
826 )
827
828 return [
829 ...commandEntries,
830 ...agentEntries,
831 ...pluginEntries,
832 ...folders,
833 ...pluginHooks,
834 ...(await settingsHooks($)),
835 ...mcpEntries(tools),
836 ...toolEntries,
837 ]
838}
839
840async function measureCosts($: EngineInterface, list: readonly Entry[]) {
841 const cache = ((await $.store.get(COSTS_KEY)) ?? {}) as Record<string, MeasuredPlugin>
842 const measured: Record<string, Cost> = {}
843
844 for (const entry of list) {
845 const info = entry.plugin
846
847 if (info === undefined) {
848 continue
849 }
850
851 const cacheKey = `${info.key}@${info.version ?? ''}`
852 let result = cache[cacheKey]
853
854 if (result === undefined) {
855 const run = await $.process
856 .run(['claude', 'plugin', 'details', info.key], { timeoutMs: CLI_TIMEOUT_MS })
857 .catch(() => undefined)
858
859 if (run === undefined || run.exitCode !== 0) {
860 continue
861 }
862
863 result = parseDetails(run.stdout)
864 cache[cacheKey] = result
865 await $.store.set(COSTS_KEY, cache)
866 }
867
868 measured[entry.id] = result.total
869
870 for (const [part, cost] of Object.entries(result.parts)) {
871 for (const kind of ['skill', 'agent', 'command'] as const) {
872 measured[idOf(kind, `${entry.name}:${part}`)] = cost
873 }
874 }
875
876 await update($, costs, () => ({ ...measured }))
877 }
878}
879
880async function fetchRepos($: EngineInterface, list: readonly Entry[]) {
881 const cache = ((await $.store.get(REPOS_KEY)) ?? {}) as Record<string, RepoStats>
882 const now = await $.clock.now()
883 const wanted = [...new Set(list.flatMap(entry => entry.plugin?.repo ?? []))]
884
885 for (const repo of wanted) {
886 const cached = cache[repo]
887
888 // Entries saved before forks, creation date and history were kept are fetched again.
889 if (cached?.history !== undefined && now - cached.fetchedAt < REPO_TTL_MS) {
890 continue
891 }
892
893 const response = await $.http
894 .fetch(`https://api.github.com/repos/${repo}`, {
895 headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'catalog-pane' },
896 })
897 .catch(() => undefined)
898
899 if (response?.ok !== true) {
900 continue
901 }
902
903 const body = JSON.parse(response.text) as {
904 stargazers_count?: number
905 forks_count?: number
906 open_issues_count?: number
907 pushed_at?: string
908 created_at?: string
909 license?: { spdx_id?: string } | null
910 }
911 const stars = body.stargazers_count ?? 0
912 const day = dayOf(now)
913 const history = (cached?.history ?? []).filter(point => point.day !== day)
914 cache[repo] = {
915 stars,
916 forks: body.forks_count ?? 0,
917 issues: body.open_issues_count ?? 0,
918 pushedAt: body.pushed_at ?? '',
919 createdAt: body.created_at ?? '',
920 license: body.license?.spdx_id,
921 history: [...history, { day, stars }].slice(-KEPT_STAR_POINTS),
922 fetchedAt: now,
923 }
924 }
925
926 await $.store.set(REPOS_KEY, cache)
927 await update($, repos, () => cache)
928}
929
930const scanLine = (text: string, label: string) =>
931 text
932 .split(/\r?\n/)
933 .map(line => new RegExp(`^\\s*\\S+\\s+\\S+\\s+${label}:\\s*(.*)$`).exec(line)?.[1])
934 .filter(found => found !== undefined)
935 .join('; ')
936
937async function scanMods($: EngineInterface, list: readonly Entry[]) {
938 const cache = ((await $.store.get(SCANS_KEY)) ?? {}) as Record<string, Scan>
939 const found: Record<string, Scan> = {}
940
941 for (const entry of list) {
942 if (entry.kind !== 'mod' || entry.root === undefined) {
943 continue
944 }
945
946 const cacheKey = `${entry.root}|${entry.plugin?.version ?? entry.detail ?? ''}`
947 let scan = cache[cacheKey]
948
949 if (scan === undefined) {
950 const run = await $.process
951 .run(['claude', 'plugin', 'validate', entry.root], { timeoutMs: CLI_TIMEOUT_MS })
952 .catch(() => undefined)
953
954 if (run === undefined) {
955 continue
956 }
957
958 scan = { hooks: scanLine(run.stdout, 'hooks'), calls: scanLine(run.stdout, 'calls') }
959 cache[cacheKey] = scan
960 await $.store.set(SCANS_KEY, cache)
961 }
962
963 found[entry.id] = scan
964 await update($, scans, () => ({ ...found }))
965 }
966}
967
968async function enrich($: EngineInterface, list: readonly Entry[]) {
969 await measureCosts($, list)
970 await scanMods($, list)
971
972 if (showsGithub) {
973 await fetchRepos($, list)
974 }
975}
976
977const skillName = (skill: string, pluginName: string | undefined) =>
978 pluginName === undefined || skill.includes(':') ? skill : `${pluginName}:${skill}`
979
980async function bump($: EngineInterface, id: string) {
981 const at = await $.clock.now()
982 await update($, pending, marks => addMark(marks, id, { count: 1, lastAt: at }))
983}
984
985async function flush($: EngineInterface) {
986 if (isFlushing) {
987 return
988 }
989
990 isFlushing = true
991
992 try {
993 const delta = await read($, pending)
994
995 if (Object.keys(delta).length > 0) {
996 const now = await $.clock.now()
997 // Re-read the store so counts from other sessions running at the same time survive.
998 const merged = merge(((await $.store.get(USAGE_KEY)) ?? {}) as Usage, delta)
999 const days = addDay(((await $.store.get(DAILY_KEY)) ?? {}) as Daily, dayOf(now), delta, now)
1000 await $.store.set(USAGE_KEY, merged)
1001 await $.store.set(DAILY_KEY, days)
1002 await update($, pending, marks => subtract(marks, delta))
1003 await update($, usage, () => merged)
1004 await update($, daily, () => days)
1005 }
1006 } finally {
1007 isFlushing = false
1008 }
1009}
1010
1011async function snapshot($: EngineInterface) {
1012 const list = await read($, entries)
1013 const waiting = await read($, pending)
1014 const now = await $.clock.now()
1015
1016 return {
1017 list,
1018 stats: statsOf(list, merge(await read($, usage), waiting), waiting, await read($, daily), await read($, costs), now),
1019 repos: await read($, repos),
1020 scans: await read($, scans),
1021 now,
1022 notes: await read($, explanations),
1023 busy: await read($, explaining),
1024 confirming: await read($, confirming),
1025 working: await read($, working),
1026 isLoading: await read($, isLoading),
1027 }
1028}
1029
1030async function refresh($: EngineInterface) {
1031 await update($, isLoading, () => true)
1032
1033 try {
1034 const list = await collect($)
1035 await update($, entries, () => list)
1036 // One enrichment at a time, so a second refresh reads the caches the first one wrote.
1037 enriching = enriching.then(() => enrich($, list)).catch(() => undefined)
1038 } finally {
1039 await update($, isLoading, () => false)
1040 }
1041}
1042
1043async function startCatalog($: EngineInterface, options: { github: boolean }) {
1044 showsGithub = options.github
1045 const stored = ((await $.store.get(USAGE_KEY)) ?? {}) as Usage
1046 const days = ((await $.store.get(DAILY_KEY)) ?? {}) as Daily
1047 const known = ((await $.store.get(EXPLANATIONS_KEY)) ?? {}) as Record<string, string>
1048 const fetched = ((await $.store.get(REPOS_KEY)) ?? {}) as Record<string, RepoStats>
1049 skills = new Set(((await $.store.get(SKILLS_KEY)) ?? []) as string[])
1050 agents = ((await $.store.get(AGENTS_KEY)) ?? {}) as Record<string, KnownAgent>
1051 await update($, usage, () => stored)
1052 await update($, daily, () => days)
1053 await update($, explanations, () => known)
1054 await update($, repos, () => (showsGithub ? fetched : {}))
1055 $.clock.every(FLUSH_MS, () => void flush($))
1056}
1057
1058function trackCatalog(on: On) {
1059 on('session.end', async ($, e, next) => {
1060 await flush($)
1061
1062 return next(e)
1063 })
1064
1065 on('command.run', async ($, e, next) => {
1066 await bump($, idOf('command', e.command))
1067
1068 return next(e)
1069 })
1070
1071 on('skill.prompt', async ($, e, next) => {
1072 const name = skillName(e.skill, e.pluginName)
1073 await bump($, idOf('skill', name))
1074
1075 if (!skills.has(name)) {
1076 skills.add(name)
1077 await $.store.set(SKILLS_KEY, [...skills])
1078 }
1079
1080 return next(e)
1081 })
1082
1083 on('agent.spawn', async ($, e, next) => {
1084 await bump($, idOf('agent', e.subagentType || 'general-purpose'))
1085
1086 return next(e)
1087 })
1088
1089 on('tool.call', async ($, e, next) => {
1090 const tool = String(e.tool)
1091 const [, server] = tool.split('__')
1092 await bump(
1093 $,
1094 tool.startsWith('mcp__') && server !== undefined ? idOf('mcp', server) : idOf('tool', tool),
1095 )
1096
1097 return next(e)
1098 })
1099
1100 on('agent.offer', async ($, e, next) => {
1101 const seen = agents[e.agent]
1102
1103 if (seen?.description !== e.description || seen.source !== e.source) {
1104 agents = { ...agents, [e.agent]: { name: e.agent, description: e.description, source: e.source } }
1105 await $.store.set(AGENTS_KEY, agents)
1106 }
1107
1108 return next(e)
1109 })
1110}
1111
1112async function explain($: EngineInterface, entry: Entry, language: string) {
1113 const result = await $.model.complete({
1114 model: 'haiku',
1115 maxTokens: 400,
1116 system: 'You explain one part of a Claude Code setup to its user. Be concrete and brief.',
1117 prompt: [
1118 `Kind: ${nounOf(entry.kind)}`,
1119 `Name: ${entry.name}`,
1120 `From: ${entry.origin}`,
1121 `Description: ${entry.description || '(none)'}`,
1122 entry.detail === undefined ? '' : `Details: ${entry.detail}`,
1123 '',
1124 `Answer in ${language}, in at most three short sentences and without a preamble: ` +
1125 'what it is good for, when to reach for it, and one concrete example of using it.',
1126 ].join('\n'),
1127 })
1128
1129 return result.isAnswered ? result.text.trim() : undefined
1130}
1131
1132async function explainInto(
1133 $: EngineInterface,
1134 entry: Entry,
1135 language: string,
1136) {
1137 await update($, explaining, ids => [...ids, entry.id])
1138
1139 try {
1140 const text = await explain($, entry, language)
1141
1142 if (text === undefined) {
1143 $.ui.toast(`No explanation for ${entry.name}: the model did not answer.`)
1144
1145 return
1146 }
1147
1148 const key = explanationKey(language, entry.id)
1149 const stored = ((await $.store.get(EXPLANATIONS_KEY)) ?? {}) as Record<string, string>
1150 await $.store.set(EXPLANATIONS_KEY, { ...stored, [key]: text })
1151 await update($, explanations, all => ({ ...all, [key]: text }))
1152 } finally {
1153 await update($, explaining, ids => ids.filter(id => id !== entry.id))
1154 }
1155}
1156
1157function insert($: EngineInterface, text: string) {
1158 void $.prompt.fill({ text, mode: 'replace' })
1159}
1160
1161async function run($: EngineInterface, name: string) {
1162 try {
1163 await $.command.run({ command: name })
1164 } catch {
1165 $.ui.toast(`/${name} could not run here; inserted it into the prompt instead.`)
1166 insert($, `/${name} `)
1167 }
1168}
1169
1170async function cli($: EngineInterface, task: string, argv: readonly string[], done: string) {
1171 await update($, working, () => task)
1172
1173 try {
1174 const result = await $.process.run(['claude', ...argv], { timeoutMs: CLI_TIMEOUT_MS })
1175 $.ui.toast(
1176 result.exitCode === 0
1177 ? `${done} It takes effect in the next session.`
1178 : `claude ${argv.join(' ')} failed: ${firstLine(result.stderr || result.stdout)}`,
1179 )
1180 } catch {
1181 $.ui.toast(`claude ${argv.join(' ')} could not start.`)
1182 } finally {
1183 await update($, working, () => null)
1184 await update($, confirming, () => null)
1185 }
1186
1187 await refresh($)
1188}
1189
1190async function ask($: EngineInterface, task: string) {
1191 await update($, confirming, () => task)
1192}
1193
1194async function cancel($: EngineInterface) {
1195 await update($, confirming, () => null)
1196}
1197
1198function confirmation(
1199 $: EngineInterface,
1200 { Box, Button, Text }: DetailTags,types/index.d.ts 94 lines1export type Kind = 'skill' | 'command' | 'agent' | 'mod' | 'plugin' | 'hook' | 'mcp' | 'tool'
2
3export type Cost = { alwaysOn: number; onInvoke?: number }
4
5export type PluginInfo = {
6 key: string
7 version?: string
8 latest?: string
9 isEnabled: boolean
10 installedAt?: string
11 updatedAt?: string
12 repo?: string
13 installs?: number
14}
15
16export type Entry = {
17 id: string
18 kind: Kind
19 name: string
20 description: string
21 origin: string
22 detail?: string
23 plugin?: PluginInfo
24 root?: string
25 isRemovable?: boolean
26}
27
28export type Scan = { hooks: string; calls: string }
29
30export type Mark = { count: number; lastAt: number }
31
32export type Usage = Record<string, Mark>
33
34export type Daily = Record<string, Record<string, number>>
35
36export type Stat = Mark & { week: number; month: number; cost?: Cost }
37
38export type StarPoint = { day: string; stars: number }
39
40export type RepoStats = {
41 stars: number
42 forks: number
43 issues: number
44 pushedAt: string
45 createdAt: string
46 license?: string
47 history: StarPoint[]
48 fetchedAt: number
49}
50
51export type KnownAgent = { name: string; description: string; source: string }
52
53export type Sort =
54 | 'usage'
55 | 'recent'
56 | 'name'
57 | 'cleanup'
58 | 'stars'
59 | 'trending'
60 | 'forks'
61 | 'pushed'
62 | 'installs'
63 | 'installed'
64
65export type View = {
66 kind: Kind
67 selected: string | null
68 trail: string[]
69 query: string
70 sort: Sort
71 origin: string
72 page: number
73}
74
75declare module 'claude-code' {
76 interface PluginState {
77 'catalog-pane': {
78 entries: Entry[]
79 isLoading: boolean
80 usage: Usage
81 pending: Usage
82 daily: Daily
83 costs: Record<string, Cost>
84 repos: Record<string, RepoStats>
85 scans: Record<string, Scan>
86 view: View
87 explanations: Record<string, string>
88 explaining: string[]
89 confirming: string | null
90 working: string | null
91 }
92 }
93}
94