Pane listing the skills and commands you created, split into global, user and project

A Claude Code mod that opens a "My skills" pane listing the skills and slash commands you created yourself, in three groups:
~/.config/skillshare/skills, and you can change it when you install the mod or later in /config.~/.claude/skills and ~/.claude/commands, minus anything already listed under Global..claude/skills and .claude/commands in the session's project root.~/.claude/skills/synced.A Starred section sits at the top: hover a row and click the ☆ after the name to star it, click ★ to unstar. Stars are saved across sessions.
Hover a name to see what the skill does. Click it to open its SKILL.md (or command file) in your default editor, or, for an Account skill, its page in Customize › Skills in the Claude desktop app.
Run /my-skills to reopen the pane and rescan. The pane also has a Refresh button.
At the prompt of a terminal session:
/plugin install my-skills --marketplace philgetzen/my-skills-mod
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install my-skills --marketplace philgetzen/my-skills-mod
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with philgetzen/my-skills-mod, then Add plugin.
hooks/register.tsx 289 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Item, Section } from '../types'
5
6const PANE = 'my-skills'
7const sections = atom({ plugin: 'my-skills', key: 'sections' } as const, [])
8// The targets of the starred items: the session's copy, kept in `$.store` so the
9// stars outlive the session.
10const starred = atom({ plugin: 'my-skills', key: 'starred' } as const, [])
11const STARRED_KEY = 'starred'
12
13// The `description:` in a file's frontmatter, folded scalars (`>`, `|`) included.
14function frontmatterDescription(text: string): string {
15 const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)
16 if (!match?.[1]) return ''
17 const lines = match[1].split(/\r?\n/)
18 const at = lines.findIndex(line => /^description:/.test(line))
19 if (at < 0) return ''
20 let value = (lines[at] ?? '').replace(/^description:\s*/, '')
21 if (/^[>|][-+]?$/.test(value)) {
22 const rest: string[] = []
23 for (const line of lines.slice(at + 1)) {
24 if (!/^\s+/.test(line)) break
25 rest.push(line.trim())
26 }
27 value = rest.join(' ')
28 }
29 return value.replace(/^["']|["']$/g, '').trim()
30}
31
32async function describe($: EngineInterface, path: string): Promise<string> {
33 try {
34 return frontmatterDescription(await $.fs.read(path))
35 } catch {
36 return ''
37 }
38}
39
40// True when `path` is a regular file that, every link followed, stays under
41// `root` (already resolved). Guards the Project folders, which come from
42// whatever repository the session opened: a SKILL.md there that links to
43// ~/.ssh/id_ed25519 must not be read or opened.
44async function isInside($: EngineInterface, path: string, root: string): Promise<boolean> {
45 try {
46 const found = await $.fs.stat(path, { resolve: true })
47 return found.kind === 'file' && found.realPath !== undefined && found.realPath.startsWith(`${root}/`)
48 } catch {
49 return false
50 }
51}
52
53// Skill folders are often symlinks (skillshare), so accept links as well as dirs.
54// With `confineTo`, a skill whose file resolves outside that root is left out.
55async function skills($: EngineInterface, folder: string, confineTo?: string): Promise<Item[]> {
56 if (!(await $.fs.exists(folder))) return []
57 const entries = await $.fs.list(folder)
58 const names = entries
59 .filter(one => (one.kind === 'dir' || one.isLink) && !one.name.startsWith('.'))
60 .map(one => one.name)
61 // A folder without a SKILL.md (such as ~/.claude/skills/synced) is not a skill.
62 const found = await Promise.all(
63 names.map(async name => {
64 const target = `${folder}/${name}/SKILL.md`
65 if (!(await $.fs.exists(target))) return undefined
66 if (confineTo !== undefined && !(await isInside($, target, confineTo))) return undefined
67 return { name, target, description: await describe($, target) }
68 }),
69 )
70 return found.filter((one): one is Item => one !== undefined)
71}
72
73type SyncedSkill = { skillId: string; name: string; description?: string }
74
75// Skills uploaded to the claude.ai account, from the manifest Claude Code syncs
76// into ~/.claude/skills/synced/<bucket>/. Anthropic's own skills there carry a
77// plain id ("pdf"); the person's carry an account id ("skill_..."), which is
78// also what their Customize page is addressed by.
79async function accountSkills($: EngineInterface, folder: string): Promise<Item[]> {
80 if (!(await $.fs.exists(folder))) return []
81 const byId = new Map<string, Item>()
82 for (const bucket of await $.fs.list(folder)) {
83 if (bucket.kind !== 'dir') continue
84 try {
85 const manifest = JSON.parse(await $.fs.read(`${folder}/${bucket.name}/manifest.json`))
86 for (const skill of (manifest.skills ?? []) as SyncedSkill[]) {
87 if (typeof skill.skillId !== 'string' || !/^skill_[A-Za-z0-9]+$/.test(skill.skillId)) continue
88 byId.set(skill.skillId, {
89 name: skill.name,
90 description: skill.description ?? '',
91 target: `claude://claude.ai/customize/skills/yours/id/${skill.skillId}`,
92 })
93 }
94 } catch {
95 // A bucket mid-sync or without a manifest lists nothing.
96 }
97 }
98 return [...byId.values()]
99}
100
101async function commands($: EngineInterface, folder: string, confineTo?: string): Promise<Item[]> {
102 if (!(await $.fs.exists(folder))) return []
103 const entries = await $.fs.list(folder)
104 const found = await Promise.all(
105 entries
106 .filter(one => one.name.endsWith('.md'))
107 .map(async one => {
108 const target = `${folder}/${one.name}`
109 if (confineTo !== undefined && !(await isInside($, target, confineTo))) return undefined
110 return { name: `/${one.name.slice(0, -3)}`, target, description: await describe($, target) }
111 }),
112 )
113 return found.filter((one): one is Item => one !== undefined)
114}
115
116async function scan($: EngineInterface, globalSetting: string): Promise<Section[]> {
117 const home = (await $.env.get('HOME')) ?? ''
118 const root = await $.session.root()
119
120 const globalDir = globalSetting.startsWith('~') ? `${home}${globalSetting.slice(1)}` : globalSetting
121 const global = await skills($, globalDir)
122 const seen = new Set(global.map(one => one.name))
123
124 const userSkills = (await skills($, `${home}/.claude/skills`)).filter(one => !seen.has(one.name))
125 const userCommands = await commands($, `${home}/.claude/commands`)
126
127 const realRoot = await $.fs
128 .stat(root, { resolve: true })
129 .then(found => found.realPath)
130 .catch(() => undefined)
131 const isProject = root !== home && realRoot !== undefined
132 const projectSkills = isProject ? await skills($, `${root}/.claude/skills`, realRoot) : []
133 const projectCommands = isProject ? await commands($, `${root}/.claude/commands`, realRoot) : []
134
135 const account = await accountSkills($, `${home}/.claude/skills/synced`)
136
137 const sort = (list: Item[]) => [...list].sort((a, b) => a.name.localeCompare(b.name))
138 const tilde = (path: string) => (home && path.startsWith(home) ? `~${path.slice(home.length)}` : path)
139
140 return [
141 { title: 'Global', folders: [tilde(globalDir)], items: sort(global) },
142 {
143 title: 'User',
144 folders: [tilde(`${home}/.claude/skills`), tilde(`${home}/.claude/commands`)],
145 items: [...sort(userSkills), ...sort(userCommands)],
146 },
147 {
148 title: 'Project',
149 folders: [tilde(`${root}/.claude`)],
150 items: [...sort(projectSkills), ...sort(projectCommands)],
151 },
152 { title: 'Account', folders: ['claude.ai, Customize › Skills'], items: sort(account) },
153 ]
154}
155
156// A file opens in the default text editor (`open -t` on macOS, `xdg-open` on
157// Linux); a claude:// link opens in the desktop app.
158async function openTarget($: EngineInterface, target: string) {
159 const isLink = target.startsWith('claude://')
160 const openers = isLink ? [['open', target], ['xdg-open', target]] : [['open', '-t', target], ['xdg-open', target]]
161 for (const argv of openers) {
162 try {
163 if ((await $.process.run(argv)).exitCode === 0) return
164 } catch {
165 // Not on this platform; try the next opener.
166 }
167 }
168 $.ui.toast(`Could not open ${target}`)
169}
170
171async function refresh($: EngineInterface, globalSetting: string) {
172 const next = await scan($, globalSetting)
173 await update($, sections, () => next)
174}
175
176async function loadStars($: EngineInterface) {
177 const saved = await $.store.get(STARRED_KEY)
178 const list = Array.isArray(saved) ? saved.filter((one): one is string => typeof one === 'string') : []
179 await update($, starred, () => list)
180}
181
182async function toggleStar($: EngineInterface, target: string) {
183 const current = await read($, starred)
184 const next = current.includes(target) ? current.filter(one => one !== target) : [...current, target]
185 await $.store.set(STARRED_KEY, next)
186 await update($, starred, () => next)
187}
188
189export const register: Register = (on, options) => {
190 const globalDir = String(options.globalSkillsDir ?? '~/.config/skillshare/skills')
191
192 on('session.start', async ($, e, next) => {
193 await $.command.register({
194 name: 'my-skills',
195 description: 'Show your skills and commands: starred, global, user, project, account',
196 })
197 await loadStars($)
198 await refresh($, globalDir)
199 void $.ui.open({ id: PANE, title: 'My skills' })
200
201 return next(e)
202 })
203
204 on('command.run', { command: 'my-skills' }, async $ => {
205 await refresh($, globalDir)
206 await $.ui.open({ id: PANE, title: 'My skills' })
207
208 return { text: 'My skills pane opened.' }
209 })
210
211 // A floating card is painted over whatever comes before it in the tree, so each
212 // list is written bottom-up and laid out with column-reverse: the rows a card
213 // drops over come earlier in the tree than the card itself. The spacer at the
214 // end leaves the last row's card room to show inside the pane.
215 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
216 const { Box, Text, Button } = $.ui.resolve(e)
217 const list = await read($, sections)
218 const stars = new Set(await read($, starred))
219 const starredItems = list
220 .flatMap(section => section.items)
221 .filter(item => stars.has(item.target))
222 .sort((a, b) => a.name.localeCompare(b.name))
223 const shown: Section[] = [
224 { title: 'Starred', folders: ['Click ☆ beside a name to star it'], items: starredItems },
225 ...list,
226 ]
227
228 return (
229 <Box flexDirection="column-reverse">
230 <Box height={8} />
231 {[...shown].reverse().map(section => (
232 <Box key={section.title} flexDirection="column-reverse" marginTop={1}>
233 {[...section.items].reverse().map(item => (
234 <Box key={`${section.title}:${item.target}`}>
235 <Button
236 key={`open:${section.title}:${item.target}`}
237 plain
238 hover={{ bold: true, color: 'suggestion' }}
239 onPress={() => openTarget($, item.target)}
240 >
241 {item.name}
242 </Button>
243 {/* The star sits after the name, so revealing it moves nothing. A
244 starred item keeps its ★ in view; an unstarred one shows ☆
245 only while its row is hovered. (A desktop draws every Button
246 with native padding, so a star before the name could not sit
247 any tighter than that.) */}
248 <Box
249 display={stars.has(item.target) ? 'flex' : 'none'}
250 hover={stars.has(item.target) ? undefined : { display: 'flex' }}
251 >
252 <Button
253 key={`star:${section.title}:${item.target}`}
254 plain
255 onPress={() => toggleStar($, item.target)}
256 >
257 {stars.has(item.target) ? '★' : '☆'}
258 </Button>
259 </Box>
260 <Box
261 position="absolute"
262 top={1}
263 marginTop={0.5}
264 left={2}
265 right={0}
266 display="none"
267 hover={{ display: 'flex' }}
268 borderStyle="round"
269 borderColor="suggestion"
270 backgroundColor="inverseText"
271 paddingX={1}
272 >
273 <Text>{item.description || 'No description.'}</Text>
274 </Box>
275 </Box>
276 ))}
277 {section.items.length === 0 && <Text dimColor>none</Text>}
278 <Text dimColor>{section.folders.join(', ')}</Text>
279 <Text bold>
280 {section.title} ({section.items.length})
281 </Text>
282 </Box>
283 ))}
284 <Button key="refresh" onPress={() => refresh($, globalDir)}>Refresh</Button>
285 </Box>
286 )
287 })
288}
289types/index.d.ts 10 lines1// `target` is the file a click opens, or the claude:// link for an account skill.
2export type Item = { name: string; target: string; description: string }
3export type Section = { title: string; folders: string[]; items: Item[] }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'my-skills': { sections: Section[]; starred: string[] }
8 }
9}
10