SLOPSHOPPER

skill-hints

Shows which installed skill fits each prompt you type, as a toast and a status line, and lists every skill in a /skill-hints pane

newpanecommandtoaststatusprompt
★ 2v0.2.1MITupdated 2026-10-21lbzepoqo/claude-plugins/plugins/skill-hints
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skill-hints
│ ┃ Skills ✕ › fix the failing auth test and add an audit log call │ ┃ Filter skills ⏎ use first │ ┃ ⏺ Read(src/auth.ts) │ ┃ (1) ⎿ Read 6 lines │ ┃ skill-hints ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Type to filter, Enter uses the first match. ⏺ Bash(bun test) │ ┃ Tab to a skill, Enter puts it in the prompt. ⎿ 3 pass, 1 fail │ ┃ Esc closes. │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /skill-hints │ ⎿ skill-hints: Skills pane opened (Esc closes it). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Skills
Filter skills ⏎ use first (1) skill-hints Type to filter, Enter uses the first match. Tab to a skill, Enter puts it in the prompt. Esc closes.
README

skill-hints

With many skills installed it is hard to remember which one fits the task at hand. This plugin asks a small model which installed skill fits each prompt you type, and shows the answer while Claude works.

How it works

When you send a prompt of 15 characters or more, the plugin sends the list of installed skills and plugin commands, with their descriptions, plus your prompt to Haiku and asks which one fits, or none. The call runs beside the turn, so it never delays the prompt.

When a skill fits:

  • a toast names its command, for example Skill that fits: /humanizer;
  • the plugin's status line under the prompt shows skill: humanizer until the next prompt.

A prompt with no match clears the status line. Built-in commands and MCP commands are never suggested.

The pane

/skill-hints opens a pane with every skill, grouped by plugin:

  • the skill that fit your last prompt sits on top, with the prompt and how long ago you sent it;
  • type to filter by name, description or plugin, and press Enter to use the first match;
  • Tab to any skill and press Enter to put its command in front of what you have typed in the prompt box.

Esc closes the pane. The Claude mobile app has no text field yet, so it shows the list without the filter.

Options

hiddenSkills: skills never suggested, comma separated. A short name such as code-review hides every skill of that name; write plugin:name to hide only one. Useful for a router skill whose description fits every prompt. Change it with /plugin configure skill-hints@lbzepoqo in Claude Code, or from the shell:

echo '{"hiddenSkills": "my-router-skill"}' | claude plugin configure skill-hints@lbzepoqo --values-stdin

Slash commands, short replies such as "yes, go on", and prompts that a task or the SDK sent get no hint.

Cost

One Haiku call per prompt. The request holds every skill's description, cut to 220 characters, so it grows by about 60 tokens per installed skill. With 50 skills that is about 3,000 tokens per prompt.

Install

claude plugin marketplace add lbzepoqo/claude-plugins
claude plugin install skill-hints@lbzepoqo

Restart Claude Code.

Develop

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 170 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptSubmitInput, Register } from 'claude-code'
3
4import type { SkillEntry, SkillHint } from '../types'
5import {
6  age,
7  classifierLabels,
8  classifierText,
9  filterSkills,
10  groupByOrigin,
11  hiddenNames,
12  hintFor,
13  hintToast,
14  promptWithSkill,
15  shortName,
16  skillCatalog,
17  suggestable,
18} from './skills'
19
20const PANE = 'skill-hints'
21const skills = atom({ plugin: 'skill-hints', key: 'skills' } as const, [] as SkillEntry[])
22const hint = atom({ plugin: 'skill-hints', key: 'hint' } as const, null as SkillHint | null)
23const filter = atom({ plugin: 'skill-hints', key: 'filter' } as const, '')
24
25// The widest name column in the pane; longer names are cut so the descriptions stay aligned.
26const NAME_ROOM = 24
27
28// Shorter prompts ("yes", "go on") answer the model rather than ask for something new.
29const MIN_PROMPT_LENGTH = 15
30
31// A request the person typed: not a slash command, a reply to the model, or a prompt the engine or a task sent.
32export function isTypedRequest(e: Pick<PromptSubmitInput, 'text' | 'origin' | 'command' | 'isMeta'>): boolean {
33  const text = e.text.trim()
34  return e.origin.kind === 'composer' && !e.command && !e.isMeta && !text.startsWith('/') && text.length >= MIN_PROMPT_LENGTH
35}
36
37// Plugins register their commands in their own session.start, so the catalog is read again before each match.
38async function refreshSkills($: EngineInterface) {
39  const catalog = skillCatalog(await $.command.list(), $.plugin.name)
40  await update($, skills, () => catalog)
41  return catalog
42}
43
44// Each match is numbered, so a slow answer for an older prompt never replaces the hint for a newer one.
45let latestMatch = 0
46
47async function suggestSkill($: EngineInterface, request: string, hidden: readonly string[]) {
48  const match = ++latestMatch
49  const catalog = suggestable(await refreshSkills($), hidden)
50  const label = catalog.length === 0 ? undefined : await $.model.classify(classifierText(catalog, request), classifierLabels(catalog), { model: 'haiku' })
51  const matched = hintFor(label, catalog, request, await $.clock.now())
52  if (match !== latestMatch) return
53  // The status line shows the hint for the latest prompt only, so a prompt with no match clears it.
54  await update($, hint, () => matched)
55  $.ui.status(matched ? `skill: ${shortName(matched.name)}` : undefined)
56  if (matched) $.ui.toast(hintToast(matched), { timeoutMs: 12_000 })
57}
58
59// Puts the skill's command in front of the draft and closes the pane, so the person can finish the prompt.
60async function useSkill($: EngineInterface, name: string) {
61  const draft = (await $.prompt.read()).text
62  const commands = (await read($, skills)).map(skill => skill.name)
63  const filled = await $.prompt.fill({ text: promptWithSkill(draft, name, commands) })
64  if (filled.isFilled) await $.ui.close({ id: PANE })
65  else $.ui.toast(`The prompt box is not available here; type /${name} instead.`)
66}
67
68// Cut by code point, so an emoji is never split.
69function clip(text: string, room: number): string {
70  const chars = Array.from(text)
71  if (room < 2) return ''
72  return chars.length <= room ? text : `${chars.slice(0, room - 1).join('')}…`
73}
74
75export const register: Register = (on, options) => {
76  const hidden = hiddenNames(options.hiddenSkills)
77
78  on('session.start', async ($, e, next) => {
79    await $.command.register({ name: 'skill-hints', description: 'List the installed skills and the one that fits your last prompt' })
80    void refreshSkills($).catch(() => undefined)
81    return next(e)
82  })
83
84  on('prompt.submit', async ($, e, next) => {
85    // The match runs beside the turn, so a slow or failed model call never holds the prompt.
86    if (isTypedRequest(e)) void suggestSkill($, e.text.trim(), hidden).catch(() => undefined)
87    return next(e)
88  })
89
90  // A hint from before /clear or a resume describes a prompt the new session never saw.
91  on('session.end', async ($, e, next) => {
92    $.ui.status(undefined)
93    await update($, hint, () => null).catch(() => undefined)
94    return next(e)
95  })
96
97  on('command.run', { command: 'skill-hints' }, async $ => {
98    await refreshSkills($)
99    await update($, filter, () => '')
100    await $.ui.open({ id: PANE, title: 'Skills', focus: true, closeOnEscape: true })
101    return { text: 'Skills pane opened (Esc closes it).' }
102  })
103
104  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
105    const elements = $.ui.resolve(e)
106    const { Box, Text, Button } = elements
107    const Input = 'Input' in elements ? elements.Input : undefined
108    const catalog = await read($, skills)
109    const matched = await read($, hint)
110    const query = await read($, filter)
111    const shown = filterSkills(catalog, query)
112    const nameWidth = Math.min(NAME_ROOM, Math.max(0, ...catalog.map(skill => shortName(skill.name).length)))
113    const descriptionRoom = e.props.bodyColumns - nameWidth - 2
114    const now = await $.clock.now()
115
116    return (
117      <Box flexDirection="column" gap={1}>
118        {matched && (
119          <Box flexDirection="column">
120            <Text color="cyan" bold>Fits your last prompt</Text>
121            <Text wrap="truncate-end">
122              <Text bold>/{matched.name}</Text>
123              <Text dimColor> {matched.description}</Text>
124            </Text>
125            <Text dimColor wrap="truncate-end">"{matched.request}" · {age(matched.at, now)}</Text>
126            <Box>
127              <Button key="use-hint" variant="primary" onPress={() => void useSkill($, matched.name).catch(() => undefined)}>
128                Put /{shortName(matched.name)} in the prompt
129              </Button>
130            </Box>
131          </Box>
132        )}
133        {Input && catalog.length > 0 && (
134          <Input
135            key="filter"
136            value={query}
137            placeholder="Filter skills"
138            submitLabel="use first"
139            autoFocus
140            onInput={value => void update($, filter, () => value).catch(() => undefined)}
141            onSubmit={value => {
142              // With nothing typed, Enter takes the suggested skill on top, never the first in the list.
143              const chosen = value.trim() ? filterSkills(catalog, value)[0]?.name : matched?.name
144              if (chosen) void useSkill($, chosen).catch(() => undefined)
145            }}
146          />
147        )}
148        {catalog.length === 0 && <Text dimColor>No skills or plugin commands are installed.</Text>}
149        {catalog.length > 0 && shown.length === 0 && <Text dimColor>No skill matches "{query}".</Text>}
150        {groupByOrigin(shown).map(group => (
151          <Box key={group.origin} flexDirection="column">
152            <Text color="blue" bold>
153              {group.origin} <Text dimColor>({group.skills.length})</Text>
154            </Text>
155            {group.skills.map(skill => (
156              <Button key={skill.name} plain onPress={() => void useSkill($, skill.name).catch(() => undefined)}>
157                <Text color={matched?.name === skill.name ? 'cyan' : undefined}>{clip(shortName(skill.name), nameWidth).padEnd(nameWidth)}</Text>
158                <Text dimColor>  {clip(skill.description, descriptionRoom)}</Text>
159              </Button>
160            ))}
161          </Box>
162        ))}
163        {e.surface === 'terminal' && (
164          <Text dimColor>Type to filter, Enter uses the first match. Tab to a skill, Enter puts it in the prompt. Esc closes.</Text>
165        )}
166      </Box>
167    )
168  })
169}
170
hooks/skills.ts 100 lines
1import type { SkillEntry, SkillHint } from '../types'
2
3export const NO_SKILL = 'none of these'
4
5// The name without its plugin prefix: `mattpocock-skills:implement` becomes `implement`.
6export function shortName(name: string): string {
7  return name.replace(/^[^:]+:/, '')
8}
9
10// The names in the `hiddenSkills` option, which lists skills by full or short name, comma separated.
11export function hiddenNames(option: unknown): string[] {
12  return typeof option === 'string' ? option.split(',').map(name => name.trim()).filter(Boolean) : []
13}
14
15// The catalog the classifier chooses from: every skill the person has not hidden.
16export function suggestable(catalog: readonly SkillEntry[], hidden: readonly string[] = []): SkillEntry[] {
17  return catalog.filter(skill => !hidden.includes(skill.name) && !hidden.includes(shortName(skill.name)))
18}
19
20// The skills whose name, description or origin holds every word of the filter.
21export function filterSkills(catalog: readonly SkillEntry[], filter: string): SkillEntry[] {
22  const words = filter.toLowerCase().split(/\s+/).filter(Boolean)
23  return catalog.filter(skill => {
24    const haystack = `${skill.name} ${skill.description} ${skill.origin}`.toLowerCase()
25    return words.every(word => haystack.includes(word))
26  })
27}
28
29// The prompt with the skill's command in front. A known command the draft already starts with is
30// replaced; any other leading word, such as a file path, stays.
31export function promptWithSkill(draft: string, name: string, commands: readonly string[]): string {
32  const first = /^\/(\S+)\s*/.exec(draft)
33  const rest = first?.[1] && commands.includes(first[1]) ? draft.slice(first[0].length) : draft
34  return `/${name} ${rest}`
35}
36
37// How long ago a hint was made, in the largest whole unit.
38export function age(at: number, now: number): string {
39  const minutes = Math.floor((now - at) / 60_000)
40  if (minutes < 1) return 'just now'
41  if (minutes < 60) return `${minutes}m ago`
42  const hours = Math.floor(minutes / 60)
43  return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`
44}
45
46// The longest description kept per skill: enough for the classifier and one pane row.
47const DESCRIPTION_ROOM = 220
48
49type CommandLike = { name: string; description: string; source: string; plugin?: string }
50
51// Skills and plugin commands the person or Claude can use. Built-in and MCP commands are
52// left out, and so are this plugin's own commands.
53export function skillCatalog(commands: readonly CommandLike[], ownPlugin: string): SkillEntry[] {
54  return commands
55    .filter(command => command.source !== 'builtin' && command.source !== 'mcp' && command.plugin !== ownPlugin)
56    .map(command => ({
57      name: command.name,
58      description: command.description.replace(/\s+/g, ' ').trim().slice(0, DESCRIPTION_ROOM),
59      origin: command.plugin ?? command.source,
60    }))
61    .sort((left, right) => left.origin.localeCompare(right.origin) || left.name.localeCompare(right.name))
62}
63
64// The text the classifier reads: the catalog, then the request.
65export function classifierText(catalog: readonly SkillEntry[], request: string): string {
66  const lines = catalog.map(skill => `- ${skill.name}: ${skill.description}`)
67  return [
68    'Skills available in this Claude Code session:',
69    ...lines,
70    '',
71    `Pick the one skill whose purpose matches this request, or "${NO_SKILL}" when no skill clearly fits.`,
72    '',
73    `Request from the user: ${request}`,
74  ].join('\n')
75}
76
77export function classifierLabels(catalog: readonly SkillEntry[]): string[] {
78  return [NO_SKILL, ...catalog.map(skill => skill.name)]
79}
80
81// The hint for a classifier answer, or null when no skill fits.
82export function hintFor(label: string | undefined, catalog: readonly SkillEntry[], request: string, at: number): SkillHint | null {
83  if (label === undefined || label === NO_SKILL) return null
84  const skill = catalog.find(entry => entry.name === label)
85  if (!skill) return null
86  return { name: skill.name, description: skill.description, request: request.slice(0, 120), at }
87}
88
89// Short enough for one toast line; the /skill-hints pane has the description.
90export function hintToast(hint: SkillHint): string {
91  return `Skill that fits: /${hint.name}`
92}
93
94// The catalog grouped by where each skill comes from, for the /skill-hints pane.
95export function groupByOrigin(catalog: readonly SkillEntry[]): { origin: string; skills: SkillEntry[] }[] {
96  const groups = new Map<string, SkillEntry[]>()
97  for (const skill of catalog) groups.set(skill.origin, [...(groups.get(skill.origin) ?? []), skill])
98  return [...groups.entries()].map(([origin, skills]) => ({ origin, skills }))
99}
100
types/index.d.ts 15 lines
1// A skill the person or Claude can use, from the session's command list.
2export type SkillEntry = { name: string; description: string; origin: string }
3// The skill the classifier matched to the latest prompt.
4export type SkillHint = { name: string; description: string; request: string; at: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'skill-hints': {
9      skills: SkillEntry[]
10      hint: SkillHint | null
11      filter: string
12    }
13  }
14}
15