SLOPSHOPPER

skill-switchboard

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…

newpaneguardcommandprocess
★ 1v1.1.0MITupdated 2026-10-21couriard/claude-mods/plugins/skill-switchboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skill-switchboard
│ ┃ Skill Switchboard ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./Tabs.tsx │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Search ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ No skills switched on. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /skill-switchboard │ ⎿ skill-switchboard: Skill Switchboard opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Skill Switchboard
▣ client module ./Tabs.tsx Search No skills switched on.
README

Skill Switchboard

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.

The Skills tab: three skills, each with its version, description and a Switch Off link

Install

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.

What it shows

  • Skills and Connectors, one tab each.
  • Search narrows the rows by name or description as you type. The ✕ beside it clears it.
  • Active Only: ticked, the rows are what is switched on in this project. Unticked, they are what is switched off here, followed by everything in your skills library that is not installed yet.
  • A row per skill: its name, version, "All Projects" when it lives in ~/.claude/skills (so it applies everywhere), its description, and a Switch On / Switch Off link.
  • Refresh, top right, reads the library (or, on the Connectors tab, the connectors) again. The library's listing is otherwise kept for fifteen minutes.

Active Only unticked: a switched-off skill and one from the library, each with a Switch On link

Connectors

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 Connectors tab: three connectors, each with a Switch Off link

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.

Settings

Claude Code asks for these when you install. Both can be left empty and changed later with /config → skill-switchboard.

SettingWhat to putDefault
Library LocationWhere 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 FolderThe 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

Where your library can live

Write the location however your library is kept; the panel works out which kind it is.

KindWrite it likeHow it is read
A folder on this computer~/skillsDirectly. Synced cloud folders (iCloud Drive, Dropbox, OneDrive, Google Drive) and mounted network drives are folders too.
A server you reach over sshmy-server:/srv/skillsOver ssh (a host name or an alias from your ~/.ssh/config), which must connect without a password prompt.
A GitHub, GitLab or Bitbucket repositorygithub:you/skills, gitlab:you/skills, bitbucket:you/skills, or the repository's web addressCloned once into ~/.claude/skill-switchboard/libraries, then brought up to date whenever you press Refresh.
Any other git repositoryhttps://git.example.com/team/skills.git, git@host:team/skills.gitThe same way.
A downloadhttps://example.com/skills.zipDownloaded 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.

What a switch does

  • A skill is on when its folder is in the project's own .claude/skills or in ~/.claude/skills, and the project's .claude/settings.json does not say "off" for it under skillOverrides.
  • Switch On from the library copies the folder into the project's own .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.
  • Switch Off writes "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.
  • Nothing is ever written to your library.

The project is the folder the session was opened in.

In the terminal and the desktop app

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.

Checks

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.

Licence

MIT, by couriard.

Source 5 files
hooks/register.tsx 935 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
935
hooks/Tabs.tsx 47 lines
1// 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
47
hooks/Check.tsx 25 lines
1// 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
25
hooks/Card.tsx 61 lines
1// 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
61
types/index.d.ts 66 lines
1/** 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