SLOPSHOPPER

helm

The control panel for Claude Code: it sets up each project with the right skills and tools, shows what is in use, and vets new tools before you add them.

newpanebandguardcommandprompt
v0.12.4Apache-2.0updated 2026-10-06rlpb/helm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · helm
│ ┃ Helm ✕ › fix the failing auth test and add an audit log call │ ┃ app 0 tools · 0 on │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Project ] [ Global ] ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ──────────── ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ✓ Healthy: nothing to fix. ⎿ 3 pass, 1 fail │ ┃ │ ┃ ╭──────────────────────────────────────────╮ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ USED IN THIS PROJECT │ │ ┃ │ Nothing used in this project yet. What │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ Claude uses here will show up, with how │ │ ┃ │ often. │ › /helm │ ┃ ╰──────────────────────────────────────────╯ ⎿ helm: Helm opened. │ ┃ │ ┃ ╭─────────────────────────────────────────── │ ┃ │ 1 · WHAT ARE YOU BUILDING? │ ┃ │ a web shop, a CLI tool, a data report… ⏎ f │ ┃ │ Describe it in a few words and Helm │ ┃ │ picks from your 0 installed tools. │ ┃ ╰─────────────────────────────────────────── │ ┃ │ ┃ ╭─────────────────────────────────────────── │ ┃ │ FIND A NEW TOOL │ ┃ │ Not installed yet? Paste links, ⎈ “app” is already under way. Read the folder and suggest tools? [ Read it ] [ Not here ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⎈ “app” is already under way. Read the folder and suggest tools? [ Read it ] [ Not here ]
Pane · Helm
app 0 tools · 0 on [ Project ] [ Global ] ──────────────────────────────────────────────────────── ✓ Healthy: nothing to fix. ╭──────────────────────────────────────────────────────────╮ │ USED IN THIS PROJECT │ │ Nothing used in this project yet. What Claude uses here │ │ will show up, with how often. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ 1 · WHAT ARE YOU BUILDING? │ │ a web shop, a CLI tool, a data report… ⏎ find tools │ │ Describe it in a few words and Helm picks from your 0 │ │ installed tools. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ FIND A NEW TOOL │ │ Not installed yet? Paste links, owner/name, names or a │ │ whole list. Helm checks each one first and installs only │ │ what you tick. │ │ Links, owner/name, names or a whole list ⏎ search │ ╰──────────────────────────────────────────────────────────╯ Language : Automatic ▾
README

<img src="docs/banner.png" alt="Helm: the control panel for Claude Code" width="100%">

The control panel for Claude Code. It sets up each project with the right skills, shows what is in use, and vets new tools before you add them.

CI License Ko-fi

English · Italiano

New plugins, skills and tools appear every week. Helm is the one place in Claude Code where you see all of them, pick the ones a project needs, and check a new one before it goes in.

What it does

  • Set up a project. Open a new folder and Helm asks what you are building. It shortlists the fitting tools from what you already have installed, and one press turns them on for that folder only. Undo puts the folder back exactly as it was.
  • Vet a tool, or a whole list. Paste a GitHub link, owner/name, a name, or a whole text full of them. Helm reads each repository (license, archived or not, last update, stars, and whether it holds a plugin, skills, an MCP server or a command-line tool, with the exact command that installs it), scans it, gives a plain verdict per line, and installs only what you tick. Changes apply at once when Claude Code allows it. A tool you added for one project is offered later for all of them.
  • See what is in use here. The top of the Project page lists only what Claude used in this folder, by category, with how often and when. A skill that has just run is lit. It costs no tokens.
  • Keep your limits in view. A quiet row above the prompt shows the 5-hour and weekly limits and the context as small bars, plus the skill Claude is using now, with a security dot and an Open Helm button. The figures follow the session as it runs.
  • Speaks your language. 16 languages, following the computer's by default, changeable from the panel.
  • Check before you trust. SkillSpector by NVIDIA scans every skill and plugin you have, and every new tool before it can be installed, for prompt injection, data theft and risky code. A scan that says "do not install" hides the Install button. It is static-only: no model, no key, nothing leaves your computer. Helm keeps it up to date.
  • Keep it healthy, and know it. The Status section of the Global tab says only what it actually looked at. Health checks broken files, duplicates, tools that do the same job and skills never used (after two weeks of watching). Updates reads the result of every plugin. Security shows green only when every tool was scanned and none is flagged. Removing a plugin works whatever scope it was installed in. None of it touches a project's own settings. Below it, every tool and skill is folded by category: press a name to see what it is and does.
  • Start a repository properly. When a GitHub connector is available, one checkbox adds a professional GitHub baseline to your first prompt: pick a license and the items you want (README, CI, SECURITY.md, Dependabot, branch protection and more), add your own directions, and it is remembered.

<img src="docs/demo.gif" alt="Helm in four steps" width="820">

<img src="docs/screenshot-project.png" alt="The Project page: what Claude used in this folder by category, then the setup steps" width="560">

Install

You need Claude Code 2.1.289 or newer.

/plugin marketplace add rlpb/helm
/plugin install helm@helm

Then type /helm. In a folder Helm has not seen, a notice also appears above the prompt: Open or Not here (remembered per folder). In a chat that is already under way it offers to read the folder and suggest tools instead.

How it stays safe

  • It runs only gh, claude plugin …, skillspector and uv tool … commands, built from plain names, and installs nothing without a yes.
  • A project's setup is written to that folder's own .claude/settings.local.json, never to your global settings. Undo restores the previous values key by key.
  • The health check only reads. Its one fix, removing a plugin whose files are gone, needs a second press; if the command cannot remove it, Helm edits the plugin registry itself after saving a copy next to it (installed_plugins.json.helm-backup).
  • Helm makes no network call of its own.

See SECURITY.md for how to report a problem and how to verify a download.

Verify a download

Releases are built by the release workflow from a tagged commit, with a SHA-256 checksum and a build-provenance attestation:

sha256sum -c SHA256SUMS.txt
gh attestation verify helm-0.12.4.zip --repo rlpb/helm

Support

Helm is free, under the Apache 2.0 license. Nothing in it is paid, and nothing will be. If it saves you time, a coffee helps keep it going.

<a href="https://ko-fi.com/rlpb_"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support on Ko-fi"></a>

License

Apache License 2.0. Reuse it, change it and ship it, including commercially; keep the license and the NOTICE with it and say what you changed. See CONTRIBUTING.md to help out.

Source 34 files
hooks/register.tsx 1030 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { BatchRow, Core, Found, Hit, Meta, Nav, ProjectState, ScanResult, Setup } from '../types'
5import { installPlan, installedAs, judge, parseItems, wrapperFile } from '../src/research'
6import type { Item } from '../src/research'
7import { loadIndex, scanTargets } from '../src/load'
8import { shortlist } from '../src/shortlist'
9import { hintFor, parseChoice, refinePrompt } from '../src/suggest'
10import { isProject, localSettings, projectKey, projectName } from '../src/project'
11import { applyPicks, setSkillOff, undoPicks } from '../src/apply'
12import type { Before } from '../src/apply'
13import { checkup, readUpdate } from '../src/tidy'
14import { review } from '../src/health'
15import { DEFAULT_SETUP, brief, hasGithub, nextLicense } from '../src/github'
16import { GLOW_MS, glow } from '../src/graph'
17import { foldVerdict, installCmd, isOverridable, parseScan, parseVersion, repoTarget, scanCmd, upgradeCmd, versionCmd } from '../src/skillspector'
18import { detectLang, isLang, t } from '../src/i18n'
19import type { Key, Lang } from '../src/i18n'
20import { Band } from '../src/views/band'
21import { Panel, ago } from '../src/views/panel'
22
23const PANE = 'helm'
24let isTerminal = false
25let fading = false
26
27const core = atom(
28  { plugin: 'helm', key: 'core' } as const,
29  {
30    scanner: { state: 'unknown', version: null },
31    scans: {},
32    scanning: null,
33    anyway: null,
34    lang: 'en',
35    pref: 'auto',
36    usage: null,
37    uses: {},
38    puses: {},
39    hint: null,
40    setup: DEFAULT_SETUP,
41    used: {},
42    tick: 0,
43    found: null,
44    hits: null,
45    batch: null,
46    candidates: [],
47    dir: '',
48    message: null,
49    report: null,
50    tracked: 0,
51    updates: null,
52    skipped: [],
53    confirm: null,
54    index: null,
55    project: null,
56    state: null,
57    chat: false,
58    busy: false,
59    failed: false,
60    ask: null,
61  } as Core,
62)
63const nav = atom({ plugin: 'helm', key: 'nav' } as const, { tab: 'project', inspect: null, open: [] } as Nav)
64
65const stateKey = (key: string) => `project:${key}`
66const undoKey = (key: string) => `undo:${key}`
67const tempKey = (key: string) => `temp:${key}`
68const ignoreKey = (key: string) => `ignored:${key}`
69const pusesKey = (key: string) => `puses:${key}`
70
71/** A line in the panel's footer, in the language shown. */
72async function say($: any, key: Key, ...vars: (string | number)[]) {
73  const c = await read($, core)
74  await update($, core, s => ({ ...s, busy: false, failed: false, message: t(c.lang, key, ...vars) }))
75}
76
77/** The same, for something that did not work: shown in red, with the reason when there is one. */
78async function fail($: any, key: Key, why: string, ...vars: (string | number)[]) {
79  const c = await read($, core)
80  const reason = why.trim().split('\n')[0].slice(0, 160)
81  await update($, core, s => ({ ...s, busy: false, failed: true, message: `${t(c.lang, key, ...vars)}${reason ? ` ${reason}` : ''}` }))
82}
83
84/** A line that says something is under way: the top of the panel shows it with a mark, until `say` or a result replaces it. */
85async function work($: any, key: Key, ...vars: (string | number)[]) {
86  const c = await read($, core)
87  await update($, core, s => ({ ...s, busy: true, failed: false, message: t(c.lang, key, ...vars) }))
88}
89
90async function setState($: any, state: ProjectState) {
91  const c = await read($, core)
92  if (!c.project) return
93  await $.store.set(stateKey(c.project.key), state)
94  await update($, core, s => ({ ...s, state }))
95}
96
97/** A chat already under way: read what the folder is about and shortlist the tools that fit it, instead of asking from zero. */
98async function lookHere($: any) {
99  const c = await read($, core)
100  if (!c.project) return
101  const names = (((await $.fs.list(c.project.root).catch(() => [])) as { name: string }[]) ?? []).map(x => x.name).filter(n => !n.startsWith('.'))
102  const notes: string[] = []
103  for (const file of ['package.json', 'README.md', 'pyproject.toml', 'Cargo.toml']) {
104    if (names.includes(file)) notes.push((((await $.fs.read(`${c.project.root}/${file}`).catch(() => '')) as string) ?? '').slice(0, 600))
105  }
106  const text = [c.project.name, ...names.slice(0, 40), ...notes].join(' ')
107  const picks = shortlist(c.index ?? [], text)
108  const named = picks.map(k => (c.index ?? []).find(x => x.key === k)?.name).filter(Boolean).slice(0, 4)
109  await setState($, 'ready')
110  await update($, core, s => ({ ...s, ask: { text: [c.project!.name, ...names.slice(0, 8)].join(' '), picks }, message: t(c.lang, 'msg.read', c.project!.name, named.join(', ') || '-') }))
111  await update($, nav, s => ({ ...s, tab: 'project' }))
112  await $.ui.open({ id: PANE, title: 'Helm', focus: true, closeOnEscape: true })
113}
114
115const disk = ($: any) => ({
116  read: async (p: string) => (await $.fs.read(p)) as string,
117  list: (p: string) => $.fs.list(p),
118  exists: (p: string) => $.fs.exists(p),
119})
120
121// ---- language and limits ----
122
123/** The computer's language: the usual variables first, then what the runtime reports. */
124async function systemLang($: any): Promise<Lang> {
125  const env: Record<string, string | undefined> = {
126    LC_ALL: ((await $.env.get('LC_ALL')) as string | undefined) ?? undefined,
127    LC_MESSAGES: ((await $.env.get('LC_MESSAGES')) as string | undefined) ?? undefined,
128    LANGUAGE: ((await $.env.get('LANGUAGE')) as string | undefined) ?? undefined,
129    LANG: ((await $.env.get('LANG')) as string | undefined) ?? undefined,
130  }
131  let runtime: string | undefined
132  try {
133    runtime = Intl.DateTimeFormat().resolvedOptions().locale
134  } catch {
135    // No Intl: the variables are all there is.
136  }
137  return detectLang(env, runtime)
138}
139
140async function setLang($: any, pref: string) {
141  if (pref !== 'auto' && !isLang(pref)) return
142  await $.store.set('lang-pref', pref)
143  const lang = pref === 'auto' ? await systemLang($) : (pref as Lang)
144  await update($, core, s => ({ ...s, pref: pref as Core['pref'], lang, message: null }))
145}
146
147let lastUsage = 0
148
149/** The same, at most once every few seconds: it is asked after every tool call, every prompt and while the row redraws during a turn. */
150async function refreshUsageSoon($: any) {
151  try {
152    const now = await $.clock.now()
153    if (now - lastUsage < 3000) return
154    lastUsage = now
155    await refreshUsage($)
156  } catch {
157    // The session may be going away; the next look will catch up.
158  }
159}
160
161/** How full the 5-hour limit, the weekly limit and the context are. */
162async function refreshUsage($: any) {
163  const u = await $.session.usage().catch(() => null)
164  if (!u) return
165  const limit = (kind: RegExp): number | null => {
166    const found = (u.rateLimits ?? []).find((x: any) => kind.test(String(x.kind)))
167    return found && Number.isFinite(Number(found.percentUsed)) ? Math.round(Number(found.percentUsed)) : null
168  }
169  const ctx = u.context?.percent ?? u.context?.breakdown?.percentage
170  // A session that has already cost something has a conversation in it.
171  const spoken = Number(u.cost?.usd ?? 0) > 0
172  const usage = { five: limit(/five|5/i), week: limit(/seven|week|7/i), ctx: ctx == null ? null : Math.round(Number(ctx)) }
173  const before = (await read($, core)).usage
174  if (before && before.five === usage.five && before.week === usage.week && before.ctx === usage.ctx) return
175  await update($, core, s => ({ ...s, chat: s.chat || spoken, usage }))
176}
177
178// ---- turning tools on for a project ----
179
180/** The way back for every key turned on here: the first value seen wins, so Undo goes to the original. */
181async function keepBefore($: any, key: string, before: Before) {
182  const old = ((await $.store.get(undoKey(key))) as Before | undefined) ?? {}
183  await $.store.set(undoKey(key), { ...before, ...old })
184}
185
186/** Turns one tool on for this folder, for good or only until the next session. */
187async function accept($: any, key: string, scope: 'project' | 'session') {
188  const c = await read($, core)
189  const entry = (c.index ?? []).find(x => x.key === key)
190  if (!c.project || !entry) return
191  const file = localSettings(c.project.root)
192  const text = (await $.fs.exists(file)) ? ((await $.fs.read(file)) as string) : ''
193  const done = applyPicks(text, [entry])
194  if (!done) return say($, 'msg.badjson')
195  await $.fs.write(file, done.text)
196  await keepBefore($, c.project.key, done.before)
197  if (scope === 'session') {
198    const temp = ((await $.store.get(tempKey(c.project.key))) as string[] | undefined) ?? []
199    await $.store.set(tempKey(c.project.key), [...new Set([...temp, key])])
200  }
201  const where = t(c.lang, scope === 'session' ? 'msg.scopeSession' : 'msg.scopeProject')
202  const live = entry.kind === 'plugin' ? await applyNow($) : true
203  await update($, core, s => ({ ...s, hint: null, message: `${t(c.lang, 'msg.on', entry.name, where)} ${takes(c.lang, live)}` }))
204}
205
206/** "No": the tool is not suggested again in this folder. */
207async function dismiss($: any, key: string) {
208  const c = await read($, core)
209  if (!c.project) return
210  const ignored = ((await $.store.get(ignoreKey(c.project.key))) as string[] | undefined) ?? []
211  await $.store.set(ignoreKey(c.project.key), [...new Set([...ignored, key])])
212  await update($, core, s => ({ ...s, hint: null }))
213}
214
215/** One small-model call over the shortlist's candidates; the size is shown before, the real use after. */
216async function refine($: any) {
217  const c = await read($, core)
218  if (!c.ask) return
219  const index = c.index ?? []
220  const candidates = shortlist(index, c.ask.text, 12).map(k => index.find(x => x.key === k)!).filter(Boolean)
221  if (candidates.length < 2) return
222  await say($, 'msg.refining')
223  const r = await $.model.complete({ model: 'haiku', prompt: refinePrompt(candidates, c.ask.text), maxTokens: 100 }).catch(() => ({ isAnswered: false }))
224  if (!r.isAnswered) return say($, 'msg.nomodel')
225  const picks = parseChoice(r.text, candidates)
226  const used = (r.usage?.input_tokens ?? 0) + (r.usage?.output_tokens ?? 0)
227  await update($, core, s => ({ ...s, ask: s.ask ? { ...s.ask, picks } : s.ask, message: t(c.lang, 'msg.refined', picks.length, candidates.length, used) }))
228}
229
230/** Turns the shortlist on for this folder only, in its own settings.local.json, and keeps the way back. */
231async function turnOnHere($: any) {
232  const c = await read($, core)
233  if (!c.project || !c.ask) return
234  const picks = (c.index ?? []).filter(x => c.ask!.picks.includes(x.key))
235  const file = localSettings(c.project.root)
236  const text = (await $.fs.exists(file)) ? ((await $.fs.read(file)) as string) : ''
237  const done = applyPicks(text, picks)
238  if (!done) return say($, 'msg.badjson')
239  await $.fs.write(file, done.text)
240  await keepBefore($, c.project.key, done.before)
241  await say($, 'msg.turnedOn', Object.keys(done.before).length, c.project.name)
242}
243
244async function undoHere($: any) {
245  const c = await read($, core)
246  if (!c.project) return
247  const before = (await $.store.get(undoKey(c.project.key))) as Before | undefined
248  const file = localSettings(c.project.root)
249  if (!before || !(await $.fs.exists(file))) return say($, 'msg.nothingUndo')
250  const text = undoPicks((await $.fs.read(file)) as string, before)
251  if (text === null) return say($, 'msg.badjson')
252  await $.fs.write(file, text)
253  await $.store.delete(undoKey(c.project.key))
254  await say($, 'msg.undone')
255}
256
257
258/** Reads what is installed again, so every list and bar shows the change at once. */
259async function refreshIndex($: any) {
260  const c = await read($, core)
261  const settings = ((await $.settings.read().catch(() => ({}))) ?? {}) as Record<string, unknown>
262  const index = await loadIndex(disk($), c.dir, settings).catch(() => null)
263  if (index) await update($, core, s => ({ ...s, index }))
264}
265
266/** Plugin changes are loaded by Claude Code only when asked: ask it to now (`/reload-plugins`), then redraw from what is installed. Says whether it took effect. */
267async function applyNow($: any): Promise<boolean> {
268  let ok = false
269  try {
270    // Only said to be active when the command exists in this build and answered without an error.
271    const offered = ((await $.command.list().catch(() => [])) as { name?: string }[]).some(x => String(x.name ?? '').replace(/^\//, '') === 'reload-plugins')
272    if (offered) {
273      const r = await $.command.run({ command: 'reload-plugins', args: '' })
274      ok = !/unknown|not found|error|failed/i.test(String(r?.text ?? ''))
275    }
276  } catch {
277    // This build does not offer it from a mod: the change waits for the next chat, and Helm says so.
278  }
279  await refreshIndex($)
280  return ok
281}
282
283/** The sentence that closes a change: active now, or from the next chat. */
284const takes = (lang: Lang, now: boolean) => t(lang, now ? 'msg.now' : 'msg.later')
285
286// ---- the health of the global setup ----
287
288async function runCheckup($: any) {
289  const c = await read($, core)
290  await work($, 'msg.checking')
291  const found = await checkup(disk($), c.dir).catch(() => [])
292  const report = [...found, ...review(c.index ?? [], c.uses, c.tracked)]
293  await update($, core, s => ({ ...s, report, confirm: null, busy: false, message: report.length ? t(c.lang, 'g.todo', report.length) : t(c.lang, 'msg.healthy') }))
294}
295
296/** Updates every plugin (trying each scope it may sit in) and every skill folder that is a git checkout, and says what happened to each. */
297async function runUpdate($: any) {
298  const c = await read($, core)
299  const index = c.index ?? []
300  const plugins = index.filter(x => x.kind === 'plugin')
301  const rows: Core['updates'] extends infer U ? (U extends { rows: infer R } ? R : never) : never = [] as any
302  let done = 0
303  for (const e of plugins) {
304    await work($, 'msg.updating', `${done += 1}/${plugins.length}`)
305    const id = e.key.slice(7)
306    let result: { state: 'current' | 'updated' | 'failed'; note?: string } = { state: 'failed' }
307    for (const scope of ['user', 'project', 'local']) {
308      const r = await $.process.run(['claude', 'plugin', 'update', id, '--scope', scope], { timeoutMs: 120_000 }).catch(() => ({ exitCode: -1, stdout: '', stderr: '' }))
309      result = readUpdate(r.exitCode, `${r.stdout ?? ''}\n${r.stderr ?? ''}`)
310      if (result.state !== 'failed') break
311    }
312    rows.push({ id, name: e.name, ...result })
313  }
314  let unsourced = 0
315  for (const e of index.filter(x => x.kind === 'skill')) {
316    const dir = `${c.dir}/skills/${e.key.slice(6)}`
317    if (!(await $.fs.exists(`${dir}/.git`))) {
318      unsourced += 1
319      continue
320    }
321    const r = await $.process.run(['git', '-C', dir, 'pull', '--ff-only'], { timeoutMs: 60_000 }).catch(() => ({ exitCode: -1, stdout: '', stderr: '' }))
322    const text = `${r.stdout ?? ''}\n${r.stderr ?? ''}`
323    rows.push({ id: e.key, name: e.name, ...(r.exitCode !== 0 ? { state: 'failed' as const, note: text.trim().split('\n').slice(-1)[0]?.slice(0, 140) } : /already up to date/i.test(text) ? { state: 'current' as const } : { state: 'updated' as const }) })
324  }
325  if (c.scanner.state === 'ready') await updateScanner($, true)
326  const updates = { at: await $.clock.now(), rows, unsourced }
327  await $.store.set('updates', updates)
328  const failed = rows.filter(x => x.state === 'failed').length
329  const changed = rows.some(x => x.state === 'updated')
330  const live = changed ? await applyNow($) : true
331  await update($, core, s => ({ ...s, updates, busy: false, failed: failed > 0, message: `${t(c.lang, 'msg.updated', rows.length - failed, rows.length)}${changed ? ` ${takes(c.lang, live)}` : ''}` }))
332}
333
334/**
335 * Takes a plugin out. The command only knows the scope a plugin was installed in, and a plugin listed
336 * under another scope fails with the default one, so each scope is tried; as a last resort the entry
337 * is removed from the registry by hand, after a copy of the file is kept next to it.
338 */
339async function removePlugin($: any, dir: string, id: string): Promise<{ ok: boolean; why: string }> {
340  let why = ''
341  for (const scope of ['user', 'project', 'local']) {
342    const r = await $.process.run(['claude', 'plugin', 'uninstall', '--scope', scope, id], { timeoutMs: 60_000 }).catch(() => ({ exitCode: -1, stderr: '' }))
343    if (r.exitCode === 0) return { ok: true, why: '' }
344    why = String(r.stderr || r.stdout || why)
345  }
346  const file = `${dir}/plugins/installed_plugins.json`
347  try {
348    const text = (await $.fs.read(file)) as string
349    const registry = JSON.parse(text)
350    if (!registry.plugins || !(id in registry.plugins)) return { ok: false, why }
351    await $.fs.write(`${file}.helm-backup`, text)
352    delete registry.plugins[id]
353    await $.fs.write(file, JSON.stringify(registry, null, 2))
354    return { ok: true, why: '' }
355  } catch {
356    return { ok: false, why }
357  }
358}
359
360/** The only fix Helm runs: it needs the second press on the same issue. */
361async function fixIssue($: any, key: string) {
362  const c = await read($, core)
363  const issue = c.report?.find(i => i.key === key)
364  if (!issue?.fix) return
365  if (c.confirm !== key) return void (await update($, core, s => ({ ...s, confirm: key })))
366  await work($, 'msg.removing', issue.a)
367  const done = issue.kind === 'stale-plugin' ? await removePlugin($, c.dir, issue.a) : await $.process.run(issue.fix, { timeoutMs: 60_000 }).then((r: any) => ({ ok: r.exitCode === 0, why: String(r.stderr ?? '') })).catch(() => ({ ok: false, why: '' }))
368  if (!done.ok) return fail($, 'msg.cannotFix', done.why, issue.a)
369  // The index drops the plugin too, so the lists match what is installed, and Claude Code is asked to let go of it now.
370  await update($, core, s => ({ ...s, index: (s.index ?? []).filter(x => x.key !== key) }))
371  await applyNow($)
372  await runCheckup($)
373}
374
375// ---- finding and installing new tools ----
376
377const gh = ($: any, args: string[]) => $.process.run(['gh', ...args], { timeoutMs: 30_000 }).catch(() => ({ exitCode: -1, stdout: '', stderr: '' }))
378const RAW = ['-H', 'Accept: application/vnd.github.raw']
379
380const rawFile = async ($: any, repo: string, path: string): Promise<string> => {
381  const r = await gh($, ['api', ...RAW, `repos/${repo}/contents/${path}`])
382  return r.exitCode === 0 ? String(r.stdout ?? '') : ''
383}
384
385/** Whether an npm package of that name is published from this repository (and not squatting the name). */
386async function publishedFrom($: any, pkg: string, repo: string): Promise<boolean> {
387  const r = await $.process.run(['npm', 'view', pkg, 'repository.url', '--json'], { timeoutMs: 20_000 }).catch(() => null)
388  return !!r && r.exitCode === 0 && String(r.stdout ?? '').toLowerCase().includes(repo.toLowerCase())
389}
390
391/** A program behind the repository: an MCP server (from its registry manifest or package) or a command-line tool, with the command that installs it. */
392async function findApp($: any, repo: string, paths: string[]): Promise<Meta['app']> {
393  const name = (repo.split('/')[1] ?? '').toLowerCase().replace(/[^a-z0-9-]/g, '-')
394  const mcp = (tail: string[]): Meta['app'] => ({ type: 'mcp', argv: ['claude', 'mcp', 'add', '--scope', '{scope}', name, ...tail] })
395  if (paths.includes('server.json')) {
396    // The MCP registry's own manifest says how the server runs.
397    try {
398      const m = JSON.parse(await rawFile($, repo, 'server.json'))
399      const pkg = (m.packages ?? []).find((p: any) => p.registryType === 'npm' || p.registryType === 'pypi')
400      // Arguments the manifest fixes for the server (the `mcp` in `rea mcp`) come after the package.
401      const args = ((pkg?.packageArguments ?? []) as any[]).filter(a => a.type === 'positional' && typeof a.value === 'string').map(a => String(a.value))
402      if (pkg?.registryType === 'npm') return mcp(['--', 'npx', '-y', pkg.version ? `${pkg.identifier}@${pkg.version}` : String(pkg.identifier), ...args])
403      if (pkg?.registryType === 'pypi') return mcp(['--', 'uvx', String(pkg.identifier), ...args])
404      const remote = (m.remotes ?? [])[0]
405      if (remote?.url) return { type: 'mcp', argv: ['claude', 'mcp', 'add', '--scope', '{scope}', '--transport', remote.type === 'sse' ? 'sse' : 'http', name, String(remote.url)] }
406    } catch {
407      // Not readable: look at the other manifests.
408    }
409  }
410  if (paths.includes('package.json')) {
411    try {
412      const pkg = JSON.parse(await rawFile($, repo, 'package.json'))
413      if (pkg.bin && typeof pkg.name === 'string') {
414        const spec = (await publishedFrom($, pkg.name, repo)) ? pkg.name : `github:${repo}`
415        const isMcp = /\bmcp\b/i.test(`${pkg.name} ${pkg.description ?? ''} ${(pkg.keywords ?? []).join(' ')}`)
416        return isMcp ? mcp(['--', 'npx', '-y', spec]) : { type: 'cli', argv: ['npm', 'install', '-g', spec] }
417      }
418    } catch {
419      // Not readable: look at the other manifests.
420    }
421  }
422  if (paths.includes('pyproject.toml') && /^\[(project|tool\.poetry)\.scripts\]/m.test(await rawFile($, repo, 'pyproject.toml'))) {
423    return { type: 'cli', argv: ['uv', 'tool', 'install', `git+https://github.com/${repo}`] }
424  }
425  if (paths.includes('Cargo.toml') && paths.includes('src/main.rs')) return { type: 'cli', argv: ['cargo', 'install', '--git', `https://github.com/${repo}.git`] }
426  if (paths.includes('go.mod') && paths.includes('main.go')) {
427    const mod = (await rawFile($, repo, 'go.mod')).match(/^module\s+(\S+)/m)?.[1]
428    if (mod) return { type: 'cli', argv: ['go', 'install', `${mod}@latest`] }
429  }
430  return null
431}
432
433/** Reads a repo the way a careful person would: its page, then whether it holds a plugin catalog or one skill. */
434async function inspect($: any, repo: string, quiet = false): Promise<Found | null> {
435  const page = await gh($, ['api', `repos/${repo}`])
436  if (page.exitCode !== 0) return null
437  const r = JSON.parse(page.stdout)
438  const cat = await gh($, ['api', ...RAW, `repos/${repo}/contents/.claude-plugin/marketplace.json`])
439  let marketplace: Meta['marketplace'] = null
440  if (cat.exitCode === 0) {
441    try {
442      const m = JSON.parse(cat.stdout)
443      const all: string[] = (m.plugins ?? []).map((p: any) => String(p.name))
444      const own = repo.split('/')[1]
445      // One plugin, or the one named like the repo: never a whole catalog at once.
446      const pick = all.length === 1 ? all : all.filter(n => n === own).slice(0, 1)
447      if (pick.length > 0) marketplace = { name: String(m.name), plugins: pick }
448    } catch {
449      // A catalog that is not JSON counts as no catalog.
450    }
451  }
452  const skill = marketplace ? { exitCode: 1 } : await gh($, ['api', ...RAW, `repos/${repo}/contents/SKILL.md`])
453  // Neither a catalog nor one skill at the top: look at the whole tree for skills in subfolders or a plugin manifest.
454  let wrap: Meta['wrap'] = null
455  let app: Meta['app'] = null
456  if (!marketplace && skill.exitCode !== 0) {
457    const tree = await gh($, ['api', `repos/${repo}/git/trees/HEAD?recursive=1`])
458    try {
459      const paths: string[] = JSON.parse(tree.stdout).tree.filter((x: any) => x.type === 'blob').map((x: any) => String(x.path))
460      const plugin = paths.includes('.claude-plugin/plugin.json')
461      const skills = paths
462        .filter(x => /(^|\/)SKILL\.md$/.test(x) && !/(^|\/)(node_modules|tests?|examples?|docs?|templates?|fixtures?|\.github)\//i.test(x))
463        .map(x => x.replace(/\/?SKILL\.md$/, ''))
464        .filter(x => x !== '' && x.split('/').length <= 4)
465        .slice(0, 60)
466      if (plugin || skills.length > 0) wrap = { plugin, skills: plugin ? [] : skills }
467      app = await findApp($, repo, paths)
468    } catch {
469      // No readable tree: the repository stays "nothing to install".
470    }
471  }
472  const meta: Meta = {
473    repo: r.full_name,
474    description: String(r.description ?? ''),
475    license: r.license?.spdx_id ?? null,
476    archived: r.archived === true,
477    pushedAt: String(r.pushed_at ?? ''),
478    stars: Number(r.stargazers_count ?? 0),
479    marketplace,
480    isSkill: skill.exitCode === 0,
481    wrap,
482    app,
483  }
484  let verdict = judge(meta, await $.clock.now())
485  const scanner = (await read($, core)).scanner.state
486  const target = repoTarget(meta.repo)
487  let scan: ScanResult | null = null
488  let missed: 'size' | 'slow' | undefined
489  if (verdict.level !== 'no' && target && scanner === 'ready') {
490    if (!quiet) await say($, 'msg.scanning')
491    // Big repositories take minutes: a patient limit, and a reason when there is still no report.
492    const got = await scanDetailed($, target, quiet ? 150_000 : 120_000)
493    scan = got.scan
494    missed = got.why
495  }
496  if (verdict.level !== 'no') verdict = foldVerdict(verdict, scan, scanner, missed)
497  return { meta, verdict, scan }
498}
499
500/** One name or link, looked at on its own: a verdict card for a repository, a short list of hits for a name. */
501async function lookAt($: any, item: Item) {
502  const c = await read($, core)
503  if (item.kind === 'other') return say($, 'batch.unsupported')
504  if (item.kind === 'repo') {
505    const found = await inspect($, item.repo)
506    return void (await update($, core, s => ({ ...s, found, message: found ? null : t(c.lang, 'msg.noRead') })))
507  }
508  const r = await gh($, ['search', 'repos', item.query, '--limit', '5', '--json', 'fullName,description,stargazersCount'])
509  let hits: Hit[] = []
510  try {
511    hits = JSON.parse(r.stdout).map((x: any) => ({ repo: x.fullName, description: String(x.description ?? ''), stars: Number(x.stargazersCount ?? 0) }))
512  } catch {
513    // No usable answer: no hits.
514  }
515  await update($, core, s => ({ ...s, hits, message: hits.length ? null : t(c.lang, 'msg.nothing') }))
516}
517
518/** What was typed or pasted: a link, a name, or a whole text with many of them. A text with none in it is read by the small model. */
519async function research($: any, input: string) {
520  const c = await read($, core)
521  await update($, core, s => ({ ...s, found: null, hits: null, batch: null, message: t(c.lang, 'msg.looking') }))
522  let items = parseItems(input)
523  if (items.length === 0 && input.trim().split(/\s+/).length >= 4) {
524    // Plain prose: ask the small model which tools the text means, once, and read its answer the same way.
525    await say($, 'msg.refining')
526    const r = await $.model
527      .complete({ model: 'haiku', prompt: `List the GitHub repositories (owner/name) or tool names this person wants, one per line, nothing else:\n\n${input.slice(0, 4000)}`, maxTokens: 200 })
528      .catch(() => ({ isAnswered: false }))
529    if (r.isAnswered) items = parseItems(String(r.text ?? ''))
530  }
531  if (items.length === 0) return say($, 'msg.type')
532  if (items.length === 1) return lookAt($, items[0])
533  return runBatch($, items)
534}
535
536const setRow = ($: any, i: number, patch: Partial<BatchRow>) => update($, core, s => ({ ...s, batch: s.batch ? s.batch.map((row, j) => (j === i ? { ...row, ...patch } : row)) : s.batch }))
537
538/** Checks every item of a pasted list, three at a time: a repository on its own, a name by searching and taking the first hit that fits. Nothing is installed here. */
539async function runBatch($: any, items: Item[]) {
540  const c = await read($, core)
541  const rows: BatchRow[] = items.map(it => ({ label: it.kind === 'repo' ? it.repo : it.kind === 'search' ? it.query : it.url, state: it.kind === 'other' ? 'unsupported' : 'wait', found: null, pick: false }))
542  await update($, core, s => ({ ...s, batch: rows, busy: true, message: t(c.lang, 'msg.batchLooking', 0, rows.length) }))
543  let next = 0
544  let done = 0
545  const worker = async () => {
546    while (next < items.length) {
547      const i = next++
548      const it = items[i]
549      if (it.kind === 'other') {
550        done += 1
551        continue
552      }
553      // Something already installed under this name is not looked up, scanned or offered again.
554      const have = installedAs(c.index ?? [], it.kind === 'repo' ? it.repo.split('/')[1] : it.query)
555      if (have) {
556        await setRow($, i, { state: 'have', via: have.name })
557        done += 1
558        continue
559      }
560      await setRow($, i, { state: 'check' })
561      let found: Found | null = null
562      let via: string | undefined
563      if (it.kind === 'repo') found = await inspect($, it.repo, true)
564      else {
565        const r = await gh($, ['search', 'repos', it.query, '--limit', '3', '--json', 'fullName,stargazersCount'])
566        let hits: { fullName: string }[] = []
567        try {
568          hits = JSON.parse(r.stdout)
569        } catch {
570          // No usable answer: no hits.
571        }
572        for (const h of hits) {
573          const f = await inspect($, h.fullName, true)
574          if (f && (!found || (found.verdict.level === 'no' && f.verdict.level !== 'no'))) {
575            found = f
576            via = h.fullName
577          }
578          if (found && found.verdict.level !== 'no') break
579        }
580      }
581      await setRow($, i, found ? { state: 'done', found, via, pick: found.verdict.level === 'ok' } : { state: 'missing' })
582      done += 1
583      await update($, core, s => ({ ...s, message: done < items.length ? t(c.lang, 'msg.batchLooking', done, items.length) : s.message }))
584    }
585  }
586  await Promise.all(Array.from({ length: Math.min(3, items.length) }, worker))
587  const after = (await read($, core)).batch ?? []
588  const ready = after.filter(x => x.state === 'done' && x.found && x.found.verdict.level !== 'no').length
589  await update($, core, s => ({ ...s, busy: false, message: t(c.lang, 'msg.batchChecked', after.length, ready) }))
590}
591
592/** The one-plugin catalog a wrapped repository installs through. */
593async function writeWrapper($: any, meta: Meta, dir: string) {
594  const w = wrapperFile(meta, `${dir}/helm-markets`)
595  if (w) await $.fs.write(w.path, w.text)
596}
597
598/** Installs the ticked rows, one after the other, after the person's yes. A failure stops that row only. */
599async function installBatch($: any, scope: 'user' | 'local') {
600  const c = await read($, core)
601  const rows = c.batch ?? []
602  const chosen = rows.map((row, i) => ({ row, i })).filter(({ row }) => row.pick && row.state === 'done' && row.found && row.found.verdict.level !== 'no')
603  if (chosen.length === 0) return
604  let ok = 0
605  let candidates = c.candidates
606  const handled = new Set<string>()
607  for (const [n, { row, i }] of chosen.entries()) {
608    // Two lines of a list that meant the same repository are installed once.
609    if (handled.has(row.found!.meta.repo)) {
610      await setRow($, i, { state: 'installed', pick: false })
611      continue
612    }
613    handled.add(row.found!.meta.repo)
614    await update($, core, s => ({ ...s, busy: true, message: t(c.lang, 'msg.batchLooking', n, chosen.length) }))
615    await setRow($, i, { state: 'check' })
616    const meta = row.found!.meta
617    const plan = installPlan(meta, scope, `${c.dir}/skills`, `${c.dir}/helm-markets`)
618    await writeWrapper($, meta, c.dir)
619    if (!plan) {
620      await setRow($, i, { state: 'failed', why: t(c.lang, 'msg.unknownForm') })
621      continue
622    }
623    let why = ''
624    for (const argv of plan) {
625      const r = await $.process.run(argv, { timeoutMs: 180_000, ...(scope === 'local' && c.project ? { cwd: c.project.root } : {}) }).catch(() => ({ exitCode: -1, stderr: '' }))
626      if (r.exitCode !== 0) {
627        why = `${argv.slice(0, 4).join(' ')} ${String(r.stderr ?? '').trim().split('\n')[0].slice(0, 120)}`.trim()
628        break
629      }
630    }
631    if (why) await setRow($, i, { state: 'failed', why })
632    else {
633      ok += 1
634      candidates = scope === 'local' ? [...new Set([...candidates, meta.repo])] : candidates.filter(x => x !== meta.repo)
635      await setRow($, i, { state: 'installed', pick: false })
636    }
637  }
638  await $.store.set('candidates', candidates)
639  const live = ok > 0 ? await applyNow($) : false
640  await update($, core, s => ({ ...s, busy: false, candidates, message: `${t(c.lang, 'msg.batchDone', ok, chosen.length)}${ok > 0 ? ` ${takes(c.lang, live)}` : ''}` }))
641}
642
643/** Installs after the person's yes. In a project the plugin is installed for that folder only, and remembered as a global candidate. */
644async function install($: any, scope: 'user' | 'local', force = false) {
645  const c = await read($, core)
646  if (!c.found || (c.found.verdict.level === 'no' && !force)) return
647  const plan = installPlan(c.found.meta, scope, `${c.dir}/skills`, `${c.dir}/helm-markets`)
648  if (!plan) return say($, 'msg.unknownForm')
649  await writeWrapper($, c.found.meta, c.dir)
650  await say($, 'msg.installing')
651  for (const argv of plan) {
652    const r = await $.process.run(argv, { timeoutMs: 180_000, ...(scope === 'local' && c.project ? { cwd: c.project.root } : {}) }).catch(() => ({ exitCode: -1 }))
653    if (r.exitCode !== 0) return say($, 'msg.stopped', argv.slice(0, 4).join(' '))
654  }
655  const repo = c.found.meta.repo
656  const candidates = scope === 'local' ? [...new Set([...c.candidates, repo])] : c.candidates.filter(x => x !== repo)
657  await $.store.set('candidates', candidates)
658  const live = await applyNow($)
659  await update($, core, s => ({ ...s, candidates, found: null, message: `${t(c.lang, scope === 'local' ? 'msg.installedHere' : 'msg.installed', repo)} ${takes(c.lang, live)}` }))
660}
661
662async function pickHit($: any, repo: string) {
663  const c = await read($, core)
664  const found = await inspect($, repo)
665  await update($, core, s => ({ ...s, hits: null, found, message: found ? null : t(c.lang, 'msg.noRead') }))
666}
667
668async function globalInstall($: any, repo: string) {
669  const found = await inspect($, repo)
670  if (!found) return say($, 'msg.noRead')
671  await update($, core, s => ({ ...s, found }))
672  await install($, 'user')
673}
674
675/** Changes the GitHub baseline and keeps it for every project. */
676async function setSetup($: any, change: (s: Setup) => Setup) {
677  const c = await read($, core)
678  const setup = change(c.setup)
679  await $.store.set('github-setup', setup)
680  await update($, core, s => ({ ...s, setup }))
681}
682
683const toggleItem = (id: string) => (s: Setup): Setup => ({ ...s, items: s.items.includes(id) ? s.items.filter(x => x !== id) : [...s.items, id] })
684
685
686// ---- the security scanner: SkillSpector, by NVIDIA ----
687
688const WEEK = 7 * 86_400_000
689
690/** Whether SkillSpector answers, and which version. */
691async function probeScanner($: any) {
692  const r = await $.process.run(versionCmd(), { timeoutMs: 20_000 }).catch(() => null)
693  const version = r && r.exitCode === 0 ? parseVersion(String(r.stdout ?? '')) : null
694  await update($, core, s => ({ ...s, scanner: version ? { state: 'ready', version } : { state: 'missing', version: null } }))
695}
696
697/** One scan: the report comes on stdout, and exit 1 only means "do not install". `why` says what went wrong when there is no report. */
698async function scanDetailed($: any, target: string, timeoutMs = 45_000): Promise<{ scan: ScanResult | null; why?: 'size' | 'slow' }> {
699  const r = await $.process.run(scanCmd(target), { timeoutMs }).catch(() => null)
700  if (!r) return { scan: null, why: 'slow' }
701  const scan = parseScan(String(r.stdout ?? ''))
702  if (scan) return { scan }
703  return { scan: null, why: /byte_limit|truncated/i.test(String(r.stderr ?? '')) ? 'size' : undefined }
704}
705const scanOne = async ($: any, target: string, timeoutMs = 45_000): Promise<ScanResult | null> => (await scanDetailed($, target, timeoutMs)).scan
706
707async function installScanner($: any) {
708  await work($, 'msg.installing')
709  const r = await $.process.run(installCmd(), { timeoutMs: 600_000 }).catch(() => null)
710  if (!r || r.exitCode !== 0) return say($, 'msg.noUv')
711  await probeScanner($)
712  await say($, 'msg.scannerInstalled')
713}
714
715/** Keeps the scanner current: by hand with Update all, and on its own once a week. */
716async function updateScanner($: any, quiet = false) {
717  const r = await $.process.run(upgradeCmd(), { timeoutMs: 600_000 }).catch(() => null)
718  if (r && r.exitCode === 0) {
719    await probeScanner($)
720    if (!quiet) await say($, 'msg.scannerUpdated')
721  }
722}
723
724/** What identifies the state of a folder: its top-level names, sizes and times. A plugin's path already holds its version. */
725async function fingerprint($: any, path: string): Promise<string> {
726  const entries = await $.fs.list(path).catch(() => [])
727  return [path, ...entries.map((e: any) => `${e.name}:${e.size}:${e.mtimeMs}`)].join('|')
728}
729
730/** Scans every installed plugin and own skill, four at a time, and keeps the results. A folder that did not change since its last scan is not scanned again, and one that takes too long is skipped. */
731async function runScan($: any) {
732  const c = await read($, core)
733  if (c.scanner.state !== 'ready') return
734  const targets = await scanTargets(disk($), c.dir, c.index ?? [])
735  const known = ((await $.store.get('scan-fps')) as Record<string, string> | undefined) ?? {}
736  const fps: Record<string, string> = {}
737  const scans: Record<string, ScanResult> = {}
738  let next = 0
739  let done = 0
740  let skipped = 0
741  await update($, core, s => ({ ...s, busy: true, scanning: { done: 0, total: targets.length }, message: null }))
742  const worker = async () => {
743    while (next < targets.length) {
744      const target = targets[next++]
745      const fp = await fingerprint($, target.path)
746      const before = c.scans[target.key]
747      if (before && known[target.key] === fp) {
748        scans[target.key] = before
749        fps[target.key] = fp
750      } else {
751        const result = await scanOne($, target.path)
752        if (result) {
753          scans[target.key] = result
754          fps[target.key] = fp
755        } else skipped += 1
756      }
757      done += 1
758      // Each result counts at once: the dot above the prompt turns as soon as there is something to say, and a run that is cut short keeps what it found.
759      const mine = scans[target.key]
760      await update($, core, s => ({ ...s, scans: mine ? { ...s.scans, [target.key]: mine } : s.scans, scanning: { done, total: targets.length } }))
761      if (done % 8 === 0) {
762        await $.store.set('scans', scans)
763        await $.store.set('scan-fps', fps)
764      }
765    }
766  }
767  await Promise.all([worker(), worker(), worker(), worker()])
768  // What timed out gets a second, patient try on its own: "all scanned" must mean all.
769  const missed = targets.filter(x => !scans[x.key])
770  for (const [k, target] of missed.entries()) {
771    await update($, core, s => ({ ...s, scanning: { done: targets.length - missed.length + k, total: targets.length } }))
772    const result = await scanOne($, target.path, 240_000)
773    if (result) {
774      scans[target.key] = result
775      fps[target.key] = await fingerprint($, target.path)
776      skipped -= 1
777      await update($, core, s => ({ ...s, scans: { ...s.scans, [target.key]: result } }))
778    }
779  }
780  const stillMissed = targets.filter(x => !scans[x.key]).map(x => (c.index ?? []).find(e => e.key === x.key)?.name ?? x.key)
781  const bad = Object.values(scans).filter(s => s.recommendation !== 'SAFE' && s.flagged > 0).length
782  await $.store.set('scans', scans)
783  await $.store.set('scan-fps', fps)
784  await update($, core, s => ({
785    ...s,
786    scans,
787    scanning: null,
788    skipped: stillMissed,
789    busy: false,
790    message: t(c.lang, 'msg.scanDone', targets.length, bad) + (stillMissed.length > 0 ? ` ${t(c.lang, 'msg.skipped', stillMissed.length)}` : ''),
791  }))
792}
793
794/** Switches a tool off or on at the user level: a plugin through the CLI, a skill through skillOverrides. Reversible. */
795async function toggleTool($: any, key: string) {
796  const c = await read($, core)
797  const entry = (c.index ?? []).find(x => x.key === key)
798  if (!entry) return
799  const off = entry.on
800  if (entry.kind === 'plugin') {
801    const r = await $.process.run(['claude', 'plugin', off ? 'disable' : 'enable', key.slice(7)], { timeoutMs: 60_000 }).catch(() => null)
802    if (!r || r.exitCode !== 0) return fail($, 'msg.cannotFix', String(r?.stderr ?? ''), entry.name)
803  } else {
804    const file = `${c.dir}/settings.json`
805    const text = (await $.fs.exists(file)) ? ((await $.fs.read(file)) as string) : ''
806    const next = setSkillOff(text, key.slice(6), off)
807    if (next === null) return fail($, 'msg.badjson', '')
808    await $.fs.write(file, next)
809  }
810  const live = entry.kind === 'plugin' ? await applyNow($) : true
811  await update($, core, s => ({ ...s, index: (s.index ?? []).map(x => (x.key === key ? { ...x, on: !off } : x)), message: `${t(c.lang, off ? 'msg.switchedOff' : 'msg.switchedOn', entry.name)} ${takes(c.lang, live)}` }))
812}
813
814/** "Install anyway": only when the scanner alone said no, and only on a second press. */
815async function installAnyway($: any, scope: 'user' | 'local') {
816  const c = await read($, core)
817  if (!c.found || !isOverridable(c.found)) return
818  if (c.anyway !== c.found.meta.repo) return void (await update($, core, s => ({ ...s, anyway: c.found!.meta.repo })))
819  await update($, core, s => ({ ...s, anyway: null }))
820  await install($, scope, true)
821}
822
823export const register: Register = on => {
824  on('session.start', async ($, e, next) => {
825    const started = await next(e)
826    const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
827    const configDir = (((await $.env.get('CLAUDE_CONFIG_DIR')) ?? '') || `${home}/.claude`).replace(/\\/g, '/').replace(/\/+$/, '')
828    const cwd = (((await $.session.cwd().catch(() => '')) ?? '') as string).replace(/\\/g, '/').replace(/\/+$/, '')
829
830    await $.command.register({ name: 'helm', description: 'Open Helm: the control panel for this project' })
831
832    const project = isProject(cwd, configDir, home) ? { root: cwd, name: projectName(cwd), key: projectKey(cwd) } : null
833    const saved = project ? ((await $.store.get(stateKey(project.key))) as ProjectState | undefined) : undefined
834    // Tools turned on "for this session" last time go back first, so the index below is read as they now stand.
835    if (project) {
836      const temp = ((await $.store.get(tempKey(project.key))) as string[] | undefined) ?? []
837      const before = ((await $.store.get(undoKey(project.key))) as Before | undefined) ?? {}
838      const file = localSettings(project.root)
839      if (temp.length > 0 && (await $.fs.exists(file))) {
840        const back = undoPicks((await $.fs.read(file)) as string, Object.fromEntries(temp.filter(k => k in before).map(k => [k, before[k]])))
841        if (back !== null) await $.fs.write(file, back)
842        await $.store.delete(tempKey(project.key))
843      }
844    }
845    const settings = ((await $.settings.read().catch(() => ({}))) ?? {}) as Record<string, unknown>
846    const index = await loadIndex(
847      { read: async p => (await $.fs.read(p)) as string, list: p => $.fs.list(p), exists: p => $.fs.exists(p) },
848      configDir,
849      settings,
850    ).catch(() => [])
851
852    const setup = { ...DEFAULT_SETUP, ...(((await $.store.get('github-setup')) as object | undefined) ?? {}) }
853    const candidates = ((await $.store.get('candidates')) as string[] | undefined) ?? []
854    const uses = ((await $.store.get('uses')) as Core['uses'] | undefined) ?? {}
855    const puses = project ? (((await $.store.get(pusesKey(project.key))) as Core['puses'] | undefined) ?? {}) : {}
856    const saidPref = await $.store.get('lang-pref')
857    const pref = (saidPref === 'auto' || isLang(saidPref) ? saidPref : 'auto') as Core['pref']
858    const lang = pref === 'auto' ? await systemLang($) : (pref as Lang)
859    const scans = ((await $.store.get('scans')) as Core['scans'] | undefined) ?? {}
860    // How long usage has been recorded: the clock starts the first time Helm runs, so "never used" is judged only after a fair time.
861    const startedAt = Number((await $.store.get('tracking-since')) ?? 0) || (await $.clock.now())
862    await $.store.set('tracking-since', startedAt)
863    const tracked = Math.floor(((await $.clock.now()) - startedAt) / 86_400_000)
864    const updates = ((await $.store.get('updates')) as Core['updates'] | undefined) ?? null
865    await update($, core, () => ({ scanner: { state: 'unknown', version: null } as Core['scanner'], scans, scanning: null, anyway: null, lang, pref, usage: null, uses, puses, hint: null, used: {}, tick: 0, found: null, hits: null, batch: null, setup, candidates, dir: configDir, message: null, report: null, tracked, updates, skipped: [], confirm: null, index, project, state: project ? (saved ?? 'new') : null, chat: false, busy: false, failed: false, ask: null }))
866    await refreshUsage($)
867    void runCheckup($).catch(() => {})
868    await probeScanner($)
869    // Once a day, in the background, scan what is new or changed since the last scan: nothing to press.
870    const lastScan = Number((await $.store.get('auto-scan')) ?? 0)
871    const nowAt = await $.clock.now()
872    const after = await read($, core)
873    if (after.scanner.state === 'ready' && nowAt - lastScan > 86_400_000 && (after.index ?? []).some(x => !after.scans[x.key])) {
874      await $.store.set('auto-scan', nowAt)
875      void runScan($).catch(() => {})
876    }
877    const checked = Number((await $.store.get('scanner-checked')) ?? 0)
878    const now = await $.clock.now()
879    if ((await read($, core)).scanner.state === 'ready' && now - checked > WEEK) {
880      await $.store.set('scanner-checked', now)
881      void updateScanner($, true)
882    }
883    return started
884  })
885
886  // A Skill call lights its dot and is counted. A terminal has no animation of its own, so it redraws while the glow fades.
887  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
888    const name = String((e as any).skill ?? '')
889    const c = await read($, core)
890    const list = c.index ?? []
891    const entry = list.find(x => x.key === `skill:${name}`) ?? list.find(x => x.kind === 'plugin' && x.key.startsWith(`plugin:${name.split(':')[0]}@`))
892    if (entry) {
893      const at = await $.clock.now()
894      const uses = { ...c.uses, [entry.key]: { n: (c.uses[entry.key]?.n ?? 0) + 1, last: at } }
895      await $.store.set('uses', uses)
896      // And in this project alone, so the project page shows what was used here, not everywhere.
897      const puses = c.project ? { ...c.puses, [entry.key]: { n: (c.puses[entry.key]?.n ?? 0) + 1, last: at } } : c.puses
898      if (c.project) await $.store.set(pusesKey(c.project.key), puses)
899      await update($, core, s => ({ ...s, uses, puses, used: { ...s.used, [entry.key]: at } }))
900      if (isTerminal && !fading) {
901        fading = true
902        void (async () => {
903          try {
904            while ((await $.clock.now()) - at < GLOW_MS + 500) {
905              await $.clock.sleep(500)
906              await update($, core, s => ({ ...s, tick: s.tick + 1 }))
907            }
908          } catch {
909            // The session went away while the glow was fading.
910          }
911          fading = false
912        })()
913      }
914    }
915    return next(e)
916  })
917
918  // The limits move with every answer of the model, not only when a turn ends: look again after each tool call.
919  on('tool.call', async ($, e, next) => {
920    const result = await next(e)
921    void refreshUsageSoon($)
922    return result
923  })
924
925  on('turn.complete', async ($, e, next) => {
926    await refreshUsage($)
927    return next(e)
928  })
929
930  // A tool that fits what was just typed is offered, and the GitHub brief rides on the first prompt of a project, once.
931  on('prompt.submit', async ($, e, next) => {
932    void refreshUsageSoon($)
933    const c = await read($, core)
934    if (!c.chat) await update($, core, s => ({ ...s, chat: true }))
935    if (c.project && c.state !== 'declined') {
936      const ignored = ((await $.store.get(ignoreKey(c.project.key))) as string[] | undefined) ?? []
937      const hint = hintFor(c.index ?? [], e.text, ignored)
938      if (hint !== c.hint) await update($, core, s => ({ ...s, hint }))
939    }
940    if (!c.project || !c.setup.on || !hasGithub(await $.tool.list().catch(() => []))) return next(e)
941    const sentKey = `gh-sent:${c.project.key}`
942    if (await $.store.get(sentKey)) return next(e)
943    const text = brief(c.setup)
944    if (text === '') return next(e)
945    await $.store.set(sentKey, true)
946    return next({ ...e, text: `${e.text}\n\n${text}` })
947  })
948
949  on('command.run', { command: 'helm' }, async $ => {
950    await $.ui.open({ id: PANE, title: 'Helm', focus: true, closeOnEscape: true })
951    return { text: 'Helm opened.' }
952  })
953
954  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
955    if (e.props.hasSurvey) return next(e)
956    const ui = $.ui.resolve(e as any) as any
957    const c = await read($, core)
958    const now = await $.clock.now()
959    // The row redraws while a turn runs; each redraw is a chance to refresh the figures (throttled).
960    if (e.props.isWorking) void refreshUsageSoon($)
961    const lit = Object.entries(c.used)
962      .sort((x, y) => y[1] - x[1])
963      .find(([key, at]) => glow({ [key]: at }, key, now) > 0)
964    const litEntry = lit ? (c.index ?? []).find(x => x.key === lit[0]) : undefined
965    const act = {
966      open: () => void $.ui.open({ id: PANE, title: 'Helm', focus: true, closeOnEscape: true }),
967      accept: (key: string, scope: 'project' | 'session') => void accept($, key, scope),
968      dismiss: (key: string) => void dismiss($, key),
969      start: () => {
970        void setState($, 'ready')
971        void $.ui.open({ id: PANE, title: 'Helm', focus: true, closeOnEscape: true })
972      },
973      look: () => void lookHere($),
974      skip: () => void setState($, 'declined'),
975    }
976    return Band({ ui, c, lang: c.lang, act, terminal: e.surface === 'terminal', width: e.props.bodyColumns ?? 100, litName: litEntry?.name, litCat: litEntry?.category })
977  })
978
979  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
980    const ui = $.ui.resolve(e as any) as any
981    const c = await read($, core)
982    const n = await read($, nav)
983    const github = hasGithub(await $.tool.list().catch(() => []))
984    const terminal = e.surface === 'terminal'
985    const width = Math.max(40, Math.min(e.props.bodyColumns ?? 80, 100))
986    const now = await $.clock.now()
987    const act = {
988      tab: (tab: Nav['tab']) => void update($, nav, s => ({ ...s, tab })),
989      fold: (cat: string) => void update($, nav, s => ({ ...s, open: (s.open ?? []).includes(cat) ? s.open.filter(x => x !== cat) : [...(s.open ?? []), cat] })),
990      foldAll: (cats: string[]) => void update($, nav, s => ({ ...s, open: (s.open ?? []).length > 0 ? [] : cats })),
991      inspect: (key: string) => void update($, nav, s => ({ ...s, inspect: s.inspect === key ? null : key })),
992      lang: (pref: string) => void setLang($, pref),
993      ask: (text: string) => void update($, core, s => ({ ...s, ask: { text, picks: shortlist(s.index ?? [], text) } })),
994      refine: () => void refine($),
995      turnOn: () => void turnOnHere($),
996      undo: () => void undoHere($),
997      gh: () => void setSetup($, s => ({ ...s, on: !s.on })),
998      license: () => void setSetup($, s => ({ ...s, license: nextLicense(s.license) })),
999      item: (id: string) => void setSetup($, toggleItem(id)),
1000      details: (text: string) => void setSetup($, s => ({ ...s, details: text })),
1001      research: (text: string) => void research($, text),
1002      pickRow: (i: number) => void update($, core, s => ({ ...s, batch: s.batch ? s.batch.map((row, j) => (j === i ? { ...row, pick: !row.pick } : row)) : s.batch })),
1003      installBatch: (scope: 'user' | 'local') => void installBatch($, scope),
1004      clearBatch: () => void update($, core, s => ({ ...s, batch: null, message: null })),
1005      pick: (repo: string) => void pickHit($, repo),
1006      install: (scope: 'user' | 'local') => void install($, scope),
1007      check: () => void runCheckup($),
1008      update: () => void runUpdate($),
1009      fix: (key: string) => void fixIssue($, key),
1010      makeGlobal: (repo: string) => void globalInstall($, repo),
1011      scan: () => void runScan($),
1012      installScanner: () => void installScanner($),
1013      toggle: (key: string) => void toggleTool($, key),
1014      anyway: (scope: 'user' | 'local') => void installAnyway($, scope),
1015    }
1016    try {
1017      isTerminal = terminal
1018      return Panel({ ui, c, n, github, act, terminal, width, now, lang: c.lang })
1019    } catch (err) {
1020      // A panel that cannot draw says why, instead of the engine's empty "nothing to show".
1021      return (
1022        <ui.Box flexDirection="column">
1023          <ui.Text bold color="claude">Helm</ui.Text>
1024          <ui.Text color="error">{`The panel could not be drawn: ${String((err as Error)?.message ?? err).slice(0, 300)}`}</ui.Text>
1025        </ui.Box>
1026      )
1027    }
1028  })
1029}
1030
src/research.ts 141 lines
1// The research box: from what the person typed to a verdict and, after a yes, an install.
2import type { Entry, Meta, Reason, Verdict } from '../types'
3
4// Everything here is pure; the calls to `gh` live in hooks/register.tsx.
5
6export type Target = { kind: 'repo'; repo: string } | { kind: 'search'; query: string } | { kind: 'none' }
7
8const REPO = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/
9
10/** A GitHub link or `owner/name` is a repo; anything else is a name to search for. */
11export function parseTarget(input: string): Target {
12  const text = input.trim()
13  if (text === '') return { kind: 'none' }
14  const url = text.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?(?:[/?#].*)?$/)
15  if (url) return { kind: 'repo', repo: `${url[1]}/${url[2]}` }
16  if (REPO.test(text)) return { kind: 'repo', repo: text }
17  return /^[\w .:-]{2,60}$/.test(text) ? { kind: 'search', query: text } : { kind: 'none' }
18}
19
20export type { Meta, Verdict }
21
22/** A plain verdict. `no` blocks the install button; `caution` shows why and still asks. */
23export function judge(m: Meta, now: number): Verdict {
24  const no: Reason[] = []
25  const caution: Reason[] = []
26  if (m.archived) no.push({ k: 'archived' })
27  if (!m.marketplace && !m.isSkill && !m.wrap && !m.app) no.push({ k: 'noform' })
28  if (!m.license || m.license === 'NOASSERTION') caution.push({ k: 'nolicense' })
29  const idle = (now - Date.parse(m.pushedAt)) / 86_400_000
30  if (Number.isFinite(idle) && idle > 365) caution.push({ k: 'idle', n: Math.round(idle / 30) })
31  if (m.stars < 20) caution.push({ k: 'stars', n: m.stars })
32  return no.length ? { level: 'no', reasons: no } : caution.length ? { level: 'caution', reasons: caution } : { level: 'ok', reasons: [] }
33}
34const SAFE = /^[A-Za-z0-9._-]+$/
35
36const SAFE_PATH = /^[A-Za-z0-9._/-]+$/
37
38/** A repository with skills in subfolders, or a plugin and no catalog, installs through a one-plugin catalog that Helm writes beside the Claude config. */
39export function wrapperFile(m: Meta, marketsDir: string): { dir: string; path: string; text: string; market: string; plugin: string } | null {
40  const [owner, name] = m.repo.split('/')
41  if (!m.wrap || !marketsDir || !SAFE.test(owner) || !SAFE.test(name)) return null
42  const skills = m.wrap.skills.filter(p => SAFE_PATH.test(p) && !p.includes('..'))
43  if (!m.wrap.plugin && skills.length === 0) return null
44  const market = `helm-${name}`
45  const dir = `${marketsDir}/${owner}-${name}`
46  const entry = { name, description: m.description.slice(0, 200), source: { source: 'url', url: `https://github.com/${m.repo}.git` }, ...(m.wrap.plugin ? {} : { strict: false, skills: skills.map(p => `./${p}`) }) }
47  const text = JSON.stringify({ name: market, owner: { name: 'Helm' }, plugins: [entry] }, null, 2)
48  return { dir, path: `${dir}/.claude-plugin/marketplace.json`, text, market, plugin: name }
49}
50
51const TOKEN = /^[A-Za-z0-9@._:/=+~-]+$/
52
53/** The command that installs a program, once the scope is filled in; null when anything in it is not a plain word. */
54function appCommand(m: Meta, scope: string): string[] | null {
55  if (!m.app || m.app.argv.length === 0 || !m.app.argv.every(a => a === '{scope}' || TOKEN.test(a))) return null
56  return m.app.argv.map(a => (a === '{scope}' ? scope : a))
57}
58
59/** The commands that install it, or `null` when the form is not one Helm knows. */
60export function installPlan(m: Meta, scope: 'user' | 'local', skillsDir: string, marketsDir = ''): string[][] | null {
61  const steps: string[][] = []
62  const wrap = wrapperFile(m, marketsDir)
63  if (m.marketplace && SAFE.test(m.marketplace.name) && m.marketplace.plugins.length > 0 && m.marketplace.plugins.every(p => SAFE.test(p))) {
64    steps.push(['claude', 'plugin', 'marketplace', 'add', m.repo], ...m.marketplace.plugins.map(p => ['claude', 'plugin', 'install', `${p}@${m.marketplace!.name}`, '--scope', scope]))
65  } else if (wrap) {
66    steps.push(['claude', 'plugin', 'marketplace', 'add', wrap.dir], ['claude', 'plugin', 'install', `${wrap.plugin}@${wrap.market}`, '--scope', scope])
67  } else if (m.isSkill) {
68    const name = m.repo.split('/')[1]
69    if (SAFE.test(name)) steps.push(['git', 'clone', '--depth', '1', `https://github.com/${m.repo}.git`, `${skillsDir}/${name}`])
70  }
71  // A repository can be both: skills for Claude and a program behind them.
72  const app = appCommand(m, scope)
73  if (app) steps.push(app)
74  return steps.length > 0 ? steps : null
75}
76
77/** What the person is told will run: the installing commands, without the plumbing of adding a catalog. */
78export const runsText = (plan: string[][]): string => plan.filter(c => c[2] !== 'marketplace').map(c => c.join(' ')).join('  ›  ')
79
80const flat = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
81
82/** The installed tool a repository name or a plain name already is, if any: same name once case and punctuation are set aside. */
83export function installedAs(index: Entry[], name: string): Entry | undefined {
84  const want = flat(name)
85  return want.length < 3 ? undefined : index.find(e => flat(e.name) === want)
86}
87
88// ---- a whole text instead of one link: every link, repo and name in it ----
89
90export type Item = { kind: 'repo'; repo: string } | { kind: 'search'; query: string } | { kind: 'other'; url: string }
91
92const GH_URL = /(?:https?:\/\/)?(?:www\.)?github\.com\/([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?(?=[/?#\s)>\]"',;]|$)[^\s)>\]"',;]*/g
93const ANY_URL = /https?:\/\/[^\s)>\]"',;]+/g
94const PAIR = /(?<![\w/.@:-])([A-Za-z0-9][A-Za-z0-9-]{0,38})\/([A-Za-z0-9][A-Za-z0-9._-]{1,99})(?![\w/])/g
95// Pairs that are ordinary words or file paths, not repositories.
96const NOT_REPO = new Set(['and/or', 'w/o', 'n/a', 'he/she', 'his/her', 'either/or', 'input/output', 'yes/no', 'true/false', 'on/off', 'read/write', 'client/server', 'pass/fail', 'tcp/ip', 'ci/cd', 'a/b', 'i/o', 'q/a'])
97// Words that are the leftovers of a sentence once its links are taken out.
98const FILLER = new Set(['and', 'or', 'the', 'see', 'also', 'plus', 'then', 'with', 'try', 'use', 'maybe', 'e', 'o', 'y', 'et', 'ou', 'und', 'oder', 'ed', 'poi', 'anche', 'vedi', 'link', 'links', 'repo', 'repos', 'tool', 'tools', 'skill', 'skills', 'plugin', 'plugins', 'list', 'lista', 'todo', 'note', 'notes'])
99const FILE_EXT = /\.(md|txt|json|ya?ml|toml|png|jpe?g|gif|svg|pdf|js|ts|tsx|jsx|py|sh|html|css|lock|zip|csv)$/i
100
101/** The tools a person named in a block of text: GitHub links, `owner/name`, other links (kept, marked), and plain names one to a line or comma. */
102export function parseItems(text: string, max = 40): Item[] {
103  const out: Item[] = []
104  const seen = new Set<string>()
105  const add = (item: Item, key: string) => {
106    const k = key.toLowerCase()
107    if (seen.has(k) || out.length >= max) return
108    seen.add(k)
109    out.push(item)
110  }
111  let rest = text.replace(/\r/g, '')
112  // Links first, then pairs, then plain names; each group keeps the order of the text.
113  const found: { at: number; item: Item; key: string }[] = []
114  for (const m of rest.matchAll(GH_URL)) found.push({ at: m.index ?? 0, item: { kind: 'repo', repo: `${m[1]}/${m[2]}` }, key: `${m[1]}/${m[2]}` })
115  rest = rest.replace(GH_URL, ' ')
116  for (const m of rest.matchAll(ANY_URL)) found.push({ at: m.index ?? 0, item: { kind: 'other', url: m[0] }, key: m[0] })
117  rest = rest.replace(ANY_URL, ' ')
118  for (const m of rest.matchAll(PAIR)) {
119    const name = m[2].replace(/[._-]+$/, '')
120    const pair = `${m[1]}/${name}`
121    if (name.length < 2 || NOT_REPO.has(pair.toLowerCase()) || FILE_EXT.test(name) || /^\d+$/.test(m[1])) continue
122    found.push({ at: m.index ?? 0, item: { kind: 'repo', repo: pair }, key: pair })
123  }
124  // Names: one per bullet, line or comma; prose lines are left alone.
125  const names: { at: number; item: Item; key: string }[] = []
126  let offset = 0
127  for (const line of rest.split('\n')) {
128    const bullet = /^\s*(?:[-*+•]|\d+[.)])\s+/.test(line)
129    const clean = line.replace(/^\s*(?:[-*+•]|\d+[.)])\s+/, '').replace(/^\s*\[[ xX]\]\s*/, '').replace(/[*_`#]/g, '').trim()
130    // A heading ("Tools to try:") or a sentence is not a list of names.
131    const parts = !clean.endsWith(':') && (bullet || clean.length <= 60) ? clean.split(/[,;]|\s+·\s+/) : []
132    for (const p of parts) {
133      const word = p.replace(PAIR, ' ').replace(/\s+/g, ' ').trim()
134      if (/^[\p{L}\p{N}][\p{L}\p{N} ._:+-]{1,39}$/u.test(word) && word.split(' ').length <= 4 && !/^\d+$/.test(word) && !FILLER.has(word.toLowerCase())) names.push({ at: offset, item: { kind: 'search', query: word }, key: `q:${word}` })
135    }
136    offset += line.length + 1
137  }
138  for (const f of [...found, ...names]) add(f.item, f.key)
139  return out
140}
141
src/load.ts 67 lines
1// Reads what is installed from the Claude Code config folder into the index. The only file in the
2// slice that touches the disk, and only through the small `Disk` seam, so tests pass a fake one.
3
4import type { Entry } from '../types'
5import { buildIndex, parseFrontmatter } from './catalog'
6
7export type Disk = {
8  read: (path: string) => Promise<string>
9  list: (path: string) => Promise<{ name: string; kind: string; isLink?: boolean }[]>
10  exists: (path: string) => Promise<boolean>
11}
12
13type Settings = { enabledPlugins?: Record<string, unknown>; skillOverrides?: Record<string, unknown> }
14
15const SAFE = /^[A-Za-z0-9._:-]+$/
16
17async function json(disk: Disk, path: string): Promise<any> {
18  try {
19    return JSON.parse(await disk.read(path))
20  } catch {
21    return null
22  }
23}
24
25/** Every installed plugin and own skill, with its on/off state taken from the merged settings. */
26export async function loadIndex(disk: Disk, configDir: string, settings: Settings): Promise<Entry[]> {
27  const installed = (await json(disk, `${configDir}/plugins/installed_plugins.json`))?.plugins ?? {}
28  const plugins: { id: string; description?: string; on: boolean }[] = []
29  for (const [id, rows] of Object.entries<any[]>(installed)) {
30    const where = rows?.[0]?.installPath
31    const manifest = where ? await json(disk, `${String(where).replace(/\\/g, '/')}/.claude-plugin/plugin.json`) : null
32    plugins.push({ id, description: manifest?.description, on: settings.enabledPlugins?.[id] === true })
33  }
34
35  const skills: { name: string; description?: string; on: boolean }[] = []
36  let dirs: Awaited<ReturnType<Disk['list']>> = []
37  try {
38    dirs = await disk.list(`${configDir}/skills`)
39  } catch {
40    // No skills folder: no own skills.
41  }
42  for (const d of dirs) {
43    if (!(d.kind === 'dir' || d.isLink) || !SAFE.test(d.name)) continue
44    const file = `${configDir}/skills/${d.name}/SKILL.md`
45    if (!(await disk.exists(file))) continue
46    const meta = parseFrontmatter(await disk.read(file).catch(() => ''))
47    const off = settings.skillOverrides?.[d.name]
48    skills.push({ name: d.name, description: meta.description, on: off !== 'off' })
49  }
50  return buildIndex({ plugins, skills })
51}
52
53/** The folders a scan can read: each plugin where it is installed, each own skill in the skills folder. */
54export async function scanTargets(disk: Disk, configDir: string, index: Entry[]): Promise<{ key: string; path: string }[]> {
55  const installed = (await json(disk, `${configDir}/plugins/installed_plugins.json`))?.plugins ?? {}
56  const out: { key: string; path: string }[] = []
57  for (const e of index) {
58    if (e.kind === 'plugin') {
59      const where = installed[e.key.slice(7)]?.[0]?.installPath
60      if (where) out.push({ key: e.key, path: String(where).replace(/\\/g, '/') })
61    } else if (SAFE.test(e.key.slice(6))) {
62      out.push({ key: e.key, path: `${configDir}/skills/${e.key.slice(6)}` })
63    }
64  }
65  return out
66}
67
src/shortlist.ts 39 lines
1// The local first pass of "which tools fit this project": words of the person's description against
2// each entry's name and description. Free and instant; a small model can refine the result later.
3
4import type { Entry } from '../types'
5
6// Words that say nothing about what is being built, and words every tool name here shares ("claude", "plugin"):
7// a name match on one of them would offer a tool for any prompt.
8const STOP = new Set([
9  'a', 'an', 'the', 'and', 'or', 'to', 'of', 'for', 'in', 'on', 'with', 'my', 'is', 'it', 'i', 'want', 'build', 'make', 'app', 'project',
10  'claude', 'code', 'plugin', 'plugins', 'skill', 'skills', 'tool', 'tools', 'helm', 'anthropic', 'use', 'using', 'add', 'fix', 'new', 'file', 'files',
11  'this', 'that', 'can', 'you', 'how', 'what', 'are', 'be', 'we', 'not', 'but', 'from', 'at', 'by', 'as', 'so', 'if', 'then', 'do', 'does', 'please',
12])
13
14const stems = (text: string): string[] =>
15  [...new Set(text.toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length > 1 && !STOP.has(w)))]
16
17/** Every entry that matches `text` at all, with its score and how many different words hit, best first. A hit in the name counts 3, in the description 1. */
18export function scored(index: Entry[], text: string): { key: string; score: number; words: number; named: number }[] {
19  const want = stems(text)
20  if (want.length === 0) return []
21  const hit = (found: string[], w: string) => found.some(f => f.startsWith(w) || w.startsWith(f))
22  return index
23    .map(e => {
24      const inName = stems(e.name)
25      const inText = stems(e.description)
26      return {
27        key: e.key,
28        score: want.reduce((n, w) => n + (hit(inName, w) ? 3 : 0) + (hit(inText, w) ? 1 : 0), 0),
29        words: want.filter(w => hit(inName, w) || hit(inText, w)).length,
30        named: want.filter(w => hit(inName, w)).length,
31      }
32    })
33    .filter(r => r.score > 0)
34    .sort((a, b) => b.score - a.score || a.key.localeCompare(b.key))
35}
36
37/** The keys of the entries that fit `text` best, best first; entries with no match are left out. */
38export const shortlist = (index: Entry[], text: string, max = 8): string[] => scored(index, text).slice(0, max).map(r => r.key)
39
src/suggest.ts 45 lines
1// Two ways to pick tools with more care than a word match, both pure:
2//  - a hint while working: a tool that is off and fits the prompt just typed
3//  - a refinement: one small-model call that reads the shortlist's candidates and keeps the ones that fit
4
5import type { Entry } from '../types'
6import { scored } from './shortlist'
7
8/** A score of 3: one word of the name, or three of the description. */
9export const HINT_MIN = 3
10/** A tool is only offered when the prompt names it (a word of its name) and a second word fits. A description alone is too wide: long ones match any prompt. */
11/** Different words of the prompt that must hit: one word, however strong, is a coincidence. */
12export const HINT_WORDS = 2
13
14/** The one off tool that fits a prompt best, or null. Ignored tools never come back. */
15export function hintFor(index: Entry[], prompt: string, ignored: string[]): string | null {
16  const off = new Map(index.filter(e => !e.on && !ignored.includes(e.key)).map(e => [e.key, e]))
17  const best = scored([...off.values()], prompt).find(r => r.words >= HINT_WORDS && r.named >= 1)
18  return best && best.score >= HINT_MIN ? best.key : null
19}
20
21/** A rough size of a text in tokens, for the price shown before the call. */
22export const estimateTokens = (text: string): number => Math.ceil(text.length / 4)
23
24/** The question for the small model: a numbered list of candidates and the person's words. */
25export function refinePrompt(candidates: Entry[], text: string): string {
26  const list = candidates.map((e, i) => `${i + 1}. ${e.name}: ${e.description || 'no description'}`).join('\n')
27  return [
28    `A person is working on: "${text}".`,
29    'Which of these tools would help? Answer with the numbers of the useful ones as a JSON array, nothing else. Use [] when none help.',
30    list,
31  ].join('\n')
32}
33
34/** The keys the model chose; anything that is not a valid number in range is dropped. */
35export function parseChoice(reply: string, candidates: Entry[]): string[] {
36  const array = reply.match(/\[[\d,\s]*\]/)
37  if (!array) return []
38  try {
39    const numbers = JSON.parse(array[0]) as unknown[]
40    return [...new Set(numbers.filter((n): n is number => Number.isInteger(n) && n >= 1 && n <= candidates.length))].map(n => candidates[n - 1].key)
41  } catch {
42    return []
43  }
44}
45
src/project.ts 32 lines
1// Where a chat runs: the project folder and who it is. Pure.
2
3const slashes = (path: string): string => path.replace(/\\/g, '/').replace(/\/+$/, '')
4
5/** A short, stable id for a folder, the same on every OS and for either slash. */
6export function projectKey(root: string): string {
7  const text = slashes(root).toLowerCase()
8  let h = 0x811c9dc5
9  for (let i = 0; i < text.length; i++) {
10    h ^= text.charCodeAt(i)
11    h = Math.imul(h, 0x01000193) >>> 0
12  }
13  return h.toString(36)
14}
15
16/** The name a person knows the folder by. */
17export const projectName = (root: string): string => slashes(root).split('/').pop() || root
18
19/** The settings file that holds a project's own setup. */
20export const localSettings = (root: string): string => `${slashes(root)}/.claude/settings.local.json`
21
22/**
23 * Whether a folder can be a project. The home folder and Claude Code's own config folder cannot:
24 * their `.claude/settings.json` is the global file, so nothing set there could be "only here".
25 */
26export function isProject(root: string, configDir: string, home = ''): boolean {
27  const r = slashes(root).toLowerCase()
28  const c = slashes(configDir).toLowerCase()
29  if (r === '' || r === c || `${r}/.claude` === c) return false
30  return !(home !== '' && r === slashes(home).toLowerCase())
31}
32
src/apply.ts 61 lines
1// Turning a shortlist on for one project only: the picks are written to the folder's own
2// `.claude/settings.local.json`, and the previous value of every key is kept so one press undoes it.
3
4import type { Entry } from '../types'
5
6export type Before = Record<string, { had: boolean; value?: unknown }>
7
8const GROUP = (key: string) => (key.startsWith('plugin:') ? 'enabledPlugins' : 'skillOverrides')
9const NAME = (key: string) => key.slice(key.indexOf(':') + 1)
10
11function parse(text: string): Record<string, any> | null {
12  try {
13    const v = JSON.parse(text.trim() === '' ? '{}' : text)
14    return v && typeof v === 'object' && !Array.isArray(v) ? v : null
15  } catch {
16    return null
17  }
18}
19
20/** The file with each off pick switched on; `null` when the file is not a JSON object (left alone). */
21export function applyPicks(text: string, picks: Entry[]): { text: string; before: Before } | null {
22  const root = parse(text)
23  if (!root) return null
24  const before: Before = {}
25  for (const e of picks.filter(p => !p.on)) {
26    const group = GROUP(e.key)
27    const bucket = root[group] && typeof root[group] === 'object' ? root[group] : {}
28    before[e.key] = { had: NAME(e.key) in bucket, value: bucket[NAME(e.key)] }
29    bucket[NAME(e.key)] = e.kind === 'plugin' ? true : 'on'
30    root[group] = bucket
31  }
32  return { text: `${JSON.stringify(root, null, 2)}\n`, before }
33}
34
35/** The file with every key `applyPicks` touched put back as it was; other keys stay as they are. */
36export function undoPicks(text: string, before: Before): string | null {
37  const root = parse(text)
38  if (!root) return null
39  for (const [key, old] of Object.entries(before)) {
40    const group = GROUP(key)
41    const bucket = root[group]
42    if (!bucket || typeof bucket !== 'object') continue
43    if (old.had) bucket[NAME(key)] = old.value
44    else delete bucket[NAME(key)]
45    if (Object.keys(bucket).length === 0) delete root[group]
46  }
47  return `${JSON.stringify(root, null, 2)}\n`
48}
49
50/** The settings text with one skill switched off (`skillOverrides`), or back to its default; null when the file is not a JSON object. */
51export function setSkillOff(text: string, name: string, off: boolean): string | null {
52  const root = parse(text)
53  if (!root) return null
54  const bucket = root.skillOverrides && typeof root.skillOverrides === 'object' ? root.skillOverrides : {}
55  if (off) bucket[name] = 'off'
56  else delete bucket[name]
57  if (Object.keys(bucket).length === 0) delete root.skillOverrides
58  else root.skillOverrides = bucket
59  return `${JSON.stringify(root, null, 2)}\n`
60}
61
src/tidy.ts 60 lines
1// The health check of the global setup. It only reads and reports; the one fix it offers is removing
2// a plugin whose files are gone, and only after a second press. It never touches a project's files.
3
4import type { Issue } from '../types'
5import type { Disk } from './load'
6
7export type { Issue }
8
9const SAFE = /^[A-Za-z0-9._:-]+$/
10
11export async function checkup(disk: Disk, configDir: string): Promise<Issue[]> {
12  const issues: Issue[] = []
13  let installed: Record<string, any[]> = {}
14  try {
15    installed = JSON.parse(await disk.read(`${configDir}/plugins/installed_plugins.json`)).plugins ?? {}
16  } catch {
17    // No registry: nothing installed to check.
18  }
19  for (const [id, rows] of Object.entries(installed)) {
20    const where = rows?.[0]?.installPath
21    if (!where || !SAFE.test(id.replace('@', ':'))) continue
22    if (!(await disk.exists(`${String(where).replace(/\\/g, '/')}/.claude-plugin/plugin.json`))) {
23      issues.push({ kind: 'stale-plugin', key: `plugin:${id}`, a: id, fix: ['claude', 'plugin', 'uninstall', id] })
24    }
25  }
26  const seen = new Map<string, string>()
27  let dirs: Awaited<ReturnType<Disk['list']>> = []
28  try {
29    dirs = await disk.list(`${configDir}/skills`)
30  } catch {
31    // No skills folder.
32  }
33  for (const d of dirs) {
34    if (!(d.kind === 'dir' || d.isLink) || !SAFE.test(d.name)) continue
35    const file = `${configDir}/skills/${d.name}/SKILL.md`
36    if (!(await disk.exists(file))) {
37      issues.push({ kind: 'broken-skill', key: `skill:${d.name}`, a: d.name })
38      continue
39    }
40    const raw = await disk.read(file).catch(() => '')
41    if (!/^description:\s*\S/m.test(raw.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '')) {
42      issues.push({ kind: 'no-description', key: `skill:${d.name}`, a: d.name })
43    }
44    const same = seen.get(d.name.toLowerCase())
45    if (same) issues.push({ kind: 'duplicate', key: `skill:${d.name}`, a: d.name, b: same })
46    seen.set(d.name.toLowerCase(), d.name)
47  }
48  return issues
49}
50
51/** The plugin ids to update, one `claude plugin update` each; skills from git are left to their owners. */
52export const updateAll = (ids: string[]): string[][] => ids.filter(id => SAFE.test(id.replace('@', ':'))).map(id => ['claude', 'plugin', 'update', id])
53
54/** What one `claude plugin update` said: "already at the latest version" is current, "updated from X to Y" is updated, anything else failed. */
55export function readUpdate(exitCode: number, text: string): { state: 'current' | 'updated' | 'failed'; note?: string } {
56  if (/already at the latest/i.test(text)) return { state: 'current' }
57  if (exitCode === 0 && /updated from/i.test(text)) return { state: 'updated' }
58  return { state: 'failed', note: text.trim().split('\n').filter(Boolean).slice(-1)[0]?.slice(0, 140) }
59}
60
src/health.ts 81 lines
1// The part of the health check that judges the setup, not just its files: which tools do much the same
2// job, and which ones are loaded and never reached for. Pure: the index and the usage record in, findings out.
3// A finding is a suggestion with a one-press way out (switch the tool off, which is reversible), never a verdict
4// that a tool is useless: Helm only knows what it has seen, and says for how long.
5
6import type { Entry, Issue } from '../types'
7
8/** Days of watching before "never used" means anything. */
9export const LEARNING_DAYS = 14
10
11// Words that say nothing about what a tool does: every description has them.
12const GENERIC = new Set(['the', 'and', 'for', 'with', 'use', 'using', 'used', 'when', 'that', 'this', 'from', 'your', 'you', 'are', 'can', 'any', 'all', 'not', 'into', 'work', 'working', 'help', 'helps', 'tool', 'tools', 'skill', 'skills', 'plugin', 'claude', 'code', 'file', 'files', 'based', 'including', 'such', 'also', 'will', 'has', 'have', 'its', 'new', 'how', 'what', 'user', 'users', 'task', 'tasks', 'agent', 'agents', 'support', 'provide', 'provides', 'create', 'creating', 'generate', 'analysis', 'analyze', 'data', 'python', 'library'])
13
14const tokens = (e: Entry): Set<string> =>
15  new Set(
16    `${e.name} ${e.name} ${e.description}`
17      .toLowerCase()
18      .split(/[^a-z0-9]+/)
19      .filter(w => w.length >= 4 && !GENERIC.has(w))
20      .map(w => w.replace(/(ing|ed|es|s)$/, '')),
21  )
22
23/** How much two tools say the same thing: shared meaningful words over all of them, 0 to 1. */
24export function overlap(a: Entry, b: Entry): number {
25  const x = tokens(a)
26  const y = tokens(b)
27  if (x.size < 5 || y.size < 5) return 0
28  let shared = 0
29  for (const w of x) if (y.has(w)) shared += 1
30  return shared / (x.size + y.size - shared)
31}
32
33/** Pairs that look like the same job done twice, most alike first. */
34export function similar(index: Entry[], threshold = 0.5, max = 12): { a: Entry; b: Entry; score: number }[] {
35  const out: { a: Entry; b: Entry; score: number }[] = []
36  for (let i = 0; i < index.length; i += 1) {
37    for (let j = i + 1; j < index.length; j += 1) {
38      const a = index[i]
39      const b = index[j]
40      // The same tool installed twice (a plugin and a copy of its skill) is the clearest case.
41      const same = a.name.toLowerCase() === b.name.toLowerCase() && a.kind !== b.kind
42      const score = same ? 1 : overlap(a, b)
43      if (score >= threshold) out.push({ a, b, score })
44    }
45  }
46  return out.sort((p, q) => q.score - p.score).slice(0, max)
47}
48
49/**
50 * What to look at: tools that do the same job (suggest switching off the one used less), and, once Helm has watched
51 * long enough, tools that are on and were never used. `days` is how long usage has been recorded.
52 */
53export function review(index: Entry[], uses: Record<string, { n: number; last: number }>, days: number): Issue[] {
54  const out: Issue[] = []
55  const flagged = new Set<string>()
56  for (const { a, b } of similar(index)) {
57    // Keep the one that is used more, then the one that is on; suggest the other.
58    const rank = (e: Entry) => (uses[e.key]?.n ?? 0) * 2 + (e.on ? 1 : 0)
59    const [keep, drop] = rank(a) >= rank(b) ? [a, b] : [b, a]
60    if (flagged.has(drop.key) || !drop.on) continue
61    flagged.add(drop.key)
62    out.push({ kind: 'similar', key: drop.key, a: drop.name, b: keep.name })
63  }
64  if (days >= LEARNING_DAYS) {
65    for (const e of index) {
66      // Plugins also work through hooks, commands and servers, which this record does not see: only skills are judged by use.
67      if (e.kind !== 'skill' || !e.on || uses[e.key] || flagged.has(e.key)) continue
68      out.push({ kind: 'unused', key: e.key, a: e.name, b: String(days) })
69    }
70  }
71  return out
72}
73
74const flatName = (e: Entry) => e.name.toLowerCase().replace(/[^a-z0-9]/g, '')
75
76/** What the lists show: a copy that is switched off while another copy of the same name is on has been dealt with, so it is not listed twice. */
77export function visible(index: Entry[]): Entry[] {
78  const on = new Set(index.filter(e => e.on).map(flatName))
79  return index.filter(e => e.on || !on.has(flatName(e)))
80}
81
src/github.ts 46 lines
1import type { Setup } from '../types'
2
3// The GitHub baseline: what a repository needs to look and be trustworthy, as a short brief that
4// is added once to the first prompt of a project. Pure. The person picks a license and which
5// items apply; the rest is the same for everyone.
6
7export const LICENSES: Setup['license'][] = ['Apache-2.0', 'MIT', 'GPL-3.0', 'none']
8
9export type Item = { id: string; label: string; line: string }
10
11export const ITEMS: Item[] = [
12  { id: 'readme', label: 'README', line: 'A README that says what the project does, how to install and use it, and how to contribute; only features that exist.' },
13  { id: 'license', label: 'License file', line: 'A LICENSE file matching the chosen license, with the copyright holder named.' },
14  { id: 'gitignore', label: '.gitignore', line: 'A .gitignore for the stack, so no build output, logs, .env files or editor folders are committed.' },
15  { id: 'ci', label: 'CI', line: 'A GitHub Actions workflow that builds, lints and tests on every push and pull request, with read-only token permissions.' },
16  { id: 'security', label: 'SECURITY.md', line: 'A SECURITY.md with a real way to report a vulnerability, and private vulnerability reporting switched on.' },
17  { id: 'dependabot', label: 'Dependabot', line: 'Dependabot version and security updates, plus secret scanning with push protection.' },
18  { id: 'protection', label: 'Branch protection', line: 'A protected default branch: no force pushes, no deletion.' },
19  { id: 'community', label: 'Contributing + conduct', line: 'CONTRIBUTING.md, CODE_OF_CONDUCT.md, and issue and pull request templates.' },
20]
21
22export const DEFAULT_SETUP: Setup = { on: false, license: 'Apache-2.0', items: ITEMS.map(i => i.id), details: '' }
23
24/** Whether a GitHub connector is among the tools the session has. */
25export const hasGithub = (tools: { name: string; mcp: boolean }[]): boolean => tools.some(t => t.mcp && /github/i.test(t.name))
26
27/** The cycle of the license button. */
28export const nextLicense = (current: Setup['license']): Setup['license'] => LICENSES[(LICENSES.indexOf(current) + 1) % LICENSES.length]
29
30/** The text added to the first prompt. Empty when nothing is selected. */
31export function brief(s: Setup): string {
32  const chosen = ITEMS.filter(i => s.items.includes(i.id))
33  if (chosen.length === 0 && s.details.trim() === '') return ''
34  const lines = chosen.map(i => `- ${i.line.replace('the chosen license', s.license === 'none' ? 'no license (all rights reserved)' : s.license)}`)
35  return [
36    '---',
37    'GitHub setup requested with this project. Before anything else, set the repository up to professional standards, using the connected GitHub tools:',
38    ...lines,
39    '- No secrets, personal data or local paths in any file or in the history.',
40    s.details.trim() === '' ? '' : `Extra directions from the owner: ${s.details.trim()}`,
41    'Work through the list, then report what is done and what still needs the owner.',
42  ]
43    .filter(l => l !== '')
44    .join('\n')
45}
46
src/graph.ts 37 lines
1// What the Map and the resting row share: one color per category, how long a used tool stays lit, and the base64 the meters' character grid needs.
2
3/** How long a used tool glows, in milliseconds. */
4export const GLOW_MS = 6000
5
6// One color per category, bright enough for a dark background.
7export const COLOR: Record<string, number> = {
8  build: 0x5aa9ff,
9  write: 0xf2b84b,
10  research: 0x6fd08c,
11  science: 0xf08f5a,
12  design: 0xe879b9,
13  web: 0x4fd1d9,
14  data: 0xb28cff,
15  security: 0xff7a6b,
16  business: 0x6fb7a8,
17  docs: 0xc9a66b,
18  setup: 0x9aa7b8,
19  other: 0x8a8f98,
20}
21
22/** How lit a tool is, 0 (dark) to 1 (just used). */
23export const glow = (used: Record<string, number>, key: string, now: number): number => {
24  const at = used[key]
25  return at === undefined ? 0 : Math.max(0, 1 - (now - at) / GLOW_MS)
26}
27
28const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
29export function base64(bytes: Uint8Array): string {
30  let out = ''
31  for (let i = 0; i < bytes.length; i += 3) {
32    const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
33    out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + (i + 1 < bytes.length ? B64[(n >> 6) & 63] : '=') + (i + 2 < bytes.length ? B64[n & 63] : '=')
34  }
35  return out
36}
37
src/skillspector.ts 103 lines
1// SkillSpector (NVIDIA, Apache-2.0) is the scanner Helm leans on for skills: prompt injection, data
2// theft, risky code. This file is the pure side of it: the commands, reading a report, and folding a
3// scan into the verdict on a tool. Static analysis only: no model, no API key, nothing leaves the
4// computer (`--no-llm`).
5
6import type { Found, Reason, ScanResult, Verdict } from '../types'
7
8export const SOURCE = 'git+https://github.com/NVIDIA/skillspector.git'
9export const installCmd = (): string[] => ['uv', 'tool', 'install', SOURCE]
10export const upgradeCmd = (): string[] => ['uv', 'tool', 'upgrade', 'skillspector']
11export const versionCmd = (): string[] => ['skillspector', '--version']
12
13/** The report goes to stdout as JSON; exit 1 means "do not install", not "it failed". */
14export const scanCmd = (target: string): string[] => ['skillspector', 'scan', target, '--no-llm', '--format', 'json']
15
16const REPO = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/
17export const repoTarget = (repo: string): string | null => (REPO.test(repo) ? `https://github.com/${repo}` : null)
18
19/** `SkillSpector v2.12.0` -> `2.12.0`; null when it is not SkillSpector answering. */
20export const parseVersion = (text: string): string | null => text.match(/SkillSpector\s+v?(\d+(?:\.\d+)*)/i)?.[1] ?? null
21
22// A finding in a test, a doc, an example, an evaluation harness, CI or editor config, or the repository's
23// own readme and install notes is not what Claude loads, so it does not decide whether a tool is safe to install.
24const SIDE = /(^|\/)(tests?|__tests__|docs?|examples?|fixtures?|evals?|benchmarks?|\.github|\.gitlab|\.opencode|\.cursor|\.vscode|\.devcontainer)\/|^(readme|install|installation|contributing|changelog|security|code_of_conduct|license|notice)[^/]*\.(md|txt)$/i
25const TESTISH = /\.(test|spec)\.[a-z]+$/i
26const isSide = (file: string): boolean => SIDE.test(file.replace(/\\/g, '/')) || TESTISH.test(file)
27
28const RANK = { ok: 0, caution: 1, no: 2 } as const
29
30/** A report read into what the panel shows; null when it is not a SkillSpector report. */
31export function parseScan(text: string): ScanResult | null {
32  let r: any
33  try {
34    r = JSON.parse(text)
35  } catch {
36    return null
37  }
38  const risk = r?.risk_assessment
39  if (!risk || typeof risk.recommendation !== 'string' || !Array.isArray(r.issues)) return null
40  const issues = (r.issues as any[]).map(i => ({
41    sev: String(i.severity ?? ''),
42    pattern: String(i.pattern ?? i.category ?? ''),
43    where: `${String(i.location?.file ?? '')}${i.location?.start_line ? `:${i.location.start_line}` : ''}`,
44    side: isSide(String(i.location?.file ?? '')),
45  }))
46  const main = issues.filter(i => !i.side)
47  const order = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
48  main.sort((a, b) => order.indexOf(a.sev) - order.indexOf(b.sev))
49  return {
50    score: Math.round(Number(risk.score ?? 0)),
51    severity: String(risk.severity ?? ''),
52    recommendation: String(risk.recommendation),
53    flagged: main.length,
54    critical: main.filter(i => i.sev === 'CRITICAL').length,
55    high: main.filter(i => i.sev === 'HIGH').length,
56    top: main.slice(0, 4).map(({ sev, pattern, where }) => ({ sev, pattern, where })),
57    testOnly: issues.length - main.length,
58  }
59}
60
61/** SkillSpector scores harshly: a big skill with long docs can reach 100. Helm blocks only on one critical finding or three high ones, outside tests and docs. */
62export const blocks = (scan: ScanResult): boolean => scan.critical >= 1 || scan.high >= 3
63
64/** Adds what SkillSpector found to a verdict. The level can only go up. */
65export function foldVerdict(v: Verdict, scan: ScanResult | null, scanner: 'unknown' | 'missing' | 'ready', missed?: 'size' | 'slow'): Verdict {
66  const reasons: Reason[] = [...v.reasons]
67  let level = v.level
68  const raise = (to: Verdict['level']) => {
69    if (RANK[to] > RANK[level]) level = to
70  }
71  if (scanner === 'missing') {
72    reasons.push({ k: 'noscanner' })
73    raise('caution')
74  } else if (!scan) {
75    reasons.push(missed ? { k: 'unscanned', n: missed === 'size' ? 1 : 2 } : { k: 'unscanned' })
76    raise('caution')
77  } else if (scan.recommendation !== 'SAFE') {
78    if (scan.flagged > 0 && scan.recommendation === 'DO_NOT_INSTALL' && blocks(scan)) {
79      reasons.push({ k: 'scanhigh', n: scan.score })
80      raise('no')
81    } else if (scan.flagged > 0) {
82      reasons.push({ k: 'scanmid', n: scan.score })
83      raise('caution')
84    } else {
85      reasons.push({ k: 'testsOnly', n: scan.testOnly })
86      raise('caution')
87    }
88  }
89  return { level, reasons }
90}
91
92/** A tool Helm may still install on a second, explicit yes: only the scanner said no, nothing about form or upkeep. */
93export const isOverridable = (f: Found): boolean => f.verdict.level === 'no' && f.verdict.reasons.some(r => r.k === 'scanhigh') && !f.verdict.reasons.some(r => r.k === 'archived' || r.k === 'noform')
94
95/** Those worth a row in the Security tile: a critical finding or three high ones, worst first. The rest is noise until it is not. */
96export const flagged = (scans: Record<string, ScanResult>): [string, ScanResult][] =>
97  Object.entries(scans)
98    .filter(([, s]) => blocks(s))
99    .sort((a, b) => b[1].score - a[1].score)
100
101/** How many scans came back with "do not install" but not enough to block: worth a look, not an alarm. */
102export const watched = (scans: Record<string, ScanResult>): number => Object.values(scans).filter(s => s.recommendation === 'DO_NOT_INSTALL' && s.flagged > 0 && !blocks(s)).length
103