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

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.
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:
Skill that fits: /humanizer;skill: humanizer until the next prompt.A prompt with no match clears the status line. Built-in commands and MCP commands are never suggested.
/skill-hints opens a pane with every skill, grouped by plugin:
Esc closes the pane. The Claude mobile app has no text field yet, so it shows the list without the filter.
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.
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.
claude plugin marketplace add lbzepoqo/claude-plugins
claude plugin install skill-hints@lbzepoqo
Restart Claude Code.
claude plugin validate .
claude plugin test .hooks/register.tsx 170 lines1import { 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}
170hooks/skills.ts 100 lines1import 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}
100types/index.d.ts 15 lines1// 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