Skill Switchboard: a Claude Code panel for your skills and connectors: Skills and Connectors tabs, a search, an Active Only filter, and a switch on every row…

A Claude Code panel for your skills and connectors. Every row has a switch, a search narrows the list as you type, and an Active Only tick box flips between what is on and what is not. Point it at your own skills library and it installs a skill from there with one click.
Open it with /skill-switchboard.

At the prompt of a terminal session of Claude Code:
/plugin install skill-switchboard --marketplace couriard/claude-mods
Answer y to add the marketplace, pick a scope, and fill in the settings below when asked (all can be left empty). In the Claude desktop app: + next to the prompt box → Plugins → Add marketplace → couriard/claude-mods → Add plugin.
~/.claude/skills (so it applies everywhere), its description, and a Switch On / Switch Off link.
The Connectors tab lists the session's connectors and their state ("8 tools", "Needs signing in", "Failed to connect"). Every connector has a switch. Connectors from your Claude account use the desktop app's own connector switch, taking effect from your next message. Others (desktop extensions, plugin or project servers, ones built into the app) are switched off by the panel itself for this session: their tools are moved out of Claude's list and every call to them is refused until you switch them back on or the session ends. Their own settings are never changed.

The app's switch also becomes the default for new sessions, so the panel records each connector's state before its first change and puts it back when the session ends. If the session ends without that step (the app force-quit), switch it back from the panel next time. Connectors need the desktop app; in a terminal-only session the tab says it could not list them.
Claude Code asks for these when you install. Both can be left empty and changed later with /config → skill-switchboard.
| Setting | What to put | Default |
|---|---|---|
| Library Location | Where your skills library lives (see below). Empty, the panel works for installed skills and connectors, and shows a note where the library would be. | Empty |
| Skills Folder | The folder inside the library that holds the skills, one folder per skill, each with a SKILL.md. Empty when the skills sit directly in the library. | Empty |
Write the location however your library is kept; the panel works out which kind it is.
| Kind | Write it like | How it is read |
|---|---|---|
| A folder on this computer | ~/skills | Directly. Synced cloud folders (iCloud Drive, Dropbox, OneDrive, Google Drive) and mounted network drives are folders too. |
| A server you reach over ssh | my-server:/srv/skills | Over ssh (a host name or an alias from your ~/.ssh/config), which must connect without a password prompt. |
| A GitHub, GitLab or Bitbucket repository | github:you/skills, gitlab:you/skills, bitbucket:you/skills, or the repository's web address | Cloned once into ~/.claude/skill-switchboard/libraries, then brought up to date whenever you press Refresh. |
| Any other git repository | https://git.example.com/team/skills.git, git@host:team/skills.git | The same way. |
| A download | https://example.com/skills.zip | Downloaded and unpacked into the same place, and fetched again on Refresh. A zip holding one top folder (as a repository's download does) is read from inside it. |
A private repository works wherever this computer's git can already reach it (an ssh key, or gh auth login then gh auth setup-git). Reading a library needs python3, and installing from it rsync; a repository needs git, a download curl and unzip. All come with macOS and most Linux systems. The panel only ever reads from your library; it never writes to it.
Example: Anthropic's own skills, github:anthropics/skills with Skills Folder skills.
.claude/skills or in ~/.claude/skills, and the project's .claude/settings.json does not say "off" for it under skillOverrides..claude/skills/<name>, leaves a small receipt (.from-vault.json) beside it, and takes any "off" out of settings.json. A folder already there that did not come from the library is left alone."off" into settings.json. When the copy came from the library, the copy is deleted too; the library still has it. A skill you wrote by hand is never deleted, and nothing in ~/.claude/skills is touched.The project is the folder the session was opened in.
Both draw the same layout. In the terminal the tabs, the tick box and the switches are pieces the panel draws itself and you click directly. The desktop app does not draw those yet, so there the panel uses the app's own buttons and links instead.
From this folder:
claude plugin validate .
claude plugin test .
The checks run against a pretend computer and a pretend library; nothing on your disk is touched.
MIT, by couriard.
hooks/register.tsx 935 lines1// Skill Switchboard: the skills and connectors of the session's project in one panel, each with
2// a switch, and a search of a skills library (a folder on this computer, or on a server reached
3// over ssh) with a switch that copies a skill in from it.
4//
5// - A skill is "on" when its folder is in the project's own .claude/skills or in ~/.claude/skills,
6// and the project's .claude/settings.json does not say "off" for it under skillOverrides.
7// - Switching on from the library copies the folder into the project's own .claude/skills with a
8// receipt (.from-vault.json) inside it, and takes any "off" out of settings.json.
9// - Switching off writes "off" into settings.json and, when the copy is one that came from the
10// library, deletes that copy (the library still has it). A hand-written skill is never deleted.
11// Nothing here ever writes to the library, and nothing writes into ~/.claude/skills.
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14
15import type { Connector, Installed, VaultEntry, View } from '../types'
16
17const PANE = 'skill-switchboard'
18const COMMAND = 'skill-switchboard'
19
20// Where the skills library is: the Library Location setting, read when the module loads (see
21// register). Empty means no library is set up, and the panel says how to set one. One setting takes
22// every kind of home, worked out from how it is written (see placeOf):
23// a folder here ~/skills, /Volumes/Team/skills, a synced iCloud, Dropbox or OneDrive folder
24// a server over ssh my-server:/srv/skills (a host or an ssh config alias, a colon, its path)
25// a git repository github:owner/repo, gitlab:owner/repo, bitbucket:owner/repo, or any git
26// address (https://…, git@…, ssh://…); kept as a local copy, updated by Refresh
27// a download https://…/skills.zip, kept unpacked locally, fetched again by Refresh
28type LibraryConfig = { location: string; skillsFolder: string }
29let library: LibraryConfig = { location: '', skillsFolder: '' }
30// The folder inside the library that holds the skills; '.' when they sit in the library folder itself.
31const libraryFolders = (): string[] => [library.skillsFolder || '.']
32
33const RECEIPT = '.from-vault.json'
34// A fresh listing is asked for after this long (fifteen minutes).
35const VAULT_STALE_MS = 15 * 60 * 1000
36const SSH_OPTIONS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']
37
38// A path escaped for the server's shell, where rsync and ssh hand it on.
39const remoteEscape = (s: string): string => s.replace(/([^A-Za-z0-9_\/.,:@%+=~-])/g, '\\$1')
40// A path on this computer with a leading ~ made the home folder.
41async function localPath($: EngineInterface, p: string): Promise<string> {
42 return p === '~' || p.startsWith('~/') ? `${(await $.env.get('HOME')) ?? ''}${p.slice(1)}` : p
43}
44
45// What a location says, before anything is fetched.
46type Place =
47 | { kind: 'folder'; path: string }
48 | { kind: 'server'; host: string; path: string }
49 | { kind: 'git'; url: string }
50 | { kind: 'download'; url: string }
51
52const GIT_HOSTS: Record<string, string> = { github: 'https://github.com', gitlab: 'https://gitlab.com', bitbucket: 'https://bitbucket.org' }
53
54function placeOf(location: string): Place {
55 const l = location.trim()
56 const short = /^(github|gitlab|bitbucket):([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(l)
57 if (short) return { kind: 'git', url: `${GIT_HOSTS[(short[1] ?? '').toLowerCase()]}/${short[2]}.git` }
58 if (/^https?:\/\/\S+\.zip(\?\S*)?$/i.test(l)) return { kind: 'download', url: l }
59 if (/^(git@|ssh:\/\/|git:\/\/)\S+$/i.test(l) || /^https?:\/\/\S+\.git\/?$/i.test(l)) return { kind: 'git', url: l }
60 // A web page of a repository on a known host (https://github.com/owner/repo) is its git address.
61 const page = /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org)\/([^/\s]+\/[^/\s]+?)\/?$/i.exec(l)
62 if (page) return { kind: 'git', url: `https://${page[1]}/${page[2]}.git` }
63 // host:path, with no slash or tilde before the colon (a path on this computer never has one).
64 const server = /^([A-Za-z0-9._@-]+):(.+)$/.exec(l)
65 if (server && !/^[A-Za-z]:[\\/]/.test(l)) return { kind: 'server', host: server[1] ?? '', path: server[2] ?? '' }
66 return { kind: 'folder', path: l }
67}
68
69// Where a git repository or a download is kept on this computer: one folder per location.
70async function cacheDir($: EngineInterface, url: string): Promise<string> {
71 const home = (await $.env.get('HOME')) ?? ''
72 const slug = url.replace(/^[a-z]+:\/\//i, '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(-80)
73 return `${home}/.claude/skill-switchboard/libraries/${slug}`
74}
75
76async function run($: EngineInterface, argv: string[], what: string, timeoutMs = 120000): Promise<void> {
77 const { exitCode, stderr } = await $.process.run(argv, { timeoutMs })
78 if (exitCode !== 0) throw new Error(`${what}: ${(stderr || '').trim().split('\n')[0] || 'no reason given'}`)
79}
80
81// The library as a folder the panel can read: on this computer, or on a server over ssh. A git
82// repository is cloned on first use and pulled again when `isFresh` (Refresh); a download is fetched
83// and unpacked the same way. Private repositories work wherever this computer's git can reach them.
84async function whereIs($: EngineInterface, isFresh: boolean): Promise<{ host: string; path: string }> {
85 const place = placeOf(library.location)
86 if (place.kind === 'server') return { host: place.host, path: place.path }
87 if (place.kind === 'folder') return { host: '', path: await localPath($, place.path) }
88 const dir = await cacheDir($, place.url)
89 if (place.kind === 'git') {
90 if (!(await $.fs.exists(`${dir}/.git`))) {
91 await run($, ['git', 'clone', '--depth', '1', place.url, dir], 'the library repository could not be copied')
92 } else if (isFresh) {
93 await run($, ['git', '-C', dir, 'pull', '--ff-only', '--depth', '1'], 'the library repository could not be updated')
94 }
95 return { host: '', path: dir }
96 }
97 // A download: fetched into the folder and unpacked there. A zip holding one top folder (as a
98 // repository's download does) is read from inside it.
99 if (isFresh || !(await $.fs.exists(`${dir}/files`))) {
100 await run($, ['rm', '-rf', dir], 'the old download could not be cleared')
101 await run($, ['mkdir', '-p', `${dir}/files`], 'the download folder could not be made')
102 await run($, ['curl', '-fsSL', '-o', `${dir}/library.zip`, place.url], 'the library could not be downloaded')
103 await run($, ['unzip', '-oq', `${dir}/library.zip`, '-d', `${dir}/files`], 'the library download could not be unpacked')
104 }
105 const inside = (await $.fs.list(`${dir}/files`)).filter(e => !e.name.startsWith('.') && !e.name.startsWith('__MACOSX'))
106 const only = inside.length === 1 && inside[0]?.kind === 'dir' ? inside[0].name : ''
107 return { host: '', path: only ? `${dir}/files/${only}` : `${dir}/files` }
108}
109
110const installed = atom({ plugin: 'skill-switchboard', key: 'installed' } as const, [])
111const vault = atom({ plugin: 'skill-switchboard', key: 'vault' } as const, null)
112const faulted = atom({ plugin: 'skill-switchboard', key: 'faulted' } as const, [])
113const connectors = atom({ plugin: 'skill-switchboard', key: 'connectors' } as const, null)
114// Servers switched off for this session by the panel itself (those the app's switch cannot reach).
115const hiddenServers = atom({ plugin: 'skill-switchboard', key: 'hidden' } as const, [])
116const view = atom({ plugin: 'skill-switchboard', key: 'view' } as const, { tab: 'skills', showActive: true, query: '', busy: '', note: '' })
117
118// A skill's name: lower case letters, digits and hyphens, nothing that could be a path.
119const NAME = /^[a-z0-9][a-z0-9-]*$/
120
121// ---------------------------------------------------------------------------------------------
122// Reading a SKILL.md's front matter: the few fields the panel shows.
123
124type Front = { name: string; description: string; version: string; category: string; tags: string[] }
125
126const frontOf = (text: string): Front => {
127 const out: Front = { name: '', description: '', version: '', category: '', tags: [] }
128 const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)
129 if (!m) return out
130 const lines = (m[1] ?? '').split(/\r?\n/)
131 const unquote = (s: string) => s.trim().replace(/^["']|["']$/g, '')
132 for (let i = 0; i < lines.length; i++) {
133 const line = lines[i] ?? ''
134 const km = /^([A-Za-z][A-Za-z0-9 _-]*?):[ \t]*(.*)$/.exec(line)
135 if (!km) continue
136 const key = (km[1] ?? '').toLowerCase()
137 let value = (km[2] ?? '').trim()
138 // A block scalar ("description: |" or ">-"): the indented lines under it, folded into one.
139 if (/^[|>][+-]?\d*$/.test(value)) {
140 const block: string[] = []
141 for (let j = i + 1; j < lines.length; j++) {
142 const next = lines[j] ?? ''
143 if (next.trim() === '') continue
144 if (/^[ \t]/.test(next)) block.push(next.trim())
145 else break
146 }
147 value = block.join(' ')
148 }
149 if (key === 'tags') {
150 const items: string[] = []
151 if (value) items.push(...value.replace(/^\[|\]$/g, '').split(',').map(unquote).filter(Boolean))
152 for (let j = i + 1; j < lines.length; j++) {
153 const next = lines[j] ?? ''
154 if (/^[ \t]*-/.test(next)) items.push(unquote(next.trim().slice(1)))
155 else if (next.trim()) break
156 }
157 out.tags = items
158 } else if (key === 'name') out.name = unquote(value)
159 else if (key === 'description') out.description = unquote(value)
160 else if (key === 'version') out.version = unquote(value)
161 else if (key === 'category') out.category = unquote(value)
162 }
163 return out
164}
165
166
167// ---------------------------------------------------------------------------------------------
168// Where things are: the project (the session's folder) and the skills in ~/.claude/skills.
169
170const projectDir = async ($: EngineInterface): Promise<string> => $.session.cwd()
171const libraryDir = async ($: EngineInterface): Promise<string> => `${(await $.env.get('HOME')) ?? ''}/.claude/skills`
172const projectSkills = (project: string): string => `${project}/.claude/skills`
173const settingsPath = (project: string): string => `${project}/.claude/settings.json`
174
175// The project's settings file: read whole, changed in one key, written back
176// whole with two-space indents. A file that cannot be parsed is a refusal, never an empty start.
177async function readSettings($: EngineInterface, project: string): Promise<Record<string, unknown>> {
178 const path = settingsPath(project)
179 if (!(await $.fs.exists(path))) return {}
180 const data = JSON.parse(await $.fs.read(path)) as unknown
181 if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('the project\'s .claude/settings.json is not an object, so nothing was changed')
182 return data as Record<string, unknown>
183}
184
185async function setOverride($: EngineInterface, project: string, name: string, isOff: boolean): Promise<void> {
186 const data = await readSettings($, project)
187 const raw = data.skillOverrides
188 const overrides: Record<string, unknown> = raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...(raw as Record<string, unknown>) } : {}
189 if (isOff) overrides[name] = 'off'
190 else delete overrides[name]
191 data.skillOverrides = overrides
192 await $.fs.write(settingsPath(project), JSON.stringify(data, null, 2) + '\n')
193}
194
195const offNames = (data: Record<string, unknown>): Set<string> => {
196 const raw = data.skillOverrides
197 const out = new Set<string>()
198 if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
199 for (const [k, v] of Object.entries(raw as Record<string, unknown>)) if (v === 'off') out.add(k)
200 }
201 return out
202}
203
204// Every skill folder in one place: a folder (or a link to one) with a SKILL.md at its top. The
205// desktop app's own skill cache (~/.claude/skills/synced) has none at its top, so it is skipped.
206async function readFolder($: EngineInterface, dir: string, where: Installed['where'], off: Set<string>): Promise<Installed[]> {
207 if (!(await $.fs.exists(dir))) return []
208 const out: Installed[] = []
209 for (const entry of await $.fs.list(dir)) {
210 if (entry.name.startsWith('.') || !NAME.test(entry.name)) continue
211 const folder = `${dir}/${entry.name}`
212 const skillMd = `${folder}/SKILL.md`
213 if (!(await $.fs.exists(skillMd))) continue
214 let front: Front
215 try {
216 front = frontOf(await $.fs.read(skillMd))
217 } catch {
218 continue
219 }
220 let isFromVault = false
221 try {
222 const receipt = JSON.parse(await $.fs.read(`${folder}/${RECEIPT}`)) as { name?: unknown; folder?: unknown }
223 isFromVault = receipt?.name === entry.name
224 } catch {
225 // No receipt: a skill written by hand, or placed here some other way.
226 }
227 out.push({
228 name: entry.name,
229 kind: 'skill',
230 version: front.version,
231 description: front.description,
232 isOff: off.has(entry.name),
233 where,
234 isFromVault,
235 })
236 }
237 return out
238}
239
240// The project's folder first, then the library; a name in both is listed once, as the project's.
241async function refresh($: EngineInterface): Promise<void> {
242 const project = await projectDir($)
243 let off = new Set<string>()
244 try {
245 off = offNames(await readSettings($, project))
246 } catch {
247 // Unreadable settings: nothing reads as off.
248 }
249 const mine = await readFolder($, projectSkills(project), 'project', off)
250 const seen = new Set(mine.map(s => s.name))
251 const library = (await readFolder($, await libraryDir($), 'library', off)).filter(s => !seen.has(s.name))
252 const list = [...mine, ...library].sort((a, b) => a.name.localeCompare(b.name))
253 await update($, installed, () => list)
254}
255
256// ---------------------------------------------------------------------------------------------
257// The library: its listing, read by a small script run here or on the library's server, sent over
258// its own input (so no path is ever quoted into a command line), and kept for fifteen minutes.
259
260const vaultReader = (root: string): string => `
261import json, os, re, sys
262ROOT = os.path.expanduser(${JSON.stringify(root)})
263FOLDERS = ${JSON.stringify(libraryFolders())}
264def front(t):
265 m = re.match(r'^---\\r?\\n(.*?)\\r?\\n---', t, re.S)
266 if not m: return {}
267 out = {}; lines = m.group(1).splitlines()
268 for i, l in enumerate(lines):
269 k = re.match(r'^([A-Za-z][A-Za-z0-9 _-]*?):[ \\t]*(.*)$', l)
270 if not k: continue
271 key, val = k.group(1).lower(), k.group(2).strip()
272 if val:
273 if re.match(r'^[|>][+-]?\\d*$', val):
274 block = []
275 for n in lines[i+1:]:
276 if n.strip() == '': continue
277 if re.match(r'^[ \\t]', n): block.append(n.strip())
278 else: break
279 out[key] = ' '.join(block)
280 else:
281 out[key] = val.strip('"').strip("'")
282 continue
283 items = []
284 for n in lines[i+1:]:
285 if re.match(r'^[ \\t]*-', n): items.append(n.strip()[1:].strip().strip('"').strip("'"))
286 elif n.strip(): break
287 out[key] = items
288 return out
289rows = []
290for folder in FOLDERS:
291 base = os.path.join(ROOT, folder)
292 if not os.path.isdir(base): continue
293 for name in sorted(os.listdir(base)):
294 d = os.path.join(base, name)
295 if name.startswith('.') or not os.path.isdir(d): continue
296 p = os.path.join(d, 'SKILL.md')
297 if not os.path.isfile(p): continue
298 try:
299 f = front(open(p, encoding='utf-8', errors='replace').read())
300 except OSError:
301 continue
302 tags = f.get('tags') or []
303 if isinstance(tags, str): tags = [t.strip() for t in tags.strip('[]').split(',') if t.strip()]
304 desc = f.get('description') or ''
305 if isinstance(desc, list): desc = ' '.join(desc)
306 ver = f.get('version') or ''
307 if isinstance(ver, list): ver = ''
308 cat = f.get('category') or ''
309 if isinstance(cat, list): cat = ''
310 rows.append({'name': name, 'folder': folder, 'version': ver, 'description': desc, 'tags': tags, 'category': cat})
311json.dump(rows, sys.stdout)
312`
313
314type VaultCache = { at: number; list: VaultEntry[] }
315
316async function fetchVault($: EngineInterface, isFresh: boolean): Promise<VaultEntry[]> {
317 const now = await $.clock.now()
318 if (!isFresh) {
319 const cached = (await $.store.get('vault')) as VaultCache | undefined
320 if (cached && Array.isArray(cached.list) && now - cached.at < VAULT_STALE_MS) return cached.list
321 }
322 if (!library.location) throw new Error(NOT_SET_UP)
323 const at = await whereIs($, isFresh)
324 const argv = at.host ? ['ssh', ...SSH_OPTIONS, at.host, 'python3', '-'] : ['python3', '-']
325 const { exitCode, stdout, stderr } = await $.process.run(argv, { stdin: vaultReader(at.path), timeoutMs: 40000 })
326 if (exitCode !== 0) throw new Error(`the library could not be read: ${(stderr || '').trim().split('\n')[0] || 'no reason given'}`)
327 const rows = JSON.parse(stdout) as { name: string; folder: string; version: string; description: string; tags: string[]; category: string }[]
328 const list: VaultEntry[] = rows
329 .filter(r => NAME.test(r.name) && libraryFolders().includes(r.folder))
330 .map(r => ({
331 name: r.name,
332 folder: r.folder,
333 kind: 'skill',
334 version: r.version,
335 description: r.description,
336 tags: r.tags,
337 }))
338 await $.store.set('vault', { at: now, list } satisfies VaultCache)
339 return list
340}
341
342// The search: every word typed must appear somewhere in the name, description or tags.
343const matches = (entry: VaultEntry, query: string): boolean => {
344 const hay = `${entry.name} ${entry.description} ${entry.tags.join(' ')}`.toLowerCase()
345 return query.toLowerCase().split(/\s+/).filter(Boolean).every(w => hay.includes(w))
346}
347
348// ---------------------------------------------------------------------------------------------
349// Switching: on, off, and on from the library.
350
351// The folder a name would take in the project, checked: a direct child of the project's own
352// .claude/skills once every link is followed, so a planted link cannot lead the delete elsewhere.
353async function projectCopy($: EngineInterface, project: string, name: string): Promise<{ path: string; exists: boolean }> {
354 if (!NAME.test(name)) throw new Error('that is not a skill name')
355 const root = projectSkills(project)
356 const path = `${root}/${name}`
357 if (!(await $.fs.exists(path))) return { path, exists: false }
358 const rootReal = (await $.fs.stat(root, { resolve: true })).realPath ?? root
359 const real = (await $.fs.stat(path, { resolve: true })).realPath ?? path
360 if (real !== `${rootReal}/${name}`) throw new Error(`${name} points outside the project's skills folder, so it was left alone`)
361 return { path, exists: true }
362}
363
364async function pullFromVault($: EngineInterface, entry: VaultEntry): Promise<void> {
365 const project = await projectDir($)
366 const copy = await projectCopy($, project, entry.name)
367 if (copy.exists) {
368 let isOurs = false
369 try {
370 isOurs = (JSON.parse(await $.fs.read(`${copy.path}/${RECEIPT}`)) as { name?: unknown }).name === entry.name
371 } catch {
372 // No receipt.
373 }
374 if (!isOurs) throw new Error(`this project already has a skill called ${entry.name} that did not come from the library, so it was left alone`)
375 }
376 // rsync, by argument vector. On a server the path is escaped for its shell, which would split
377 // it at a space otherwise and land the copy somewhere else. --delete keeps the copy exact.
378 const at = await whereIs($, false)
379 const base = at.path.replace(/\/+$/, '')
380 const source = at.host
381 ? `${at.host}:${remoteEscape(`${base}/${entry.folder}/${entry.name}/`)}`
382 : `${base}/${entry.folder}/${entry.name}/`
383 const argv = at.host
384 ? ['rsync', '-a', '--delete', '--exclude', '.git', '-e', `ssh ${SSH_OPTIONS.join(' ')}`, source, `${copy.path}/`]
385 : ['rsync', '-a', '--delete', '--exclude', '.git', source, `${copy.path}/`]
386 const { exitCode, stderr } = await $.process.run(argv, { timeoutMs: 120000 })
387 if (exitCode !== 0) throw new Error(`the library could not be read: ${(stderr || '').trim().split('\n')[0] || 'no reason given'}`)
388 if (!(await $.fs.exists(`${copy.path}/SKILL.md`))) throw new Error(`${entry.name} came down without a SKILL.md`)
389 const today = new Date(await $.clock.now()).toISOString().slice(0, 10)
390 const receipt = { name: entry.name, folder: entry.folder, version: entry.version, from: library.location, pulled: today }
391 await $.fs.write(`${copy.path}/${RECEIPT}`, JSON.stringify(receipt, null, 2) + '\n')
392 await setOverride($, project, entry.name, false)
393}
394
395async function switchOn($: EngineInterface, name: string): Promise<string> {
396 const project = await projectDir($)
397 const have = (await read($, installed)).find(s => s.name === name)
398 if (have) {
399 await setOverride($, project, name, false)
400 return `${name} switched on`
401 }
402 const entry = (await fetchVault($, true)).find(v => v.name === name)
403 if (!entry) throw new Error(`${name} is not in the library`)
404 await pullFromVault($, entry)
405 return `${name} copied from the library and switched on`
406}
407
408async function switchOff($: EngineInterface, name: string): Promise<string> {
409 const project = await projectDir($)
410 const have = (await read($, installed)).find(s => s.name === name)
411 if (!have) throw new Error(`${name} is not installed`)
412 if (have.where === 'project' && have.isFromVault) {
413 const copy = await projectCopy($, project, name)
414 if (copy.exists) {
415 const { exitCode, stderr } = await $.process.run(['rm', '-rf', copy.path])
416 if (exitCode !== 0) throw new Error(`could not remove ${name}: ${(stderr || '').trim().split('\n')[0]}`)
417 }
418 await setOverride($, project, name, true)
419 return `${name} switched off and its copy removed (the library still has it)`
420 }
421 await setOverride($, project, name, true)
422 return `${name} switched off`
423}
424
425// Runs one switch with the row marked busy, then re-reads the folders and leaves a note.
426async function act($: EngineInterface, name: string, job: () => Promise<string>): Promise<void> {
427 await update($, view, v => ({ ...v, busy: name, note: '' }))
428 let note: string
429 try {
430 note = await job()
431 } catch (err) {
432 note = err instanceof Error ? err.message : String(err)
433 }
434 await refresh($)
435 await update($, view, v => ({ ...v, busy: '', note }))
436}
437
438// The search box is always shown. Ticked, it narrows what is switched on; unticked, what is off
439// here and the library, which it reads if needed.
440async function search($: EngineInterface, query: string): Promise<void> {
441 const v = await update($, view, prev => ({ ...prev, query: query.trim(), note: '' }))
442 if (!v.showActive) await loadVault($, false)
443}
444
445
446// Reads the library's listing (kept fifteen minutes) into state for the drawing.
447async function loadVault($: EngineInterface, isFresh: boolean): Promise<void> {
448 if (!isFresh && (await read($, vault)) !== null) return
449 if (!library.location) return
450 await update($, view, v => ({ ...v, note: 'Reading the library…' }))
451 try {
452 if (isFresh) await $.store.delete('vault')
453 const list = await fetchVault($, isFresh)
454 await update($, vault, () => list)
455 await update($, view, v => ({ ...v, note: '' }))
456 } catch (err) {
457 await update($, view, v => ({ ...v, note: err instanceof Error ? err.message : String(err) }))
458 }
459}
460
461// ---------------------------------------------------------------------------------------------
462// Connectors: switched on and off for the session. The toolkit has no connector switch of its own, so
463// the panel asks the desktop app's: its ccd_connectors server lists this session's connectors and
464// turns a Claude-account connector on or off, the same switch as the composer's + menu. Only those
465// ("connector" rows) can be switched; desktop extensions, plugin and project servers are listed
466// without a switch, since they are managed in their own settings.
467//
468// The app's switch also becomes the default for new sessions. To keep a change to this session,
469// the panel records each connector's state before its first change here and puts it back at
470// session.end. A session that ends without that event firing (the app force-quit) keeps the new
471// default; flip it back from the panel in the next session.
472
473const CONNECTOR_SERVER = 'ccd_connectors'
474const restoreKey = async ($: EngineInterface): Promise<string> => `restore:${await $.session.id()}`
475
476const textOf = (result: { content?: { type?: string; text?: string }[] }): string =>
477 (result.content ?? []).map(b => (b.type === 'text' ? b.text ?? '' : '')).join('')
478
479async function loadConnectors($: EngineInterface): Promise<void> {
480 try {
481 const result = await $.mcp.call(CONNECTOR_SERVER, 'session_connectors_status', {})
482 if (result.isError) throw new Error(textOf(result) || 'the app would not list its connectors')
483 const data = JSON.parse(textOf(result)) as { servers?: { name?: string; id?: string; kind?: string; status?: string; tool_count?: number }[] }
484 const list: Connector[] = (data.servers ?? [])
485 .filter(x => typeof x.name === 'string' && x.name)
486 .map(x => ({
487 name: String(x.name),
488 ...(x.id ? { id: String(x.id) } : {}),
489 kind: String(x.kind ?? 'other'),
490 status: String(x.status ?? 'unknown'),
491 ...(typeof x.tool_count === 'number' ? { toolCount: x.tool_count } : {}),
492 }))
493 .sort((a, b) => a.name.localeCompare(b.name))
494 await update($, connectors, () => list)
495 } catch (err) {
496 await update($, view, v => ({ ...v, note: `Could not list the connectors: ${err instanceof Error ? err.message : String(err)}` }))
497 }
498}
499
500async function setConnector($: EngineInterface, c: Connector, isOn: boolean): Promise<void> {
501 const result = await $.mcp.call(CONNECTOR_SERVER, 'set_session_connector_enabled', { connector: c.id ?? c.name, enabled: isOn })
502 if (result.isError) throw new Error(textOf(result) || `the app would not switch ${c.name}`)
503}
504
505// A server's tools are named mcp__<server>__<tool>, the server's name with anything but letters,
506// digits, _ and - made _.
507const toolPrefix = (server: string): string => `mcp__${server.replace(/[^A-Za-z0-9_-]/g, '_')}__`
508const serverOfTool = (tool: string, hidden: readonly string[]): string | undefined => hidden.find(h => tool.startsWith(toolPrefix(h)))
509
510// A server the app's switch cannot reach (a desktop extension, a plugin's or the project's server,
511// one built into the app) is switched off for this session by the panel: its tools leave the
512// prompt's list (behind tool search) and every call to them is refused, until it is switched back
513// on or the session ends. Nothing in its own settings changes.
514async function switchHidden($: EngineInterface, c: Connector): Promise<string> {
515 const isOff = (await read($, hiddenServers)).includes(c.name)
516 await update($, hiddenServers, list => (isOff ? list.filter(n => n !== c.name) : [...list, c.name]))
517 $.ui.invalidate('tool.describe')
518 return isOff
519 ? `${c.name} switched back on for this session`
520 : `${c.name} switched off for this session: its tools are hidden and refused until you switch it on`
521}
522
523async function switchConnector($: EngineInterface, name: string): Promise<string> {
524 const c = (await read($, connectors))?.find(x => x.name === name)
525 if (!c) throw new Error(`${name} is not one of this session's connectors`)
526 if (c.kind !== 'connector') return switchHidden($, c)
527 const isOn = c.status === 'disabled'
528 // Its state before the first change this session, so session.end can put it back.
529 const key = await restoreKey($)
530 const before = ((await $.store.get(key)) ?? {}) as Record<string, { id?: string; wasOn: boolean }>
531 if (!(c.name in before)) await $.store.set(key, { ...before, [c.name]: { ...(c.id ? { id: c.id } : {}), wasOn: !isOn } })
532 await setConnector($, c, isOn)
533 await loadConnectors($)
534 return `${c.name} switched ${isOn ? 'on' : 'off'} for this session; it takes effect from your next message`
535}
536
537// Puts back every connector this session changed, so the change never outlives the session.
538async function restoreConnectors($: EngineInterface): Promise<void> {
539 const key = await restoreKey($)
540 const before = ((await $.store.get(key)) ?? {}) as Record<string, { id?: string; wasOn: boolean }>
541 for (const [name, b] of Object.entries(before)) {
542 try {
543 await setConnector($, { name, ...(b.id ? { id: b.id } : {}), kind: 'connector', status: '' }, b.wasOn)
544 } catch {
545 // The app has gone; the default stays as it was left.
546 }
547 }
548 await $.store.delete(key)
549}
550
551// What a connector's row says beneath its name: where it comes from.
552const KIND_WORDS: Record<string, string> = {
553 connector: 'A connector from your Claude account.',
554 desktop: 'A desktop extension. Switched off here, it is off for this session only.',
555 server: 'A server from a plugin or this project’s settings. Switched off here, it is off for this session only.',
556 plugin: 'A server from a plugin. Switched off here, it is off for this session only.',
557 other: 'Built into the app. Switched off here, it is off for this session only.',
558}
559// Its state, beside its name.
560const statusWords = (c: Connector): string =>
561 c.status === 'connected'
562 ? c.toolCount !== undefined ? `${c.toolCount} tool${c.toolCount === 1 ? '' : 's'}` : 'Connected'
563 : c.status === 'needs_auth' ? 'Needs signing in (type /mcp)'
564 : c.status === 'failed' ? 'Failed to connect'
565 : c.status === 'pending' ? 'Connecting'
566 : ''
567
568async function openPane($: EngineInterface): Promise<void> {
569 await refresh($)
570 await $.ui.open({ id: PANE, title: 'Skill Switchboard', focus: true })
571}
572
573// ---------------------------------------------------------------------------------------------
574// The layout: Skills | Connectors tabs, a search and an "Active Only" tick box on one line, then
575// one row per skill or connector with its switch.
576//
577// Ticked, the rows are what is switched on here. Unticked, they are everything that is not: what
578// is switched off here, then everything in the library not installed here; switching a library
579// row on installs it. The switcher, the tick box and the
580// cards are small drawings of the mod's own (Tabs.tsx, Check.tsx, Card.tsx), because the app gives
581// a panel no segmented control, tick box or switch, and no way to click a drawing.
582
583const TABS = [
584 { value: 'skills', label: 'Skills' },
585 { value: 'connectors', label: 'Connectors' },
586]
587const CARDS_MAX = 40
588
589// A surface that failed to draw the panel's own pieces is noted, so it falls back to the app's own
590// buttons and drawings there; the reason goes to the session's debug log.
591async function noteFault($: EngineInterface, surface: string, reason: string): Promise<void> {
592 await update($, faulted, list => (list.includes(surface) ? list : [...list, surface]))
593 $.ui.log(`skill-switchboard: the ${surface} could not draw the panel's own pieces (${reason}); using the app's own instead`, { to: 'debug' })
594}
595
596// ---------------------------------------------------------------------------------------------
597// The fallback drawing, for a surface that cannot draw the panel's own pieces: the same layout
598// with the app's own buttons for the switcher and the tick box, and each card one SVG with its
599// switch drawn inside, pressed through the button under it.
600
601const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
602const ROW_CSS = `<style>
603.t{fill:#1f1f1f}.s{fill:#3d3d3b}.m{fill:#8a8a86}.ln{stroke:#e4e4e1}
604@media (prefers-color-scheme: dark){.t{fill:#f2f2f0}.s{fill:#cfcfcb}.m{fill:#8f8f8b}.ln{stroke:#3a3a38}}
605</style>`
606const xml = (s: string): string => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
607const charEm = (ch: string): number => (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%mw]/.test(ch) ? 0.72 : 0.56)
608const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
609const wrapText = (s: string, size: number, maxW: number, maxLines: number): string[] => {
610 const words = s.split(/\s+/).filter(Boolean)
611 const lines: string[] = []
612 let line = ''
613 for (const w of words) {
614 const next = line ? `${line} ${w}` : w
615 if (textWidth(next, size) <= maxW) {
616 line = next
617 continue
618 }
619 if (line) lines.push(line)
620 line = w
621 if (lines.length === maxLines) break
622 }
623 if (lines.length < maxLines && line) lines.push(line)
624 if (lines.length === maxLines && words.join(' ') !== lines.join(' ')) {
625 let cut = lines[maxLines - 1] ?? ''
626 while (cut && textWidth(cut + '…', size) > maxW) cut = cut.slice(0, -1).trimEnd()
627 lines[maxLines - 1] = cut + '…'
628 }
629 return lines.slice(0, maxLines)
630}
631// The desktop row: a hairline above, the name with its version and where it lives
632// beside it, the description on up to two lines in near-white, then the app's own small button
633// under it, aligned left. The text starts a few pixels in, level with the button's own label.
634const ROW_NAME = 12.5
635const ROW_DESC = 11.5
636const ROW_META = 10.5
637const ROW_X = 4
638// The last 12 is room between the description and the button under it.
639const rowHeight = (lines: number): number => 24 + 6 + lines * 15 + 12
640const rowSvg = (W: number, c: CardRow): string => {
641 const desc = wrapText(c.description || 'No description in its file.', ROW_DESC, W - ROW_X * 2, 2)
642 const H = rowHeight(desc.length)
643 const meta = [c.version, c.tag].filter(Boolean).join(' · ')
644 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${ROW_CSS}
645<line class="ln" x1="0" y1="0.5" x2="${W}" y2="0.5"/>
646<text class="t" x="${ROW_X}" y="23" font-family="${FONT}" font-size="${ROW_NAME}" font-weight="600"${c.isOn ? '' : ' opacity="0.6"'}>${xml(c.name)}</text>
647${meta ? `<text class="m" x="${ROW_X + textWidth(c.name, ROW_NAME) + 10}" y="23" font-family="${FONT}" font-size="${ROW_META}">${xml(meta)}</text>` : ''}
648${desc.map((l, i) => `<text class="s" x="${ROW_X}" y="${41 + i * 15}" font-family="${FONT}" font-size="${ROW_DESC}">${xml(l)}</text>`).join('')}
649</svg>`
650}
651
652// The switch's words, and a blank label as wide as them for the button laid over them: no-break
653// spaces (ordinary ones collapse), about a quarter of the type's size each, one extra for safety.
654const switchWords = (c: { name: string; isOn: boolean }, busy: string): string => (busy === c.name ? '…' : c.isOn ? 'Switch Off' : 'Switch On')
655// About 4px at the panel's text size: a four-per-em space and a hair space, neither of which collapses.
656const SWITCH_INSET = '\u2005\u200a'
657
658// One row's worth: an installed skill, a library entry not installed here, or a connector.
659// `canSwitch` false draws the row without a switch (a connector managed in its own settings).
660type CardRow = { name: string; version: string; description: string; tag: string; isOn: boolean; canSwitch?: boolean }
661
662// ---------------------------------------------------------------------------------------------
663
664// Shown where the library's rows would be, until a library is set up: how to set one.
665const NOT_SET_UP =
666 'No skills library is set up. To list skills you have not installed yet, tell the panel where yours live: ' +
667 'in a terminal, type /config, find skill-switchboard and set Library Location to a folder (~/skills), ' +
668 'a server (my-server:/srv/skills), a git repository (github:you/skills) or a download (https://…/skills.zip).'
669
670const clean = (v: unknown, fallback = ''): string => (typeof v === 'string' ? v.trim() : fallback)
671
672export const register: Register = (on, options) => {
673 // The library, from the person's own settings (each a userConfig field in plugin.json).
674 // An older install's Library Folder and Library Server settings still count, as one location.
675 const oldFolder = clean(options.libraryFolder)
676 const oldServer = clean(options.libraryServer)
677 library = {
678 location: clean(options.libraryLocation) || (oldFolder ? (oldServer ? `${oldServer}:${oldFolder}` : oldFolder) : ''),
679 skillsFolder: clean(options.skillsFolder),
680 }
681
682 // Connectors this session changed go back to how they were, so a change stays the session's.
683 on('session.end', async ($, e, next) => {
684 try {
685 await restoreConnectors($)
686 } catch {
687 // Nothing to put back, or the app has gone.
688 }
689 return next(e)
690 })
691
692 // A server switched off by the panel: its tools go behind tool search, and calls are refused.
693 on('tool.describe', async ($, e, next) => {
694 const answer = await next(e)
695 return serverOfTool(e.tool, await read($, hiddenServers)) ? { ...answer, isDeferred: true } : answer
696 })
697 on('tool.check', async ($, e, next) => {
698 const server = serverOfTool(e.tool, await read($, hiddenServers))
699 if (server) return { decision: 'deny', reason: `${server} is switched off for this session in the Skill Switchboard` }
700 return next(e)
701 })
702
703 on('session.start', async ($, e, next) => {
704 const started = await next(e)
705 await $.command.register({ name: COMMAND, description: 'Open the Skill Switchboard: your skills and connectors, each with a switch' })
706 return started
707 })
708
709 on('command.run', { command: COMMAND }, async $ => {
710 await openPane($)
711 return { text: 'Skill Switchboard opened.' }
712 })
713
714 // A surface that could not draw one of the panel's own pieces: note it, so the next drawing on
715 // that surface leaves them out and uses the fallback.
716 on('ui.fault', async ($, e, next) => {
717 if (e.requestId === PANE) await noteFault($, e.surface, `${e.module} (${e.phase}): ${e.reason}`)
718 return next(e)
719 })
720
721 // The panel's own drawings tell this hook what was clicked: the switcher a tab, the tick box a
722 // flip, a card its switch.
723 on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e) => {
724 const data = (e.data ?? {}) as { toggle?: unknown; tab?: unknown }
725 if (e.element === 'tabs' && (data.tab === 'skills' || data.tab === 'connectors')) {
726 const tab = data.tab
727 await update($, view, v => ({ ...v, tab, note: '' }))
728 if (tab === 'connectors') await loadConnectors($)
729 return {}
730 }
731 if (e.element === 'show-active' && data.toggle === true) {
732 const v = await update($, view, prev => ({ ...prev, showActive: !prev.showActive, note: '' }))
733 if (!v.showActive) await loadVault($, false)
734 return {}
735 }
736 const name = /^card-(.+)$/.exec(e.element)?.[1] ?? ''
737 if (!name || data.toggle !== true || (await read($, view)).busy) return {}
738 if ((await read($, view)).tab === 'connectors') {
739 await act($, name, () => switchConnector($, name))
740 return {}
741 }
742 const have = (await read($, installed)).find(x => x.name === name)
743 await act($, name, () => (have && !have.isOff ? switchOff($, name) : switchOn($, name)))
744 return {}
745 })
746
747 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
748 const ui = $.ui.resolve(e)
749 const { Box, Text, Button } = ui
750 const list = await read($, installed)
751 const v = await read($, view)
752 const vaultList = await read($, vault)
753 const kind = 'skill'
754 const connectorList = await read($, connectors)
755 const hidden = await read($, hiddenServers)
756 const mine = list
757 const installedNames = new Set(list.map(s => s.name))
758
759 const toCard = (s: Installed): CardRow => ({
760 name: s.name,
761 version: s.version,
762 description: s.description,
763 tag: s.where === 'library' ? 'All Projects' : '',
764 isOn: !s.isOff,
765 })
766 const fromVault = (entry: VaultEntry): CardRow => ({ name: entry.name, version: entry.version, description: entry.description, tag: '', isOn: false })
767 const fromConnector = (c: Connector): CardRow => ({
768 name: c.name,
769 version: '',
770 description: KIND_WORDS[c.kind] ?? KIND_WORDS.other ?? '',
771 tag: hidden.includes(c.name) ? 'Off for this session' : statusWords(c),
772 isOn: c.status !== 'disabled' && !hidden.includes(c.name),
773 canSwitch: true,
774 })
775 const cards: CardRow[] = (
776 v.tab === 'connectors'
777 ? (connectorList ?? []).filter(c => (c.status !== 'disabled' && !hidden.includes(c.name)) === v.showActive).map(fromConnector)
778 : v.showActive
779 ? mine.filter(s => !s.isOff).map(toCard)
780 : [
781 ...mine.filter(s => s.isOff).map(toCard),
782 ...(vaultList ?? []).filter(entry => entry.kind === kind && !installedNames.has(entry.name)).map(fromVault),
783 ]
784 ).filter(c => !v.query || matches({ name: c.name, description: c.description, tags: [], folder: 'Skills', kind, version: c.version }, v.query))
785
786 const shown = cards.slice(0, CARDS_MAX)
787 const rowW = Math.max(240, Math.min(720, (e.props.bodyColumns || 40) * 8 - 8))
788 const flip = (name: string) =>
789 void (async () => {
790 if ((await read($, view)).busy) return
791 if ((await read($, view)).tab === 'connectors') {
792 await act($, name, () => switchConnector($, name))
793 return
794 }
795 const have = (await read($, installed)).find(x => x.name === name)
796 await act($, name, () => (have && !have.isOff ? switchOff($, name) : switchOn($, name)))
797 })()
798 // The panel's own pieces (Tabs, Check, Card) are drawn only in the terminal. The desktop app
799 // lists them as drawable but, as of Claude Code 2.1.299, leaves the whole pane blank when a
800 // tree holds one ("has not drawn in this pane"), without raising ui.fault.
801 // So the desktop gets the same layout from its own buttons and drawn cards.
802 const canDrawOwn = e.surface === 'terminal' && 'Client' in ui && !(await read($, faulted)).includes(e.surface)
803 const noun = v.tab === 'connectors' ? 'connectors' : 'skills'
804 const empty = v.query
805 ? `Nothing matches "${v.query}".`
806 : v.showActive
807 ? `No ${noun} switched on.`
808 : v.tab === 'connectors'
809 ? connectorList === null ? '' : 'Every connector is switched on.'
810 : vaultList === null ? '' : `Every ${kind} is switched on.`
811
812 return (
813 // Two steps of room between the top rows and before the rows.
814 <Box flexDirection="column" gap={2}>
815 {/* The tabs, with the refresh link on the same line at the right. */}
816 <Box key="top" flexDirection="row" justifyContent="space-between" alignItems="center">
817 {canDrawOwn && 'Client' in ui ? (
818 <ui.Client key="tabs" module="./Tabs.tsx" props={{ options: TABS, value: v.tab }} />
819 ) : (
820 <Box key="tabs-fallback" flexDirection="row" gap={1}>
821 {TABS.map(t => (
822 <Button
823 key={`tab-${t.value}`}
824 label={t.label}
825 variant={v.tab === t.value ? 'primary' : 'secondary'}
826 onPress={() =>
827 void (async () => {
828 await update($, view, prev => ({ ...prev, tab: t.value as View['tab'], note: '' }))
829 if (t.value === 'connectors') await loadConnectors($)
830 })()
831 }
832 />
833 ))}
834 </Box>
835 )}
836 {v.tab === 'connectors' ? (
837 <Button key="refresh-connectors" label="Refresh" plain dimColor onPress={() => void loadConnectors($)} />
838 ) : (
839 library.location ? <Button key="refresh-vault" label="Refresh" plain dimColor onPress={() => void loadVault($, true)} /> : null
840 )}
841 </Box>
842 {/* Search and Active Only on one line: the search box takes the
843 room, its ✕ beside it once something is typed, the tick box at the right. Search as you
844 type, no label and no button; the desktop draws a submit button only when submitLabel
845 is given, so none is. */}
846 <Box key="search-row" flexDirection="row" alignItems="center" gap={2}>
847 {'Input' in ui ? (
848 <Box flexGrow={1} flexDirection="row" alignItems="center" gap={1}>
849 <Box flexGrow={1}>
850 <ui.Input key="search" placeholder="Search" value={v.query} onInput={text => void search($, text)} onSubmit={text => void search($, text)} />
851 </Box>
852 {v.query ? <Button key="clear-search" label="✕" plain dimColor onPress={() => void search($, '')} /> : null}
853 </Box>
854 ) : null}
855 {canDrawOwn && 'Client' in ui ? (
856 <ui.Client key="show-active" module="./Check.tsx" props={{ label: 'Active Only', isOn: v.showActive }} />
857 ) : (
858 <Button
859 key="show-active-fallback"
860 label={`${v.showActive ? '☑' : '☐'}\u00a0\u00a0Active Only`}
861 plain
862 onPress={() =>
863 void (async () => {
864 const next = await update($, view, prev => ({ ...prev, showActive: !prev.showActive, note: '' }))
865 if (!next.showActive) await loadVault($, false)
866 })()
867 }
868 />
869 )}
870 </Box>
871 <Box key="cards" flexDirection="column">
872 {v.tab !== 'connectors' && !v.showActive && !library.location ? (
873 <Box key="setup">
874 <Text dimColor>{NOT_SET_UP}</Text>
875 </Box>
876 ) : null}
877 {shown.length === 0 && empty ? (
878 <Text key="empty" dimColor>
879 {empty}
880 </Text>
881 ) : null}
882 {shown.map(c =>
883 canDrawOwn && 'Client' in ui ? (
884 <ui.Client
885 key={`card-${c.name}`}
886 module="./Card.tsx"
887 width="100%"
888 props={{ name: c.name, version: c.version, description: c.description, tag: c.tag, isOn: c.isOn, isBusy: v.busy === c.name, canSwitch: c.canSwitch !== false }}
889 />
890 ) : 'Svg' in ui ? (
891 <Box key={`card-${c.name}`} flexDirection="column" marginBottom={1}>
892 <ui.Svg source={rowSvg(rowW, c)} alt={`${c.name}: ${c.isOn ? 'on' : 'off'}`} width={rowW} height={rowHeight(wrapText(c.description || 'No description in its file.', ROW_DESC, rowW - ROW_X * 2, 2).length)} />
893 {/* Underlined at rest, not on hover. A desktop button has no
894 underline setting, so the words are ordinary underlined text, and the button lies
895 exactly over them carrying the same words, so its hover highlight always fits them
896 whatever size the desktop draws them at. The keyed box is the hover group that takes the
897 underline off while the pointer is over it. */}
898 {c.canSwitch === false ? null : (
899 <Box key={`switch-box-${c.name}`} flexDirection="row">
900 {/* A sliver before the words, the button's own inner padding (about 4px), kept out
901 of the underline: the hover highlight then pads both sides evenly, and the
902 words line up with the row's text above. */}
903 <Text>{SWITCH_INSET}</Text>
904 <Text underline dimColor={c.isOn} hover={{ underline: false }}>
905 {switchWords(c, v.busy)}
906 </Text>
907 <Box position="absolute" left={0} top={0}>
908 <Button key={`switch-${c.name}`} label={switchWords(c, v.busy)} plain dimColor={c.isOn} onPress={() => flip(c.name)} />
909 </Box>
910 </Box>
911 )}
912 </Box>
913 ) : (
914 <Box key={`card-${c.name}`} flexDirection="row" gap={1}>
915 <Text bold>{c.name}</Text>
916 <Button key={`switch-${c.name}`} label={c.isOn ? 'Switch Off' : 'Switch On'} plain dimColor onPress={() => flip(c.name)} />
917 </Box>
918 ),
919 )}
920 {cards.length > CARDS_MAX ? (
921 <Text key="more" dimColor>
922 {`${cards.length - CARDS_MAX} more. Search to narrow them down.`}
923 </Text>
924 ) : null}
925 </Box>
926 {v.note ? (
927 <Box key="note">
928 <Text>{v.note}</Text>
929 </Box>
930 ) : null}
931 </Box>
932 )
933 })
934}
935hooks/Tabs.tsx 47 lines1// The tabs switcher, drawn in the terminal: one rounded pill split into parts, the chosen one
2// lit. A click on any part chooses it.
3import type { ClientModule } from 'claude-code'
4
5type Props = { options: { value: string; label: string }[]; value: string }
6
7const LIT = '#ececec'
8const INK = '#1f1f1e'
9const EDGE = '#3a3a38'
10
11const Tabs: ClientModule<Props> = (props, surface) => {
12 const { Box, Text } = surface.elements
13 // Each half is its label with two cells of room either side; the divider takes one cell.
14 const widths = props.options.map(o => o.label.length + 4)
15 surface.onPointer(event => {
16 if (event.type !== 'down' || event.button !== 'left') return
17 // Inside the left border (one cell), walk the halves and their dividers.
18 let x = event.x - 1
19 for (let i = 0; i < props.options.length; i++) {
20 const w = widths[i] ?? 0
21 if (x < w) {
22 const option = props.options[i]
23 if (option && option.value !== props.value) surface.post({ tab: option.value })
24 return
25 }
26 x -= w + 1
27 }
28 })
29 return (
30 <Box borderStyle="round" borderColor={EDGE} flexDirection="row">
31 {props.options.map((o, i) => {
32 const isLit = o.value === props.value
33 return (
34 <Box key={o.value} flexDirection="row">
35 {i > 0 ? <Text color={EDGE}>│</Text> : null}
36 <Text bold={isLit} dimColor={!isLit} backgroundColor={isLit ? LIT : undefined} color={isLit ? INK : undefined}>
37 {` ${o.label} `}
38 </Text>
39 </Box>
40 )
41 })}
42 </Box>
43 )
44}
45
46export default Tabs
47hooks/Check.tsx 25 lines1// The "Active Only" tick box, drawn in the terminal: a box, ticked or empty, and its label. A click
2// anywhere on it flips it.
3import type { ClientModule } from 'claude-code'
4
5type Props = { label: string; isOn: boolean }
6
7const ON = '#3B9C5F'
8
9const Check: ClientModule<Props> = (props, surface) => {
10 const { Box, Text } = surface.elements
11 surface.onPointer(event => {
12 if (event.type === 'down' && event.button === 'left') surface.post({ toggle: true })
13 })
14 return (
15 <Box flexDirection="row" gap={1}>
16 <Text color={props.isOn ? ON : undefined} dimColor={!props.isOn}>
17 {props.isOn ? '☑' : '☐'}
18 </Text>
19 <Text>{props.label}</Text>
20 </Box>
21 )
22}
23
24export default Check
25hooks/Card.tsx 61 lines1// One skill or connector as a card, drawn in the terminal: the name at the top,
2// its description in grey on up to two lines, and the on/off switch inside the card at the bottom
3// right. A click on the switch flips it; a click anywhere else on the card does nothing, so a
4// card can be read without changing anything by accident.
5import type { ClientModule } from 'claude-code'
6
7type Props = { name: string; version: string; description: string; tag: string; isOn: boolean; isBusy: boolean; canSwitch?: boolean }
8
9const ON = '#3B9C5F'
10const OFF = '#5a5a58'
11const KNOB = '#ffffff'
12const EDGE = '#3a3a38'
13// The switch's width in cells: a rounded end, five cells of body, the other rounded end.
14const SWITCH_W = 7
15
16// Cuts a description to two lines of the card's width, by words, with an ellipsis when it had to.
17const twoLines = (text: string, width: number): string => {
18 const room = Math.max(10, width) * 2
19 if (text.length <= room) return text
20 const cut = text.slice(0, room - 1)
21 return cut.slice(0, Math.max(cut.lastIndexOf(' '), room - 12)).trimEnd() + '…'
22}
23
24const Card: ClientModule<Props> = (props, surface) => {
25 const { Box, Text } = surface.elements
26 // The switch sits on the card's last inside row, at its right, inside the border and padding.
27 surface.onPointer(event => {
28 if (event.type !== 'down' || event.button !== 'left' || props.isBusy || props.canSwitch === false) return
29 const isOnSwitchRow = surface.rows === 0 || event.y >= surface.rows - 3
30 const isOnSwitchColumns = surface.columns === 0 || event.x >= surface.columns - 3 - SWITCH_W
31 if (isOnSwitchRow && isOnSwitchColumns) surface.post({ toggle: true })
32 })
33 const fill = props.isOn ? ON : OFF
34 const inner = Math.max(10, surface.columns - 4)
35 return (
36 <Box borderStyle="round" borderColor={EDGE} paddingX={1} flexDirection="column" width="100%">
37 <Box flexDirection="row" gap={1}>
38 <Text bold dimColor={!props.isOn}>
39 {props.name}
40 </Text>
41 {props.version ? <Text dimColor>{props.version}</Text> : null}
42 {props.tag ? <Text dimColor>· {props.tag}</Text> : null}
43 </Box>
44 <Text dimColor wrap="wrap">
45 {twoLines(props.description || 'No description in its file.', inner)}
46 </Text>
47 {props.canSwitch === false ? null : (
48 <Box flexDirection="row" justifyContent="flex-end">
49 <Text color={fill}>◖</Text>
50 <Text backgroundColor={fill} color={KNOB}>
51 {props.isBusy ? ' ··· ' : props.isOn ? ' ●' : '● '}
52 </Text>
53 <Text color={fill}>◗</Text>
54 </Box>
55 )}
56 </Box>
57 )
58}
59
60export default Card
61types/index.d.ts 66 lines1/** One skill on this computer: in the project's own .claude/skills or in ~/.claude/skills. */
2export type Installed = {
3 name: string
4 kind: 'skill'
5 version: string
6 description: string
7 /** "off" in the project's .claude/settings.json skillOverrides. */
8 isOff: boolean
9 /** Which folder holds it: the project's .claude/skills or ~/.claude/skills. */
10 where: 'project' | 'library'
11 /** True when the folder carries the library's receipt (.from-vault.json), so switching it off may delete it. */
12 isFromVault: boolean
13}
14
15/** One entry of the skills library's listing. */
16export type VaultEntry = {
17 name: string
18 /** The library folder it sits in: the Skills Folder setting, or '.' for the library folder itself. */
19 folder: string
20 kind: 'skill'
21 version: string
22 description: string
23 tags: string[]
24}
25
26/** One of this session's connectors, as the desktop app's ccd_connectors server lists it. */
27export type Connector = {
28 name: string
29 /** A Claude-account connector's id, which the app's switch takes. */
30 id?: string
31 /** connector (a Claude-account connector: the only kind the panel switches), desktop, server, plugin or other. */
32 kind: string
33 /** connected, needs_auth, failed, pending or disabled. */
34 status: string
35 toolCount?: number
36}
37
38export type View = {
39 /** Which list is open: the project's skills, or this session's connectors. */
40 tab: 'skills' | 'connectors'
41 /** Ticked: what is switched on. Unticked: what is switched off here, and the library's not installed. */
42 showActive: boolean
43 /** The search box's text, as last submitted. */
44 query: string
45 /** The name being switched or pulled right now, so its button reads busy. */
46 busy: string
47 /** The last outcome or error, one line under the lists. */
48 note: string
49}
50
51declare module 'claude-code' {
52 interface PluginState {
53 'skill-switchboard': {
54 installed: Installed[]
55 vault: VaultEntry[] | null
56 view: View
57 /** Surfaces that failed to draw the panel's own pieces, so it falls back on them. */
58 faulted: string[]
59 /** This session's connectors, read when the Connectors tab opens; null before. */
60 connectors: Connector[] | null
61 /** Servers switched off for this session by the panel itself, by name. */
62 hidden: string[]
63 }
64 }
65}
66