Holds risky shell commands (recursive deletes, git reset --hard, git clean, force pushes, discarded changes, migrations and third-party installs for the whole…

<h1 align="center">impact-radius</h1>
A Claude Code mod that holds a risky shell command, shows what it would change, and waits for you to press Run or Cancel
<img src="https://img.shields.io/badge/Claude_Code-%E2%89%A5_2.1.293-D97757?logo=claude&logoColor=fff" alt="Claude Code 2.1.293 or newer"> <img src="https://img.shields.io/badge/version-1.1.0-blue" alt="Version"> <img src="https://img.shields.io/badge/type-mod-6f42c1" alt="Claude Code mod"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<img src="media/pane-light.png" alt="The impact-radius pane in a light theme, holding Remove-Item -Recurse -Force: it would delete 14 files (1.8 MB), with Cancel focused and Run beside it" width="430"> <img src="media/pane-dark.png" alt="The same pane in a dark theme" width="430">
Claude sometimes runs commands that can wreck work or put someone else's software on your whole machine: a recursive delete, git reset --hard, a force push, npm install -g, curl … | sh. impact-radius holds those commands before they run, measures what they would change, and asks you. Claude waits for your answer.
It watches Bash and PowerShell, and it measures with the engine's own file and process calls, never bash, so it works the same on Windows, macOS and Linux.
| Command | What it shows |
|---|---|
rm -r/-f (Bash); Remove-Item -Recurse/-Force and its aliases rm, del, rd, ri (PowerShell) | How many files it would delete, how much space they take, and the first ten |
git reset --hard, git checkout -- ., git restore . | The files with uncommitted changes that would be lost, and the commits a reset to an older commit drops from the branch |
git clean (without -n) | What it would remove, according to git clean -n |
git push --force, -f, --force-with-lease or +branch | The remote commits that would disappear |
Migrations: Django, Alembic, Rails, Prisma and any … migrate | The pending migrations |
| Third-party installs for the whole user or system (list below) | What would be installed and where; for npm, which packages you already have and their version |
What gets installed outside the project is there for every session, and it's someone else's code running with your permissions, also while it installs. So these are held:
npm install -g (also i, add, update and --location=global), pnpm add -g, bun add -g, yarn global add, volta install, deno install -g.pip install (and python -m pip, py -m pip) outside a virtual environment or with --user, pipx install, uv tool install and uv pip install --system.cargo install, go install, gem install, dotnet tool install -g, nvm install.winget, choco/cinst, scoop, brew, apt/apt-get, dnf, yum, zypper, pacman -S, apk, snap, flatpak and port, when installing or upgrading.Install-Module, Install-Package, Install-Script, Install-PSResource and Update-Module, saying whether it's for your user or for everyone (-Scope AllUsers).code --install-extension (also Cursor and VSCodium), claude plugin install at user scope (the default) and claude mcp add --scope user.curl … | sh, wget -qO- … | bash, curl … | python3 -, irm … | iex, bash -c "$(curl …)" and iex ((New-Object Net.WebClient).DownloadString(…)). It shows where the script comes from.<img src="media/install-dark.png" alt="The pane holding npm install -g typescript@latest cowsay: typescript is already installed at 7.0.2 and cowsay is new" width="560">
What stays in the project goes through without a question: npm install without -g, yarn add, pip install inside a virtual environment (active in the session, activated in the same command, or using the pip of .venv), uv pip install, pip install --target, claude plugin install --scope project or claude mcp add without --scope user. Downloading and reading data isn't held either, like curl … | jq or curl … | python3 -m json.tool.
A recursive delete inside the system's temporary folder isn't held, because that is where Claude keeps its scratch files: every target has to lie below TEMP, TMP or TMPDIR (or /tmp and /var/tmp on Linux and macOS), exist, and hold no symbolic link or junction. The temporary folder itself, a wildcard right under it, a path built from any other variable and a delete too big to count are still held. The command then goes on to Claude Code's own permission check, like any other command.
It follows cd, Set-Location, pushd/popd and git -C on the same line, to measure in the right folder. Commands that only read (git status, Get-ChildItem, rm of a single file without options, Remove-Item -WhatIf, git clean -n…) go through without opening anything. When a line holds several risky commands, the pane measures the first and names the others, because Run approves the whole line.
tool.check), impact-radius classifies the command (hooks/classify.ts and hooks/installs.ts, pure logic with their own tests). If your rules already deny it, it asks nothing.git, run in English so its output can be read whatever your locale. A huge delete stops counting at 20,000 files or 8 seconds and says so ("at least…"). For a global npm install it reads npm ls -g, which only reads the disk; other installs are described, not measured.sleep; on Windows, Git for Windows' sleep.exe (found with git --exec-path) or, without it, PowerShell's Start-Sleep.One held command at a time: another one (a subagent's, say) waits for your answer to the first. A call that arrives while a command is held, from inside that wait, is not held again: a harmless one goes through and a risky one is refused, saying another command is waiting. In a session nobody can answer, such as a plain claude -p, a risky command is refused at once instead of waiting 10 minutes.
c Cancel, r Run); the desktop app draws its own buttons, with Cancel as the main one.In /config, under impact-radius, Language is English (the default) or Español. It sets the pane and what Claude reads when a command is cancelled.
It needs no rules: it hooks into Claude Code's own permission decision. What it runs to measure (git status, git clean -n, git log, npm ls -g, the migration listing, and the wait with sleep or Start-Sleep) is started by the mod and doesn't go through your rules.
How it lives with your rules:
deny rules win. A risky command that is already denied is refused without opening the pane.allow rules don't skip it. A risky command is held even if a rule allows it, because the decision has to be yours, with the effect in front of you.ask rules: the pane replaces the engine's question. Pressing Run is the permission, and you are not asked twice.Plugins asking about a command ($.tool.check with no real call behind it) don't open anything. Listing migrations runs the project's own tool (python manage.py showmigrations, bin/rails db:migrate:status…), which loads the project's code. Only Bash and PowerShell are watched: a file Claude deletes with another tool doesn't go through here.
Inside a Claude Code session:
/plugin install impact-radius --marketplace pablodiazjorge/impact-radius
Or in a terminal:
claude plugin marketplace add pablodiazjorge/impact-radius
claude plugin install impact-radius@impact-radius
The classifier, the measuring and the words are plain modules tested directly; the hooks are tested against a simulated engine.
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
CI runs both on Ubuntu, macOS and Windows with Claude Code 2.1.293.
The idea comes from Anthropic's blast-radius. impact-radius is written from scratch; see ACKNOWLEDGEMENTS.md.
hooks/register.tsx 331 lines1// impact-radius: holds a risky shell command (recursive deletes, git reset --hard, git clean, force
2// pushes, discarded changes, migrations and third-party installs for the whole user or system),
3// shows what it would change and waits for the person to press Run or Cancel.
4//
5// It measures with $.fs and git, never bash, so it works the same on Windows. To hold the command,
6// the hook waits inside a short `$.process.run` (time spent inside a `$` call doesn't count against
7// the hook's budget, except `$.clock.sleep`): `sleep` on Linux and macOS; on Windows, Git for
8// Windows' `sleep.exe` or, without it, PowerShell's `Start-Sleep`.
9
10import type { EngineInterface, RenderElement, RenderInput, Register } from 'claude-code'
11
12import { classify, type Risk } from './classify'
13import { type Facts, measure, type Probe } from './measure'
14import { denyReason, type Lang, langOf, type Reason, type Report, report, words } from './messages'
15import { resolvePath, type Shell } from './shell'
16import { temporaryPath, temporaryRoots, type TemporaryVariables } from './temp'
17
18const PANE = 'impact-radius'
19const WAIT_MAX_MS = 10 * 60 * 1000
20
21type Decision = 'run' | Reason
22type Held = {
23 command: string
24 risks: Risk[]
25 report: Report | null
26 decision: Decision | null
27 place: 'pane' | 'band'
28}
29
30let lang: Lang = 'en'
31// The held command, or null. One at a time: a turn's commands come one after another, and a
32// subagent's waits for the answer to the one before.
33let held: Held | null = null
34let sleepArgv: Promise<string[]> | null = null
35let windows: Promise<boolean> | null = null
36
37// ---------------------------------------------------------------------------
38// Environment
39
40function isWindows($: EngineInterface): Promise<boolean> {
41 windows ??= (async () => (await $.env.get('OS')) === 'Windows_NT')()
42 return windows
43}
44
45async function homeOf($: EngineInterface): Promise<string> {
46 return ((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '').replace(/\\/g, '/')
47}
48
49/** How to wait a moment without spending the hook's budget. */
50async function findSleep($: EngineInterface): Promise<string[]> {
51 if (!(await isWindows($))) return ['sleep', '0.25']
52 try {
53 const run = await $.process.run(['git', '--exec-path'], { timeoutMs: 5_000 })
54 const path = run.stdout.trim().replace(/\\/g, '/')
55 const root = path.replace(/\/(mingw64|mingw32|clangarm64)\/libexec\/git-core\/?$/i, '')
56 const sleep = `${root}/usr/bin/sleep.exe`
57 if (run.exitCode === 0 && root !== path && (await $.fs.exists(sleep))) return [sleep, '0.25']
58 } catch {
59 // no Git for Windows: PowerShell is always there
60 }
61 return ['powershell', '-NoProfile', '-NonInteractive', '-Command', 'Start-Sleep -Milliseconds 300']
62}
63
64async function pause($: EngineInterface): Promise<void> {
65 sleepArgv ??= findSleep($)
66 await $.process.run(await sleepArgv, { timeoutMs: 5_000 })
67}
68
69/** Whether the session runs inside a Python virtual environment (not conda's `base`). */
70async function inVirtualEnv($: EngineInterface): Promise<boolean> {
71 if (await $.env.get('VIRTUAL_ENV')) return true
72 return Boolean(await $.env.get('CONDA_PREFIX')) && (await $.env.get('CONDA_DEFAULT_ENV')) !== 'base'
73}
74
75/** The real folder of a `cd`, or null when it doesn't exist. */
76async function realDir($: EngineInterface, base: string, dir: string): Promise<string | null> {
77 if (dir === '-') return null // `cd -` depends on the shell's history
78 const path = resolvePath(base, dir, await homeOf($))
79 try {
80 return (await $.fs.stat(path)).kind === 'dir' ? path : null
81 } catch {
82 return null
83 }
84}
85
86/**
87 * Whether a delete only touches the system's temporary folder, where Claude keeps its scratch files:
88 * every target lies below TEMP, TMP, TMPDIR, /tmp or /var/tmp, exists, and holds no symbolic link or
89 * junction, and the walk counted everything. Anything unknown or failing keeps the delete held.
90 */
91async function temporaryDelete($: EngineInterface, risk: Risk, base: string, shell: Shell): Promise<boolean> {
92 if (risk.kind !== 'delete' || risk.targets.length === 0) return false
93 try {
94 const windows = await isWindows($)
95 const vars: TemporaryVariables = { TEMP: await $.env.get('TEMP'), TMP: await $.env.get('TMP'), TMPDIR: await $.env.get('TMPDIR') }
96 const roots = temporaryRoots(vars, windows)
97 if (roots.length === 0) return false
98 const cwd = risk.dir ? await realDir($, base, risk.dir) : base
99 if (cwd === null) return false
100 const home = await homeOf($)
101 const paths = risk.targets.map(target => temporaryPath(target, cwd, home, vars, roots, shell, windows))
102 if (paths.some(path => path === null)) return false
103 const facts = await measure({ ...risk, targets: paths as string[] }, cwd, await probe($))
104 return facts.kind === 'delete' && !facts.truncated && facts.links === 0 && facts.found >= risk.targets.length
105 } catch {
106 return false
107 }
108}
109
110/** The engine calls measure.ts needs, so `$` never leaves this file. */
111async function probe($: EngineInterface): Promise<Probe> {
112 return {
113 windows: await isWindows($),
114 home: await homeOf($),
115 now: () => $.clock.now(),
116 stat: path => $.fs.stat(path).then(
117 s => s,
118 () => null,
119 ),
120 list: path => $.fs.list(path).then(
121 l => [...l],
122 () => null,
123 ),
124 run: (argv, options) =>
125 $.process.run(argv, options).then(
126 r => ({ exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr }),
127 () => ({ exitCode: -1, stdout: '', stderr: '' }),
128 ),
129 }
130}
131
132const commandOf = (input: unknown) => String((input as { command?: unknown } | undefined)?.command ?? '')
133const shellOf = (tool: string): Shell => (tool === 'Bash' ? 'bash' : 'powershell')
134
135/** The effect Claude reads: the first risk's, and the other risky commands of the line. */
136function effectOf(entry: Held): string {
137 const others = [...new Set(entry.risks.slice(1).map(r => r.command))]
138 const summary = entry.report?.summary ?? entry.risks[0]?.command ?? ''
139 return others.length > 0 ? `${summary} (${words(lang).also(others.join(', '))})` : summary
140}
141
142// ---------------------------------------------------------------------------
143// Drawing
144
145/** What another mod draws under the band; the engine's marker (`type: 'engine'`) doesn't count. */
146const drawnBelow = (tree: unknown): RenderElement | null =>
147 typeof tree === 'object' && tree !== null && (tree as { type?: unknown }).type !== 'engine' ? (tree as RenderElement) : null
148
149// All text is in the theme's text color, the only one that keeps 7:1 contrast in every theme;
150// color goes to the border and the icons.
151function draw($: EngineInterface, e: RenderInput, entry: Held, below: RenderElement | null = null) {
152 const { Box, Text, Button } = $.ui.resolve(e)
153 const t = words(lang)
154 const first = entry.risks[0]?.command ?? ''
155 const r = entry.report ?? { summary: first, lines: [], more: 0, note: '' }
156 const others = [...new Set(entry.risks.slice(1).map(x => x.command))]
157 const width = Math.max(t.command.length, t.effect.length) + 2
158 // The buttons answer for the command they were drawn for, never for whichever is held now.
159 const decide = (d: Decision) => () => {
160 if (entry.decision === null) entry.decision = d
161 }
162 // The terminal shows a hotkey only on a plain button; the desktop draws native ones.
163 const terminal = e.surface === 'terminal'
164 const panel = (
165 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
166 <Text bold>
167 <Text color="warning">{'⚠ '}</Text>
168 {`${t.name}: ${first}`}
169 </Text>
170 <Box flexDirection="column" marginTop={1}>
171 <Text wrap="truncate-end">
172 <Text bold>{t.command.padEnd(width)}</Text>
173 {entry.command}
174 </Text>
175 <Text>
176 <Text bold>{t.effect.padEnd(width)}</Text>
177 <Text color="error">{'● '}</Text>
178 <Text bold>{r.summary}</Text>
179 </Text>
180 </Box>
181 {r.lines.length > 0 ? (
182 <Box flexDirection="column" marginTop={1}>
183 {r.lines.map((line, i) => (
184 <Text key={`line-${i}`} wrap="truncate-end">
185 {` ${line}`}
186 </Text>
187 ))}
188 {r.more > 0 && <Text>{` ${t.more(r.more)}`}</Text>}
189 </Box>
190 ) : null}
191 {r.note ? (
192 <Box marginTop={1}>
193 <Text wrap="wrap">{r.note}</Text>
194 </Box>
195 ) : null}
196 {others.length > 0 ? <Text wrap="wrap">{t.also(others.join(', '))}</Text> : null}
197 <Box flexDirection="row" marginTop={1} gap={2}>
198 {terminal ? (
199 <Button key="cancel" plain label={t.cancel} hotkey={t.cancelKey} autoFocus onPress={decide('cancel')} />
200 ) : (
201 <Button key="cancel" variant="primary" label={t.cancel} hotkey={t.cancelKey} autoFocus onPress={decide('cancel')} />
202 )}
203 {terminal ? (
204 <Button key="run" plain label={t.run} hotkey={t.runKey} onPress={decide('run')} />
205 ) : (
206 <Button key="run" label={t.run} hotkey={t.runKey} onPress={decide('run')} />
207 )}
208 <Text>{t.waiting}</Text>
209 </Box>
210 </Box>
211 )
212 return below ? (
213 <Box flexDirection="column">
214 {panel}
215 {below}
216 </Box>
217 ) : (
218 panel
219 )
220}
221
222// ---------------------------------------------------------------------------
223// Hooks
224
225export const register: Register = (on, options) => {
226 lang = langOf(options?.language)
227
228 // Held at the permission check (`tool.check`), not at the run: the person's answer is the
229 // permission, so the engine doesn't ask again after Run.
230 on('tool.check', async ($, e, next) => {
231 if (e.tool !== 'Bash' && e.tool !== 'PowerShell') return next(e)
232 // Another plugin's `$.tool.check` is not a command about to run.
233 if (!e.tool_use_id) return next(e)
234 const command = commandOf(e.input)
235 let risks = classify(command, shellOf(e.tool))
236 // pip without `--user` inside a virtual environment installs into the project.
237 const venvDependent = (r: Risk) => r.kind === 'install' && r.venvDependent === true
238 if (risks.some(venvDependent) && (await inVirtualEnv($))) risks = risks.filter(r => !venvDependent(r))
239 // A delete inside the temporary folder isn't held: the command goes on to the engine's own check.
240 if (risks.some(r => r.kind === 'delete')) {
241 const base = (await $.session.cwd()).replace(/\\/g, '/')
242 const kept: Risk[] = []
243 for (const r of risks) if (!(await temporaryDelete($, r, base, shellOf(e.tool)))) kept.push(r)
244 risks = kept
245 }
246 const first = risks[0]
247 if (first === undefined) return next(e)
248 // When the person's rules already forbid it, there is nothing to ask.
249 const verdict = await next(e)
250 if (verdict.decision === 'deny') return verdict
251 // Nobody can press anything (a plain `claude -p`): don't wait ten minutes for it.
252 if ((await $.session.surfaces()).length === 0) return { decision: 'deny', reason: denyReason('nobody', first.command, lang) }
253
254 // One held command at a time: another one (a subagent's, say) waits for its answer.
255 // `held` is checked and taken with no await in between.
256 while (held !== null) {
257 if (next.signal.aborted) return { decision: 'deny', reason: denyReason('interrupted', first.command, lang) }
258 await pause($)
259 }
260 const mine: Held = { command, risks, report: null, decision: null, place: 'pane' }
261 held = mine
262
263 let placed = false
264 try {
265 const base = (await $.session.cwd()).replace(/\\/g, '/')
266 const cwd = first.dir ? await realDir($, base, first.dir) : base
267 const facts: Facts = cwd === null ? { kind: 'unmeasured', reason: 'missing-dir', detail: first.dir ?? '' } : await measure(first, cwd, await probe($))
268 mine.report = report(first, facts, lang)
269
270 const opened = await $.ui.open({ id: PANE, title: words(lang).name, focus: true })
271 placed = opened.isPlaced
272 if (!placed) mine.place = 'band'
273 $.ui.invalidate('ui.render')
274
275 const start = await $.clock.now()
276 while (mine.decision === null) {
277 if (next.signal.aborted) {
278 mine.decision = 'interrupted'
279 break
280 }
281 if ((await $.clock.now()) - start > WAIT_MAX_MS) {
282 mine.decision = 'timeout'
283 break
284 }
285 await pause($)
286 }
287 } catch {
288 mine.decision = 'error' // anything unexpected leaves the command unrun
289 } finally {
290 // Close this command's pane before letting go, so the next one's isn't closed.
291 try {
292 if (placed) await $.ui.close({ id: PANE })
293 } catch {
294 // already closed
295 }
296 if (held === mine) held = null
297 $.ui.invalidate('ui.render')
298 }
299
300 const t = words(lang)
301 if (mine.decision === 'run') {
302 $.ui.toast(`${t.name}: ${t.ran}`)
303 return { decision: 'allow', reason: `${t.name}: ${t.allowed}` }
304 }
305 return { decision: 'deny', reason: denyReason(mine.decision ?? 'error', effectOf(mine), lang) }
306 }).catch(($, e, next) => {
307 if (e.tool !== 'Bash' && e.tool !== 'PowerShell') return next(e)
308 // Not run: the call came in beneath one this hook is holding. Only a risky one is stopped; a
309 // delete in the temporary folder is stopped too, since nothing can be measured from here.
310 if (next.error.kind === 're-entry') {
311 const risks = e.tool_use_id ? classify(commandOf(e.input), shellOf(e.tool)) : []
312 const first = risks[0]
313 return first === undefined ? next(e) : { decision: 'deny', reason: denyReason('busy', first.command, lang) }
314 }
315 // A guard that fails doesn't let the command through.
316 return { decision: 'deny', reason: denyReason('error', words(lang).notMeasured, lang) }
317 })
318
319 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e, next) => {
320 if (held === null || held.report === null) return next(e)
321 return draw($, e, held)
322 })
323
324 // When the screen can't place the pane (a narrow terminal), the notice goes above the prompt.
325 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
326 const entry = held
327 if (entry === null || entry.report === null || entry.place !== 'band' || e.props.hasSurvey) return next(e)
328 return draw($, e, entry, drawnBelow(await next(e)))
329 })
330}
331hooks/classify.ts 176 lines1// Which shell commands are risky and which folder they run in. Returns data, never prose: the
2// words live in messages.ts. No `$`: tested directly.
3//
4// The idea comes from blast-radius, in Anthropic's claude-code-playground; this code is new and
5// also understands PowerShell, Claude's usual shell on Windows, and third-party installs.
6
7import { activatesVenv, type InstallRisk, installRisk } from './installs'
8import { commandName, isParam, joinDir, segments, type Shell, stripPrefixes } from './shell'
9
10export type GitOp = 'reset-hard' | 'clean' | 'push-force' | 'discard'
11export type MigrationTool = 'django' | 'alembic' | 'rails' | 'prisma' | 'other'
12
13/** `command` is a short literal label (`rm -rf`, `git reset --hard`); `dir` is null for the session's folder. */
14export type DeleteRisk = { kind: 'delete'; command: string; dir: string | null; targets: string[] }
15export type GitRisk = { kind: 'git'; op: GitOp; command: string; dir: string | null; args: string[] }
16export type MigrationRisk = { kind: 'migrate'; tool: MigrationTool; command: string; dir: string | null }
17export type Risk = DeleteRisk | GitRisk | MigrationRisk | InstallRisk
18
19/** A change of folder (`cd`, `Set-Location`, `pushd`, `popd`): null is the session's, `-` the unknown previous one. */
20type DirChange = { cd: string | null }
21
22/** Commands that only read: a stray "migrate" among their words is not a migration. */
23const READ_ONLY = new Set([
24 'ls', 'cat', 'echo', 'printf', 'grep', 'rg', 'find', 'less', 'head', 'tail', 'cd', 'git',
25 'get-content', 'gc', 'type', 'select-string', 'sls', 'get-childitem', 'gci', 'dir', 'write-output', 'write-host',
26])
27/** Remove-Item and its PowerShell aliases. */
28const POWERSHELL_DELETE = new Set(['remove-item', 'ri', 'rm', 'del', 'erase', 'rd', 'rmdir'])
29const POWERSHELL_CD = new Set(['cd', 'chdir', 'sl', 'set-location'])
30const PUSHD = new Set(['pushd', 'push-location'])
31const POPD = new Set(['popd', 'pop-location'])
32
33function powershellDelete(command: string, args: string[], dir: string | null): DeleteRisk | null {
34 const targets: string[] = []
35 const flags: string[] = []
36 let recurse = false
37 let force = false
38 for (let i = 0; i < args.length; i++) {
39 const a = args[i] ?? ''
40 if (!a.startsWith('-')) {
41 targets.push(...a.split(',').filter(Boolean))
42 continue
43 }
44 const value = a.includes(':') ? a.slice(a.indexOf(':') + 1) : undefined
45 if (isParam(a, 'whatif', 1)) return null // only shows what it would do
46 if (isParam(a, 'recurse', 1)) {
47 recurse = true
48 flags.push('-Recurse')
49 } else if (isParam(a, 'force', 2)) {
50 force = true
51 flags.push('-Force')
52 } else if (isParam(a, 'path', 1) || isParam(a, 'literalpath', 1) || /^-(lp|pspath)$/i.test(a.split(':')[0] ?? '')) {
53 const v = value ?? args[++i]
54 if (v) targets.push(...v.split(',').filter(Boolean))
55 } else if (['filter', 'include', 'exclude', 'credential', 'stream'].some(n => isParam(a, n, 2)) && value === undefined) {
56 i++ // its value is not a path
57 }
58 }
59 if (!recurse && !force) return null
60 return { kind: 'delete', command: `${command} ${flags.join(' ')}`.trim(), dir, targets }
61}
62
63function bashDelete(args: string[], dir: string | null): DeleteRisk | null {
64 const end = args.indexOf('--')
65 const options = (end < 0 ? args : args.slice(0, end)).filter(a => a.startsWith('-') && a !== '-')
66 const recurse = options.some(f => f === '--recursive' || (/^-[^-]/.test(f) && /[rR]/.test(f)))
67 const force = options.some(f => f === '--force' || (/^-[^-]/.test(f) && f.includes('f')))
68 if (!recurse && !force) return null
69 const targets = end < 0 ? args.filter(a => !a.startsWith('-') || a === '-') : [...args.slice(0, end).filter(a => !a.startsWith('-')), ...args.slice(end + 1)]
70 return { kind: 'delete', command: `rm ${options.join(' ')}`.trim(), dir, targets }
71}
72
73function gitRisk(args: string[], dir: string | null): GitRisk | null {
74 let gitDir = dir
75 let i = 0
76 while (i < args.length && (args[i] ?? '').startsWith('-')) {
77 if (args[i] === '-C' && i + 1 < args.length) {
78 gitDir = joinDir(gitDir, args[i + 1])
79 i += 2
80 } else if (args[i] === '-c' && i + 1 < args.length) i += 2
81 else i += 1
82 }
83 const sub = args[i]
84 const rest = args.slice(i + 1)
85 if (sub === 'reset' && rest.includes('--hard')) return { kind: 'git', op: 'reset-hard', command: 'git reset --hard', dir: gitDir, args: rest }
86 if (sub === 'clean' && !rest.some(a => a === '-n' || a === '--dry-run' || /^-[a-zA-Z]*n/.test(a))) {
87 return { kind: 'git', op: 'clean', command: 'git clean', dir: gitDir, args: rest }
88 }
89 if (sub === 'push' && rest.some(a => a === '--force' || a === '-f' || a.startsWith('--force-with-lease') || /^\+/.test(a))) {
90 return { kind: 'git', op: 'push-force', command: 'git push --force', dir: gitDir, args: rest }
91 }
92 const stagedOnly = sub === 'restore' && rest.includes('--staged') && !rest.includes('--worktree') && !rest.includes('-W')
93 if ((sub === 'checkout' || sub === 'restore') && rest.includes('.') && !stagedOnly) {
94 return { kind: 'git', op: 'discard', command: `git ${sub} -- .`, dir: gitDir, args: rest }
95 }
96 return null
97}
98
99function migration(name: string, args: string[], dir: string | null): MigrationRisk | null {
100 const line = [name, ...args].join(' ')
101 if (/\balembic\s+upgrade\b/.test(line)) return { kind: 'migrate', tool: 'alembic', command: 'alembic upgrade', dir }
102 if (/\bdb:migrate(?!:status\b)/.test(line)) return { kind: 'migrate', tool: 'rails', command: 'db:migrate', dir }
103 if (/\bprisma\s+migrate\s+(dev|deploy|reset)\b/.test(line)) return { kind: 'migrate', tool: 'prisma', command: 'prisma migrate', dir }
104 if (/\bmanage\.py\s+migrate\b/.test(line)) return { kind: 'migrate', tool: 'django', command: 'manage.py migrate', dir }
105 if (!READ_ONLY.has(name.toLowerCase()) && args.includes('migrate')) return { kind: 'migrate', tool: 'other', command: 'migrate', dir }
106 return null
107}
108
109/** One segment: a risk, a change of folder or nothing. */
110function classifySegment(
111 words: string[],
112 dir: string | null,
113 stack: (string | null)[],
114 shell: Shell,
115 input: string[] | null,
116 inVenv: boolean,
117): Risk | DirChange | null {
118 const [first, ...args] = stripPrefixes(words, shell)
119 if (first === undefined) return null
120 const name = commandName(first, shell)
121 const target = () => {
122 if (shell === 'bash') return args[0]
123 for (let i = 0; i < args.length; i++) {
124 const a = args[i] ?? ''
125 if (!a.startsWith('-')) return a
126 if (isParam(a, 'path', 1) || isParam(a, 'literalpath', 1)) return a.includes(':') ? a.slice(a.indexOf(':') + 1) : args[i + 1]
127 }
128 return undefined
129 }
130 if (name === 'cd' || (shell === 'powershell' && POWERSHELL_CD.has(name))) {
131 const a = target()
132 return { cd: a === '-' ? '-' : joinDir(dir, a) }
133 }
134 if (PUSHD.has(name)) {
135 stack.push(dir)
136 return { cd: joinDir(dir, target()) }
137 }
138 if (POPD.has(name)) return { cd: stack.length > 0 ? (stack.pop() ?? null) : '-' }
139 if (shell === 'bash' && name === 'rm') {
140 const r = bashDelete(args, dir)
141 if (r) return r
142 }
143 if (shell === 'powershell' && POWERSHELL_DELETE.has(name)) {
144 const r = powershellDelete(name === 'remove-item' ? 'Remove-Item' : name, args, dir)
145 if (r) return r
146 }
147 if (name === 'git') {
148 const r = gitRisk(args, dir)
149 if (r) return r
150 }
151 return installRisk(first, name, args, dir, shell, input, inVenv) ?? migration(name, args, dir)
152}
153
154/** Every risky segment of a command line, in order; empty when there is none. */
155export function classify(command: string, shell: Shell): Risk[] {
156 const risks: Risk[] = []
157 let dir: string | null = null
158 const subshells: (string | null)[] = []
159 const stack: (string | null)[] = []
160 let previous: string[] | null = null
161 let inVenv = false
162 for (const segment of segments(command, shell)) {
163 for (let k = 0; k < segment.opens; k++) subshells.push(dir)
164 const input = segment.piped ? previous : null
165 const r: Risk | DirChange | null = segment.words.length > 0 ? classifySegment(segment.words, dir, stack, shell, input, inVenv) : null
166 if (r !== null && 'kind' in r) risks.push(r)
167 else if (r !== null) dir = r.cd
168 if (segment.words.length > 0) {
169 if (activatesVenv(segment.words, shell)) inVenv = true
170 previous = stripPrefixes(segment.words, shell)
171 }
172 for (let k = 0; k < segment.closes && subshells.length > 0; k++) dir = subshells.pop() ?? null
173 }
174 return risks
175}
176hooks/measure.ts 238 lines1// What a risky command would change, measured without running it. Returns facts, never prose:
2// the words live in messages.ts. It never sees `$`: register.tsx hands it a Probe of literal
3// engine calls, so it is tested with a plain object.
4
5import type { GitRisk, MigrationRisk, Risk } from './classify'
6import { type InstallRisk, npmPackageName } from './installs'
7import { baseName, dirOf, hasWildcards, positionals, relativeTo, resolvePath, wildcardToRegex } from './shell'
8
9export type Stat = { kind: string; size: number; isLink: boolean }
10export type Entry = Stat & { name: string }
11export type Run = { exitCode: number; stdout: string; stderr: string }
12
13/** What measuring needs from the engine. `stat` and `list` give null on a missing path; `run` never throws. */
14export type Probe = {
15 windows: boolean
16 home: string
17 now(): Promise<number>
18 stat(path: string): Promise<Stat | null>
19 list(path: string): Promise<Entry[] | null>
20 run(argv: string[], options: { cwd?: string; timeoutMs: number; env?: Record<string, string> }): Promise<Run>
21}
22
23export type Facts =
24 | { kind: 'unmeasured'; reason: 'missing-dir' | 'error'; detail: string }
25 | { kind: 'delete'; targets: string[]; found: number; files: number; bytes: number; sample: string[]; truncated: boolean; links: number }
26 | { kind: 'clean'; ok: boolean; paths: string[]; error: string }
27 | { kind: 'discard'; ok: boolean; files: string[]; commits: string[]; shortstat: string; error: string }
28 | { kind: 'push'; target: string; source: string; known: boolean; commits: string[] }
29 | { kind: 'migrate'; argv: string[] | null; ok: boolean; pending: string[] }
30 | { kind: 'install'; installed: Record<string, string> | null }
31
32/** How many paths a list keeps; the rest is only counted. */
33export const LIST_MAX = 10
34const WALK_MAX = 20_000
35const WALK_MS = 8_000
36/** git in English whatever the person's locale: its output is parsed. */
37const GIT_ENV = { LC_ALL: 'C', LANGUAGE: 'C' }
38
39/** Never throws: what could not be measured comes back as `unmeasured`. */
40export async function measure(risk: Risk, cwd: string, probe: Probe): Promise<Facts> {
41 try {
42 if (risk.kind === 'install') return await measureInstall(risk, probe)
43 if (risk.kind === 'delete') return await measureDelete(risk.targets, cwd, probe)
44 if (risk.kind === 'migrate') return await measureMigration(risk, cwd, probe)
45 if (risk.op === 'push-force') return await measurePush(risk, cwd, probe)
46 if (risk.op === 'clean') return await measureClean(risk, cwd, probe)
47 return await measureDiscard(risk, cwd, probe)
48 } catch (error) {
49 return { kind: 'unmeasured', reason: 'error', detail: String(error instanceof Error ? error.message : error).slice(0, 200) }
50 }
51}
52
53// ---------------------------------------------------------------------------
54// Deletes
55
56/** The paths a target names; with wildcards, only in its last part. */
57async function expand(target: string, cwd: string, probe: Probe): Promise<string[]> {
58 const path = resolvePath(cwd, target, probe.home)
59 if (!hasWildcards(path)) return (await probe.stat(path)) ? [path] : []
60 const parent = dirOf(path)
61 if (hasWildcards(parent)) return []
62 const pattern = wildcardToRegex(baseName(path))
63 const entries = (await probe.list(parent)) ?? []
64 return entries.filter(e => pattern.test(e.name)).map(e => `${parent.replace(/\/$/, '')}/${e.name}`)
65}
66
67async function measureDelete(targets: string[], cwd: string, probe: Probe): Promise<Facts> {
68 const deadline = (await probe.now()) + WALK_MS
69 const sample: string[] = []
70 let files = 0
71 let bytes = 0
72 let found = 0
73 let truncated = false
74 // Symbolic links and junctions, counted but never followed: Windows PowerShell's Remove-Item -Recurse can delete through one.
75 let links = 0
76 const count = (path: string, size: number) => {
77 files++
78 bytes += size
79 if (sample.length < LIST_MAX) sample.push(relativeTo(cwd, path))
80 if (files >= WALK_MAX) truncated = true
81 }
82 for (const target of targets) {
83 for (const path of await expand(target, cwd, probe)) {
84 if (truncated) break
85 found++
86 const st = await probe.stat(path)
87 if (st === null) continue
88 if (st.isLink) links++
89 if (st.kind !== 'dir' || st.isLink) {
90 count(path, st.size)
91 continue
92 }
93 let level = [path]
94 while (level.length > 0 && !truncated) {
95 const below: string[] = []
96 for (const dir of level) {
97 for (const e of (await probe.list(dir)) ?? []) {
98 const child = `${dir.replace(/\/$/, '')}/${e.name}`
99 if (e.isLink) links++
100 if (e.kind === 'dir' && !e.isLink) below.push(child)
101 else count(child, e.size)
102 if (truncated) break
103 }
104 if (truncated || (await probe.now()) > deadline) {
105 truncated = true
106 break
107 }
108 }
109 level = below
110 }
111 }
112 }
113 return { kind: 'delete', targets, found, files, bytes, sample, truncated, links }
114}
115
116// ---------------------------------------------------------------------------
117// git
118
119const git = (probe: Probe, cwd: string, args: string[]) => probe.run(['git', ...args], { cwd, timeoutMs: 15_000, env: GIT_ENV })
120
121/** `git clean` arguments for a dry run: `-n` is added, and `-f`, `-i` and `-q` would change it. */
122export function cleanDryRunArgs(args: string[]): string[] {
123 const options: string[] = []
124 const paths: string[] = []
125 for (let i = 0; i < args.length; i++) {
126 const a = args[i] ?? ''
127 if (a === '--') {
128 paths.push(...args.slice(i + 1))
129 break
130 }
131 if (a === '-e' || a === '--exclude') {
132 options.push(a, args[i + 1] ?? '')
133 i++
134 } else if (a.startsWith('--exclude=') || /^-e./.test(a)) options.push(a)
135 else if (/^-[a-zA-Z]+$/.test(a)) {
136 const kept = a.replace(/[finq]/g, '')
137 if (kept !== '-') options.push(kept)
138 } else if (!a.startsWith('-')) paths.push(a)
139 }
140 return ['clean', '-n', ...options, '--', ...paths]
141}
142
143async function measureClean(risk: GitRisk, cwd: string, probe: Probe): Promise<Facts> {
144 const run = await git(probe, cwd, cleanDryRunArgs(risk.args))
145 if (run.exitCode !== 0) return { kind: 'clean', ok: false, paths: [], error: run.stderr.trim().slice(0, 200) }
146 const paths = run.stdout
147 .split('\n')
148 .filter(l => l.startsWith('Would remove '))
149 .map(l => l.slice('Would remove '.length))
150 return { kind: 'clean', ok: true, paths, error: '' }
151}
152
153/** The commit `git reset --hard` moves to (HEAD when it names none). */
154export function resetTarget(args: string[]): string {
155 const end = args.indexOf('--')
156 return (end < 0 ? args : args.slice(0, end)).find(a => !a.startsWith('-')) ?? 'HEAD'
157}
158
159async function measureDiscard(risk: GitRisk, cwd: string, probe: Probe): Promise<Facts> {
160 const status = await git(probe, cwd, ['status', '--porcelain'])
161 if (status.exitCode !== 0) return { kind: 'discard', ok: false, files: [], commits: [], shortstat: '', error: status.stderr.trim().slice(0, 200) }
162 const rows = status.stdout.split('\n').filter(l => l.length > 3 && !l.startsWith('??'))
163 const reset = risk.op === 'reset-hard'
164 // reset --hard loses staged and unstaged changes; checkout -- . only unstaged ones.
165 const files = (reset ? rows : rows.filter(l => l[1] !== ' ')).map(l => `${l.slice(0, 2)} ${l.slice(3)}`)
166 const target = reset ? resetTarget(risk.args) : 'HEAD'
167 const commits = target === 'HEAD' ? [] : (await git(probe, cwd, ['log', '--oneline', '--no-decorate', `${target}..HEAD`])).stdout.split('\n').filter(Boolean)
168 const shortstat = (await git(probe, cwd, ['diff', '--shortstat', reset ? target : '--'])).stdout.trim()
169 return { kind: 'discard', ok: true, files, commits, shortstat, error: '' }
170}
171
172const PUSH_VALUE = new Set(['-o', '--push-option', '--repo', '--receive-pack', '--exec'])
173
174async function measurePush(risk: GitRisk, cwd: string, probe: Probe): Promise<Facts> {
175 const [remote, refspec] = positionals(risk.args, PUSH_VALUE)
176 let source = 'HEAD'
177 let target = ''
178 if (refspec) {
179 const ref = refspec.replace(/^\+/, '')
180 const [from, to] = ref.includes(':') ? ref.split(':') : [ref, ref]
181 source = from || 'HEAD'
182 target = `${remote ?? 'origin'}/${(to ?? '').replace(/^refs\/heads\//, '')}`
183 } else if (remote === undefined) {
184 // No remote and no branch: wherever this branch pushes to.
185 const push = await git(probe, cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{push}'])
186 if (push.exitCode === 0 && push.stdout.trim()) target = push.stdout.trim()
187 }
188 if (!target || target.endsWith('/') || target.endsWith('/HEAD')) {
189 const branch = (await git(probe, cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
190 target = `${remote ?? 'origin'}/${branch}`
191 }
192 if ((await git(probe, cwd, ['rev-parse', '--verify', '--quiet', target])).exitCode !== 0) return { kind: 'push', target, source, known: false, commits: [] }
193 const commits = (await git(probe, cwd, ['log', '--oneline', '--no-decorate', `${source}..${target}`])).stdout.split('\n').filter(Boolean)
194 return { kind: 'push', target, source, known: true, commits }
195}
196
197// ---------------------------------------------------------------------------
198// Migrations
199
200const LISTERS: Record<string, { argv: (windows: boolean) => string[]; pending: (l: string) => boolean; clean: (l: string) => string }> = {
201 django: { argv: w => [w ? 'python' : 'python3', 'manage.py', 'showmigrations', '--plan'], pending: l => l.startsWith('[ ]'), clean: l => l.slice(4) },
202 alembic: { argv: () => ['alembic', 'history', '-r', 'current:head'], pending: l => l.includes('->'), clean: l => l },
203 rails: { argv: () => ['bin/rails', 'db:migrate:status'], pending: l => /^\s*down\b/.test(l), clean: l => l.trim() },
204 prisma: { argv: w => [w ? 'npx.cmd' : 'npx', '--no-install', 'prisma', 'migrate', 'status'], pending: l => /^\s{2}\S/.test(l), clean: l => l.trim() },
205}
206
207async function measureMigration(risk: MigrationRisk, cwd: string, probe: Probe): Promise<Facts> {
208 const lister = LISTERS[risk.tool]
209 if (lister === undefined) return { kind: 'migrate', argv: null, ok: false, pending: [] }
210 const argv = lister.argv(probe.windows)
211 const run = await probe.run(argv, { cwd, timeoutMs: 20_000 })
212 if (run.exitCode !== 0) return { kind: 'migrate', argv, ok: false, pending: [] }
213 return { kind: 'migrate', argv, ok: true, pending: run.stdout.split('\n').filter(lister.pending).map(lister.clean) }
214}
215
216// ---------------------------------------------------------------------------
217// Installs
218
219/** The global npm packages and their versions, read from disk only (no network); null if unreadable. */
220async function globalNpm(probe: Probe): Promise<Record<string, string> | null> {
221 const run = await probe.run([probe.windows ? 'npm.cmd' : 'npm', 'ls', '-g', '--depth=0', '--json'], { timeoutMs: 15_000 })
222 try {
223 const data = JSON.parse(run.stdout) as { dependencies?: Record<string, { version?: unknown }> }
224 return Object.fromEntries(Object.entries(data.dependencies ?? {}).map(([name, d]) => [name, typeof d.version === 'string' ? d.version : '?']))
225 } catch {
226 return null
227 }
228}
229
230async function measureInstall(risk: InstallRisk, probe: Probe): Promise<Facts> {
231 return { kind: 'install', installed: risk.manager === 'npm' && risk.packages.length > 0 ? await globalNpm(probe) : null }
232}
233
234/** The installed version of an npm package spec (`typescript@5.7` → `5.6.2`), if any. */
235export function installedVersion(installed: Record<string, string> | null, spec: string): string | undefined {
236 return installed?.[npmPackageName(spec)]
237}
238hooks/messages.ts 400 lines1// Every word the person and Claude read, in English and Spanish. Built from the data classify.ts
2// and measure.ts return. No `$`: tested directly.
3
4import type { Risk } from './classify'
5import type { InstallRisk, Package } from './installs'
6import { type Facts, installedVersion, LIST_MAX } from './measure'
7
8export type Lang = 'en' | 'es'
9
10/** The `language` setting: `Español` (or `es`, `Spanish`) is Spanish, anything else English. */
11export function langOf(option: unknown): Lang {
12 return typeof option === 'string' && /^(es|español|espanol|spanish)\b/i.test(option.trim()) ? 'es' : 'en'
13}
14
15/** Why a held command did not run. */
16export type Reason = 'cancel' | 'timeout' | 'interrupted' | 'error' | 'nobody' | 'busy'
17
18/** What the panel shows: one line of effect, up to LIST_MAX lines, how many more, and a note. */
19export type Report = { summary: string; lines: string[]; more: number; note: string }
20
21// ---------------------------------------------------------------------------
22// Numbers
23
24const NBSP = '\u00a0'
25
26/** `1,234` in English; in Spanish no separator up to four digits and a non-breaking space after (`20 000`). */
27export function formatNumber(n: number, lang: Lang): string {
28 const digits = String(Math.round(Math.abs(n)))
29 const sign = n < 0 ? '-' : ''
30 if (lang === 'es' && digits.length <= 4) return sign + digits
31 return sign + digits.replace(/\B(?=(\d{3})+(?!\d))/g, lang === 'es' ? NBSP : ',')
32}
33
34/** `3.0 KB` in English, `3,0 KB` in Spanish, with a non-breaking space before the unit. */
35export function formatSize(bytes: number, lang: Lang): string {
36 if (!Number.isFinite(bytes) || bytes < 1024) return `${Math.max(0, Math.round(bytes || 0))}${NBSP}B`
37 const units = ['KB', 'MB', 'GB', 'TB']
38 let n = bytes
39 let i = -1
40 while (n >= 1024 && i < units.length - 1) {
41 n /= 1024
42 i++
43 }
44 const fixed = n.toFixed(n < 10 ? 1 : 0)
45 return `${lang === 'es' ? fixed.replace('.', ',') : fixed}${NBSP}${units[i]}`
46}
47
48export function plural(n: number, one: string, many: string, lang: Lang): string {
49 return `${formatNumber(n, lang)} ${n === 1 ? one : many}`
50}
51
52// ---------------------------------------------------------------------------
53// The words
54
55type Words = {
56 name: string
57 command: string
58 effect: string
59 cancel: string
60 run: string
61 cancelKey: string
62 runKey: string
63 waiting: string
64 more: (n: number) => string
65 also: (commands: string) => string
66 ran: string
67 allowed: string
68 reasons: Record<Reason, string>
69 deny: (reason: string, effect: string) => string
70 notMeasured: string
71 unmeasured: (command: string) => string
72 unmeasuredNote: (detail: string) => string
73 inDir: (command: string, dir: string) => string
74 missingDir: (dir: string) => string
75 deleteNoTargets: (command: string) => string
76 deleteNoTargetsNote: string
77 deleteMissing: (targets: string) => string
78 deleteMissingNote: string
79 deleteEmptyDirs: (n: number) => string
80 deleteFiles: (n: number, size: string, atLeast: boolean) => string
81 paths: (targets: string) => string
82 stoppedCounting: (paths: string) => string
83 cleanFailed: string
84 cleanNothing: string
85 cleanPaths: (n: number) => string
86 cleanNote: string
87 notARepo: (command: string) => string
88 discardNothing: string
89 discardFiles: (n: number) => string
90 dropCommits: (n: number) => string
91 discardFilesAndCommits: (files: number, commits: number) => string
92 discardNote: (shortstat: string) => string
93 statusNote: string
94 reflogNote: string
95 pushTo: (target: string) => string
96 pushUnknown: (target: string) => string
97 pushNoLoss: (target: string) => string
98 pushDrops: (target: string, n: number) => string
99 pushNote: (target: string, source: string) => string
100 migrateUnknownTool: string
101 migrateUnknownToolNote: string
102 migrateFailed: (command: string) => string
103 migrateFailedNote: (argv: string) => string
104 migrateNothing: (command: string) => string
105 migratePending: (n: number) => string
106 accordingTo: (argv: string) => string
107 remote: (source: string | undefined) => string
108 remoteNote: string
109 installs: (action: 'install' | 'update', what: string, scope: string) => string
110 thirdParty: string
111 packages: (n: number) => string
112 pkg: (p: Package) => string
113 alreadyInstalled: (pkg: string, version: string) => string
114 newPackage: (pkg: string) => string
115 noVenv: string
116 installNote: string
117 scope: (risk: InstallRisk) => string
118}
119
120const en = (n: number, one: string, many: string) => plural(n, one, many, 'en')
121const es = (n: number, one: string, many: string) => plural(n, one, many, 'es')
122
123const EN = {
124 name: 'Impact radius',
125 command: 'Command',
126 effect: 'Effect',
127 cancel: 'Cancel',
128 run: 'Run',
129 cancelKey: 'c',
130 runKey: 'r',
131 waiting: 'Claude is waiting for your answer',
132 more: n => `and ${formatNumber(n, 'en')} more`,
133 also: commands => `Also in this command: ${commands}`,
134 ran: 'running it',
135 allowed: 'the user pressed Run',
136 reasons: {
137 cancel: 'the user pressed Cancel',
138 timeout: 'nobody answered within 10 minutes',
139 interrupted: 'the turn was interrupted',
140 error: 'the mod hit an error while holding it',
141 nobody: 'nobody can answer in this session',
142 busy: 'another command was already waiting for an answer',
143 },
144 deny: (reason, effect) => `Impact radius held this command and did not run it: ${reason}. Expected effect: ${effect}. Do not run it again unless the user asks you to.`,
145 notMeasured: 'could not be measured',
146 unmeasured: command => `${command} (could not measure it)`,
147 unmeasuredNote: detail => `Could not measure it: ${detail}`,
148 inDir: (command, dir) => `${command} in ${dir}`,
149 missingDir: dir => `Can't find the folder ${dir}, so I couldn't measure what it would change.`,
150 deleteNoTargets: command => `${command} with no known paths`,
151 deleteNoTargetsNote: "The command doesn't say what it deletes (it may come through a pipe), so I can't measure it.",
152 deleteMissing: targets => `deletes nothing: ${targets} doesn't exist`,
153 deleteMissingNote: "The paths don't exist, so there is nothing to delete.",
154 deleteEmptyDirs: n => `deletes ${en(n, 'empty folder', 'empty folders')}`,
155 deleteFiles: (n, size, atLeast) => `deletes ${atLeast ? 'at least ' : ''}${en(n, 'file', 'files')} (${size})`,
156 paths: targets => `Paths: ${targets}`,
157 stoppedCounting: paths => `${paths}. There are so many that I stopped counting.`,
158 cleanFailed: "git clean (couldn't simulate it)",
159 cleanNothing: 'removes nothing: no untracked file matches',
160 cleanPaths: n => `removes ${en(n, 'untracked path', 'untracked paths')}`,
161 cleanNote: "According to git clean -n. What isn't in git can't be recovered.",
162 notARepo: command => `${command} (not a repository?)`,
163 discardNothing: 'discards nothing: there are no uncommitted changes',
164 discardFiles: n => `discards uncommitted changes in ${en(n, 'file', 'files')}`,
165 dropCommits: n => `drops ${en(n, 'commit', 'commits')} from the branch`,
166 discardFilesAndCommits: (files, commits) => `discards uncommitted changes in ${en(files, 'file', 'files')} and drops ${en(commits, 'commit', 'commits')} from the branch`,
167 discardNote: shortstat => `${shortstat}. Uncommitted changes can't be recovered.`,
168 statusNote: 'According to git status --porcelain.',
169 reflogNote: 'Dropped commits can be recovered with git reflog.',
170 pushTo: target => `force-pushes to ${target}`,
171 pushUnknown: target => `There's no local copy of ${target}, so I don't know which commits it would drop. Run git fetch first.`,
172 pushNoLoss: target => `force-pushes to ${target}: drops no commits`,
173 pushDrops: (target, n) => `force-pushes to ${target} and drops ${en(n, 'commit', 'commits')}`,
174 pushNote: (target, source) => `Commits on ${target} that ${source} doesn't have, as of the last fetch.`,
175 migrateUnknownTool: 'runs migrations',
176 migrateUnknownToolNote: "I don't know how to list this tool's pending migrations.",
177 migrateFailed: command => `runs ${command}`,
178 migrateFailedNote: argv => `I couldn't list the pending migrations (${argv} failed).`,
179 migrateNothing: command => `runs ${command}: nothing pending`,
180 migratePending: n => `applies ${en(n, 'pending migration', 'pending migrations')}`,
181 accordingTo: argv => `According to ${argv}.`,
182 remote: source => `runs an installer downloaded from ${source ?? 'the internet'}`,
183 remoteNote: "It's a script from the internet that runs with your permissions without being saved first, so what it will do can't be reviewed. If in doubt, open it in a browser first.",
184 installs: (action, what, scope) => `${action === 'update' ? 'updates' : 'installs'} ${what} ${scope}`,
185 thirdParty: 'third-party software',
186 packages: n => en(n, 'package', 'packages'),
187 pkg: p => (p.from === 'requirements' ? `the requirements in ${p.name}` : p.from === 'editable' ? `${p.name} (editable)` : p.from === 'path' ? `the folder ${p.name}` : p.name),
188 alreadyInstalled: (pkg, version) => `${pkg} (already installed: ${version})`,
189 newPackage: pkg => `${pkg} (new)`,
190 noVenv: "I don't see an active virtual environment.",
191 installNote: "It lands outside the project, for all your sessions, and it's third-party code that runs with your permissions (also while it installs). Check the exact name and where it comes from.",
192 scope: risk => {
193 switch (risk.scope) {
194 case 'js-global': return `in your user's global ${risk.manager}`
195 case 'python-tool': return 'as a Python tool for your user'
196 case 'python-system': return "in the system's Python"
197 case 'python-user': return "in your user's Python"
198 case 'python-outside-venv': return "in the system's or your user's Python, outside a virtual environment"
199 case 'machine': return `on this machine (${risk.via})`
200 case 'system': return `in the system (${risk.via})`
201 case 'user-manager': return `for your user (${risk.via})`
202 case 'ruby-gems': return "in your user's or the system's Ruby gems"
203 case 'cargo-bin': return 'in ~/.cargo/bin, for your user'
204 case 'go-bin': return "in Go's bin folder, for your user"
205 case 'volta': return 'as a Volta tool for your user'
206 case 'nvm': return 'as a Node version for your user'
207 case 'powershell-user': return "in your user's PowerShell modules"
208 case 'powershell-all-users': return 'for every user on this machine'
209 case 'editor': return `in ${risk.via}, for your user`
210 case 'claude-plugin': return 'in Claude Code, for your user'
211 case 'claude-mcp-user': return 'as a Claude Code MCP server for your user'
212 case 'deno-global': return 'as a global Deno tool'
213 case 'dotnet-global': return 'as a global .NET tool'
214 case 'remote': return 'with whatever the installer decides'
215 }
216 },
217} satisfies Words
218
219const ES = {
220 name: 'Radio de impacto',
221 command: 'Orden',
222 effect: 'Efecto',
223 cancel: 'Cancelar',
224 run: 'Ejecutar',
225 cancelKey: 'c',
226 runKey: 'e',
227 waiting: 'Claude espera tu respuesta',
228 more: n => `y ${formatNumber(n, 'es')} más`,
229 also: commands => `También en esta orden: ${commands}`,
230 ran: 'la ejecuto',
231 allowed: 'la persona pulsó Ejecutar',
232 reasons: {
233 cancel: 'la persona pulsó Cancelar',
234 timeout: 'nadie respondió en 10 minutos',
235 interrupted: 'el turno se interrumpió',
236 error: 'el mod tuvo un error mientras la retenía',
237 nobody: 'en esta sesión no hay nadie que pueda responder',
238 busy: 'ya había otra orden esperando respuesta',
239 },
240 deny: (reason, effect) => `Radio de impacto retuvo esta orden y no la ejecutó: ${reason}. Efecto previsto: ${effect}. No la repitas salvo que la persona te lo pida.`,
241 notMeasured: 'no se pudo medir',
242 unmeasured: command => `${command} (no he podido medirlo)`,
243 unmeasuredNote: detail => `No he podido medirlo: ${detail}`,
244 inDir: (command, dir) => `${command} en ${dir}`,
245 missingDir: dir => `No encuentro la carpeta ${dir}, así que no he podido medir qué cambiaría.`,
246 deleteNoTargets: command => `${command} sin rutas conocidas`,
247 deleteNoTargetsNote: 'La orden no dice qué borra (por ejemplo, lo recibe por una tubería), así que no puedo medirlo.',
248 deleteMissing: targets => `no borraría nada: no existe ${targets}`,
249 deleteMissingNote: 'Las rutas no existen, así que no hay nada que borrar.',
250 deleteEmptyDirs: n => `borraría ${es(n, 'carpeta vacía', 'carpetas vacías')}`,
251 deleteFiles: (n, size, atLeast) => `borraría ${atLeast ? 'al menos ' : ''}${es(n, 'archivo', 'archivos')} (${size})`,
252 paths: targets => `Rutas: ${targets}`,
253 stoppedCounting: paths => `${paths}. Hay tantos que he dejado de contar.`,
254 cleanFailed: 'git clean (no he podido simularlo)',
255 cleanNothing: 'no quitaría nada: ningún archivo sin seguimiento coincide',
256 cleanPaths: n => `quitaría ${es(n, 'ruta sin seguimiento', 'rutas sin seguimiento')}`,
257 cleanNote: 'Según git clean -n. Lo que no está en git no se puede recuperar.',
258 notARepo: command => `${command} (¿no es un repositorio?)`,
259 discardNothing: 'no descartaría nada: no hay cambios sin confirmar',
260 discardFiles: n => `descartaría los cambios sin confirmar de ${es(n, 'archivo', 'archivos')}`,
261 dropCommits: n => `sacaría ${es(n, 'commit', 'commits')} de la rama`,
262 discardFilesAndCommits: (files, commits) => `descartaría los cambios sin confirmar de ${es(files, 'archivo', 'archivos')} y sacaría ${es(commits, 'commit', 'commits')} de la rama`,
263 discardNote: shortstat => `${shortstat}. Los cambios sin confirmar no se pueden recuperar.`,
264 statusNote: 'Según git status --porcelain.',
265 reflogNote: 'Los commits que salen de la rama se pueden recuperar con git reflog.',
266 pushTo: target => `forzaría la subida a ${target}`,
267 pushUnknown: target => `No hay copia local de ${target}, así que no sé qué commits quitaría. Haz antes git fetch.`,
268 pushNoLoss: target => `forzaría la subida a ${target}: no quitaría ningún commit`,
269 pushDrops: (target, n) => `forzaría la subida a ${target}: quitaría ${es(n, 'commit', 'commits')}`,
270 pushNote: (target, source) => `Commits de ${target} que ${source} no tiene, según el último fetch.`,
271 migrateUnknownTool: 'ejecutaría migraciones',
272 migrateUnknownToolNote: 'No sé listar las migraciones pendientes de esta herramienta.',
273 migrateFailed: command => `ejecutaría ${command}`,
274 migrateFailedNote: argv => `No he podido listar las migraciones pendientes (falló ${argv}).`,
275 migrateNothing: command => `ejecutaría ${command}: no hay nada pendiente`,
276 migratePending: n => `aplicaría ${es(n, 'migración pendiente', 'migraciones pendientes')}`,
277 accordingTo: argv => `Según ${argv}.`,
278 remote: source => `ejecutaría un instalador descargado de ${source ?? 'internet'}`,
279 remoteNote: 'Es un script de internet que se ejecuta con tus permisos sin guardarse antes, así que no se puede revisar lo que hará. Si dudas, ábrelo antes en el navegador.',
280 installs: (action, what, scope) => `${action === 'update' ? 'actualizaría' : 'instalaría'} ${what} ${scope}`,
281 thirdParty: 'software de terceros',
282 packages: n => es(n, 'paquete', 'paquetes'),
283 pkg: p => (p.from === 'requirements' ? `los requisitos de ${p.name}` : p.from === 'editable' ? `${p.name} (editable)` : p.from === 'path' ? `la carpeta ${p.name}` : p.name),
284 alreadyInstalled: (pkg, version) => `${pkg} (ya instalado: ${version})`,
285 newPackage: pkg => `${pkg} (nuevo)`,
286 noVenv: 'No veo un entorno virtual activo.',
287 installNote: 'Queda fuera del proyecto, para todas tus sesiones, y es código de terceros que se ejecuta con tus permisos (también al instalarse). Comprueba el nombre exacto y de dónde viene.',
288 scope: risk => {
289 switch (risk.scope) {
290 case 'js-global': return `en el ${risk.manager} global de tu usuario`
291 case 'python-tool': return 'como herramienta de Python de tu usuario'
292 case 'python-system': return 'en el Python del sistema'
293 case 'python-user': return 'en el Python de tu usuario'
294 case 'python-outside-venv': return 'en el Python del sistema o de tu usuario, fuera de un entorno virtual'
295 case 'machine': return `en el equipo (${risk.via})`
296 case 'system': return `en el sistema (${risk.via})`
297 case 'user-manager': return `para tu usuario (${risk.via})`
298 case 'ruby-gems': return 'en las gemas de Ruby de tu usuario o del sistema'
299 case 'cargo-bin': return 'en ~/.cargo/bin, para tu usuario'
300 case 'go-bin': return 'en la carpeta bin de Go, para tu usuario'
301 case 'volta': return 'como herramienta de Volta para tu usuario'
302 case 'nvm': return 'como versión de Node para tu usuario'
303 case 'powershell-user': return 'en los módulos de PowerShell de tu usuario'
304 case 'powershell-all-users': return 'para todos los usuarios del equipo'
305 case 'editor': return `en ${risk.via}, para tu usuario`
306 case 'claude-plugin': return 'en Claude Code, para tu usuario'
307 case 'claude-mcp-user': return 'como servidor MCP de Claude Code para tu usuario'
308 case 'deno-global': return 'como herramienta global de Deno'
309 case 'dotnet-global': return 'como herramienta global de .NET'
310 case 'remote': return 'con lo que decida el instalador'
311 }
312 },
313} satisfies Words
314
315const WORDS: Record<Lang, Words> = { en: EN, es: ES }
316
317export function words(lang: Lang): Words {
318 return WORDS[lang]
319}
320
321// ---------------------------------------------------------------------------
322// Reports
323
324const listed = (items: string[]) => ({ lines: items.slice(0, LIST_MAX), more: Math.max(0, items.length - LIST_MAX) })
325
326/** What the panel says about a risk, from what was measured. */
327export function report(risk: Risk, facts: Facts, lang: Lang): Report {
328 const t = WORDS[lang]
329 switch (facts.kind) {
330 case 'unmeasured':
331 return facts.reason === 'missing-dir'
332 ? { summary: t.inDir(risk.command, facts.detail), lines: [], more: 0, note: t.missingDir(facts.detail) }
333 : { summary: t.unmeasured(risk.command), lines: [], more: 0, note: t.unmeasuredNote(facts.detail) }
334 case 'delete': {
335 const targets = facts.targets.join(' ')
336 if (facts.targets.length === 0) return { summary: t.deleteNoTargets(risk.command), lines: [], more: 0, note: t.deleteNoTargetsNote }
337 if (facts.found === 0) return { summary: t.deleteMissing(targets), lines: [], more: 0, note: t.deleteMissingNote }
338 if (facts.files === 0) return { summary: t.deleteEmptyDirs(facts.found), lines: [], more: 0, note: t.paths(targets) }
339 return {
340 summary: t.deleteFiles(facts.files, formatSize(facts.bytes, lang), facts.truncated),
341 lines: facts.sample.slice(0, LIST_MAX),
342 more: Math.max(0, facts.files - Math.min(facts.sample.length, LIST_MAX)),
343 note: facts.truncated ? t.stoppedCounting(t.paths(targets)) : t.paths(targets),
344 }
345 }
346 case 'clean':
347 if (!facts.ok) return { summary: t.cleanFailed, lines: [], more: 0, note: facts.error }
348 return { summary: facts.paths.length === 0 ? t.cleanNothing : t.cleanPaths(facts.paths.length), ...listed(facts.paths), note: t.cleanNote }
349 case 'discard': {
350 if (!facts.ok) return { summary: t.notARepo(risk.command), lines: [], more: 0, note: facts.error }
351 const files = facts.files.length
352 const commits = facts.commits.length
353 const summary =
354 files > 0 && commits > 0 ? t.discardFilesAndCommits(files, commits) : commits > 0 ? t.dropCommits(commits) : files > 0 ? t.discardFiles(files) : t.discardNothing
355 const note = [facts.shortstat ? t.discardNote(facts.shortstat) : t.statusNote, commits > 0 ? t.reflogNote : ''].filter(Boolean).join(' ')
356 return { summary, ...listed([...facts.files, ...facts.commits]), note }
357 }
358 case 'push':
359 if (!facts.known) return { summary: t.pushTo(facts.target), lines: [], more: 0, note: t.pushUnknown(facts.target) }
360 return {
361 summary: facts.commits.length === 0 ? t.pushNoLoss(facts.target) : t.pushDrops(facts.target, facts.commits.length),
362 ...listed(facts.commits),
363 note: t.pushNote(facts.target, facts.source),
364 }
365 case 'migrate': {
366 if (facts.argv === null) return { summary: t.migrateUnknownTool, lines: [], more: 0, note: t.migrateUnknownToolNote }
367 const argv = facts.argv.join(' ')
368 if (!facts.ok) return { summary: t.migrateFailed(risk.command), lines: [], more: 0, note: t.migrateFailedNote(argv) }
369 return {
370 summary: facts.pending.length === 0 ? t.migrateNothing(risk.command) : t.migratePending(facts.pending.length),
371 ...listed(facts.pending),
372 note: t.accordingTo(argv),
373 }
374 }
375 case 'install': {
376 if (risk.kind !== 'install') return { summary: risk.command, lines: [], more: 0, note: '' }
377 if (risk.scope === 'remote') return { summary: t.remote(risk.source), lines: [], more: 0, note: t.remoteNote }
378 const names = risk.packages.map(t.pkg)
379 const lines = risk.packages.map((p, i) => {
380 const name = names[i] ?? p.name
381 if (facts.installed === null) return name
382 const version = installedVersion(facts.installed, p.name)
383 return version ? t.alreadyInstalled(name, version) : t.newPackage(name)
384 })
385 const what = names.length === 0 ? t.thirdParty : names.length === 1 ? (names[0] ?? '') : t.packages(names.length)
386 return {
387 summary: t.installs(risk.action, what, t.scope(risk)),
388 ...listed(lines),
389 note: [risk.venvDependent ? t.noVenv : '', t.installNote].filter(Boolean).join(' '),
390 }
391 }
392 }
393}
394
395/** What Claude reads when the command does not run. */
396export function denyReason(reason: Reason, effect: string, lang: Lang): string {
397 const t = WORDS[lang]
398 return t.deny(t.reasons[reason], effect)
399}
400hooks/shell.ts 256 lines1// Splitting a command line into segments and words, and the path and option helpers the rules
2// share. No `$`: tested directly.
3
4export type Shell = 'bash' | 'powershell'
5
6/**
7 * One command between `&&`, `||`, `;`, `|`, newlines or braces. `opens` and `closes` count the
8 * parentheses around it, to know where a `cd` made inside a subshell ends; `piped` means it reads
9 * what the previous segment writes.
10 */
11export type Segment = { words: string[]; opens: number; closes: number; piped: boolean }
12
13// ---------------------------------------------------------------------------
14// Segments and words
15
16/** Splits a command line into segments and each segment into words, honouring quotes. */
17export function segments(command: string, shell: Shell): Segment[] {
18 const escape = shell === 'bash' ? '\\' : '`'
19 const out: Segment[] = []
20 let words: string[] = []
21 let word = ''
22 let started = false
23 let quote = ''
24 let opens = 0
25 let closes = 0
26 let piped = false
27 let nested = 0 // inside $( … ) or @( … ): part of the word
28 const endWord = () => {
29 if (started) words.push(word)
30 word = ''
31 started = false
32 }
33 const endSegment = () => {
34 endWord()
35 if (words.length > 0 || opens > 0 || closes > 0) out.push({ words, opens, closes, piped })
36 words = []
37 opens = 0
38 closes = 0
39 piped = false
40 }
41 for (let i = 0; i < command.length; i++) {
42 const c = command[i] ?? ''
43 const next = command[i + 1] ?? ''
44 if (quote) {
45 if (c === quote) quote = ''
46 else if (c === escape && quote === '"' && i + 1 < command.length) word += command[++i]
47 else word += c
48 continue
49 }
50 if (nested > 0) {
51 word += c
52 if (c === '(') nested++
53 else if (c === ')') nested--
54 continue
55 }
56 if (c === '"' || c === "'") {
57 quote = c
58 started = true
59 } else if (c === escape && i + 1 < command.length && next !== '\n') {
60 word += command[++i]
61 started = true
62 } else if (c === '(' && (word.endsWith('$') || word.endsWith('@'))) {
63 word += c
64 nested = 1
65 } else if (c === '(' && !started && words.length === 0) {
66 opens++
67 } else if (c === ')') {
68 endWord()
69 closes++
70 } else if (c === ' ' || c === '\t' || c === '\r') {
71 endWord()
72 } else if (c === '\n' || c === ';' || c === '{' || c === '}') {
73 endSegment()
74 } else if ((c === '&' || c === '|') && next === c) {
75 i++
76 endSegment()
77 } else if (c === '|') {
78 endSegment()
79 piped = true
80 } else if (c === '&' && shell === 'bash' && command[i - 1] !== '>' && next !== '>') {
81 endSegment() // `&` sends the command to the background; in PowerShell it is the call operator
82 } else {
83 word += c
84 started = true
85 }
86 }
87 endSegment()
88 return out
89}
90
91const SUDO_OPTIONS = new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U'])
92const PREFIXES = new Set(['command', 'exec', 'env', 'nohup', 'time', 'then', 'do', 'else', 'elif', 'if', 'while', 'until', '!', 'xargs'])
93
94/** A command's name: no path, no `.exe`, and case-insensitive in PowerShell. */
95export function commandName(word: string, shell: Shell): string {
96 const bare = word.replace(/^\\/, '').split(/[\\/]/).pop() ?? ''
97 const name = bare.replace(/\.(exe|cmd|bat)$/i, '')
98 return shell === 'powershell' ? name.toLowerCase() : name
99}
100
101/** Drops what comes before the real command: variables, `sudo`, `nice`, `&`… */
102export function stripPrefixes(words: string[], shell: Shell): string[] {
103 const w = [...words]
104 const isVariable = (s: string | undefined) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(s ?? '')
105 for (;;) {
106 const first = w[0]
107 if (first === undefined) return w
108 if (shell === 'bash' && isVariable(first)) w.shift()
109 else if (shell === 'powershell' && (first === '&' || first === '.')) w.shift()
110 else if (PREFIXES.has(shell === 'powershell' ? first.toLowerCase() : first)) w.shift()
111 else if (first === 'sudo') {
112 w.shift()
113 while ((w[0] ?? '').startsWith('-')) {
114 const option = w.shift() ?? ''
115 if (SUDO_OPTIONS.has(option)) w.shift()
116 }
117 } else if (first === 'nice') {
118 w.shift()
119 if (w[0] === '-n') w.splice(0, 2)
120 else if (/^-\d+$/.test(w[0] ?? '')) w.shift()
121 } else return w
122 }
123}
124
125/** A PowerShell parameter, abbreviated or not: `-Rec` stands for `-Recurse` from `minimum` letters. */
126export function isParam(arg: string, name: string, minimum: number): boolean {
127 const m = /^-([A-Za-z]+)(:.*)?$/.exec(arg)
128 const written = (m?.[1] ?? '').toLowerCase()
129 return written.length >= minimum && name.startsWith(written)
130}
131
132/** The words that are not options, skipping the value of each option in `withValue`. */
133export function positionals(args: string[], withValue: ReadonlySet<string> = new Set()): string[] {
134 const out: string[] = []
135 for (let i = 0; i < args.length; i++) {
136 const a = args[i] ?? ''
137 if (a === '--') {
138 out.push(...args.slice(i + 1))
139 break
140 }
141 if (a.startsWith('-') && a.length > 1) {
142 if (!a.includes('=') && withValue.has(a)) i++
143 continue
144 }
145 out.push(a)
146 }
147 return out
148}
149
150/** An option's value (`--scope user` or `--scope=user`); the last one if repeated. */
151export function optionValue(args: string[], ...names: string[]): string | undefined {
152 let value: string | undefined
153 for (let i = 0; i < args.length; i++) {
154 const a = args[i] ?? ''
155 if (names.includes(a)) value = args[i + 1]
156 const joined = names.find(n => a.startsWith(`${n}=`))
157 if (joined) value = a.slice(joined.length + 1)
158 }
159 return value
160}
161
162/** Every value of an option that can repeat. */
163export function optionValues(args: string[], ...names: string[]): string[] {
164 const out: string[] = []
165 for (let i = 0; i < args.length; i++) {
166 const a = args[i] ?? ''
167 if (names.includes(a) && args[i + 1] !== undefined) out.push(args[++i] ?? '')
168 const joined = names.find(n => a.startsWith(`${n}=`))
169 if (joined) out.push(a.slice(joined.length + 1))
170 }
171 return out
172}
173
174// ---------------------------------------------------------------------------
175// Paths
176
177/** Joins a folder and what `cd` got: an absolute path wins, a relative one is appended. */
178export function joinDir(dir: string | null, arg: string | undefined): string {
179 if (arg === undefined || arg === '' || arg === '~') return '~'
180 if (isAbsolute(arg)) return arg
181 return dir ? `${dir.replace(/[\\/]+$/, '')}/${arg}` : arg
182}
183
184export function isAbsolute(path: string): boolean {
185 return /^([\\/]|~[\\/]|~$|[A-Za-z]:[\\/]|[A-Za-z]:$)/.test(path)
186}
187
188/**
189 * `path` seen from `base`, absolute and with forward slashes. Understands `~`, Windows drive
190 * letters and Git Bash paths (`/c/Users/…`) when the base is a Windows one.
191 */
192export function resolvePath(base: string, path: string, home = ''): string {
193 let p = path.replace(/\\/g, '/')
194 const b = base.replace(/\\/g, '/').replace(/\/+$/, '')
195 if (home && (p === '~' || p.startsWith('~/'))) p = home.replace(/\\/g, '/').replace(/\/+$/, '') + p.slice(1)
196 if (/^[A-Za-z]:/.test(b)) p = p.replace(/^\/([A-Za-z])(?=\/|$)/, (_, letter: string) => `${letter.toUpperCase()}:`)
197 let root = ''
198 let rest = p
199 if (/^[A-Za-z]:/.test(p)) {
200 root = p.slice(0, 2).toUpperCase()
201 rest = p.slice(2)
202 } else if (!p.startsWith('/')) {
203 const drive = /^[A-Za-z]:/.test(b) ? b.slice(0, 2).toUpperCase() : ''
204 root = drive
205 rest = `${b.slice(drive.length)}/${p}`
206 }
207 const parts: string[] = []
208 for (const part of rest.split('/')) {
209 if (part === '' || part === '.') continue
210 if (part === '..') parts.pop()
211 else parts.push(part)
212 }
213 return `${root}/${parts.join('/')}`
214}
215
216export function dirOf(path: string): string {
217 const i = path.lastIndexOf('/')
218 if (i < 0) return '.'
219 if (i === 0) return '/'
220 const parent = path.slice(0, i)
221 return /^[A-Za-z]:$/.test(parent) ? `${parent}/` : parent
222}
223
224export function baseName(path: string): string {
225 return path.slice(path.lastIndexOf('/') + 1)
226}
227
228/** `path` relative to `base` when it is inside it; otherwise whole. */
229export function relativeTo(base: string, path: string): string {
230 const b = base.replace(/\/+$/, '')
231 return path.toLowerCase().startsWith(`${b.toLowerCase()}/`) ? path.slice(b.length + 1) : path
232}
233
234export function hasWildcards(text: string): boolean {
235 return /[*?[]/.test(text)
236}
237
238/** A shell wildcard (`*.log`, `build?`, `[ab]*`) as a case-insensitive regular expression. */
239export function wildcardToRegex(pattern: string): RegExp {
240 let re = ''
241 for (let i = 0; i < pattern.length; i++) {
242 const c = pattern[i] ?? ''
243 if (c === '*') re += '.*'
244 else if (c === '?') re += '.'
245 else if (c === '[') {
246 const end = pattern.indexOf(']', i + 1)
247 if (end < 0) re += '\\['
248 else {
249 re += `[${pattern.slice(i + 1, end).replace(/^!/, '^').replace(/\\/g, '\\\\')}]`
250 i = end
251 }
252 } else re += c.replace(/[.+^${}()|\\]/g, m => `\\${m}`)
253 }
254 return new RegExp(`^${re}$`, 'i')
255}
256hooks/temp.ts 56 lines1// Which delete targets lie inside the system's temporary folder, where Claude keeps its scratch
2// files: a recursive delete there is not held. No `$`: register.tsx hands in the environment's
3// values, so it is tested directly.
4
5import { hasWildcards, isAbsolute, resolvePath, type Shell } from './shell'
6
7export type TemporaryVariables = { TEMP?: string; TMP?: string; TMPDIR?: string }
8
9/** The temporary folders: TEMP, TMP and TMPDIR as the environment gives them, plus /tmp and /var/tmp off Windows. */
10export function temporaryRoots(vars: TemporaryVariables, windows: boolean): string[] {
11 const candidates = [vars.TEMP, vars.TMP, vars.TMPDIR, ...(windows ? [] : ['/tmp', '/var/tmp'])]
12 const roots = candidates
13 .map(value => (value ?? '').trim())
14 .filter(value => value !== '' && isAbsolute(value))
15 .map(value => resolvePath('/', value))
16 // A drive or the filesystem's root is never a temporary folder, whatever a variable says.
17 .filter(root => !/^([A-Za-z]:)?\/?$/.test(root))
18 return [...new Set(roots)]
19}
20
21/**
22 * The target with a leading temporary-folder variable replaced (`$TMPDIR/x`, `${TEMP}/x`, `$env:TEMP\x`);
23 * null when it still names a variable, an expression or a command, whose value is unknown here.
24 */
25export function expandTemporaryVariables(target: string, vars: TemporaryVariables, shell: Shell): string | null {
26 const pattern = shell === 'bash' ? /^\$(?:\{(TEMP|TMP|TMPDIR)\}|(TEMP|TMP|TMPDIR))(?=$|[\\/])/ : /^\$(?:\{env:(TEMP|TMP|TMPDIR)\}|env:(TEMP|TMP|TMPDIR))(?=$|[\\/])/i
27 const match = pattern.exec(target)
28 let expanded = target
29 if (match) {
30 const name = (match[1] ?? match[2] ?? '').toUpperCase() as keyof TemporaryVariables
31 const value = vars[name]
32 if (!value) return null
33 expanded = value + target.slice(match[0].length)
34 }
35 return /[$%`]/.test(expanded) ? null : expanded
36}
37
38/** Whether a resolved path lies strictly below a temporary folder, through a first folder named without wildcards. */
39export function insideTemporary(path: string, roots: string[], windows: boolean): boolean {
40 const fold = (text: string) => (windows ? text.toLowerCase() : text)
41 return roots.some(root => {
42 const base = root.replace(/\/+$/, '')
43 if (!fold(path).startsWith(`${fold(base)}/`)) return false
44 const first = path.slice(base.length + 1).split('/')[0] ?? ''
45 return first !== '' && !hasWildcards(first)
46 })
47}
48
49/** The resolved path of a delete target when it lies inside a temporary folder, or null. */
50export function temporaryPath(target: string, cwd: string, home: string, vars: TemporaryVariables, roots: string[], shell: Shell, windows: boolean): string | null {
51 const expanded = expandTemporaryVariables(target, vars, shell)
52 if (expanded === null) return null
53 const path = resolvePath(cwd, expanded, home)
54 return insideTemporary(path, roots, windows) ? path : null
55}
56hooks/installs.ts 342 lines1// Third-party software installed for the whole user or system, outside the project: a global
2// package, a language tool, a module, an editor extension, a Claude Code plugin or an installer
3// piped from the internet. No `$`: tested directly.
4
5import { commandName, isParam, optionValue, optionValues, positionals, type Shell, stripPrefixes } from './shell'
6
7/** Where an install lands, phrased by `messages.ts`. */
8export type Scope =
9 | 'js-global'
10 | 'python-tool'
11 | 'python-system'
12 | 'python-user'
13 | 'python-outside-venv'
14 | 'machine'
15 | 'system'
16 | 'user-manager'
17 | 'ruby-gems'
18 | 'cargo-bin'
19 | 'go-bin'
20 | 'volta'
21 | 'nvm'
22 | 'powershell-user'
23 | 'powershell-all-users'
24 | 'editor'
25 | 'claude-plugin'
26 | 'claude-mcp-user'
27 | 'deno-global'
28 | 'dotnet-global'
29 | 'remote'
30
31/** What gets installed, as the command names it; `from` says it is not a package name. */
32export type Package = { name: string; from?: 'requirements' | 'editable' | 'path' }
33
34export type InstallRisk = {
35 kind: 'install'
36 /** The command as a short literal label: `npm install -g`, `winget install`… */
37 command: string
38 dir: string | null
39 /** `npm`, `pip`, `winget`… or `remote` for an installer piped from the internet. */
40 manager: string
41 action: 'install' | 'update'
42 /** Empty when the command doesn't say. */
43 packages: Package[]
44 scope: Scope
45 /** The tool's display name for the scopes that mention it: `Homebrew`, `apt`, `Cursor`… */
46 via?: string
47 /** Where a remote installer is downloaded from. */
48 source?: string
49 /** pip without `--user`: only for the whole user when no virtual environment is active. */
50 venvDependent?: true
51}
52
53const UPDATE = /\b(upgrade|update|up|refresh|full-upgrade|dist-upgrade)\b/i
54
55const install = (manager: string, command: string, packages: Package[], scope: Scope, dir: string | null, via?: string): InstallRisk => ({
56 kind: 'install',
57 command,
58 dir,
59 manager,
60 action: UPDATE.test(command) ? 'update' : 'install',
61 packages,
62 scope,
63 ...(via ? { via } : {}),
64})
65
66const named = (names: string[]): Package[] => names.map(name => ({ name }))
67
68// ---------------------------------------------------------------------------
69// JavaScript
70
71const NPM_VALUE = new Set(['--registry', '--prefix', '--tag', '--location', '-w', '--workspace', '--omit', '--include', '--save-prefix', '--cache', '--userconfig', '--filter', '-C', '--dir'])
72const NPM_SUBCOMMANDS = new Set(['install', 'i', 'in', 'add', 'isntall', 'update', 'up', 'upgrade'])
73const PNPM_BUN_SUBCOMMANDS = new Set(['add', 'install', 'i', 'a', 'update', 'up', 'upgrade'])
74
75/** npm, pnpm and bun with `-g`, and `yarn global add`. */
76function javascript(name: string, args: string[], dir: string | null): InstallRisk | null {
77 if (name === 'yarn') {
78 const [global, sub, ...packages] = positionals(args, NPM_VALUE)
79 if (global !== 'global' || (sub !== 'add' && sub !== 'upgrade')) return null
80 return install('yarn', `yarn global ${sub}`, named(packages), 'js-global', dir)
81 }
82 const global =
83 args.some(a => a === '-g' || a === '--global' || a === '--global=true' || a === '--location=global') ||
84 optionValue(args, '--location') === 'global'
85 if (!global) return null
86 const [sub, ...packages] = positionals(args, NPM_VALUE)
87 if (!(name === 'npm' ? NPM_SUBCOMMANDS : PNPM_BUN_SUBCOMMANDS).has(sub ?? '')) return null
88 return install(name, `${name} ${sub} -g`, named(packages), 'js-global', dir)
89}
90
91/** A package name without its version (`@scope/x@1.2` → `@scope/x`). */
92export function npmPackageName(spec: string): string {
93 if (spec.startsWith('@')) {
94 const i = spec.indexOf('@', 1)
95 return i < 0 ? spec : spec.slice(0, i)
96 }
97 const i = spec.indexOf('@')
98 return i <= 0 ? spec : spec.slice(0, i)
99}
100
101// ---------------------------------------------------------------------------
102// Python
103
104const IS_PIP = /^pip(\d+(\.\d+)*)?$/i
105const IS_PYTHON = /^(python(\d+(\.\d+)*)?|py)$/i
106const PIP_VALUE = new Set([
107 '-r', '--requirement', '-c', '--constraint', '-e', '--editable', '-i', '--index-url', '--extra-index-url', '-f',
108 '--find-links', '--proxy', '--log', '--timeout', '--retries', '--platform', '--python-version', '--implementation',
109 '--abi', '--src', '--upgrade-strategy', '--progress-bar', '--trusted-host', '--cert', '--client-cert', '--cache-dir',
110 '--exists-action', '--global-option', '--config-settings', '-C', '--only-binary', '--no-binary',
111])
112
113/** A pip or python from a virtual environment, by its path (`.venv/bin/pip`, `venv\Scripts\python`). */
114export function isVenvCommand(command: string): boolean {
115 return /(^|[\\/])\.?(venv|env|virtualenv)[^\\/]*[\\/]/i.test(command)
116}
117
118/** Whether a segment activates a Python virtual environment for what follows. */
119export function activatesVenv(words: string[], shell: Shell): boolean {
120 const [first, ...args] = stripPrefixes(words, shell)
121 if (first === undefined) return false
122 const name = commandName(first, shell).toLowerCase()
123 const isActivate = (path: string | undefined) => /(^|[\\/])activate(\.(ps1|bat|fish|csh))?$/i.test(path ?? '')
124 if ((name === 'source' || name === '.') && isActivate(args[0])) return true
125 if (isActivate(first)) return true
126 if (name === 'conda' && args[0] === 'activate') return (args[1] ?? 'base') !== 'base'
127 return name === 'workon' || (name === 'poetry' && args[0] === 'shell') || (name === 'pyenv' && args[0] === 'activate')
128}
129
130/** pip, `python -m pip`, `uv pip --system`, `uv tool` and pipx. */
131function python(first: string, name: string, args: string[], dir: string | null, inVenv: boolean): InstallRisk | null {
132 const n = name.toLowerCase()
133 if (n === 'pipx' && args[0] === 'install') return install('pipx', 'pipx install', named(positionals(args.slice(1), PIP_VALUE)), 'python-tool', dir)
134 if (n === 'uv' && args[0] === 'tool' && args[1] === 'install') {
135 return install('uv', 'uv tool install', named(positionals(args.slice(2), PIP_VALUE)), 'python-tool', dir)
136 }
137 let rest: string[] | null = null
138 if (IS_PIP.test(n)) rest = args
139 else if (n === 'uv' && args[0] === 'pip') rest = args.includes('--system') ? args.slice(1) : null
140 else if (IS_PYTHON.test(n)) {
141 const m = args.indexOf('-m')
142 if (m >= 0 && /^pip\d*$/i.test(args[m + 1] ?? '')) rest = args.slice(m + 2)
143 }
144 if (rest === null) return null
145 const [sub, ...packages] = positionals(rest, PIP_VALUE)
146 if (sub !== 'install') return null
147 if (rest.some(a => ['--dry-run', '-h', '--help'].includes(a) || /^(--target|--prefix|--root|-t)(=|$)/.test(a))) return null
148 if (n !== 'uv' && (inVenv || isVenvCommand(first))) return null
149 const all: Package[] = [
150 ...named(packages),
151 ...optionValues(rest, '-r', '--requirement').map(name => ({ name, from: 'requirements' as const })),
152 ...optionValues(rest, '-e', '--editable').map(name => ({ name, from: 'editable' as const })),
153 ]
154 if (n === 'uv') return install('uv', 'uv pip install --system', all, 'python-system', dir)
155 if (rest.includes('--user')) return install('pip', 'pip install --user', all, 'python-user', dir)
156 return {
157 ...install('pip', 'pip install', all, 'python-outside-venv', dir),
158 ...(rest.includes('--break-system-packages') ? {} : { venvDependent: true as const }),
159 }
160}
161
162// ---------------------------------------------------------------------------
163// System package managers and language tools
164
165/** The subcommands that install or update, where it lands and the options that take a value. */
166const SYSTEM: Record<string, { subs: string[]; scope: Scope; via?: string; value?: string[]; names?: string[] }> = {
167 winget: {
168 subs: ['install', 'add', 'upgrade', 'update'],
169 scope: 'machine',
170 via: 'winget',
171 value: ['--source', '-s', '--version', '-v', '--scope', '--location', '-l', '--override', '--log', '-o', '--custom', '--architecture', '-a', '--locale', '--header', '--id', '--name', '--query', '-q', '--moniker', '-m'],
172 names: ['--id', '--name', '--query', '-q', '--moniker', '-m'],
173 },
174 choco: { subs: ['install', 'upgrade'], scope: 'machine', via: 'Chocolatey', value: ['--version', '--source', '-s', '--params', '--package-parameters'] },
175 scoop: { subs: ['install', 'update'], scope: 'user-manager', via: 'Scoop' },
176 brew: { subs: ['install', 'reinstall', 'upgrade'], scope: 'machine', via: 'Homebrew' },
177 apt: { subs: ['install', 'reinstall', 'upgrade', 'full-upgrade', 'dist-upgrade'], scope: 'system', via: 'apt' },
178 'apt-get': { subs: ['install', 'reinstall', 'upgrade', 'dist-upgrade'], scope: 'system', via: 'apt' },
179 dnf: { subs: ['install', 'reinstall', 'upgrade', 'update'], scope: 'system', via: 'dnf' },
180 yum: { subs: ['install', 'reinstall', 'upgrade', 'update'], scope: 'system', via: 'yum' },
181 zypper: { subs: ['install', 'in', 'update', 'up'], scope: 'system', via: 'zypper' },
182 apk: { subs: ['add', 'upgrade'], scope: 'system', via: 'apk' },
183 snap: { subs: ['install', 'refresh'], scope: 'system', via: 'snap' },
184 flatpak: { subs: ['install', 'update'], scope: 'system', via: 'Flatpak' },
185 port: { subs: ['install', 'upgrade'], scope: 'machine', via: 'MacPorts' },
186 gem: { subs: ['install', 'update'], scope: 'ruby-gems', value: ['-v', '--version', '-i', '--install-dir', '-n', '--bindir', '--source', '-s', '-P', '--trust-policy', '--platform'] },
187 cargo: {
188 subs: ['install', 'binstall'],
189 scope: 'cargo-bin',
190 value: ['--version', '--git', '--branch', '--tag', '--rev', '--path', '--root', '--index', '--registry', '--target', '--features', '-F', '-j', '--jobs', '--profile', '--bin', '--example', '--target-dir', '--config'],
191 },
192 go: { subs: ['install'], scope: 'go-bin', value: ['-tags', '-ldflags', '-gcflags', '-mod', '-modfile', '-p', '-pkgdir', '-toolexec', '-overlay'] },
193 volta: { subs: ['install'], scope: 'volta' },
194 nvm: { subs: ['install'], scope: 'nvm' },
195}
196
197function system(name: string, args: string[], dir: string | null): InstallRisk | null {
198 const n = name.toLowerCase()
199 if (n === 'cinst') return install('choco', 'cinst', named(positionals(args)), 'machine', dir, 'Chocolatey')
200 if (n === 'pacman') {
201 if (!args.some(a => /^-S[yu]*$/.test(a) || a === '--sync')) return null
202 return install('pacman', 'pacman -S', named(positionals(args)), 'system', dir, 'pacman')
203 }
204 const rule = SYSTEM[n]
205 if (rule === undefined) return null
206 const [sub, ...rest] = positionals(args, new Set(rule.value ?? []))
207 if (!rule.subs.includes(sub ?? '')) return null
208 const packages = named([...rest, ...(rule.names ? optionValues(args, ...rule.names) : [])])
209 if (n === 'cargo' && args.includes('--path')) packages.push({ name: optionValue(args, '--path') ?? '.', from: 'path' })
210 return install(n, `${n} ${sub}`, packages, rule.scope, dir, rule.via)
211}
212
213// ---------------------------------------------------------------------------
214// PowerShell modules, editors, Claude Code, Deno and .NET
215
216const POWERSHELL_MODULES = new Set(['install-module', 'install-package', 'install-script', 'install-psresource', 'update-module', 'update-psresource'])
217const EDITORS: Record<string, string> = { code: 'VS Code', 'code-insiders': 'VS Code Insiders', codium: 'VSCodium', cursor: 'Cursor' }
218
219function powershellModule(name: string, args: string[], dir: string | null): InstallRisk | null {
220 if (!POWERSHELL_MODULES.has(name)) return null
221 const packages: string[] = []
222 let scope = ''
223 for (let i = 0; i < args.length; i++) {
224 const a = args[i] ?? ''
225 const value = a.includes(':') ? a.slice(a.indexOf(':') + 1) : undefined
226 if (!a.startsWith('-')) packages.push(...a.split(',').filter(Boolean))
227 else if (isParam(a, 'name', 1)) packages.push(...(value ?? args[++i] ?? '').split(',').filter(Boolean))
228 else if (isParam(a, 'scope', 2)) scope = (value ?? args[++i] ?? '').toLowerCase()
229 else if (['repository', 'requiredversion', 'minimumversion', 'maximumversion', 'version', 'source', 'providername', 'credential', 'proxy'].some(p => isParam(a, p, 3)) && value === undefined) i++
230 }
231 const label = name.replace(/(^|-)([a-z])/g, (_, dash: string, letter: string) => `${dash}${letter.toUpperCase()}`).replace('Psresource', 'PSResource')
232 return install('powershell', label, named(packages), scope === 'allusers' ? 'powershell-all-users' : 'powershell-user', dir)
233}
234
235/** VS Code extensions (and its forks), Claude Code plugins and user MCP servers, Deno and .NET tools. */
236function application(name: string, args: string[], dir: string | null): InstallRisk | null {
237 const n = name.toLowerCase()
238 const editor = EDITORS[n]
239 if (editor !== undefined) {
240 const extensions = optionValues(args, '--install-extension')
241 return extensions.length > 0 ? install(n, `${n} --install-extension`, named(extensions), 'editor', dir, editor) : null
242 }
243 if (n === 'claude') {
244 const [group, sub, ...rest] = positionals(args, new Set(['--scope', '-s', '-e', '--env', '-H', '--header', '-t', '--transport']))
245 const scope = optionValue(args, '--scope', '-s')
246 if ((group === 'plugin' || group === 'plugins') && (sub === 'install' || sub === 'i')) {
247 return (scope ?? 'user') === 'user' ? install('claude', 'claude plugin install', named(rest), 'claude-plugin', dir) : null
248 }
249 if (group === 'mcp' && (sub === 'add' || sub === 'add-json') && scope === 'user') {
250 return install('claude', `claude mcp ${sub} --scope user`, named(rest.slice(0, 1)), 'claude-mcp-user', dir)
251 }
252 return null
253 }
254 if (n === 'deno' && args[0] === 'install' && args.some(a => a === '-g' || a === '--global')) {
255 const packages = positionals(args.slice(1), new Set(['-n', '--name', '--root', '-c', '--config', '--import-map', '--lock', '--cert', '--location']))
256 return install('deno', 'deno install -g', named(packages), 'deno-global', dir)
257 }
258 if (n === 'dotnet' && args[0] === 'tool' && (args[1] === 'install' || args[1] === 'update') && args.some(a => a === '-g' || a === '--global')) {
259 const packages = positionals(args.slice(2), new Set(['--version', '--add-source', '--configfile', '--framework', '-f', '--tool-path', '--tool-manifest', '-a', '--arch', '-v', '--verbosity']))
260 return install('dotnet', `dotnet tool ${args[1]} -g`, named(packages), 'dotnet-global', dir)
261 }
262 return null
263}
264
265// ---------------------------------------------------------------------------
266// Installers piped from the internet
267
268const DOWNLOADERS = new Set(['curl', 'wget', 'iwr', 'irm', 'invoke-webrequest', 'invoke-restmethod'])
269const INTERPRETERS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'python', 'python3', 'py', 'node', 'perl', 'ruby', 'iex', 'invoke-expression', 'pwsh', 'powershell'])
270const DOWNLOAD_INSIDE = /(\$\(|<\(|\(|^)\s*(curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod)(\.exe)?\b|downloadstring\(/i
271
272/**
273 * Whether the interpreter runs what reaches it through the pipe as a script: `sh`, `bash -s`,
274 * `python3 -` or `iex` do; `python3 -m json.tool` or `node -e …` don't, they only read data.
275 */
276function runsInputAsScript(interpreter: string, args: string[]): boolean {
277 if (interpreter === 'iex' || interpreter === 'invoke-expression') return true
278 if (interpreter === 'pwsh' || interpreter === 'powershell') {
279 return args.length === 0 || args.includes('-') || args.some((a, i) => /^-c(ommand)?$/i.test(a) && args[i + 1] === '-')
280 }
281 for (const a of args) {
282 if (['-c', '-m', '-e', '--eval', '-p', '--print', '-r'].includes(a)) return false
283 if (a === '-') return true
284 if (!a.startsWith('-')) return false // a script file: what comes through the pipe is data
285 }
286 return true
287}
288
289const urlIn = (words: string[]): string | undefined => {
290 for (const w of words) {
291 const url = /https?:\/\/[^\s'"()]+/i.exec(w)?.[0]
292 if (url) return url
293 }
294 return undefined
295}
296
297/**
298 * An installer from the internet that runs without being saved: `curl … | sh`, `irm … | iex`,
299 * `bash -c "$(curl …)"`, `iex (irm …)` or `iex ((New-Object Net.WebClient).DownloadString(…))`.
300 */
301function remoteInstaller(name: string, args: string[], input: string[] | null, shell: Shell, dir: string | null): InstallRisk | null {
302 const interpreter = name.toLowerCase()
303 if (!INTERPRETERS.has(interpreter)) return null
304 let downloader: string | undefined
305 let words: string[] = []
306 const [piped, ...pipedArgs] = input ?? []
307 if (piped !== undefined && DOWNLOADERS.has(commandName(piped, shell).toLowerCase()) && runsInputAsScript(interpreter, args)) {
308 downloader = commandName(piped, shell).toLowerCase()
309 words = pipedArgs
310 } else if (args.some(a => DOWNLOAD_INSIDE.test(a))) {
311 downloader = /(curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod|downloadstring)/i.exec(args.join(' '))?.[1]?.toLowerCase()
312 words = args
313 }
314 if (downloader === undefined) return null
315 const source = urlIn(words)
316 return {
317 ...install('remote', `${downloader === 'downloadstring' ? 'DownloadString' : downloader} … | ${interpreter}`, [], 'remote', dir),
318 ...(source ? { source } : {}),
319 }
320}
321
322/** A third-party install for the whole user or system, or null. */
323export function installRisk(
324 first: string,
325 name: string,
326 args: string[],
327 dir: string | null,
328 shell: Shell,
329 input: string[] | null,
330 inVenv: boolean,
331): InstallRisk | null {
332 const n = name.toLowerCase()
333 if (n === 'npm' || n === 'pnpm' || n === 'bun' || n === 'yarn') return javascript(n, args, dir)
334 return (
335 python(first, name, args, dir, inVenv) ??
336 system(name, args, dir) ??
337 (shell === 'powershell' ? powershellModule(n, args, dir) : null) ??
338 application(name, args, dir) ??
339 remoteInstaller(name, args, input, shell, dir)
340 )
341}
342