SLOPSHOPPER

toolbelt

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…

newpaneguardcommandtoastprocess
v0.3.0no licenseupdated 2026-10-13a-pavithraa/claude-mods/plugins/toolbelt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · toolbelt
│ ┃ toolbelt ✕ › fix the failing auth test and add an audit log call │ ┃ ✓ Nothing unused, nothing loaded twice. │ ┃ Every plugin, skill and server was used in ⏺ Read(src/auth.ts) │ ┃ the last 14 days. ⎿ Read 6 lines │ ┃ Can't read Claude Code's usage counters, so ⏺ Update(src/auth.ts) │ ┃ skills are not judged. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Changes apply in the next session. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /unused │ ⎿ toolbelt: Nothing unused. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · toolbelt
✓ Nothing unused, nothing loaded twice. Every plugin, skill and server was used in the last 14 days. Can't read Claude Code's usage counters, so skills are not judged. Changes apply in the next session.
README

toolbelt

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:

  • A plugin: Disable runs 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.
  • A user skill: Disable moves its folder to ~/.claude/skills-disabled; Enable moves it back.
  • An MCP server: the CLI has no disable, so the button is Remove (claude mcp remove), which also deletes its env and keys.
  • Each row has a mark and a bar for how many tokens it adds to every prompt; the dearest stand out. The list is paged, 8 to a page. A terminal narrower than 110 columns cannot seat the pane, so /unused shows the top 15 as a notice instead.
  • A server Toolbelt can't remove (a claude.ai connector, one a plugin or an app added) is still listed when unused, with where to switch it off and only Keep. A server your config names that gave the session no tools is listed as not connected, used or not, unless you switched it off for the repo; it is left out of the toast, since servers may still be connecting at startup.
  • Keep mutes an item for good.

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.

Source 5 files
hooks/register.tsx 398 lines
1import { 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}
398
hooks/actions.ts 98 lines
1import 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]
98
hooks/pane.tsx 197 lines
1import 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}
197
hooks/usage.ts 464 lines
1import 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)
464
types/index.d.ts 73 lines
1export 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