Lists the plugins, skills and MCP servers you have not used lately and that cost tokens every turn (/unused), judged by Claude Code's own counters, and…

It is a mod rather than a script because it reads what the session actually loaded: the skills and MCP tools Claude Code lists at runtime, with whether each server connected and whether its tools are deferred. Config files alone do not show that.
Lists the plugins, skills and MCP servers nobody used in 14 days, dearest first, so you can disable or remove them. Change the window with claude plugin configure toolbelt@my-mods (7, 14, 30, 60 or 90 days). It does not judge whether a skill is good; it counts use.
Plugins and skills are judged by the usage counters Claude Code keeps in ~/.claude.json (the ones /skill-doctor reads), so the list agrees with Claude Code and /unused answers at once; nothing is read from transcripts. Those counters are internal: if they cannot be read the pane says skills are not judged. Nothing there counts MCP use, so Toolbelt counts server calls itself from the first session and lists a server only once that tracking is as old as the window. A server's scope (user, project, local) is read from .mcp.json and the same config, and a project or local server is unused per repo.
A plugin is one row, because its skills can only be switched off together; it is listed only if none of its skills was used. Above those, Loaded twice lists a plugin installed from two marketplaces (the copy updated longest ago is the one offered for Disable or Uninstall) and a user skill that a plugin also carries (Disable moves your copy aside); these are listed whether or not they are used. A toast says how many are unused and what they cost per turn, at most once a day and only when the list changed. /unused opens the pane:
claude plugin disable on its name@marketplace id (all its skills go; it stays installed). Uninstall… asks again. anthropic-skills, which the desktop app syncs from your account, offers Disable only.~/.claude/skills-disabled; Enable moves it back.claude mcp remove), which also deletes its env and keys./unused shows the top 15 as a notice instead.Every action asks a second time and takes effect in the next session. Actions run the claude CLI; no config file is edited by hand.
hooks/register.tsx 398 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Confirm, Disabled, Health, Item, Notice, Unit, Uses } from '../types'
5import { argvFor, disableOf, enableOf, moveArgv, removeOf } from './actions'
6import type { Step } from './actions'
7import { paneOf } from './pane'
8import type { Counters } from './usage'
9import {
10 DAY_MS,
11 DEFAULT_DAYS,
12 SYNCED_PLUGIN,
13 combine,
14 countUse,
15 daysOf,
16 installsOf,
17 isConnector,
18 keyOf,
19 listText,
20 projectServers,
21 readCounters,
22 serverOf,
23 serverScope,
24 signature,
25 switchedOff,
26 syncedBare,
27 toastText,
28 unusedUnits,
29 withIds,
30} from './usage'
31
32const units = atom({ plugin: 'toolbelt', key: 'units' } as const, [])
33const disabled = atom({ plugin: 'toolbelt', key: 'disabled' } as const, [])
34const confirm = atom({ plugin: 'toolbelt', key: 'confirm' } as const, null)
35const notice = atom({ plugin: 'toolbelt', key: 'notice' } as const, { text: '', isError: false })
36const page = atom({ plugin: 'toolbelt', key: 'page' } as const, 0)
37const health = atom({ plugin: 'toolbelt', key: 'health' } as const, { isCounted: true })
38const total = atom({ plugin: 'toolbelt', key: 'total' } as const, 0)
39
40const PANE = 'toolbelt'
41const USES_KEY = 'uses'
42const SINCE_KEY = 'since'
43const MUTED_KEY = 'muted'
44const DISABLED_KEY = 'disabled'
45const TOAST_KEY = 'toast'
46/** Kept by 0.1.0: uses noted live between transcript reads, now counted straight into `uses`. */
47const LIVE_KEY = 'live'
48const RETIRED_KEYS = ['scanned', 'backfilled', 'scopes', 'debug']
49
50type Toast = { at: number; sig: string }
51
52async function stored<T>($: EngineInterface, key: string, fallback: T): Promise<T> {
53 const held = await $.store.get(key)
54
55 return held === undefined || held === null ? fallback : (held as T)
56}
57
58// The module's own: a reload asks the shell again; the store keeps everything else.
59let home = ''
60let isWindows = false
61/** The window, as the plugin's settings had it when the module loaded. */
62let days = DEFAULT_DAYS
63/** Whether each tool waits behind ToolSearch, as the session placed it. */
64const deferred = new Map<string, boolean>()
65/** Plugins switched off or removed this session: their other skills stay loaded until it ends, so they are not listed. */
66const gone = new Set<string>()
67
68/** Store writes that read the whole value first run one after another, so parallel calls do not drop counts. */
69let writes: Promise<unknown> = Promise.resolve()
70
71function exclusive<T>(job: () => Promise<T>): Promise<T> {
72 const done = writes.then(job)
73 writes = done.catch(() => undefined)
74
75 return done
76}
77
78async function learnShell($: EngineInterface) {
79 if (home !== '') return
80
81 const win = await $.process.run(['cmd.exe', '/d', '/c', 'echo', '%USERPROFILE%']).catch(() => undefined)
82 const winHome = win?.exitCode === 0 ? win.stdout.trim() : ''
83
84 if (winHome !== '' && winHome !== '%USERPROFILE%') {
85 isWindows = true
86 home = winHome.split('\\').join('/')
87
88 return
89 }
90
91 const posix = await $.process.run(['sh', '-c', 'echo "$HOME"']).catch(() => undefined)
92 home = posix?.exitCode === 0 ? posix.stdout.trim() : ''
93}
94
95const run = ($: EngineInterface, argv: string[]) => $.process.run(argvFor(argv, isWindows))
96
97async function readText($: EngineInterface, path: string): Promise<string | undefined> {
98 const text = await $.fs.read(path).catch(() => undefined)
99
100 return typeof text === 'string' ? text : undefined
101}
102
103/** The skills the desktop app synced, by folder name: one folder per account under `skills/synced`. */
104async function syncedSkills($: EngineInterface) {
105 const root = `${home}/.claude/skills/synced`
106 const names = new Set<string>()
107
108 for (const bucket of home === '' ? [] : await $.fs.list(root).catch(() => [])) {
109 if (bucket.kind !== 'dir') continue
110
111 for (const skill of await $.fs.list(`${root}/${bucket.name}`).catch(() => [])) {
112 if (skill.kind === 'dir') names.add(skill.name)
113 }
114 }
115
116 return names
117}
118
119async function inventory($: EngineInterface, counters: Counters | undefined, root: string, listed: string[]): Promise<Item[]> {
120 const items: Item[] = []
121 const synced = await syncedSkills($)
122
123 for (const command of await $.command.list()) {
124 if (command.source !== 'user' && command.source !== 'plugin') continue
125 if (command.source === 'plugin' && !command.name.includes(':')) continue
126
127 const isUserSkill = command.source === 'user' && home !== '' && (await $.fs.exists(`${home}/.claude/skills/${command.name}`).catch(() => false))
128 const isSynced = command.source === 'user' && !isUserSkill && synced.has(syncedBare(command.name))
129 const plugin = isSynced ? SYNCED_PLUGIN : command.plugin
130
131 if (plugin !== undefined && gone.has(plugin)) continue
132
133 items.push({
134 kind: 'skill',
135 name: isSynced ? `${SYNCED_PLUGIN}:${syncedBare(command.name)}` : command.name,
136 scope: command.source === 'plugin' || isSynced ? 'plugin' : isUserSkill ? 'user' : 'unknown',
137 ...(plugin === undefined ? {} : { plugin }),
138 cost: command.name.length + command.description.length,
139 })
140 }
141
142 const servers = new Map<string, number>()
143
144 for (const tool of await $.tool.list()) {
145 const server = tool.mcp ? serverOf(tool.name) : undefined
146
147 if (server === undefined) continue
148
149 // An MCP tool is deferred unless a hook moved it, so one not yet described costs its name only.
150 const chars = deferred.get(tool.name) === false ? tool.name.length + tool.description.length : tool.name.length
151 servers.set(server, (servers.get(server) ?? 0) + chars)
152 }
153
154 for (const [name, cost] of servers) {
155 items.push({ kind: 'mcp', name, scope: serverScope(name, root, listed, counters), cost })
156 }
157
158 return items
159}
160
161async function refresh($: EngineInterface) {
162 const now = await $.clock.now()
163 const root = await $.session.root()
164 const text = home === '' ? undefined : await readText($, `${home}/.claude.json`)
165 const counters = text === undefined ? undefined : readCounters(text)
166 const listed = projectServers((await readText($, `${root}/.mcp.json`)) ?? '')
167 const items = await inventory($, counters, root, listed)
168 const installs = installsOf((home === '' ? undefined : await readText($, `${home}/.claude/plugins/installed_plugins.json`)) ?? '')
169 const view = {
170 project: root,
171 now,
172 since: await stored($, SINCE_KEY, now),
173 days,
174 muted: await stored<string[]>($, MUTED_KEY, []),
175 installs,
176 off: switchedOff(await $.settings.read().catch(() => ({}))),
177 listed,
178 }
179 const unused = withIds(unusedUnits(items, counters, await stored<Uses>($, USES_KEY, {}), view), installs).filter(
180 unit => disableOf(unit, home) !== undefined || removeOf(unit) !== undefined || (unit.kind === 'mcp' && !isConnector(unit.name)),
181 )
182
183 await update($, units, () => unused)
184 await update($, health, (): Health => ({ isCounted: counters !== undefined }))
185 await update($, total, () => items.reduce((sum, item) => sum + item.cost, 0))
186
187 return unused
188}
189
190async function note($: EngineInterface, key: string) {
191 const project = await $.session.root()
192 const at = await $.clock.now()
193
194 await exclusive(async () => $.store.set(USES_KEY, countUse(await stored<Uses>($, USES_KEY, {}), key, project, at)))
195}
196
197/** 0.1.0 kept a live tally beside the transcript counts and a few keys for reading them; fold the tally in once and drop the rest. */
198async function tidyStore($: EngineInterface) {
199 const live = await $.store.get(LIVE_KEY)
200
201 if (live !== undefined && live !== null) {
202 await exclusive(async () => {
203 await $.store.set(USES_KEY, combine(await stored<Uses>($, USES_KEY, {}), live as Uses))
204 await $.store.delete(LIVE_KEY)
205 })
206 }
207
208 for (const key of RETIRED_KEYS) {
209 if ((await $.store.get(key)) !== undefined) await $.store.delete(key)
210 }
211}
212
213/** Every session: toast when the unused list changed since the last toast and a day has passed. */
214async function startup($: EngineInterface) {
215 await learnShell($)
216 await tidyStore($)
217
218 if ((await $.store.get(SINCE_KEY)) === undefined) await $.store.set(SINCE_KEY, await $.clock.now())
219
220 // Servers may still be connecting this early, so one that looks down is left out of the toast.
221 const unused = (await refresh($)).filter(unit => unit.isDown !== true)
222 const now = await $.clock.now()
223 const last = await stored<Toast | null>($, TOAST_KEY, null)
224 const sig = signature(unused)
225
226 if (unused.length === 0 || last?.sig === sig || (last !== null && now - last.at < DAY_MS)) return
227
228 await $.store.set(TOAST_KEY, { at: now, sig })
229 $.ui.toast(toastText(unused, days), { timeoutMs: 10000 })
230}
231
232async function say($: EngineInterface, text: string, isError = false) {
233 await update($, notice, (): Notice => ({ text, isError }))
234}
235
236async function execute($: EngineInterface, step: Step): Promise<string | undefined> {
237 if (step.move !== undefined) {
238 const parent = step.move.to.split('/').slice(0, -1).join('/')
239 const made = await run($, isWindows ? ['mkdir', parent.split('/').join('\\')] : ['mkdir', '-p', parent]).catch(() => undefined)
240
241 if (made === undefined && !(await $.fs.exists(parent).catch(() => false))) return `could not create ${parent}`
242
243 const moved = await $.process.run(moveArgv(step.move, isWindows)).catch(() => undefined)
244
245 return moved?.exitCode === 0 ? undefined : (moved?.stderr.trim() || 'the move failed')
246 }
247
248 if (step.argv === undefined) return 'nothing to run'
249
250 const ran = await run($, step.argv).catch(() => undefined)
251
252 return ran?.exitCode === 0 ? undefined : (ran?.stderr.trim() || 'the command failed')
253}
254
255async function act($: EngineInterface, unit: Unit, which: 'disable' | 'remove') {
256 const step = which === 'disable' ? disableOf(unit, home) : removeOf(unit)
257 await update($, confirm, () => null)
258
259 if (step === undefined) return
260
261 const failure = await execute($, step)
262
263 if (failure !== undefined) {
264 await say($, `${unit.name}: ${failure}`, true)
265
266 return
267 }
268
269 const key = step.record?.key ?? keyOf(unit.kind, unit.name)
270 const next = await exclusive(async () => {
271 const held = await stored<Disabled[]>($, DISABLED_KEY, [])
272 const kept = [...held.filter(one => one.key !== key), ...(step.record === undefined ? [] : [step.record])]
273 await $.store.set(DISABLED_KEY, kept)
274
275 return kept
276 })
277
278 if (unit.kind === 'plugin') gone.add(unit.name)
279
280 await update($, disabled, () => next)
281 await say($, which === 'disable' ? `✓ ${step.record?.name ?? unit.name} disabled. Takes effect next session.` : `✓ ${unit.name} removed.`)
282 await refresh($)
283}
284
285async function enable($: EngineInterface, held: Disabled) {
286 const failure = await execute($, enableOf(held))
287
288 if (failure !== undefined) {
289 await say($, `${held.name}: ${failure}`, true)
290
291 return
292 }
293
294 if (held.how === 'plugin') gone.delete(held.name)
295
296 const next = await exclusive(async () => {
297 const kept = (await stored<Disabled[]>($, DISABLED_KEY, [])).filter(one => one.key !== held.key)
298 await $.store.set(DISABLED_KEY, kept)
299
300 return kept
301 })
302 await update($, disabled, () => next)
303 await say($, `✓ ${held.name} enabled. Takes effect next session.`)
304 await refresh($)
305}
306
307/** A record whose plugin or folder is gone can never be enabled; forgetting only drops the record. */
308async function forget($: EngineInterface, held: Disabled) {
309 const next = await exclusive(async () => {
310 const kept = (await stored<Disabled[]>($, DISABLED_KEY, [])).filter(one => one.key !== held.key)
311 await $.store.set(DISABLED_KEY, kept)
312
313 return kept
314 })
315
316 await update($, disabled, () => next)
317 await say($, `${held.name} forgotten. Nothing was changed.`)
318}
319
320async function keep($: EngineInterface, unit: Unit) {
321 await exclusive(async () => $.store.set(MUTED_KEY, [...(await stored<string[]>($, MUTED_KEY, [])), keyOf(unit.kind, unit.name)]))
322 await say($, `${unit.name} kept. Toolbelt will not list it again.`)
323 await refresh($)
324}
325
326export const register: Register = (on, options) => {
327 days = daysOf(options.unusedDays)
328 deferred.clear()
329 gone.clear()
330
331 on('session.start', async ($, e, next) => {
332 await $.command.register({ name: 'unused', description: 'Plugins, skills and MCP servers nobody used lately; disable or remove them' })
333 $.clock.after(0, () => void startup($).catch(() => undefined))
334
335 return next(e)
336 })
337
338 on('tool.describe', async ($, e, next) => {
339 const result = await next(e)
340 deferred.set(e.tool, result.isDeferred ?? e.isDeferred === true)
341
342 return result
343 }).catch(($, e, next) => next(e))
344
345 on('tool.call', async ($, e, next) => {
346 const ran = await next(e)
347 const server = serverOf(e.tool)
348
349 if (server !== undefined) await note($, keyOf('mcp', server))
350
351 return ran
352 }).catch(($, e, next) => next(e))
353
354 on('command.run', { command: 'unused' }, async $ => {
355 await learnShell($)
356 const held = await stored<Disabled[]>($, DISABLED_KEY, [])
357 await update($, disabled, () => held)
358 const unused = await refresh($)
359 await update($, confirm, () => null)
360 await update($, notice, (): Notice => ({ text: '', isError: false }))
361 await update($, page, () => 0)
362
363 if (unused.length === 0) return { text: 'Nothing unused.' }
364
365 void $.ui.open({ id: PANE, title: 'Unused' }).then(opened => {
366 if (!opened.isPlaced) $.ui.toast(listText(unused, days), { timeoutMs: 20000 })
367 })
368
369 return { text: `${unused.length} unused. Opening the pane; if it does not seat, the list follows as a notice.` }
370 })
371
372 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
373 const view = {
374 units: await read($, units),
375 disabled: await read($, disabled),
376 confirm: await read($, confirm),
377 notice: await read($, notice),
378 page: await read($, page),
379 days,
380 isCounted: (await read($, health)).isCounted,
381 total: await read($, total),
382 now: await $.clock.now(),
383 since: await stored($, SINCE_KEY, 0),
384 home,
385 }
386
387 return paneOf($.ui.resolve(e), view, {
388 ask: (key, step) => update($, confirm, () => ({ key, step }) satisfies Confirm),
389 cancel: () => update($, confirm, () => null),
390 run: (unit, step) => act($, unit, step),
391 keep: unit => keep($, unit),
392 enable: held => enable($, held),
393 turn: next => update($, page, () => next),
394 forget: held => forget($, held),
395 })
396 })
397}
398hooks/actions.ts 98 lines1import type { Disabled, Unit } from '../types'
2import { isSynced, keyOf, pluralSkills } from './usage'
3
4export type Step = {
5 /** The pane's button. */
6 label: string
7 /** What the confirm line asks. */
8 question: string
9 argv?: string[]
10 /** A user skill is switched off by moving its folder. */
11 move?: { from: string; to: string }
12 record?: Disabled
13}
14
15export const pluginKey = (plugin: string) => keyOf('plugin', plugin)
16
17const skillsText = (unit: Unit) => pluralSkills(unit.skills.length)
18
19/** Disable is the safe button. A server has no disable in the CLI, so it only offers removal. */
20export function disableOf(unit: Unit, home: string): Step | undefined {
21 if (unit.kind === 'plugin') {
22 return {
23 label: 'Disable',
24 question: `Disable ${unit.name}? Its ${skillsText(unit)} go with it. It stays installed.`,
25 argv: ['claude', 'plugin', 'disable', unit.id ?? unit.name],
26 record: { key: pluginKey(unit.name), name: unit.name, how: 'plugin', plugin: unit.id ?? unit.name },
27 }
28 }
29
30 if (unit.kind === 'duplicate' && unit.id !== undefined) {
31 return {
32 label: 'Disable',
33 question: `Disable ${unit.id}? The other copy of ${unit.name} stays. This one stays installed.`,
34 argv: ['claude', 'plugin', 'disable', unit.id],
35 record: { key: keyOf('duplicate', unit.name), name: unit.id, how: 'plugin', plugin: unit.id },
36 }
37 }
38
39 if ((unit.kind === 'skill' || unit.kind === 'duplicate') && unit.scope === 'user') {
40 const from = `${home}/.claude/skills/${unit.name}`
41 const to = `${home}/.claude/skills-disabled/${unit.name}`
42
43 return {
44 label: unit.kind === 'duplicate' ? 'Disable my copy' : 'Disable',
45 question: `Move ${unit.name} to ~/.claude/skills-disabled? It comes back with Enable.`,
46 move: { from, to },
47 record: { key: keyOf('skill', unit.name), name: unit.name, how: 'folder', from, to },
48 }
49 }
50
51 return undefined
52}
53
54/** Removal is the only button for a server, which cannot be disabled. */
55export function removeOf(unit: Unit): Step | undefined {
56 if (unit.kind === 'mcp' && (unit.scope === 'user' || unit.scope === 'project' || unit.scope === 'local')) {
57 return {
58 label: 'Remove',
59 question: `Remove ${unit.name}? Its env and keys go too. No undo.`,
60 argv: ['claude', 'mcp', 'remove', unit.name, '-s', unit.scope],
61 }
62 }
63
64 if (unit.kind === 'duplicate' && unit.id !== undefined) {
65 return {
66 label: 'Uninstall…',
67 question: `Uninstall ${unit.id}? The other copy of ${unit.name} stays. No undo.`,
68 argv: ['claude', 'plugin', 'uninstall', unit.id],
69 }
70 }
71
72 if (unit.kind === 'plugin' && !isSynced(unit.id)) {
73 return {
74 label: 'Uninstall…',
75 question: `Uninstall ${unit.name}? Its ${skillsText(unit)} and settings go too. No undo.`,
76 argv: ['claude', 'plugin', 'uninstall', unit.id ?? unit.name],
77 }
78 }
79
80 return undefined
81}
82
83export function enableOf(held: Disabled): Step {
84 if (held.how === 'plugin' && held.plugin !== undefined) {
85 return { label: 'Enable', question: '', argv: ['claude', 'plugin', 'enable', held.plugin] }
86 }
87
88 return { label: 'Enable', question: '', move: { from: held.to ?? '', to: held.from ?? '' } }
89}
90
91/** Windows starts the CLI through the shell; a bare spawn fails with ENOENT. */
92export const argvFor = (argv: string[], isWindows: boolean) => (isWindows ? ['cmd.exe', '/d', '/c', ...argv] : argv)
93
94export const moveArgv = (move: { from: string; to: string }, isWindows: boolean) =>
95 isWindows
96 ? ['cmd.exe', '/d', '/c', 'move', '/Y', move.from.split('/').join('\\'), move.to.split('/').join('\\')]
97 : ['mv', move.from, move.to]
98hooks/pane.tsx 197 lines1import type { Elements, RenderInput } from 'claude-code'
2
3import type { Confirm, Disabled, Notice, Unit, UnitKind } from '../types'
4import { disableOf, removeOf } from './actions'
5import { agoText, barText, countParts, dayText, keyOf, pluralSkills, skillsText, tierOf, tokenText, tokensOf, totalText } from './usage'
6import type { Tier } from './usage'
7
8export type PaneView = {
9 units: Unit[]
10 disabled: Disabled[]
11 confirm: Confirm | null
12 notice: Notice
13 page: number
14 days: number
15 isCounted: boolean
16 /** Characters the whole inventory adds to each prompt. */
17 total: number
18 /** When tracking of server calls began. */
19 since: number
20 now: number
21 home: string
22}
23
24export type PaneActions = {
25 ask: (key: string, step: 'disable' | 'remove') => Promise<unknown>
26 cancel: () => Promise<unknown>
27 run: (unit: Unit, step: 'disable' | 'remove') => Promise<unknown>
28 keep: (unit: Unit) => Promise<unknown>
29 enable: (held: Disabled) => Promise<unknown>
30 turn: (page: number) => Promise<unknown>
31 forget: (held: Disabled) => Promise<unknown>
32}
33
34export const PAGE_UNITS = 8
35const SHARE_CELLS = 12
36const UNIT_CELLS = 8
37const SKILLS_SHOWN = 5
38
39const HEADINGS: Record<UnitKind, string> = { duplicate: 'LOADED TWICE', plugin: 'PLUGINS', skill: 'SKILLS', mcp: 'MCP SERVERS' }
40
41const GLYPHS: Record<Tier, string> = { high: '✦', mid: '✦', low: '·' }
42
43/** The priority signal: dear units draw in `warning`, middling ones in `claude`, cheap ones dim. */
44const tintOf = (tier: Tier) => (tier === 'high' ? { color: 'warning' as const } : tier === 'mid' ? { color: 'claude' as const } : { dimColor: true })
45
46/** A server Toolbelt cannot remove: what it is, and where it is switched off instead. */
47const SERVER_NOTES: Partial<Record<Unit['scope'], { what: string; how: string }>> = {
48 builtin: { what: 'claude.ai connector', how: 'Switch it off in the Claude app.' },
49 plugin: { what: 'from a plugin', how: 'Disable the plugin that brings it.' },
50 unknown: { what: 'not in your config', how: 'Managed by the app that added it.' },
51}
52
53function detailOf(unit: Unit, view: PaneView) {
54 const lastUsed = unit.lastAt === undefined ? 'never used' : `last used ${agoText(unit.lastAt, view.now)}`
55
56 if (unit.kind === 'duplicate') return unit.detail ?? ''
57 if (unit.kind === 'plugin') return `${pluralSkills(unit.skills.length)} · ${lastUsed}`
58 if (unit.kind === 'skill') return lastUsed
59
60 const where = `${SERVER_NOTES[unit.scope]?.what ?? unit.scope}${unit.isDown === true ? ' · not connected in this session' : ''}`
61
62 return `${where} · ${unit.lastAt === undefined ? `no calls since ${dayText(view.since)}` : `last called ${agoText(unit.lastAt, view.now)}`}`
63}
64
65const bareSkills = (unit: Unit) => unit.skills.map(skill => (skill.startsWith(`${unit.name}:`) ? skill.slice(unit.name.length + 1) : skill))
66
67export function paneOf(ui: Elements[RenderInput<'Pane'>['surface']], view: PaneView, act: PaneActions) {
68 const { Box, Button, Text } = ui
69 const { confirm } = view
70 const pages = Math.max(1, Math.ceil(view.units.length / PAGE_UNITS))
71 const page = Math.min(view.page, pages - 1)
72 const shown = view.units.slice(page * PAGE_UNITS, (page + 1) * PAGE_UNITS)
73 const dearest = Math.max(1, ...view.units.map(unit => unit.cost))
74 const unusedCost = view.units.reduce((sum, unit) => sum + unit.cost, 0)
75 const share = view.total === 0 ? 0 : unusedCost / view.total
76 const bar = barText(share, SHARE_CELLS)
77
78 return (
79 <Box flexDirection="column">
80 {view.units.length === 0 ? (
81 <Box flexDirection="column">
82 <Text color="success">✓ Nothing unused, nothing loaded twice.</Text>
83 <Text dimColor wrap="wrap">{`Every plugin, skill and server was used in the last ${view.days} days.`}</Text>
84 </Box>
85 ) : (
86 <Box flexDirection="column">
87 <Box justifyContent="space-between">
88 <Box gap={1}>
89 <Text color="claude">✦</Text>
90 <Text bold>Toolbelt</Text>
91 </Box>
92 <Text dimColor>{`unused ${view.days} days`}</Text>
93 </Box>
94 <Text wrap="wrap">{`~${totalText(view.units)} tokens ride in every prompt for things you don't use or load twice`}</Text>
95 <Box>
96 <Text color="claude">{bar.filled}</Text>
97 <Text dimColor>{bar.empty}</Text>
98 <Text dimColor wrap="truncate-end">{` ${Math.round(share * 100)}% of your listing · ${countParts(view.units).join(' · ')}`}</Text>
99 </Box>
100 </Box>
101 )}
102 {!view.isCounted && <Text color="warning" wrap="wrap">Can't read Claude Code's usage counters, so skills are not judged.</Text>}
103 {view.notice.text !== '' && <Text color={view.notice.isError ? 'error' : 'success'} wrap="wrap">{view.notice.text}</Text>}
104 {shown.map((unit, at) => {
105 const key = keyOf(unit.kind, unit.name)
106 const tier = tierOf(tokensOf(unit.cost))
107 const tint = tintOf(tier)
108 const bars = barText(unit.cost / dearest, UNIT_CELLS)
109 const disable = disableOf(unit, view.home)
110 const remove = removeOf(unit)
111 const pending = confirm?.key === key ? confirm : null
112 const step = pending === null ? undefined : pending.step === 'disable' ? disable : remove
113 const isSectionStart = at === 0 || shown[at - 1]?.kind !== unit.kind
114 const count = view.units.filter(one => one.kind === unit.kind).length
115
116 return (
117 <Box key={key} flexDirection="column">
118 {isSectionStart && (
119 <Box marginTop={1} justifyContent="space-between">
120 <Text bold dimColor>{`${HEADINGS[unit.kind]} · ${count}`}</Text>
121 {at === 0 && pages > 1 && (
122 <Box gap={1}>
123 <Button key="prev" plain label="◀" onPress={() => act.turn(Math.max(0, page - 1))} />
124 <Text dimColor>{`${page + 1} of ${pages}`}</Text>
125 <Button key="next" plain label="▶" onPress={() => act.turn(Math.min(pages - 1, page + 1))} />
126 </Box>
127 )}
128 </Box>
129 )}
130 <Box marginTop={1} justifyContent="space-between">
131 <Box gap={1}>
132 <Text {...tint}>{GLYPHS[tier]}</Text>
133 <Text bold wrap="truncate-end">{unit.name}</Text>
134 </Box>
135 <Box flexShrink={0}>
136 <Text dimColor>{`~${tokenText(tokensOf(unit.cost))} tok`}</Text>
137 </Box>
138 </Box>
139 <Box paddingLeft={2}>
140 <Text {...tint}>{bars.filled}</Text>
141 <Text dimColor>{bars.empty}</Text>
142 <Text dimColor wrap="truncate-end">{` ${detailOf(unit, view)}`}</Text>
143 </Box>
144 {unit.kind === 'plugin' && (
145 <Box paddingLeft={2}>
146 <Text dimColor wrap="truncate-end">{skillsText(bareSkills(unit), SKILLS_SHOWN)}</Text>
147 </Box>
148 )}
149 {unit.kind === 'mcp' && SERVER_NOTES[unit.scope] !== undefined && (
150 <Box paddingLeft={2}>
151 <Text dimColor wrap="truncate-end">{SERVER_NOTES[unit.scope]?.how}</Text>
152 </Box>
153 )}
154 {pending !== null && step !== undefined ? (
155 <Box flexDirection="column" borderStyle="round" borderColor={pending.step === 'remove' ? 'error' : 'warning'} paddingX={1}>
156 <Text wrap="wrap">{step.question}</Text>
157 <Box gap={1}>
158 <Button key={`yes:${key}`} variant="primary" label="Yes" onPress={() => act.run(unit, pending.step)} />
159 <Button key={`no:${key}`} plain label="Cancel" onPress={() => act.cancel()} />
160 </Box>
161 </Box>
162 ) : (
163 <Box paddingLeft={2} gap={1} flexWrap="wrap">
164 {disable !== undefined && <Button key={`disable:${key}`} variant="secondary" label={disable.label} onPress={() => act.ask(key, 'disable')} />}
165 {remove !== undefined && (
166 <Button
167 key={`remove:${key}`}
168 {...(disable === undefined ? { variant: 'secondary' as const } : { plain: true, dimColor: true })}
169 label={remove.label}
170 onPress={() => act.ask(key, 'remove')}
171 />
172 )}
173 <Button key={`keep:${key}`} plain dimColor label="Keep" onPress={() => act.keep(unit)} />
174 </Box>
175 )}
176 </Box>
177 )
178 })}
179 {view.disabled.length > 0 && (
180 <Box flexDirection="column" marginTop={1}>
181 <Text bold dimColor>SWITCHED OFF BY TOOLBELT</Text>
182 {view.disabled.map(held => (
183 <Box key={held.key} gap={1}>
184 <Text>{held.name}</Text>
185 <Button key={`enable:${held.key}`} plain label="Enable" onPress={() => act.enable(held)} />
186 <Button key={`forget:${held.key}`} plain dimColor label="Forget" onPress={() => act.forget(held)} />
187 </Box>
188 ))}
189 </Box>
190 )}
191 <Box marginTop={1}>
192 <Text dimColor wrap="wrap">Changes apply in the next session.</Text>
193 </Box>
194 </Box>
195 )
196}
197hooks/usage.ts 464 lines1import type { Item, Unit, UnitKind, Use, Uses } from '../types'
2
3export const DAY_MS = 86_400_000
4export const DAY_CHOICES = ['7', '14', '30', '60', '90']
5export const DEFAULT_DAYS = 14
6
7/** The window the person chose in the plugin's settings; anything else is the default. */
8export const daysOf = (option: unknown) => (typeof option === 'string' && DAY_CHOICES.includes(option) ? Number(option) : DEFAULT_DAYS)
9const CHARS_PER_TOKEN = 4
10const HIGH_TOKENS = 500
11const MID_TOKENS = 100
12const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
13
14export const keyOf = (kind: UnitKind, name: string) => `${kind}:${name}`
15
16/** `mcp__exa__web_search_exa` is server `exa`. */
17export const serverOf = (tool: string) => /^mcp__(.+?)__/.exec(tool)?.[1]
18
19/** One form for a path: forward slashes, no trailing slash, and case folded when it starts with a Windows drive. */
20export function normalPath(path: string) {
21 const slashed = path.split('\\').join('/').replace(/\/+$/, '')
22
23 return /^[a-z]:/i.test(slashed) ? slashed.toLowerCase() : slashed
24}
25
26/** A session started in a subdirectory still belongs to the repo above it. */
27const isInRepo = (cwd: string, repo: string) => cwd === repo || cwd.startsWith(`${repo}/`)
28
29const isPerProject = (scope: Item['scope']) => scope === 'project' || scope === 'local'
30
31export function countUse(uses: Uses, key: string, project: string, at: number): Uses {
32 const held = uses[key]
33 const place = normalPath(project)
34
35 return {
36 ...uses,
37 [key]: {
38 count: (held?.count ?? 0) + 1,
39 lastAt: Math.max(held?.lastAt ?? 0, at),
40 byProject: { ...held?.byProject, [place]: Math.max(held?.byProject?.[place] ?? 0, at) },
41 },
42 }
43}
44
45function lastInRepo(use: Use | undefined, project: string) {
46 const repo = normalPath(project)
47 const times = Object.entries(use?.byProject ?? {}).flatMap(([cwd, at]) => (isInRepo(normalPath(cwd), repo) ? [at] : []))
48
49 return times.length === 0 ? undefined : Math.max(...times)
50}
51
52export type Counters = {
53 skills: Record<string, { count: number; lastAt: number }>
54 userServers: string[]
55 /** Local-scope servers by normalised project path. */
56 localServers: Record<string, string[]>
57 /** Servers the person switched off in a project, by normalised project path. */
58 disabledServers: Record<string, string[]>
59}
60
61const asRecord = (value: unknown): Record<string, unknown> | undefined =>
62 typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
63
64const own = <T>(record: Record<string, T>, key: string): T | undefined => (Object.hasOwn(record, key) ? record[key] : undefined)
65
66/** What Claude Code's own config says about use; undefined when the text is not that config. */
67export function readCounters(text: string): Counters | undefined {
68 let parsed: unknown
69
70 try {
71 parsed = JSON.parse(text)
72 } catch {
73 return undefined
74 }
75
76 const config = asRecord(parsed)
77 const usage = asRecord(config?.skillUsage)
78
79 if (config === undefined || usage === undefined) return undefined
80
81 const skills: Counters['skills'] = {}
82
83 for (const [name, entry] of Object.entries(usage)) {
84 const counter = asRecord(entry)
85
86 if (typeof counter?.usageCount === 'number' && typeof counter.lastUsedAt === 'number') {
87 skills[name] = { count: counter.usageCount, lastAt: counter.lastUsedAt }
88 }
89 }
90
91 const localServers: Counters['localServers'] = {}
92 const disabledServers: Counters['disabledServers'] = {}
93
94 for (const [path, project] of Object.entries(asRecord(config.projects) ?? {})) {
95 const entry = asRecord(project)
96 const names = Object.keys(asRecord(entry?.mcpServers) ?? {})
97 const off = [entry?.disabledMcpServers, entry?.disabledMcpjsonServers].flatMap(list => (Array.isArray(list) ? list.filter((name): name is string => typeof name === 'string') : []))
98 const place = normalPath(path)
99
100 if (names.length > 0) localServers[place] = [...(localServers[place] ?? []), ...names]
101 if (off.length > 0) disabledServers[place] = [...(disabledServers[place] ?? []), ...off]
102 }
103
104 return { skills, userServers: Object.keys(asRecord(config.mcpServers) ?? {}), localServers, disabledServers }
105}
106
107/** The servers a repo's `.mcp.json` lists; none when the text is not that file. */
108export function projectServers(text: string): string[] {
109 try {
110 return Object.keys(asRecord(asRecord(JSON.parse(text))?.mcpServers) ?? {})
111 } catch {
112 return []
113 }
114}
115
116/** One install of a plugin: its `name@marketplace` id, and what the record says of its version and last update. */
117export type Install = { id: string; version: string; updatedAt: number }
118export type Installs = Record<string, Install[]>
119
120/** Installs by plugin name, from the CLI's install record; none when the text is not that record. */
121export function installsOf(text: string): Installs {
122 let parsed: unknown
123
124 try {
125 parsed = JSON.parse(text)
126 } catch {
127 return {}
128 }
129
130 const installs: Installs = {}
131
132 for (const [id, entries] of Object.entries(asRecord(asRecord(parsed)?.plugins) ?? {})) {
133 const name = id.slice(0, id.lastIndexOf('@'))
134 const entry = asRecord(Array.isArray(entries) ? entries[0] : entries)
135 const updatedAt = Date.parse(typeof entry?.lastUpdated === 'string' ? entry.lastUpdated : '')
136
137 if (name === '') continue
138
139 installs[name] = [
140 ...(own(installs, name) ?? []),
141 { id, version: typeof entry?.version === 'string' ? entry.version : '', updatedAt: Number.isNaN(updatedAt) ? 0 : updatedAt },
142 ]
143 }
144
145 return installs
146}
147
148/** The plugin ids the settings switch off (`enabledPlugins` set to false). */
149export const switchedOff = (settings: Record<string, unknown>) =>
150 Object.entries(asRecord(settings.enabledPlugins) ?? {}).flatMap(([id, isOn]) => (isOn === false ? [id] : []))
151
152const marketOf = (id: string) => id.slice(id.lastIndexOf('@') + 1)
153
154const labelOf = (install: Install) => `${install.version === '' ? '' : `${install.version} `}from ${marketOf(install.id)}`
155
156/**
157 * What is loaded twice, whether or not it is used: a plugin installed from two marketplaces, of which the copy updated
158 * longest ago is the one to drop, and a user skill whose name a plugin's skill also carries.
159 */
160export function duplicateUnits(items: Item[], installs: Installs, off: string[], muted: string[]): Unit[] {
161 const units: Unit[] = []
162
163 for (const [name, all] of Object.entries(installs)) {
164 const live = all.filter(install => !off.includes(install.id)).sort((a, b) => a.updatedAt - b.updatedAt || a.id.localeCompare(b.id))
165 const [stale, ...kept] = live
166 const newest = kept.at(-1)
167
168 if (stale === undefined || newest === undefined) continue
169
170 const seen = new Set<string>()
171 const repeated = items.filter(item => item.plugin === name && (seen.has(item.name) || !seen.add(item.name)))
172
173 units.push({
174 kind: 'duplicate',
175 name,
176 id: stale.id,
177 scope: 'plugin',
178 cost: repeated.reduce((sum, item) => sum + item.cost, 0),
179 skills: [],
180 uses: 0,
181 detail: `${labelOf(stale)} · ${labelOf(newest)} is newer`,
182 })
183 }
184
185 for (const item of items) {
186 if (item.kind !== 'skill' || item.scope !== 'user') continue
187
188 const twin = items.find(other => other.kind === 'skill' && other.plugin !== undefined && other.name.endsWith(`:${item.name}`))
189
190 if (twin?.plugin !== undefined) {
191 units.push({ kind: 'duplicate', name: item.name, scope: 'user', cost: item.cost, skills: [], uses: 0, detail: `your copy · ${twin.plugin} has one too` })
192 }
193 }
194
195 return units.filter(unit => !muted.includes(keyOf('duplicate', unit.name)))
196}
197
198/** The desktop app syncs this plugin's skills from the account into `~/.claude/skills/synced`: the install record does not name it, and its skills are listed as bare user commands. */
199export const SYNCED_PLUGIN = 'anthropic-skills'
200const SYNCED_IDS: Record<string, string> = { [SYNCED_PLUGIN]: `${SYNCED_PLUGIN}@synced` }
201
202/** A synced skill is listed as `docx`, or `anthropic-skills:docx` when a user skill already has the bare name. */
203export const syncedBare = (name: string) => (name.startsWith(`${SYNCED_PLUGIN}:`) ? name.slice(SYNCED_PLUGIN.length + 1) : name)
204
205/** A synced plugin can be switched off but not uninstalled: the app brings it back. */
206export const isSynced = (id: string | undefined) => id?.endsWith('@synced') === true
207
208/** Each plugin unit with the id the CLI takes: a bare name is refused for a synced plugin and is ambiguous between two installs. */
209export const withIds = (units: Unit[], installs: Installs): Unit[] =>
210 units.map(unit => {
211 if (unit.kind !== 'plugin') return unit
212
213 const all = own(installs, unit.name) ?? []
214 const id = all.length === 1 ? all[0]?.id : all.length === 0 ? own(SYNCED_IDS, unit.name) : undefined
215
216 return id === undefined ? unit : { ...unit, id }
217 })
218
219/** Where a server is defined, read from data and in this order: app-managed, plugin-bundled, the repo's `.mcp.json`, the config's entry for the repo, the config's user entries. */
220export function serverScope(name: string, root: string, listed: string[], counters: Counters | undefined): Item['scope'] {
221 if (name.startsWith('claude_ai_') || isConnector(name)) return 'builtin'
222 if (name.startsWith('plugin_')) return 'plugin'
223 if (listed.includes(name)) return 'project'
224 if (counters?.localServers[normalPath(root)]?.includes(name)) return 'local'
225 if (counters?.userServers.includes(name)) return 'user'
226
227 return 'unknown'
228}
229
230/** A skill's counter: its full name first, then its bare name, but only when one loaded skill ends in that name. */
231export function skillUse(counters: Counters, name: string, loaded: string[]): { count: number; lastAt: number } | undefined {
232 const full = own(counters.skills, name)
233
234 if (full !== undefined) return full
235
236 const bare = name.slice(name.lastIndexOf(':') + 1)
237 const sharing = loaded.filter(one => one === bare || one.endsWith(`:${bare}`))
238
239 return sharing.length === 1 ? own(counters.skills, bare) : undefined
240}
241
242export type View = {
243 project: string
244 now: number
245 since: number
246 days: number
247 muted: string[]
248 installs?: Installs
249 off?: string[]
250 /** The servers the repo's `.mcp.json` lists. */
251 listed?: string[]
252}
253
254const dearestFirst = (a: Unit, b: Unit) => b.cost - a.cost || a.name.localeCompare(b.name)
255
256const onePerName = (skills: Item[]) => skills.filter((skill, at) => skills.findIndex(other => other.name === skill.name) === at)
257
258/** A plugin loaded twice is costed once here: the duplicate row already counts the spare copy. */
259function pluginUnits(items: Item[], counters: Counters, view: View, cutoff: number, twice: Unit[]): Unit[] {
260 const loaded = items.filter(item => item.kind === 'skill').map(item => item.name)
261 const byPlugin = new Map<string, Item[]>()
262
263 for (const item of items) {
264 if (item.kind !== 'skill' || item.scope !== 'plugin' || item.plugin === undefined) continue
265
266 byPlugin.set(item.plugin, [...(byPlugin.get(item.plugin) ?? []), item])
267 }
268
269 return [...byPlugin].flatMap(([name, skills]): Unit[] => {
270 const used = skills.flatMap(skill => skillUse(counters, skill.name, loaded) ?? [])
271 const lastAt = used.length === 0 ? undefined : Math.max(...used.map(one => one.lastAt))
272
273 if (view.muted.includes(keyOf('plugin', name)) || (lastAt !== undefined && lastAt > cutoff)) return []
274
275 return [
276 {
277 kind: 'plugin',
278 name,
279 scope: 'plugin',
280 cost: (twice.some(unit => unit.scope === 'plugin' && unit.name === name) ? onePerName(skills) : skills).reduce((sum, skill) => sum + skill.cost, 0),
281 skills: [...new Set([...skills].sort((a, b) => b.cost - a.cost || a.name.localeCompare(b.name)).map(skill => skill.name))],
282 uses: used.reduce((sum, one) => sum + one.count, 0),
283 ...(lastAt === undefined ? {} : { lastAt }),
284 },
285 ]
286 })
287}
288
289function skillUnits(items: Item[], counters: Counters, view: View, cutoff: number, twice: Unit[]): Unit[] {
290 const loaded = items.filter(item => item.kind === 'skill').map(item => item.name)
291
292 return items.flatMap((item): Unit[] => {
293 const isTwice = twice.some(unit => unit.scope === 'user' && unit.name === item.name) || view.muted.includes(keyOf('duplicate', item.name))
294
295 if (item.kind !== 'skill' || item.scope !== 'user' || isTwice) return []
296
297 const use = skillUse(counters, item.name, loaded)
298
299 if (view.muted.includes(keyOf('skill', item.name)) || (use !== undefined && use.lastAt > cutoff)) return []
300
301 return [{ kind: 'skill', name: item.name, scope: item.scope, cost: item.cost, skills: [], uses: use?.count ?? 0, ...(use === undefined ? {} : { lastAt: use.lastAt }) }]
302 })
303}
304
305function serverUnits(items: Item[], uses: Uses, view: View, cutoff: number): Unit[] {
306 return items.flatMap((item): Unit[] => {
307 if (item.kind !== 'mcp') return []
308
309 const key = keyOf('mcp', item.name)
310 const use = uses[key]
311 const lastAt = isPerProject(item.scope) ? lastInRepo(use, view.project) : use?.lastAt
312
313 if (view.muted.includes(key) || (lastAt !== undefined && lastAt > cutoff)) return []
314
315 return [{ kind: 'mcp', name: item.name, scope: item.scope, cost: item.cost, skills: [], uses: use?.count ?? 0, ...(lastAt === undefined ? {} : { lastAt }) }]
316 })
317}
318
319/**
320 * Servers the config names that gave this session no tools, used or not: one that fails to start costs nothing per turn
321 * but is dead weight in the config. A server the person switched off for the repo is left out.
322 */
323function downUnits(items: Item[], counters: Counters, uses: Uses, view: View): Unit[] {
324 const repo = normalPath(view.project)
325 const connected = items.flatMap(item => (item.kind === 'mcp' ? [item.name] : []))
326 const off = own(counters.disabledServers, repo) ?? []
327 const configured = new Map<string, Item['scope']>()
328
329 for (const [scope, names] of [['project', view.listed ?? []], ['local', own(counters.localServers, repo) ?? []], ['user', counters.userServers]] as const) {
330 for (const name of names) if (!configured.has(name)) configured.set(name, scope)
331 }
332
333 return [...configured].flatMap(([name, scope]): Unit[] => {
334 const key = keyOf('mcp', name)
335
336 if (connected.includes(name) || off.includes(name) || view.muted.includes(key)) return []
337
338 const use = uses[key]
339
340 return [{ kind: 'mcp', name, scope, cost: 0, skills: [], uses: use?.count ?? 0, ...(use === undefined ? {} : { lastAt: use.lastAt }), isDown: true }]
341 })
342}
343
344/**
345 * What is loaded twice, then the plugins, user skills and servers nobody used for `days`, each group dearest first.
346 * Skills are judged by Claude Code's own counters and need no waiting; a server is judged by Toolbelt's tally,
347 * so nothing counts as unused before that tracking is `days` old. Without counters skills are not judged.
348 */
349export function unusedUnits(items: Item[], counters: Counters | undefined, uses: Uses, view: View): Unit[] {
350 const cutoff = view.now - view.days * DAY_MS
351 const isWaiting = view.now - view.since < view.days * DAY_MS
352
353 const twice = duplicateUnits(items, view.installs ?? {}, view.off ?? [], view.muted).sort(dearestFirst)
354
355 return [
356 ...twice,
357 ...(counters === undefined ? [] : [...pluginUnits(items, counters, view, cutoff, twice).sort(dearestFirst), ...skillUnits(items, counters, view, cutoff, twice).sort(dearestFirst)]),
358 ...(isWaiting ? [] : serverUnits(items, uses, view, cutoff).sort(dearestFirst)),
359 ...(counters === undefined ? [] : downUnits(items, counters, uses, view).sort(dearestFirst)),
360 ]
361}
362
363export const signature = (units: Unit[]) => units.map(unit => keyOf(unit.kind, unit.name)).sort().join('|')
364
365export const tokensOf = (chars: number) => Math.ceil(chars / CHARS_PER_TOKEN)
366
367export const tokenText = (tokens: number) => (tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens))
368
369export const totalText = (units: Unit[]) => tokenText(units.reduce((sum, unit) => sum + tokensOf(unit.cost), 0))
370
371export type Tier = 'high' | 'mid' | 'low'
372
373export const tierOf = (tokens: number): Tier => (tokens >= HIGH_TOKENS ? 'high' : tokens >= MID_TOKENS ? 'mid' : 'low')
374
375/** `never used`, `today`, `yesterday`, `41 days ago`. */
376export function agoText(lastAt: number | undefined, now: number) {
377 if (lastAt === undefined) return 'never used'
378
379 const days = Math.floor((now - lastAt) / DAY_MS)
380
381 return days <= 0 ? 'today' : days === 1 ? 'yesterday' : `${days} days ago`
382}
383
384/** A bar in two parts so each can take its own colour; a positive share fills at least one cell. */
385export function barText(share: number, cells: number) {
386 const clamped = Math.min(1, Math.max(0, Number.isFinite(share) ? share : 0))
387 const filled = clamped === 0 ? 0 : Math.max(1, Math.round(clamped * cells))
388
389 return { filled: '▰'.repeat(filled), empty: '▱'.repeat(cells - filled) }
390}
391
392/** `grilling, tdd +23`: the first `shown` names and a count of the rest. */
393export const skillsText = (skills: string[], shown: number) =>
394 skills.length <= shown ? skills.join(', ') : `${skills.slice(0, shown).join(', ')} +${skills.length - shown}`
395
396/** `14 Jul`, from UTC parts so the result does not depend on the machine's zone. */
397export function dayText(at: number) {
398 const date = new Date(at)
399
400 return `${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]}`
401}
402
403const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`
404
405export const KINDS = ['duplicate', 'plugin', 'skill', 'mcp'] as const
406
407/** `12 plugins`, `2 skills`, `3 servers`; a kind with none is left out. */
408export const countParts = (units: Unit[]) =>
409 KINDS.flatMap(kind => {
410 const count = units.filter(unit => unit.kind === kind).length
411
412 return count === 0 ? [] : [plural(count, kind === 'mcp' ? 'server' : kind)]
413 })
414
415export const pluralSkills = (count: number) => plural(count, 'skill')
416
417export function toastText(units: Unit[], days: number) {
418 const twice = units.filter(unit => unit.kind === 'duplicate').length
419 const parts = countParts(units.filter(unit => unit.kind !== 'duplicate'))
420 const counts = parts.length < 2 ? parts.join('') : `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}`
421 const said = [...(twice === 0 ? [] : [`${twice} loaded twice`]), ...(counts === '' ? [] : [`${counts} unused ${days} days`])]
422
423 return `toolbelt: ${said.join('; ')}, ~${totalText(units)} tokens each turn. /unused to review.`
424}
425
426export const TEXT_UNITS = 15
427
428const lineOf = (unit: Unit) => {
429 const detail = unit.kind === 'plugin' ? pluralSkills(unit.skills.length) : (unit.detail ?? unit.scope)
430
431 return `${unit.kind} ${unit.name} (${detail}) ~${tokenText(tokensOf(unit.cost))}`
432}
433
434/** The list as plain lines, for a screen too narrow to seat the pane. */
435export function listText(units: Unit[], days: number) {
436 const more = units.length > TEXT_UNITS ? [`…and ${units.length - TEXT_UNITS} more, cheaper. Widen the terminal to 110 columns for the pane.`] : []
437
438 return [`${units.length} unused for ${days} days, ~${totalText(units)} tokens/turn:`, ...units.slice(0, TEXT_UNITS).map(lineOf), ...more].join('\n')
439}
440
441/** What two tallies add up to: counts add, the latest use and the latest use in each repo win. */
442export function combine(a: Uses, b: Uses): Uses {
443 const keys = new Set([...Object.keys(a), ...Object.keys(b)])
444
445 return Object.fromEntries(
446 [...keys].map(key => {
447 const [one, two] = [a[key], b[key]]
448 const projects = new Set([...Object.keys(one?.byProject ?? {}), ...Object.keys(two?.byProject ?? {})])
449
450 return [
451 key,
452 {
453 count: (one?.count ?? 0) + (two?.count ?? 0),
454 lastAt: Math.max(one?.lastAt ?? 0, two?.lastAt ?? 0),
455 byProject: Object.fromEntries([...projects].map(project => [project, Math.max(one?.byProject?.[project] ?? 0, two?.byProject?.[project] ?? 0)])),
456 },
457 ]
458 }),
459 )
460}
461
462/** A claude.ai connector is named by a UUID and is managed in the app, not here. */
463export const isConnector = (name: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-/.test(name)
464types/index.d.ts 73 lines1export type Kind = 'skill' | 'mcp'
2export type Scope = 'user' | 'project' | 'local' | 'plugin' | 'builtin' | 'unknown'
3
4/** One skill or MCP server that adds text to every prompt. */
5export type Item = {
6 kind: Kind
7 name: string
8 scope: Scope
9 /** The plugin that carries the skill, when it comes from one. */
10 plugin?: string
11 /** Characters it adds to each prompt: a skill's description, a server's listed tools. */
12 cost: number
13}
14
15/** `byProject` holds the last call in each repo: project and local servers are unused per repo. */
16export type Use = { count: number; lastAt: number; byProject?: Record<string, number> }
17export type Uses = Record<string, Use>
18
19export type UnitKind = 'duplicate' | 'plugin' | 'skill' | 'mcp'
20
21/** One thing the person can switch off in one step: a plugin with all its skills, a user skill, a server, or the spare copy of something loaded twice. */
22export type Unit = {
23 kind: UnitKind
24 name: string
25 /** The `name@marketplace` the CLI acts on: a plugin's, absent when two installs share the name; a duplicate's, the copy to drop. */
26 id?: string
27 scope: Scope
28 /** Characters added to each prompt; for a plugin, the sum over its skills. */
29 cost: number
30 /** For a plugin: its skills, dearest first. */
31 skills: string[]
32 uses: number
33 lastAt?: number
34 /** For a duplicate: which copy this is and where the other one lives. */
35 detail?: string
36 /** For a server: the config names it but it gave this session no tools. */
37 isDown?: boolean
38}
39
40/** What Toolbelt itself switched off, so the pane can offer the way back. */
41export type Disabled = {
42 key: string
43 name: string
44 how: 'plugin' | 'folder'
45 plugin?: string
46 from?: string
47 to?: string
48}
49
50/** What the pane last reported; a failure draws in the error colour. */
51export type Notice = { text: string; isError: boolean }
52
53/** A pending second click: which unit, and whether it is a disable or a removal. */
54export type Confirm = { key: string; step: 'disable' | 'remove' }
55
56/** Whether Claude Code's usage counters could be read; without them skills are not judged. */
57export type Health = { isCounted: boolean }
58
59declare module 'claude-code' {
60 interface PluginState {
61 toolbelt: {
62 units: Unit[]
63 disabled: Disabled[]
64 confirm: Confirm | null
65 notice: Notice
66 page: number
67 health: Health
68 /** Characters the whole inventory adds to each prompt, used or not. */
69 total: number
70 }
71 }
72}
73