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.

<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.
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.
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.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">
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.
gh, claude plugin …, skillspector and uv tool … commands, built from plain names, and installs nothing without a yes..claude/settings.local.json, never to your global settings. Undo restores the previous values key by key.installed_plugins.json.helm-backup).See SECURITY.md for how to report a problem and how to 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
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>
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.
hooks/register.tsx 1030 lines1import { 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}
1030src/research.ts 141 lines1// 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}
141src/load.ts 67 lines1// 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}
67src/shortlist.ts 39 lines1// 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)
39src/suggest.ts 45 lines1// 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}
45src/project.ts 32 lines1// 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}
32src/apply.ts 61 lines1// 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}
61src/tidy.ts 60 lines1// 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}
60src/health.ts 81 lines1// 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}
81src/github.ts 46 lines1import 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}
46src/graph.ts 37 lines1// 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}
37src/skillspector.ts 103 lines1// 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