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

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.
Skills · 2 and lists them beside it..claude/skills folder.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.Choose how long new picks last:
/clear, or open a new chat.Use the same skills in a project every time? Save them once:
/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.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.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.
You need Claude Code.
Type this at the prompt:
/plugin install skills-picker --marketplace Leromem/skills-picker
y when asked Add marketplace?Installed skills-picker. Plugin is now active. The Skills button shows above the message box right away.claude plugin install skills-picker --marketplace Leromem/skills-picker
Then start a new Claude Code chat.
/plugin doesn't run in the desktop app. Do this instead:
Leromem/skills-picker.claude plugin marketplace update skills-picker
Then open a new chat, or run /reload-plugins.
claude plugin uninstall skills-picker@skills-picker
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.
MIT
hooks/register.tsx 444 lines1import { 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}
444types/index.d.ts 27 lines1export 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