A live pane that shows which skills are active, which ones fit your prompt (scored 1 to 10), popular skills you have not installed yet, and which ones you…

Claude Code can load hundreds of skills. It rarely picks the one you would have picked.
skills-panel is a Claude Code mod: a pane next to the conversation that shows, before Claude answers, which of your skills fit the prompt you just typed, scored from 1 to 10, and which popular skills you don't have yet. I built it because I have 1,604 skills installed and kept forgetting the right one existed.

╭ Active ──────────────────────────────╮
│ claude-seo:seo-audit SEO │
│ humaniseur-fr x2 Writing │
╰──────────────────────────────────────╯
[ Suggested 2 ] [ Discover 3 ] [ Top used ]
╭──────────────────────────────────────╮
│ for: "write the Instagram caption..."│
│ 1. /copywriting-hooks │
│ █████████░ 9 Writing rule │
│ Three openings to pick from │
│ 2. /social-content │
│ ███████░░░ 7 Marketing │
│ Built for Instagram captions │
╰──────────────────────────────────────╯
Active sits at the top and lists the skills loaded in the current session, with a count when one runs more than once. Below it, three tabs.
Suggested ranks the skills that fit your last prompt, scored from 1 to 10, each with a one-line reason. Click one and /skill-name is added after whatever you have already typed. The ones scoring 7 or more are also passed to Claude along with your prompt, so it can load them without you clicking anything.
Discover lists popular skills you haven't installed, found on skills.sh for the same prompt: name, repository and install count, 1,000 installs at least. Click one and its install command (npx skills add owner/repo@skill -g -y) is added to the prompt; send it and Claude installs it. The tab shows how many it found, so you know there is something to look at without leaving Suggested.

Top used shows what you actually reach for, in this project or across all of them, filterable by tag (SEO, Writing, Design, Dev, Workflow and a few more).
The pane remembers the last tab you opened.
Two things make the suggestions better over time. If a project's CLAUDE.md names a skill in backticks, that skill gets a boost and a rule badge; if the sentence says "never", "don't" or "jamais", the skill is never suggested in that project. And every skill you use or click gains a little weight in that project, while one shown five times and never touched loses some.
In a Claude Code terminal session:
/plugin install skills-panel --marketplace rubenhaccoun-cyber/skills-panel
Answer y to add the marketplace, then pick the user scope. The pane opens on its own in every new session. If it doesn't show (terminals under 144 columns keep it closed until asked), type /skills-panel.
The install command only works in a terminal. Once installed at user scope, the mod also loads in the desktop app's Code tab.
~/.claude/skills, in the project's .claude/skills, or through plugins.Type a prompt as usual, for example write the Instagram caption for the new collection. A couple of seconds later the Suggested block fills in, and Claude starts its answer with the best-scored skills already in front of it. Short replies like "ok" or "go ahead" don't trigger anything, and the previous suggestions stay on screen.
| Command | What it does |
|---|---|
/skills-panel | Opens the pane |
/skills-rescan | Re-reads installed skills and the project's CLAUDE.md rules |
/skills-stats | Most used skills across projects, and how many per tag you have never used |
Change them in the Claude Code config menu, or in ~/.claude/settings.json under pluginConfigs.
| Setting | Default | Effect |
|---|---|---|
language | en | en or fr, for the pane and for the reasons next to each suggestion |
contextMinScore | 7 | Suggestions at or above this score are passed to Claude |
maxSuggestions | 10 | How many suggestions the pane shows at most |
shareWithClaude | true | Set to false to keep suggestions in the pane only |
discover | true | Set to false to turn the Discover tab off; nothing is then sent to skills.sh |
Each prompt of 12 characters or more (slash commands are skipped) goes through two Haiku calls before it reaches Claude:
CLAUDE.md, and scores each one. The same answer carries 2 to 4 English keywords describing the task.Once the prompt has gone on to Claude, the mod sends those keywords to the skills.sh search, drops the skills you already have and the ones under 1,000 installs, and fills the Discover tab. Claude never waits for it: the search usually takes 0.3 to 1.5 seconds and runs alongside its answer.
The conversation matters more than you'd think. Before I added it, typing "hybrid + context" in a chat about this very mod got me suggestions for RAG pipelines.
Both calls run on your own Claude Code session, with the same account and limits as the rest of your conversation; there is no separate API key. For 1,600 skills the cached name list is about 15,000 tokens. Prompts shorter than 12 characters cost nothing.
What leaves your machine: the new prompt, your last four messages (500 characters each at most), skill names and descriptions. They go to Anthropic through your session and nowhere else. With Discover on, skills.sh also receives the 2 to 4 keywords for each prompt, never the prompt itself; set discover to false to stop that. Usage history stays in Claude Code's local plugin store.
Tags are guessed from skill names and descriptions, so a few land in the wrong place or in Other. History is grouped by folder name, which means two folders with the same name share it. And Haiku sometimes overrates a skill whose name matches your wording; the score is a hint, not a verdict.
Discover ranks by install count, which says nothing about quality or fit, and install counts can be inflated. Read a skill's page on skills.sh and its SKILL.md before installing it, especially for API keys or paid accounts it needs.
Issues and pull requests are welcome, especially new languages and better tag rules. Please run the validator and the tests before opening a PR.
The code is in hooks/: register.tsx for the hooks and the pane, skills.ts for parsing and scoring, i18n.ts for the two languages. To work on your own copy:
claude --plugin-dir /path/to/skills-panel
claude plugin validate /path/to/skills-panel
claude plugin test /path/to/skills-panel
Saving a file reloads the mod in the running session. Adding a language means adding one object to i18n.ts and its code to the language options in .claude-plugin/plugin.json.
MIT. Use it, change it, fork it, ship your own version; just keep the copyright notice.
hooks/register.tsx 578 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ActiveSkill, Found, Suggestion, TopSkill } from '../types'
5import { STRINGS } from './i18n'
6import type { Lang } from './i18n'
7import {
8 adjust,
9 parseFound,
10 parseNames,
11 parsePicks,
12 parseSearch,
13 parseSkillMd,
14 projectRules,
15 shortlist,
16 tagOf,
17 tokenize,
18} from './skills'
19import type { SkillInfo, Stat } from './skills'
20
21const PANE = 'skills-panel'
22const TITLE = 'Skills'
23const active = atom({ plugin: 'skills-panel', key: 'active' } as const, [] as ActiveSkill[])
24const suggested = atom({ plugin: 'skills-panel', key: 'suggested' } as const, [] as Suggestion[])
25const note = atom({ plugin: 'skills-panel', key: 'note' } as const, '')
26const about = atom({ plugin: 'skills-panel', key: 'about' } as const, '')
27const top = atom({ plugin: 'skills-panel', key: 'top' } as const, [] as TopSkill[])
28const topAll = atom({ plugin: 'skills-panel', key: 'topAll' } as const, [] as TopSkill[])
29const topScope = atom({ plugin: 'skills-panel', key: 'topScope' } as const, 'project')
30const tagFilter = atom({ plugin: 'skills-panel', key: 'tagFilter' } as const, '')
31const ruleNote = atom({ plugin: 'skills-panel', key: 'ruleNote' } as const, '')
32const cacheNote = atom({ plugin: 'skills-panel', key: 'cacheNote' } as const, '')
33const found = atom({ plugin: 'skills-panel', key: 'found' } as const, [] as Found[])
34const foundQuery = atom({ plugin: 'skills-panel', key: 'foundQuery' } as const, '')
35const tab = atom({ plugin: 'skills-panel', key: 'tab' } as const, 'suggested')
36const TABS = ['suggested', 'discover', 'top']
37const REGISTRY = 'https://skills.sh'
38
39type InstalledPlugins = { plugins?: Record<string, { installPath?: string }[]> }
40type Usage = Record<string, Record<string, Stat>>
41
42// Fixed colors rather than theme keys: each surface renders theme keys differently and the API
43// does not say which theme is active, so these are dark enough for white text (4.5:1+) everywhere.
44const TAG_COLORS: Record<string, string> = {
45 seo: '#1a7f37',
46 video: '#cf222e',
47 writing: '#b4532a',
48 ecommerce: '#8a5a00',
49 marketing: '#bf3989',
50 design: '#8250df',
51 data: '#1b7c83',
52 dev: '#0969da',
53 workflow: '#3b45c4',
54 other: '#6e7781',
55}
56const RULE_COLOR = '#9a6700'
57const ALL_COLOR = '#b4532a'
58const tagColor = (tag: string) => TAG_COLORS[tag] ?? '#6e7781'
59const scoreColor = (score: number) => (score >= 8 ? 'success' : score >= 5 ? 'warning' : 'subtle')
60const bar = (score: number) => '█'.repeat(score) + '░'.repeat(10 - score)
61const GAUGE_WIDTH = 96
62const GAUGE_HEIGHT = 8
63const GAUGE_HEX = (score: number) => (score >= 8 ? '#2DA44E' : score >= 5 ? '#D29922' : '#8B949E')
64const gauge = (score: number) =>
65 `<svg xmlns="http://www.w3.org/2000/svg" width="${GAUGE_WIDTH}" height="${GAUGE_HEIGHT}" viewBox="0 0 ${GAUGE_WIDTH} ${GAUGE_HEIGHT}">` +
66 `<rect width="${GAUGE_WIDTH}" height="${GAUGE_HEIGHT}" rx="${GAUGE_HEIGHT / 2}" fill="#8B949E" fill-opacity="0.25"/>` +
67 `<rect width="${Math.max(GAUGE_HEIGHT, (GAUGE_WIDTH * score) / 10)}" height="${GAUGE_HEIGHT}" rx="${GAUGE_HEIGHT / 2}" fill="${GAUGE_HEX(score)}"/>` +
68 '</svg>'
69const thousands = (count: number) => String(count).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
70const compact = (count: number) =>
71 count >= 1e6 ? `${(count / 1e6).toFixed(1).replace(/\.0$/, '')}M` : `${(count / 1e3).toFixed(1).replace(/\.0$/, '')}K`
72// The registry's own install syntax; a name with spaces must be quoted.
73const installCommand = (skill: Found) => {
74 const target = `${skill.source}@${skill.name}`
75 return `npx skills add ${/\s/.test(target) ? `"${target}"` : target} -g -y`
76}
77
78// Set from the plugin's userConfig each time the module loads.
79let lang: Lang = 'en'
80let minContextScore = 7
81let maxSuggestions = 10
82let isContextOn = true
83let isDiscoverOn = true
84const t = () => STRINGS[lang]
85const tagLabel = (tag: string) => t().tags[tag] ?? tag
86
87const JUDGE_INTENT =
88 'Judge what the user actually wants done, not words that merely appear in the request: ' +
89 'a question about a tool already being discussed, or a short confirmation, needs no unrelated skill.'
90
91const situation = (conversation: string, request: string) =>
92 (conversation === '' ? '' : `Recent conversation (the request may refer to it):\n${conversation}\n\n`) +
93 `New request from the user to a coding agent:\n${request}`
94
95const selectPrompt = (conversation: string, request: string) =>
96 situation(conversation, request) +
97 '\n\nFrom the skill catalog above, list up to 60 skill names that could help with the new request. ' +
98 JUDGE_INTENT +
99 ' Answer with a JSON array of exact names only, [] if none.'
100
101const pickPrompt = (conversation: string, request: string, candidates: SkillInfo[], rules: string[]) =>
102 situation(conversation, request) +
103 '\n\nCandidate skills:\n' +
104 candidates.map(skill => `- ${skill.name}: ${skill.description.slice(0, 200)}`).join('\n') +
105 (rules.length > 0
106 ? `\n\nThe project's CLAUDE.md requires these skills whenever the request involves what they cover: ${rules.join(', ')}.`
107 : '') +
108 `\n\nPick up to ${maxSuggestions} skills that would help with this request, most relevant first, ` +
109 'including ones that are only moderately useful. ' +
110 'Give each a relevance score from 1 (marginal) to 10 (essential). ' +
111 'Answer with a JSON object only: {"search": "<2 to 4 English keywords to look this task up in a public skill registry>", ' +
112 `"picks": [{"name": "<exact skill name>", "score": <1-10>, "why": "<reason in ${t().reasonLanguage}, 10 words max>"}]}. ` +
113 JUDGE_INTENT +
114 ' Leave picks empty if none is clearly relevant.'
115
116const contextFor = (picks: Suggestion[], banned: string[]) =>
117 `${t().context}\n` +
118 picks.map(pick => `- ${pick.name} (${pick.score}/10${pick.isRule ? `, ${t().contextRule}` : ''}) : ${pick.why}`).join('\n') +
119 (banned.length > 0 ? `\n${t().contextForbidden(banned)}` : '')
120
121let index: SkillInfo[] = []
122let catalog = ''
123let byName = new Map<string, SkillInfo>()
124let required: string[] = []
125let forbidden: string[] = []
126let project = ''
127
128const tagOfName = (name: string) => tagOf(byName.get(name) ?? { name, description: '' })
129
130async function scanDir($: EngineInterface, root: string, prefix: string): Promise<SkillInfo[]> {
131 if (!(await $.fs.exists(root))) return []
132 const entries = (await $.fs.list(root)).filter(entry => entry.kind === 'dir' || entry.isLink)
133 const found = await Promise.all(
134 entries.map(async entry => {
135 const text = await $.fs.read(`${root}/${entry.name}/SKILL.md`).catch(() => undefined)
136 if (text === undefined) return undefined
137 const info = parseSkillMd(text, entry.name)
138 return { ...info, name: prefix + info.name }
139 }),
140 )
141 return found.filter(info => info !== undefined)
142}
143
144async function loadUsage($: EngineInterface): Promise<Usage> {
145 return ((await $.store.get('usage')) ?? {}) as Usage
146}
147
148const ranked = (counts: Map<string, number>): TopSkill[] =>
149 [...counts]
150 .filter(([, count]) => count > 0)
151 .map(([name, count]) => ({ name, count, tag: tagOfName(name) }))
152 .sort((a, b) => b.count - a.count)
153 .slice(0, 50)
154
155async function refreshTop($: EngineInterface, usage: Usage) {
156 const here = new Map<string, number>()
157 const everywhere = new Map<string, number>()
158 for (const [folder, stats] of Object.entries(usage)) {
159 for (const [name, stat] of Object.entries(stats)) {
160 const count = stat.used + stat.clicked
161 everywhere.set(name, (everywhere.get(name) ?? 0) + count)
162 if (folder === project) here.set(name, count)
163 }
164 }
165 await update($, top, () => ranked(here))
166 await update($, topAll, () => ranked(everywhere))
167}
168
169async function bump($: EngineInterface, names: string[], field: 'used' | 'clicked' | 'shown') {
170 if (names.length === 0) return
171 const usage = await loadUsage($)
172 const stats = usage[project] ?? {}
173 const now = await $.clock.now()
174 for (const name of names) {
175 const stat = stats[name] ?? { used: 0, clicked: 0, shown: 0, last: 0 }
176 stats[name] = { ...stat, [field]: stat[field] + 1, last: field === 'shown' ? stat.last : now }
177 }
178 usage[project] = stats
179 await $.store.set('usage', usage)
180 await refreshTop($, usage)
181}
182
183async function setScope($: EngineInterface, scope: string) {
184 await update($, topScope, () => scope)
185 await update($, tagFilter, () => '')
186 await $.store.set('topScope', scope)
187}
188
189async function setTab($: EngineInterface, value: string) {
190 await update($, tab, () => value)
191 await $.store.set('tab', value)
192}
193
194// Runs after the prompt has gone on, so the registry round trip never delays Claude's answer.
195function lookUp($: EngineInterface, query: string) {
196 if (!isDiscoverOn || query === '') return
197 $.clock.after(0, () => void discover($, query))
198}
199
200async function discover($: EngineInterface, query: string) {
201 await update($, foundQuery, () => query)
202 const response = await $.http
203 .fetch(`${REGISTRY}/api/search?q=${encodeURIComponent(query)}&limit=20`)
204 .catch(() => undefined)
205 const list = response?.ok ? parseFound(response.text, index.map(skill => skill.name)) : []
206 await update($, found, () => list)
207}
208
209async function reindex($: EngineInterface): Promise<number> {
210 const home = await $.env.get('HOME')
211 const installed = JSON.parse(
212 await $.fs.read(`${home}/.claude/plugins/installed_plugins.json`).catch(() => '{}'),
213 ) as InstalledPlugins
214 const roots: [string, string][] = [
215 [`${home}/.claude/skills`, ''],
216 ['.claude/skills', ''],
217 ...Object.entries(installed.plugins ?? {}).flatMap(([key, installs]): [string, string][] => {
218 const path = installs[0]?.installPath
219 return path === undefined ? [] : [[`${path}/skills`, `${key.split('@')[0]}:`]]
220 }),
221 ]
222 const all = (await Promise.all(roots.map(([root, prefix]) => scanDir($, root, prefix)))).flat()
223 byName = new Map(all.map(skill => [skill.name, skill]))
224 index = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name))
225 catalog = `Skill catalog (one name per line):\n${index.map(skill => skill.name).join('\n')}`
226
227 const root = await $.session.root()
228 project = root.split('/').filter(Boolean).pop() ?? root
229 const claudeMd = await $.fs.ancestors({ names: ['CLAUDE.md'] })
230 const rules = projectRules(
231 claudeMd.map(file => file.content),
232 index.map(skill => skill.name),
233 )
234 required = rules.required
235 forbidden = rules.forbidden
236 await update($, ruleNote, () =>
237 [
238 required.length > 0 ? t().rules(project, required) : '',
239 forbidden.length > 0 ? t().forbidden(forbidden) : '',
240 ]
241 .filter(Boolean)
242 .join('\n'),
243 )
244 const savedScope = await $.store.get('topScope')
245 await update($, topScope, () => (savedScope === 'all' ? 'all' : 'project'))
246 const savedTab = await $.store.get('tab')
247 await update($, tab, () => (typeof savedTab === 'string' && TABS.includes(savedTab) ? savedTab : 'suggested'))
248 await refreshTop($, await loadUsage($))
249 await update($, note, () => t().indexed(index.length))
250 return index.length
251}
252
253async function recommend($: EngineInterface, request: string): Promise<Suggestion[]> {
254 await update($, note, () => t().analyzing)
255 const recent = await $.session.messages().catch(() => [])
256 const conversation = recent
257 .filter(message => message.text.trim() !== '')
258 .slice(-4)
259 .map(message => `${message.role === 'user' ? 'User' : 'Assistant'}: ${message.text.slice(0, 500)}`)
260 .join('\n\n')
261 // The catalog block never changes between prompts, so the API serves it from its cache.
262 const selection = await $.model.complete({
263 model: 'haiku',
264 maxTokens: 900,
265 timeoutMs: 8000,
266 system: [{ text: catalog, cache: true }],
267 prompt: selectPrompt(conversation, request),
268 })
269 const { cache_read_input_tokens: cacheRead, cache_creation_input_tokens: cacheWritten, input_tokens: sent } =
270 selection.usage
271 await update($, cacheNote, () =>
272 cacheRead > 0
273 ? t().cacheRead(thousands(cacheRead))
274 : cacheWritten > 0
275 ? t().cacheWritten(thousands(cacheWritten))
276 : t().cacheOff(thousands(sent)),
277 )
278 const selected = selection.isAnswered ? parseNames(selection.text, byName, 60) : []
279 const ruled = required.flatMap(name => byName.get(name) ?? [])
280 const candidates = [
281 ...new Map(
282 [...ruled, ...selected, ...shortlist(index, tokenize(request), 20)]
283 .filter(skill => !forbidden.includes(skill.name))
284 .map(skill => [skill.name, skill]),
285 ).values(),
286 ]
287 const fallbackQuery = tokenize(request).slice(0, 4).join(' ')
288 if (candidates.length === 0) {
289 await settle($, request, [])
290 lookUp($, fallbackQuery)
291 return []
292 }
293 const reply = await $.model.complete({
294 model: 'haiku',
295 maxTokens: 1000,
296 timeoutMs: 8000,
297 prompt: pickPrompt(conversation, request, candidates, required),
298 })
299 const stats = (await loadUsage($))[project] ?? {}
300 const picks = (reply.isAnswered ? parsePicks(reply.text, candidates, maxSuggestions) : [])
301 .map(pick => {
302 const isRule = required.includes(pick.name)
303 return { ...pick, isRule, score: adjust(pick.score, stats[pick.name], isRule), tag: tagOfName(pick.name) }
304 })
305 .sort((a, b) => b.score - a.score)
306 await settle($, request, picks)
307 lookUp($, (reply.isAnswered ? parseSearch(reply.text) : '') || fallbackQuery)
308 await bump(
309 $,
310 picks.map(pick => pick.name),
311 'shown',
312 )
313 return picks
314}
315
316// A prompt with no pick keeps the previous suggestions on screen.
317async function settle($: EngineInterface, request: string, picks: Suggestion[]) {
318 let isEmpty = picks.length === 0
319 await update($, suggested, previous => {
320 isEmpty = picks.length === 0 && (previous ?? []).length === 0
321 return picks.length > 0 ? picks : (previous ?? [])
322 })
323 if (picks.length > 0) await update($, about, () => request)
324 await update($, note, () => (isEmpty ? t().nothingYet : ''))
325}
326
327async function statsReport($: EngineInterface): Promise<string> {
328 const usage = await loadUsage($)
329 const totals = new Map<string, number>()
330 for (const stats of Object.values(usage)) {
331 for (const [name, stat] of Object.entries(stats)) {
332 totals.set(name, (totals.get(name) ?? 0) + stat.used + stat.clicked)
333 }
334 }
335 const used = [...totals].filter(([, count]) => count > 0).sort((a, b) => b[1] - a[1])
336 const byTag = new Map<string, { used: number; unused: number }>()
337 for (const skill of index) {
338 const tag = tagOf(skill)
339 const row = byTag.get(tag) ?? { used: 0, unused: 0 }
340 if ((totals.get(skill.name) ?? 0) > 0) row.used += 1
341 else row.unused += 1
342 byTag.set(tag, row)
343 }
344
345 return [
346 t().statsTitle(index.length, used.length),
347 '',
348 t().statsTop,
349 t().statsTopHeader,
350 '|---|---|---|---|',
351 ...used
352 .slice(0, 15)
353 .map(([name, count], rank) => `| ${rank + 1} | ${name} | ${tagLabel(tagOfName(name))} | ${count} |`),
354 '',
355 t().statsByTag,
356 t().statsByTagHeader,
357 '|---|---|---|',
358 ...[...byTag]
359 .sort((a, b) => b[1].used - a[1].used || b[1].unused - a[1].unused)
360 .map(([tag, row]) => `| ${tagLabel(tag)} | ${row.used} | ${row.unused} |`),
361 ].join('\n')
362}
363
364export const register: Register = (on, options) => {
365 lang = options.language === 'fr' ? 'fr' : 'en'
366 minContextScore = typeof options.contextMinScore === 'number' ? options.contextMinScore : 7
367 maxSuggestions = typeof options.maxSuggestions === 'number' ? options.maxSuggestions : 10
368 isContextOn = options.shareWithClaude !== false
369 isDiscoverOn = options.discover !== false
370
371 on('session.start', async ($, e, next) => {
372 await $.command.register({ name: 'skills-panel', description: t().commandPanel })
373 await $.command.register({ name: 'skills-rescan', description: t().commandRescan })
374 await $.command.register({ name: 'skills-stats', description: t().commandStats })
375 await update($, suggested, () => [])
376 await update($, note, () => t().indexing)
377 $.clock.after(0, () => void reindex($))
378 void $.ui.open({ id: PANE, title: TITLE })
379
380 return next(e)
381 })
382
383 on('command.run', { command: 'skills-panel' }, async $ => {
384 await $.ui.open({ id: PANE, title: TITLE })
385
386 return { text: t().paneOpened }
387 })
388
389 on('command.run', { command: 'skills-rescan' }, async $ => {
390 const count = await reindex($)
391
392 return { text: t().rescanned(count) }
393 })
394
395 on('command.run', { command: 'skills-stats' }, async $ => ({ text: await statsReport($) }))
396
397 // Awaited before next(e) so the suggestions are drawn, and handed to the model, before it answers.
398 on('prompt.submit', async ($, e, next) => {
399 const text = e.text.trim()
400 const isEligible = e.origin.kind === 'composer' && !text.startsWith('/') && text.length >= 12
401 if (!isEligible || index.length === 0) return next(e)
402 const strong = (await recommend($, text)).filter(pick => pick.score >= minContextScore)
403 if (!isContextOn || strong.length === 0) return next(e)
404
405 return next({ ...e, context: [...(e.context ?? []), contextFor(strong, forbidden)] })
406 })
407
408 on('skill.prompt', async ($, e, next) => {
409 await update($, active, list =>
410 list.some(skill => skill.name === e.skill)
411 ? list.map(skill => (skill.name === e.skill ? { ...skill, count: skill.count + 1 } : skill))
412 : [...list, { name: e.skill, count: 1 }],
413 )
414 await bump($, [e.skill], 'used')
415
416 return next(e)
417 })
418
419 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
420 const elements = $.ui.resolve(e)
421 const { Box, Button, Text } = elements
422 const Svg = 'Svg' in elements ? elements.Svg : undefined
423 const used = await read($, active)
424 const picks = await read($, suggested)
425 const status = await read($, note)
426 const prompt = await read($, about)
427 const scope = await read($, topScope)
428 const ranking = scope === 'all' ? await read($, topAll) : await read($, top)
429 const filter = await read($, tagFilter)
430 const rules = await read($, ruleNote)
431 const cache = await read($, cacheNote)
432 const finds = await read($, found)
433 const query = await read($, foundQuery)
434 const view = await read($, tab)
435 const tagCounts = ranking.reduce(
436 (counts, skill) => counts.set(skill.tag, (counts.get(skill.tag) ?? 0) + skill.count),
437 new Map<string, number>(),
438 )
439 const shownTop = ranking.filter(skill => filter === '' || skill.tag === filter).slice(0, 10)
440 const countWidth = Math.max(1, ...shownTop.map(skill => String(skill.count).length))
441 const append = async (addition: string) => {
442 const { text } = await $.prompt.read()
443 const separator = text === '' || /\s$/.test(text) ? '' : ' '
444 await $.prompt.fill({ text: `${text}${separator}${addition} `, mode: 'replace' })
445 }
446 const insert = async (name: string) => {
447 await bump($, [name], 'clicked')
448 await append(`/${name}`)
449 }
450 const pill = (label: string, color: string) => (
451 <Text backgroundColor={color} color="white">{` ${label} `}</Text>
452 )
453 const segment = (key: string, label: string, isOn: boolean, onPress: () => unknown) => (
454 <Button key={key} label={label} variant={isOn ? 'primary' : 'secondary'} dimColor={!isOn} onPress={onPress} />
455 )
456 const filterPill = (key: string, label: string, color: string, isOn: boolean, onPress: () => unknown) => (
457 // Button takes a color only on hover, so the filled background never sits behind the label at rest.
458 <Box key={`${key}-pill`} hover={{ scope: `filter-${key}`, backgroundColor: color }} paddingRight={1} gap={1}>
459 <Text backgroundColor={isOn ? color : undefined} color={isOn ? 'white' : color}>{' ● '}</Text>
460 <Button key={key} plain label={label} dimColor={!isOn} hover={{ scope: `filter-${key}`, color: 'white' }} onPress={onPress} />
461 </Box>
462 )
463
464 return (
465 <Box flexDirection="column" gap={1}>
466 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
467 <Text bold>{t().active}</Text>
468 {used.length === 0 && <Text dimColor>{t().noActive}</Text>}
469 {used.map(skill => (
470 <Box gap={1}>
471 <Text>{skill.name}</Text>
472 {skill.count > 1 && <Text dimColor>{`x${skill.count}`}</Text>}
473 {pill(tagLabel(tagOfName(skill.name)), tagColor(tagOfName(skill.name)))}
474 </Box>
475 ))}
476 </Box>
477
478 <Box gap={1} flexWrap="wrap">
479 {segment('tab-suggested', picks.length > 0 ? `${t().suggested} ${picks.length}` : t().suggested, view === 'suggested', () =>
480 setTab($, 'suggested'),
481 )}
482 {segment('tab-discover', finds.length > 0 ? `${t().discover} ${finds.length}` : t().discover, view === 'discover', () =>
483 setTab($, 'discover'),
484 )}
485 {segment('tab-top', t().topUsed, view === 'top', () => setTab($, 'top'))}
486 </Box>
487
488 {view === 'suggested' && (
489 <Box flexDirection="column" gap={1}>
490 <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
491 {prompt !== '' && picks.length > 0 && (
492 <Text dimColor>{t().forPrompt(prompt.length > 60 ? `${prompt.slice(0, 60)}...` : prompt)}</Text>
493 )}
494 {status !== '' && <Text dimColor>{status}</Text>}
495 {picks.map((pick, rank) => (
496 <Box flexDirection="column" marginTop={1}>
497 <Box gap={1}>
498 <Text dimColor>{`${rank + 1}.`}</Text>
499 <Button key={`use-${pick.name}`} plain label={`/${pick.name}`} onPress={() => insert(pick.name)} />
500 </Box>
501 <Box gap={1} alignItems="center">
502 {Svg === undefined ? (
503 <Text color={scoreColor(pick.score)}>{bar(pick.score)}</Text>
504 ) : (
505 <Svg source={gauge(pick.score)} alt={t().scoreAlt(pick.score)} width={GAUGE_WIDTH} height={GAUGE_HEIGHT} />
506 )}
507 <Text bold color={scoreColor(pick.score)}>{`${pick.score}`}</Text>
508 {pill(tagLabel(pick.tag), tagColor(pick.tag))}
509 {pick.isRule && pill(t().rule, RULE_COLOR)}
510 </Box>
511 <Text dimColor>{pick.why}</Text>
512 </Box>
513 ))}
514 </Box>
515 {rules !== '' && <Text dimColor>{rules}</Text>}
516 {cache !== '' && <Text dimColor>{cache}</Text>}
517 </Box>
518 )}
519
520 {view === 'discover' && (
521 <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
522 {!isDiscoverOn && <Text dimColor>{t().discoverOff}</Text>}
523 {isDiscoverOn && query === '' && <Text dimColor>{t().discoverIdle}</Text>}
524 {isDiscoverOn && query !== '' && <Text dimColor>{t().discoverFor(query)}</Text>}
525 {isDiscoverOn && query !== '' && finds.length === 0 && <Text dimColor>{t().discoverNone}</Text>}
526 {finds.map((skill, rank) => {
527 const kind = tagOf({ name: skill.name, description: '' })
528 return (
529 <Box flexDirection="column" marginTop={1}>
530 <Box gap={1}>
531 <Text dimColor>{`${rank + 1}.`}</Text>
532 <Button key={`add-${skill.name}`} plain label={skill.name} onPress={() => append(installCommand(skill))} />
533 {pill(tagLabel(kind), tagColor(kind))}
534 </Box>
535 <Text dimColor>{`${skill.source}, ${t().installs(compact(skill.installs))}`}</Text>
536 </Box>
537 )
538 })}
539 {finds.length > 0 && (
540 <Box marginTop={1}>
541 <Text dimColor>{t().discoverHint}</Text>
542 </Box>
543 )}
544 </Box>
545 )}
546
547 {view === 'top' && (
548 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
549 <Box gap={1}>
550 {segment('scope-project', project === '' ? t().project : project, scope !== 'all', () => setScope($, 'project'))}
551 {segment('scope-all', t().allProjects, scope === 'all', () => setScope($, 'all'))}
552 </Box>
553 {ranking.length === 0 && <Text dimColor>{scope === 'all' ? t().noHistory : t().noHistoryHere}</Text>}
554 {ranking.length > 0 && (
555 <Box flexWrap="wrap" columnGap={1} rowGap={0} marginTop={1}>
556 {filterPill('tag-all', t().all, ALL_COLOR, filter === '', () => update($, tagFilter, () => ''))}
557 {[...tagCounts].map(([tag, count]) =>
558 filterPill(`tag-${tag}`, `${tagLabel(tag)} ${count}`, tagColor(tag), filter === tag, () =>
559 update($, tagFilter, current => (current === tag ? '' : tag)),
560 ),
561 )}
562 </Box>
563 )}
564 {shownTop.map((skill, rank) => (
565 <Box gap={1}>
566 <Text dimColor>{`${rank + 1}.`}</Text>
567 <Text bold color="claude">{String(skill.count).padStart(countWidth)}</Text>
568 <Text>{skill.name}</Text>
569 {pill(tagLabel(skill.tag), tagColor(skill.tag))}
570 </Box>
571 ))}
572 </Box>
573 )}
574 </Box>
575 )
576 })
577}
578hooks/i18n.ts 119 lines1export type Lang = 'en' | 'fr'
2
3const en = {
4 indexing: 'Indexing skills...',
5 indexed: (count: number) => `${count} skills indexed. Waiting for a prompt.`,
6 analyzing: 'Reading the prompt...',
7 nothingYet: 'No relevant skill yet.',
8 rules: (project: string, names: string[]) => `Rules (${project}): ${names.join(', ')}`,
9 forbidden: (names: string[]) => `Forbidden: ${names.join(', ')}`,
10 cacheRead: (tokens: string) => `cache: read ${tokens} tokens`,
11 cacheWritten: (tokens: string) => `cache: wrote ${tokens} tokens`,
12 cacheOff: (tokens: string) => `cache: off, ${tokens} tokens sent uncached`,
13 active: 'Active',
14 noActive: 'No skill loaded in this session.',
15 suggested: 'Suggested',
16 forPrompt: (prompt: string) => `for: "${prompt}"`,
17 rule: 'rule',
18 topUsed: 'Top used',
19 project: 'Project',
20 allProjects: 'All projects',
21 all: 'All',
22 noHistory: 'No history yet.',
23 noHistoryHere: 'No history in this project yet.',
24 scoreAlt: (score: number) => `Score ${score} out of 10`,
25 discover: 'Discover',
26 discoverIdle: 'Skills you have not installed show up here after a prompt.',
27 discoverFor: (query: string) => `skills.sh: "${query}"`,
28 discoverNone: 'No popular skill found for this request.',
29 discoverOff: 'Discovery is off in the mod settings.',
30 discoverHint: 'Click one to put its install command in the prompt.',
31 installs: (count: string) => `${count} installs`,
32 paneOpened: 'Skills pane opened.',
33 rescanned: (count: number) => `${count} skills indexed.`,
34 commandPanel: 'Open the pane of active and suggested skills',
35 commandRescan: 'Re-index installed skills and CLAUDE.md rules',
36 commandStats: 'Most used and never used skills, by tag',
37 reasonLanguage: 'English',
38 context: 'The skills-panel mod suggests these skills for this request. Load the ones that truly apply with the Skill tool before you start:',
39 contextRule: 'project rule',
40 contextForbidden: (names: string[]) => `Forbidden in this project: ${names.join(', ')}.`,
41 statsTitle: (installed: number, used: number) => `## Skills: ${installed} installed, ${used} used since the mod was installed`,
42 statsTop: '### Most used (all projects)',
43 statsTopHeader: '| # | Skill | Tag | Uses |',
44 statsByTag: '### By tag',
45 statsByTagHeader: '| Tag | Used | Never used |',
46 tags: {
47 seo: 'SEO',
48 video: 'Video',
49 writing: 'Writing',
50 ecommerce: 'E-commerce',
51 marketing: 'Marketing',
52 design: 'Design',
53 data: 'Data',
54 dev: 'Dev',
55 workflow: 'Workflow',
56 other: 'Other',
57 } as Record<string, string>,
58}
59
60const fr: typeof en = {
61 indexing: 'Indexation des skills...',
62 indexed: count => `${count} skills indexés. En attente d'un prompt.`,
63 analyzing: 'Analyse du prompt...',
64 nothingYet: "Aucun skill pertinent pour l'instant.",
65 rules: (project, names) => `Règles (${project}) : ${names.join(', ')}`,
66 forbidden: names => `Interdits : ${names.join(', ')}`,
67 cacheRead: tokens => `cache : lu ${tokens} tokens`,
68 cacheWritten: tokens => `cache : écrit ${tokens} tokens`,
69 cacheOff: tokens => `cache : inactif, ${tokens} tokens envoyés sans cache`,
70 active: 'Actifs',
71 noActive: 'Aucun skill chargé dans cette session.',
72 suggested: 'Conseillés',
73 forPrompt: prompt => `pour : « ${prompt} »`,
74 rule: 'règle',
75 topUsed: 'Top utilisés',
76 project: 'Projet',
77 allProjects: 'Tous les projets',
78 all: 'Tous',
79 noHistory: "Pas encore d'historique.",
80 noHistoryHere: "Pas encore d'historique dans ce projet.",
81 scoreAlt: score => `Score ${score} sur 10`,
82 discover: 'Découvrir',
83 discoverIdle: "Les skills que tu n'as pas installés apparaissent ici après un prompt.",
84 discoverFor: query => `skills.sh : « ${query} »`,
85 discoverNone: 'Aucun skill populaire trouvé pour cette demande.',
86 discoverOff: 'La découverte est désactivée dans les réglages du mod.',
87 discoverHint: "Clique sur un skill pour écrire sa commande d'installation dans le prompt.",
88 installs: count => `${count} installations`,
89 paneOpened: 'Panneau Skills ouvert.',
90 rescanned: count => `${count} skills indexés.`,
91 commandPanel: 'Ouvre le panneau des skills actifs et conseillés',
92 commandRescan: 'Réindexe les skills installés et les règles CLAUDE.md',
93 commandStats: 'Bilan des skills les plus et jamais utilisés, par tag',
94 reasonLanguage: 'French',
95 context:
96 "Le mod skills-panel recommande ces skills pour cette demande. Charge avec l'outil Skill ceux qui s'appliquent vraiment avant de commencer :",
97 contextRule: 'règle du projet',
98 contextForbidden: names => `Interdits dans ce projet : ${names.join(', ')}.`,
99 statsTitle: (installed, used) => `## Skills : ${installed} installés, ${used} utilisés depuis l'installation du mod`,
100 statsTop: '### Les plus utilisés (tous projets)',
101 statsTopHeader: '| # | Skill | Tag | Utilisations |',
102 statsByTag: '### Par tag',
103 statsByTagHeader: '| Tag | Utilisés | Jamais utilisés |',
104 tags: {
105 seo: 'SEO',
106 video: 'Vidéo',
107 writing: 'Écriture',
108 ecommerce: 'E-commerce',
109 marketing: 'Marketing',
110 design: 'Design',
111 data: 'Data',
112 dev: 'Dev',
113 workflow: 'Workflow',
114 other: 'Autre',
115 },
116}
117
118export const STRINGS: Record<Lang, typeof en> = { en, fr }
119hooks/skills.ts 183 lines1export type SkillInfo = { name: string; description: string }
2export type RawPick = { name: string; why: string; score: number }
3export type Stat = { used: number; clicked: number; shown: number; last: number }
4export type Found = { name: string; source: string; installs: number }
5
6// find-skills advises against recommending registry skills under 1,000 installs.
7const MIN_INSTALLS = 1000
8
9const STOPWORDS = new Set(
10 (
11 'les des une pour dans avec sur que qui est pas plus par mon mes ton tes son ses nous vous leur ' +
12 'cette ces aux fait faire peux peut veux tout tous comme mais donc alors aussi bien tres ' +
13 'the and for with this that from into your you are can how what when use using'
14 ).split(' '),
15)
16
17const unquote = (value: string) => value.replace(/^(['"])([\s\S]*)\1$/, '$2')
18
19export function parseSkillMd(text: string, fallback: string): SkillInfo {
20 const lines = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1]?.split(/\r?\n/) ?? []
21 const field = (key: string): string => {
22 const at = lines.findIndex(line => line.startsWith(`${key}:`))
23 if (at < 0) return ''
24 const inline = (lines[at] ?? '').slice(key.length + 1).trim()
25 if (inline !== '' && !/^[|>][+-]?$/.test(inline)) return unquote(inline)
26 const block: string[] = []
27 for (const line of lines.slice(at + 1)) {
28 if (!/^\s+\S/.test(line)) break
29 block.push(line.trim())
30 }
31 return block.join(' ')
32 }
33
34 return { name: field('name') || fallback, description: field('description').slice(0, 300) }
35}
36
37export function tokenize(text: string): string[] {
38 return text
39 .normalize('NFD')
40 .replace(/[̀-ͯ]/g, '')
41 .toLowerCase()
42 .split(/[^a-z0-9]+/)
43 .filter(word => word.length >= 3 && !STOPWORDS.has(word))
44}
45
46export function shortlist(skills: SkillInfo[], words: string[], limit = 25): SkillInfo[] {
47 const unique = [...new Set(words)]
48
49 return skills
50 .map(skill => {
51 const name = skill.name.toLowerCase()
52 const description = skill.description.toLowerCase()
53 const score = unique.reduce(
54 (sum, word) => sum + (name.includes(word) ? 3 : 0) + (description.includes(word) ? 1 : 0),
55 0,
56 )
57 return { skill, score }
58 })
59 .filter(scored => scored.score > 0)
60 .sort((a, b) => b.score - a.score)
61 .slice(0, limit)
62 .map(scored => scored.skill)
63}
64
65export function parsePicks(reply: string, candidates: SkillInfo[], limit = 10): RawPick[] {
66 const known = new Set(candidates.map(skill => skill.name))
67 const json = /\[[\s\S]*\]/.exec(reply)?.[0]
68 if (json === undefined) return []
69 let parsed: unknown
70 try {
71 parsed = JSON.parse(json)
72 } catch {
73 return []
74 }
75 if (!Array.isArray(parsed)) return []
76
77 return parsed
78 .filter(
79 (pick): pick is RawPick =>
80 typeof pick?.name === 'string' &&
81 typeof pick?.why === 'string' &&
82 typeof pick?.score === 'number' &&
83 known.has(pick.name),
84 )
85 .sort((a, b) => b.score - a.score)
86 .slice(0, limit)
87}
88
89const TAG_RULES: [string, RegExp][] = [
90 ['seo', /\bseo\b|schema|sitemap|serp|backlink|search console|indexing/],
91 ['video', /video|vidéo|\breels?\b|remotion|ffmpeg|heygen|short-form/],
92 ['writing', /copywrit|humani|writing|writer|copy-edit|redaction|de-ai|article|blog|newsletter/],
93 ['ecommerce', /shopify|e-?commerce|stripe|checkout|payment|storefront|merchant/],
94 ['marketing', /marketing|campaign|\bads\b|social|instagram|linkedin|launch|\bcro\b|email/],
95 ['design', /design|\bui\b|\bux\b|figma|tailwind|typograph|animation|motion|visual|\bcss\b|brand|interface/],
96 ['data', /\bdata\b|analytics|\bsql\b|chart|dashboard|statistic|spreadsheet|xlsx/],
97 ['dev', /react|next\.?js|typescript|javascript|python|\bapi\b|backend|frontend|debug|\btest|\bgit\b|deploy|vercel|supabase|database|code/],
98 ['workflow', /brainstorm|\bplan|workflow|subagent|\bagents?\b|hook|config|setup|session|memory|worktree|prompt/],
99]
100
101const firstTag = (text: string) => TAG_RULES.find(([, rule]) => rule.test(text))?.[0]
102
103// The name alone decides first: a description often mentions other domains in passing.
104export function tagOf(skill: SkillInfo): string {
105 const name = skill.name.toLowerCase()
106 return firstTag(name) ?? firstTag(`${name} ${skill.description.toLowerCase()}`) ?? 'other'
107}
108
109// Skills named in backticks in CLAUDE.md: required, unless the sentence forbids them.
110export function projectRules(texts: string[], names: string[]): { required: string[]; forbidden: string[] } {
111 const byShort = new Map<string, string>()
112 for (const name of names) {
113 byShort.set(name, name)
114 const short = name.split(':').pop()
115 if (short !== undefined && !byShort.has(short)) byShort.set(short, name)
116 }
117 const required = new Set<string>()
118 const forbidden = new Set<string>()
119 for (const sentence of texts.join('\n').split(/[.;\n]/)) {
120 const isBan = /\b(jamais|never|interdit|forbidden|do not|don't)\b/i.test(sentence)
121 for (const [, token] of sentence.matchAll(/`([a-z0-9][a-z0-9:-]*)`/g)) {
122 const name = token === undefined ? undefined : byShort.get(token)
123 if (name === undefined) continue
124 if (isBan) forbidden.add(name)
125 else required.add(name)
126 }
127 }
128
129 return { required: [...required].filter(name => !forbidden.has(name)), forbidden: [...forbidden] }
130}
131
132export function adjust(score: number, stat: Stat | undefined, isRule: boolean): number {
133 const bonus = stat === undefined ? 0 : Math.min(2, (stat.used + stat.clicked) * 0.5)
134 const penalty = stat !== undefined && stat.shown >= 5 && stat.used + stat.clicked === 0 ? 1 : 0
135
136 return Math.max(1, Math.min(10, Math.round(score + bonus - penalty + (isRule ? 1 : 0))))
137}
138
139export function parseSearch(reply: string): string {
140 return (/"search"\s*:\s*"([^"]{1,80})"/.exec(reply)?.[1] ?? '').trim()
141}
142
143// skills.sh search results, minus skills already installed and little-used ones, most installed first.
144export function parseFound(body: string, installed: string[], limit = 5): Found[] {
145 let parsed: unknown
146 try {
147 parsed = JSON.parse(body)
148 } catch {
149 return []
150 }
151 const skills = (parsed as { skills?: unknown } | null)?.skills
152 if (!Array.isArray(skills)) return []
153 const have = new Set(installed.map(name => (name.split(':').pop() ?? name).toLowerCase()))
154 const best = new Map<string, Found>()
155 for (const skill of skills) {
156 if (typeof skill?.name !== 'string' || typeof skill?.source !== 'string' || typeof skill?.installs !== 'number') continue
157 const key = skill.name.toLowerCase()
158 const id = typeof skill.skillId === 'string' ? skill.skillId.toLowerCase() : key
159 if (skill.installs < MIN_INSTALLS || have.has(key) || have.has(id)) continue
160 if ((best.get(key)?.installs ?? -1) < skill.installs) {
161 best.set(key, { name: skill.name, source: skill.source, installs: skill.installs })
162 }
163 }
164
165 return [...best.values()].sort((a, b) => b.installs - a.installs).slice(0, limit)
166}
167
168export function parseNames(reply: string, byName: Map<string, SkillInfo>, limit: number): SkillInfo[] {
169 const json = /\[[\s\S]*\]/.exec(reply)?.[0]
170 if (json === undefined) return []
171 let parsed: unknown
172 try {
173 parsed = JSON.parse(json)
174 } catch {
175 return []
176 }
177 if (!Array.isArray(parsed)) return []
178
179 return parsed
180 .flatMap(name => (typeof name === 'string' ? (byName.get(name) ?? []) : []))
181 .slice(0, limit)
182}
183types/index.d.ts 25 lines1export type ActiveSkill = { name: string; count: number }
2export type Suggestion = { name: string; why: string; score: number; tag: string; isRule: boolean }
3export type TopSkill = { name: string; count: number; tag: string }
4export type Found = { name: string; source: string; installs: number }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'skills-panel': {
9 active: ActiveSkill[]
10 suggested: Suggestion[]
11 note: string
12 about: string
13 top: TopSkill[]
14 topAll: TopSkill[]
15 topScope: string
16 tagFilter: string
17 ruleNote: string
18 cacheNote: string
19 found: Found[]
20 foundQuery: string
21 tab: string
22 }
23 }
24}
25