SLOPSHOPPER

skills

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

newspinnerguardpromptmodel
v0.1.0MITupdated 2026-10-09sruthik27/claude-foundry/mods/skills
A shopper browsing a rack in a slop shop
README

skills

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.

Install

claude plugin install skills --marketplace sruthik27/claude-foundry

Requires Claude Code 2.1.296+. Reload plugins or start a new session after installation.

Try it

Type a request relevant to one of your installed skills.

Details

MIT licensed. Maintained by Sruthik Issac.

Source 3 files
hooks/register.tsx 62 lines
1import { 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}
62
hooks/suggest.ts 56 lines
1import 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}
56
types/index.d.ts 17 lines
1export 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