/skills: per-prompt skill suggestions, what Claude actually loaded, and one-key use of a suggested skill

Suggests up to three installed skills below the prompt, and removes a suggestion once the skill is loaded. Uses model-backed selection. It does not install skills.
claude plugin install skills --marketplace sruthik27/claude-foundry
Requires Claude Code 2.1.296+. Reload plugins or start a new session after installation.
Type a request relevant to one of your installed skills.
MIT licensed. Maintained by Sruthik Issac.
hooks/register.tsx 62 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { EMPTY, hintLine, isRealPrompt, markLoaded, parseSuggestions, suggestPrompt } from './suggest.js'
5import type { Skill } from './suggest.js'
6
7const model = atom({ plugin: 'skills', key: 'model' } as const, EMPTY)
8
9/** Process state (not drawn): the prompt counter and the skill list, fetched once. */
10const S: { turn: number; skills: Skill[] } = { turn: 0, skills: [] }
11
12export const register: Register = on => {
13 on('session.start', async ($, e, next) => {
14 S.turn = 0; S.skills = []
15 await update($, model, () => EMPTY)
16 return next(e)
17 })
18
19 // Shown live in the hint row under the prompt; a pick disappears once Claude (or you) loads it.
20 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
21 const line = hintLine(await read($, model), Math.max(20, (e.viewport?.columns ?? 100) - 40))
22 if (!line) return next(e)
23 return next({ ...e, props: { ...e.props, tail: line } })
24 })
25
26 // Each real prompt: ask haiku for the top skills, in the background so the turn never waits.
27 on('prompt.submit', async ($, e, next) => {
28 const text = String(e.text ?? '')
29 if (!isRealPrompt(text)) return next(e)
30 const turn = ++S.turn
31 await update($, model, () => ({ ...EMPTY, turn, pending: true }))
32 void pick($, text, turn)
33 return next(e)
34 })
35
36 // Claude loading a skill, or you running one by name, removes it from the picks.
37 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
38 const r = await next(e)
39 if (r.deny === undefined && !r.isError && !e.agentId) await update($, model, m => markLoaded(m, e.skill))
40 return r
41 })
42 on('command.run', async ($, e, next) => {
43 const r = await next(e)
44 if (S.skills.some(x => x.name === e.command)) await update($, model, m => markLoaded(m, e.command))
45 return r
46 })
47}
48
49async function pick($: import('claude-code').EngineInterface, text: string, turn: number): Promise<void> {
50 try {
51 if (S.skills.length === 0) {
52 const list = await $.command.list()
53 S.skills = list.filter(c => c.source === 'user' || c.source === 'plugin').map(c => ({ name: c.name, description: c.description }))
54 }
55 const reply = await $.model.complete({ model: 'haiku', effort: 'low', maxTokens: 200, timeoutMs: 30000, prompt: suggestPrompt(text, S.skills) })
56 const picks = reply.isAnswered ? parseSuggestions(reply.text, new Set(S.skills.map(x => x.name))) : []
57 await update($, model, m => (m.turn !== turn ? m : { ...m, pending: false, suggestions: picks.filter(p => !m.loaded.includes(p.name)) }))
58 } catch {
59 await update($, model, m => (m.turn !== turn ? m : { ...m, pending: false }))
60 }
61}
62hooks/suggest.ts 56 lines1import type { SkillsModel, Suggestion } from '../types'
2
3export const EMPTY: SkillsModel = { turn: 0, pending: false, suggestions: [], loaded: [] }
4
5export type Skill = { name: string; description: string }
6
7/** Prompts worth suggesting for: typed by a person, with words in them. */
8export function isRealPrompt(text: string): boolean {
9 const t = text.trim()
10 if (t.length < 12) return false
11 if (/^</.test(t)) return false // notifications, command envelopes, system text
12 if (/^\/\S+\s*$/.test(t)) return false // a bare slash command
13 if (/^Self-drive is still on|^SELF-DRIVE MODE/.test(t)) return false
14 return true
15}
16
17export function suggestPrompt(ask: string, skills: readonly Skill[]): string {
18 const list = skills.map(s => `- ${s.name}: ${s.description.replace(/\s+/g, ' ').slice(0, 220)}`).join('\n')
19 return `Pick the skills an AI coding assistant should load for this request.
20
21Request:
22${ask.slice(0, 2000)}
23
24Available skills:
25${list}
26
27Answer with up to 3 lines, most useful first, each exactly:
28<skill name> | <why, under 12 words>
29Only skills that clearly fit the request. If none fit, answer: NONE`
30}
31
32export function parseSuggestions(reply: string, known: ReadonlySet<string>): Suggestion[] {
33 const out: Suggestion[] = []
34 for (const line of reply.split('\n')) {
35 const m = /^\s*[-*\d.)\s]*`?([A-Za-z0-9:_-]+)`?\s*\|\s*(.+)$/.exec(line)
36 if (!m || !known.has(m[1]!) || out.some(s => s.name === m[1])) continue
37 out.push({ name: m[1]!, why: m[2]!.trim().slice(0, 80) })
38 }
39 return out.slice(0, 3)
40}
41
42/** A skill was loaded: drop it from the picks, remember it for picks still on the way. */
43export function markLoaded(m: SkillsModel, name: string): SkillsModel {
44 return { ...m, suggestions: m.suggestions.filter(s => s.name !== name), loaded: [...m.loaded, name] }
45}
46
47/** The hint-row line: the picks Claude hasn't loaded, as slash commands, one row. */
48export function hintLine(m: SkillsModel, width: number): string | undefined {
49 if (m.pending) return '◇ picking skills…'
50 if (m.suggestions.length === 0) return undefined
51 const line = `◇ ${m.suggestions.map(s => `/${s.name}`).join(' · ')}`
52 if (line.length <= width) return line
53 const short = `◇ ${m.suggestions.map(s => `/${s.name.replace(/^[a-z-]+:/, '')}`).join(' · ')}`
54 return short.length <= width ? short : short.slice(0, Math.max(10, width - 1)) + '…'
55}
56types/index.d.ts 17 lines1export type Suggestion = { name: string; why: string }
2export type SkillsModel = {
3 /** The turn the picks are for; a newer prompt replaces them. */
4 turn: number
5 pending: boolean
6 /** Picks Claude has not loaded yet: a loaded one is removed. */
7 suggestions: Suggestion[]
8 /** Skills loaded this turn, so a pick arriving after the load is not shown. */
9 loaded: string[]
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 skills: { model: SkillsModel }
15 }
16}
17