SLOPSHOPPER

skills-panel

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…

newpanecommandpromptmodelnetwork
v0.3.0MITupdated 2026-10-09rubenhaccoun-cyber/skills-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skills-panel
│ ┃ Skills ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Active │ ⏺ Read(src/auth.ts) │ ┃ │ No skill loaded in this session. │ ⎿ Read 6 lines │ ┃ ╰──────────────────────────────────────────╯ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ Suggested ] [ Discover ] [ Top used ] ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ 0 skills indexed. Waiting for a prompt. │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ✻ Worked for 42s · done 4:20 PM │ │ › /skills-panel │ ⎿ skills-panel: Skills pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Skills
╭──────────────────────────────────────────────────────────╮ │ Active │ │ No skill loaded in this session. │ ╰──────────────────────────────────────────────────────────╯ [ Suggested ] [ Discover ] [ Top used ] ╭──────────────────────────────────────────────────────────╮ │ 0 skills indexed. Waiting for a prompt. │ ╰──────────────────────────────────────────────────────────╯
README

skills-panel

License: MIT Claude Code

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.

skills-panel next to a conversation in the Claude Code desktop app

╭ 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      │
╰──────────────────────────────────────╯

What it does

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.

The Discover tab listing a skill not installed, with its repository and install count

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.

Install

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.

Requirements

  • Claude Code 2.1.293 or later. Mods are an early-access API and may change between releases; this version was built and tested on 2.1.293.
  • At least a few skills installed, in ~/.claude/skills, in the project's .claude/skills, or through plugins.

Usage

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.

Commands

CommandWhat it does
/skills-panelOpens the pane
/skills-rescanRe-reads installed skills and the project's CLAUDE.md rules
/skills-statsMost used skills across projects, and how many per tag you have never used

Settings

Change them in the Claude Code config menu, or in ~/.claude/settings.json under pluginConfigs.

SettingDefaultEffect
languageenen or fr, for the pane and for the reasons next to each suggestion
contextMinScore7Suggestions at or above this score are passed to Claude
maxSuggestions10How many suggestions the pane shows at most
shareWithClaudetrueSet to false to keep suggestions in the pane only
discovertrueSet to false to turn the Discover tab off; nothing is then sent to skills.sh

How suggestions are picked

Each prompt of 12 characters or more (slash commands are skipped) goes through two Haiku calls before it reaches Claude:

  1. Haiku reads the names of every installed skill, your last four messages and the new prompt, and keeps up to 60 candidates. The name list is sent as a cached block, so after the first prompt it is read from the prompt cache. The line at the bottom of the pane says whether it was.
  2. Haiku reads the descriptions of those candidates, plus a keyword shortlist and any skills required by 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.

Cost and privacy

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.

Limits

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.

Contributing and forking

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.

License

MIT. Use it, change it, fork it, ship your own version; just keep the copyright notice.

Source 4 files
hooks/register.tsx 578 lines
1import { 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}
578
hooks/i18n.ts 119 lines
1export 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 }
119
hooks/skills.ts 183 lines
1export 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}
183
types/index.d.ts 25 lines
1export 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