SLOPSHOPPER

skills-picker

A Skills button above the prompt: pick skills to apply to your next message or the whole chat.

newbandtoastprompt
v0.1.2MITupdated 2026-10-25Leromem/skills-picker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skills-picker
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM [ Skills ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ Skills ] ⟨Claude Code's own drawing⟩
README

Skills picker

A mod for Claude Code that adds a Skills button above the message box. Click it, pick one or more skills, and Claude loads them before answering your next message, or every message for the rest of the chat.

What it does

  • Skills button: opens the picker. While skills are picked, it reads Skills · 2 and lists them beside it.
  • ★ Pinned: your favourite skills, shown first. Press ☆ Pin skills, then click skills to pin or unpin them. Pins are kept across all chats.
  • Most used: the skills you run most often, counted from the moment you install the mod.
  • This project: skills installed in the current project's .claude/skills folder.
  • Manual: skills only you can start, such as ones with disable-model-invocation: true. Clicking one puts /skill-name at the front of your message box, so it runs as a command when you send. With Whole chat selected, the command is put back in front of the message box after every send, until you click it again or press Clear.
  • All skills: everything else, with a filter box.

Choose how long new picks last:

  • Next message only: the pick is used once, then drops off.
  • Whole chat: the pick stays until you press Clear, run /clear, or open a new chat.

Project defaults

Use the same skills in a project every time? Save them once:

  • Save picks as project default: stores the current picks for this project folder. Every new chat there, and every /clear, starts with them picked for the whole chat. A manual skill in the default is already typed at the front of your message box.
  • Standing note: a line such as time matters here or just answer, sent to Claude with every message in this project, slash commands included. It shows as Note: … beside the Skills button. Clear the field and press Enter to remove it.
  • Remove project default: stops restoring the picks. The note stays until you clear it.

Each project keeps its own defaults.

When you send a message, the mod adds a hidden note telling Claude to load the picked skills first. The text you typed isn't changed.

Install

You need Claude Code.

In a terminal session of Claude Code

Type this at the prompt:

/plugin install skills-picker --marketplace Leromem/skills-picker
  1. Answer y when asked Add marketplace?
  2. Press Enter to choose the user scope, so the mod works in every project.
  3. You'll see Installed skills-picker. Plugin is now active. The Skills button shows above the message box right away.

From a regular shell (PowerShell, Terminal, bash)

claude plugin install skills-picker --marketplace Leromem/skills-picker

Then start a new Claude Code chat.

In the Claude desktop app

/plugin doesn't run in the desktop app. Do this instead:

  1. Click + next to the message box, then Plugins.
  2. Choose Add marketplace and enter Leromem/skills-picker.
  3. Choose Add plugin and pick skills-picker.
  4. Open a new chat.

Update

claude plugin marketplace update skills-picker

Then open a new chat, or run /reload-plugins.

Uninstall

claude plugin uninstall skills-picker@skills-picker

Privacy

Everything stays on your computer. Your pins, usage counts and project defaults are saved in Claude Code's local plugin storage, and the mod sends nothing anywhere.

License

MIT

Source 2 files
hooks/register.tsx 444 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pick, PickMode, ProjectDefault, SkillItem } from '../types'
5
6const isOpen = atom({ plugin: 'skills-picker', key: 'isOpen' } as const, false)
7const isShowingAll = atom({ plugin: 'skills-picker', key: 'isShowingAll' } as const, false)
8const mode = atom({ plugin: 'skills-picker', key: 'mode' } as const, 'once' as PickMode)
9const filter = atom({ plugin: 'skills-picker', key: 'filter' } as const, '')
10const picks = atom({ plugin: 'skills-picker', key: 'picks' } as const, [] as Pick[])
11const skills = atom({ plugin: 'skills-picker', key: 'skills' } as const, null as SkillItem[] | null)
12// Skills only the person may start (`disable-model-invocation`), such as savvy-flow: run as slash commands.
13const manual = atom({ plugin: 'skills-picker', key: 'manual' } as const, [] as SkillItem[])
14const uses = atom({ plugin: 'skills-picker', key: 'uses' } as const, {} as Record<string, number>)
15// Favourites, kept across chats in the store; `isPinning` turns a click into pin/unpin.
16const pins = atom({ plugin: 'skills-picker', key: 'pins' } as const, [] as string[])
17const isPinning = atom({ plugin: 'skills-picker', key: 'isPinning' } as const, false)
18// This chat's project folder (store key) and its saved default, kept across chats in the store.
19const project = atom({ plugin: 'skills-picker', key: 'project' } as const, '')
20const projectDefault = atom({ plugin: 'skills-picker', key: 'projectDefault' } as const, null as ProjectDefault | null)
21
22// How many skills the unfiltered "All skills" row shows before "Show all".
23const PREVIEW = 18
24const MOST_USED = 8
25
26const modeLabel = (m: PickMode): string => (m === 'once' ? 'once' : 'whole chat')
27
28// The session's skill listing, as /context counts it: user-wide, project and plugin skills.
29const loadSkills = async ($: EngineInterface): Promise<void> => {
30  const usage = await $.session.usage({ breakdown: 'summary' })
31  const listed = usage.context.breakdown?.skills?.skillFrontmatter ?? []
32  const seen = new Set<string>()
33  const list: SkillItem[] = []
34  for (const s of listed) {
35    const name = s.pluginName && !s.name.includes(':') ? `${s.pluginName}:${s.name}` : s.name
36    if (seen.has(name)) continue
37    seen.add(name)
38    list.push({ name, source: String(s.source) })
39  }
40  list.sort((a, b) => a.name.localeCompare(b.name))
41  await update($, skills, () => list)
42
43  // A plugin's skill or command is `plugin:name`; one without a colon is a mod's own
44  // command (/filetree), which takes no message, so it stays out.
45  const commands = await $.command.list().catch(() => [])
46  const manualList = commands
47    .filter(c => (c.source === 'user' || (c.source === 'plugin' && c.name.includes(':'))) && !seen.has(c.name))
48    .map(c => ({ name: c.name, source: 'manual' }))
49    .sort((a, b) => a.name.localeCompare(b.name))
50  await update($, manual, () => manualList)
51}
52
53// Puts `/name ` in front of the draft (in place of a command already there), so
54// sending it runs the skill as if typed.
55const startWith = async ($: EngineInterface, name: string): Promise<void> => {
56  const { text } = await $.prompt.read()
57  const rest = text.startsWith('/') ? text.replace(/^\/\S*\s*/, '') : text
58  await $.prompt.fill({ text: `/${name} ${rest}`, mode: 'replace' })
59}
60
61// A whole-chat manual pick: the draft starts with its command again after every send.
62// A draft that already starts with a command (typed /clear, say) is left alone.
63const keepPrefix = async ($: EngineInterface): Promise<void> => {
64  const pick = (await read($, picks)).find(p => p.manual)
65  if (!pick) return
66  const { text } = await $.prompt.read()
67  if (text.startsWith('/')) return
68  await $.prompt.fill({ text: `/${pick.name} ${text}`, mode: 'replace' })
69}
70
71// Takes `/name ` back off the draft when its whole-chat pick ends.
72const dropPrefix = async ($: EngineInterface, name: string): Promise<void> => {
73  const { text } = await $.prompt.read()
74  if (text.startsWith(`/${name} `) || text === `/${name}`) {
75    await $.prompt.fill({ text: text.slice(name.length + 2), mode: 'replace' })
76  }
77}
78
79// One key per folder: `D:\x` and `d:/x` are the same project.
80const projectKey = (cwd: string): string => cwd.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
81
82type Defaults = Record<string, ProjectDefault>
83const loadDefaults = async ($: EngineInterface): Promise<Defaults> =>
84  ((await $.store.get('defaults')) as Defaults | undefined) ?? {}
85
86// Writes this project's default (null removes it) to the store and the band.
87const saveDefault = async ($: EngineInterface, value: ProjectDefault | null): Promise<void> => {
88  const key = await read($, project)
89  if (!key) return
90  const all = { ...(await loadDefaults($)) }
91  const isEmpty = !value || (value.skills.length === 0 && !value.manual && !value.note)
92  if (isEmpty) delete all[key]
93  else all[key] = value
94  await $.store.set('defaults', all)
95  await update($, projectDefault, () => (isEmpty ? null : value))
96}
97
98// A new chat (or a /clear) in a project with a default starts with its picks, whole chat.
99// Set when a default puts a manual skill back, so the next band fills the draft once.
100let needsPrefix = false
101const applyDefault = async ($: EngineInterface): Promise<void> => {
102  const saved = await read($, projectDefault)
103  if (!saved) return
104  const restored: Pick[] = saved.skills.map(name => ({ name, mode: 'chat' }))
105  if (saved.manual) restored.push({ name: saved.manual, mode: 'chat', manual: true })
106  await update($, picks, () => restored)
107  needsPrefix = Boolean(saved.manual)
108}
109
110const noteContext = (note: string): string => `The user's standing note for this project, for every message: ${note}`
111
112const instruction = (names: string[]): string =>
113  `The user picked these skills in the Skills bar to apply to this message: ${names.join(', ')}.\n` +
114  'Before anything else, invoke each one with the Skill tool and follow its instructions while handling this message. ' +
115  'A picked skill already loaded earlier in this conversation does not need to be invoked again; keep following it.'
116
117export const register: Register = on => {
118  on('session.start', async ($, e, next) => {
119    const saved = (await $.store.get('uses')) as Record<string, number> | undefined
120    if (saved) await update($, uses, () => saved)
121    const pinned = (await $.store.get('pins')) as string[] | undefined
122    if (pinned) await update($, pins, () => pinned)
123    const key = projectKey(e.cwd)
124    await update($, project, () => key)
125    await update($, projectDefault, () => null)
126    const stored = (await loadDefaults($))[key]
127    if (stored) {
128      await update($, projectDefault, () => stored)
129      await applyDefault($)
130    }
131    return next(e)
132  })
133
134  // A /clear ends the chat: the picks end with it, and the project's default comes back
135  // (a /clear raises no session.start).
136  on('session.end', async ($, e, next) => {
137    await update($, picks, () => [])
138    await update($, isOpen, () => false)
139    await applyDefault($).catch(() => undefined)
140    return next(e)
141  })
142
143  // Counts every skill run (typed, picked here, or chosen by Claude) for "Most used".
144  on('skill.prompt', async ($, e, next) => {
145    const counts = { ...(await read($, uses)), [e.skill]: ((await read($, uses))[e.skill] ?? 0) + 1 }
146    await update($, uses, () => counts)
147    await $.store.set('uses', counts)
148    return next(e)
149  })
150
151  on('prompt.submit', async ($, e, next) => {
152    const isPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
153    // A failed read never holds up the message: it goes out without picks.
154    const all = await read($, picks).catch(() => [] as Pick[])
155    // Manual picks travel as the slash command itself, never as an instruction.
156    const chosen = all.filter(p => !p.manual)
157    const note = (await read($, projectDefault).catch(() => null))?.note
158    if (!isPerson || e.isMeta || (all.length === 0 && !note)) return next(e)
159
160    // The note goes with every message, a slash command's too; picks only with plain ones.
161    const usesPicks = !e.command && chosen.length > 0
162    const added = [
163      ...(note ? [noteContext(note)] : []),
164      ...(usesPicks ? [instruction(chosen.map(p => p.name))] : []),
165    ]
166    const result = await next(added.length ? { ...e, context: [...(e.context ?? []), ...added] } : e)
167    if (!('drop' in result)) {
168      if (usesPicks) await update($, picks, prev => prev.filter(p => p.mode === 'chat')).catch(() => undefined)
169      await keepPrefix($).catch(() => undefined)
170    }
171    return result
172  })
173
174  // The draft may only clear after the submit hook: put the command back once the turn ends too.
175  on('turn.complete', async ($, e, next) => {
176    const result = await next(e)
177    await keepPrefix($).catch(() => undefined)
178    return result
179  })
180
181  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
182    if (e.props.hasSurvey) return next(e)
183    // A default's manual skill: the prompt box exists by the first band, so fill it here once.
184    if (needsPrefix) {
185      needsPrefix = false
186      void keepPrefix($).catch(() => undefined)
187    }
188    // Other mods' bands (savvy-progress, handoff) draw beneath this one: keep them.
189    const rest = await next(e)
190
191    const ui = $.ui.resolve(e)
192    const { Box, Text, Button } = ui
193    // The phone app draws no text field: there the list goes unfiltered.
194    const Input = 'Input' in ui ? ui.Input : null
195    const open = await read($, isOpen)
196    const chosen = await read($, picks)
197    const current = await read($, mode)
198    const saved = await read($, projectDefault)
199
200    const toggle = (name: string) =>
201      update($, picks, prev =>
202        prev.some(p => p.name === name) ? prev.filter(p => p.name !== name) : [...prev, { name, mode: current }],
203      )
204    const pressSkills = async () => {
205      const willOpen = !open
206      await update($, isOpen, () => willOpen)
207      if (willOpen) await loadSkills($)
208    }
209
210    const head = (
211      <Box flexDirection="row" gap={1} flexWrap="wrap" alignItems="center">
212        <Button
213          key="skills-toggle"
214          label={chosen.length ? `Skills · ${chosen.length}` : 'Skills'}
215          variant={chosen.length ? 'primary' : undefined}
216          onPress={() => void pressSkills()}
217        />
218        {!open && chosen.length > 0 && (
219          <Text dimColor>{chosen.map(p => `${p.name} (${modeLabel(p.mode)})`).join(' · ')}</Text>
220        )}
221        {!open && saved?.note && <Text dimColor>Note: {saved.note}</Text>}
222        {chosen.length > 0 && (
223          <Button
224            key="skills-clear"
225            label="Clear"
226            plain
227            dimColor
228            onPress={() =>
229              void (async () => {
230                for (const p of chosen.filter(c => c.manual)) await dropPrefix($, p.name)
231                await update($, picks, () => [])
232              })()
233            }
234          />
235        )}
236        {/* "Done" sits under the list, often scrolled out of the band: this one is always in view. */}
237        {open && <Button key="skills-minimize" label="▾ Minimize" plain onPress={() => void update($, isOpen, () => false)} />}
238      </Box>
239    )
240
241    if (!open) {
242      return (
243        <Box flexDirection="column">
244          {head}
245          {rest}
246        </Box>
247      )
248    }
249
250    const list = (await read($, skills)) ?? []
251    const manualList = await read($, manual)
252    const counts = await read($, uses)
253    const query = (await read($, filter)).trim().toLowerCase()
254    const showAll = await read($, isShowingAll)
255    const picked = new Set(chosen.map(p => p.name))
256    const favourites = await read($, pins)
257    const pinning = await read($, isPinning)
258
259    const togglePin = async (name: string) => {
260      const updated = favourites.includes(name) ? favourites.filter(n => n !== name) : [...favourites, name]
261      await update($, pins, () => updated)
262      await $.store.set('pins', updated)
263    }
264
265    const chip = (s: SkillItem, section: string) => {
266      if (pinning) {
267        return (
268          <Button
269            key={`${section}:${s.name}`}
270            label={`${favourites.includes(s.name) ? '★' : '☆'} ${s.name}`}
271            onPress={() => void togglePin(s.name)}
272          />
273        )
274      }
275      if (s.source === 'manual') {
276        const kept = chosen.find(c => c.name === s.name)
277        // "Next message only" fills the draft once; "Whole chat" keeps it in front of every message.
278        const press = async () => {
279          if (kept) {
280            await update($, picks, prev => prev.filter(p => p.name !== s.name))
281            await dropPrefix($, s.name)
282            return
283          }
284          if (current === 'chat') {
285            await update($, picks, prev => [...prev.filter(p => !p.manual), { name: s.name, mode: 'chat', manual: true }])
286          }
287          await startWith($, s.name)
288          await update($, isOpen, () => false)
289        }
290        return (
291          <Button
292            key={`${section}:${s.name}`}
293            label={kept ? `✓ /${s.name} · whole chat` : `/${s.name}`}
294            variant={kept ? 'primary' : undefined}
295            onPress={() => void press().catch(() => $.ui.toast(`Couldn't start /${s.name}`))}
296          />
297        )
298      }
299      const p = chosen.find(c => c.name === s.name)
300      return (
301        <Button
302          key={`${section}:${s.name}`}
303          label={p ? `✓ ${s.name} · ${modeLabel(p.mode)}` : s.name}
304          variant={p ? 'primary' : undefined}
305          onPress={() => void toggle(s.name)}
306        />
307      )
308    }
309    const row = (key: string, title: string, items: SkillItem[]) =>
310      items.length === 0 ? null : (
311        <Box key={key} flexDirection="column">
312          <Text dimColor>{title}</Text>
313          <Box flexDirection="row" flexWrap="wrap" gap={1}>
314            {items.map(s => chip(s, key))}
315          </Box>
316        </Box>
317      )
318
319    const modeButton = (m: PickMode) => (
320      <Button
321        key={`skills-mode-${m}`}
322        label={`${current === m ? '●' : '○'} ${m === 'once' ? 'Next message only' : 'Whole chat'}`}
323        plain
324        onPress={() => void update($, mode, () => m)}
325      />
326    )
327
328    const body = (() => {
329      if (list.length === 0) return <Text dimColor>Loading skills…</Text>
330      if (query) {
331        const found = [...list, ...manualList].filter(s => s.name.toLowerCase().includes(query))
332        return found.length ? row('found', `Matches · ${found.length}`, found) : <Text dimColor>No skill matches “{query}”.</Text>
333      }
334      const most = list
335        .filter(s => (counts[s.name] ?? 0) > 0)
336        .sort((a, b) => (counts[b.name] ?? 0) - (counts[a.name] ?? 0))
337        .slice(0, MOST_USED)
338      const project = list.filter(s => s.source === 'projectSettings')
339      const all = showAll ? list : list.slice(0, PREVIEW)
340      // In pin order, as the person added them; a pin whose skill is gone is skipped.
341      const pinned = favourites.flatMap(n => [...list, ...manualList].filter(s => s.name === n))
342      return (
343        <Box flexDirection="column" gap={1}>
344          {row('pinned', `★ Pinned · ${pinned.length}`, pinned)}
345          {pinned.length === 0 && !pinning && <Text dimColor>No pinned skills yet: press “☆ Pin skills” to add favourites.</Text>}
346          {row('most', 'Most used', most)}
347          {row('project', `This project · ${project.length}`, project)}
348          {row('manual', `Manual · ${manualList.length} · starts your message with the command (Whole chat: every message)`, manualList)}
349          {row('all', `All skills · ${list.length}`, all)}
350          {list.length > PREVIEW && (
351            <Button
352              key="skills-more"
353              label={showAll ? 'Show fewer' : `Show all ${list.length}`}
354              plain
355              dimColor
356              onPress={() => void update($, isShowingAll, () => !showAll)}
357            />
358          )}
359        </Box>
360      )
361    })()
362
363    return (
364      <Box flexDirection="column" gap={1}>
365        {head}
366        <Box flexDirection="row" gap={2} flexWrap="wrap" alignItems="center">
367          <Text dimColor>New picks apply to:</Text>
368          {modeButton('once')}
369          {modeButton('chat')}
370          <Button
371            key="skills-pin-mode"
372            label={pinning ? '✓ Done pinning' : '☆ Pin skills'}
373            variant={pinning ? 'primary' : undefined}
374            onPress={() => void update($, isPinning, () => !pinning)}
375          />
376        </Box>
377        {pinning && <Text dimColor>Click a skill to pin or unpin it. Pins stay in every chat.</Text>}
378        <Box key="skills-default" flexDirection="column">
379          <Text dimColor>
380            {saved && (saved.skills.length > 0 || saved.manual)
381              ? `This project's default (every new chat here): ${[...saved.skills, ...(saved.manual ? [`/${saved.manual}`] : [])].join(', ')}`
382              : 'This project has no default picks: pick skills, then save them so every new chat here starts with them.'}
383          </Text>
384          <Box flexDirection="row" gap={1} flexWrap="wrap">
385            {chosen.length > 0 && (
386              <Button
387                key="skills-default-save"
388                label="Save picks as project default"
389                onPress={() =>
390                  void saveDefault($, {
391                    skills: chosen.filter(p => !p.manual).map(p => p.name),
392                    manual: chosen.find(p => p.manual)?.name,
393                    note: saved?.note,
394                  }).then(() => $.ui.toast('Saved: new chats in this project start with these picks'))
395                }
396              />
397            )}
398            {saved && (saved.skills.length > 0 || saved.manual) && (
399              <Button
400                key="skills-default-clear"
401                label="Remove project default"
402                plain
403                dimColor
404                onPress={() => void saveDefault($, { skills: [], note: saved.note })}
405              />
406            )}
407          </Box>
408          {Input && (
409            <Input
410              key="skills-note"
411              placeholder="Standing note for this project, sent with every message (e.g. time matters here)"
412              submitLabel="save note"
413              value={saved?.note ?? ''}
414              onSubmit={(v: string) =>
415                void saveDefault($, {
416                  skills: saved?.skills ?? [],
417                  manual: saved?.manual,
418                  note: v.trim() || undefined,
419                })
420              }
421            />
422          )}
423        </Box>
424        {Input && (
425          <Input
426            key="skills-filter"
427            placeholder="Filter skills…"
428            submitLabel="filter"
429            value={await read($, filter)}
430            onInput={(v: string) => void update($, filter, () => v)}
431            onSubmit={(v: string) => void update($, filter, () => v)}
432          />
433        )}
434        {body}
435        <Box flexDirection="row" gap={1}>
436          <Button key="skills-done" label="Done" variant="primary" onPress={() => void update($, isOpen, () => false)} />
437          {picked.size > 0 && <Text dimColor>Applies when you send your next message.</Text>}
438        </Box>
439        {rest}
440      </Box>
441    )
442  })
443}
444
types/index.d.ts 27 lines
1export type PickMode = 'once' | 'chat'
2// `manual`: a slash-command skill kept at the front of every message for the whole chat.
3export type Pick = { name: string; mode: PickMode; manual?: true }
4export type SkillItem = { name: string; source: string }
5// A project's saved setup, restored as whole-chat picks in every new chat there.
6// `note`: a standing line (e.g. "time matters here") sent with every message.
7export type ProjectDefault = { skills: string[]; manual?: string; note?: string }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'skills-picker': {
12      isOpen: boolean
13      isShowingAll: boolean
14      mode: PickMode
15      filter: string
16      picks: Pick[]
17      skills: SkillItem[] | null
18      manual: SkillItem[]
19      uses: Record<string, number>
20      pins: string[]
21      isPinning: boolean
22      project: string
23      projectDefault: ProjectDefault | null
24    }
25  }
26}
27