SLOPSHOPPER

impact-radius

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

panebandguardtoastprocess
★ 2v1.1.0MITupdated 2026-10-17pablodiazjorge/impact-radius
A shopper browsing a rack in a slop shop
README

<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.

What it holds

CommandWhat 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 +branchThe remote commits that would disappear
Migrations: Django, Alembic, Rails, Prisma and any … migrateThe 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

Third-party installs

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:

  • Global JavaScript packages: npm install -g (also i, add, update and --location=global), pnpm add -g, bun add -g, yarn global add, volta install, deno install -g.
  • Python: 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.
  • Language tools: cargo install, go install, gem install, dotnet tool install -g, nvm install.
  • System package managers: winget, choco/cinst, scoop, brew, apt/apt-get, dnf, yum, zypper, pacman -S, apk, snap, flatpak and port, when installing or upgrading.
  • PowerShell: Install-Module, Install-Package, Install-Script, Install-PSResource and Update-Module, saying whether it's for your user or for everyone (-Scope AllUsers).
  • Editors and Claude Code: code --install-extension (also Cursor and VSCodium), claude plugin install at user scope (the default) and claude mcp add --scope user.
  • Installers from the internet that run without being saved: 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.

How it works

  1. When the engine decides whether a Bash or PowerShell call may run (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.
  2. If it's risky, it measures it with the engine's file calls and 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.
  3. It opens the Impact radius pane with the expected effect and the buttons. If the screen can't place the pane (a narrow terminal), the notice goes above the prompt, with whatever other mods draw there underneath.
  4. It waits inside a short process, because time inside an engine call doesn't count against a hook's budget. On Linux and macOS it uses sleep; on Windows, Git for Windows' sleep.exe (found with git --exec-path) or, without it, PowerShell's Start-Sleep.
  5. Run is the permission: the engine doesn't ask again afterwards. With Cancel, after 10 minutes without an answer, if the turn is interrupted or if the mod fails, the command doesn't run, and Claude reads why and the expected effect, with an instruction not to run it again unless you ask.

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.

The pane

  • Cancel comes first and has the focus, so Enter never runs anything by accident. On the terminal the buttons show their keys (c Cancel, r Run); the desktop app draws its own buttons, with Cancel as the main one.
  • Every word is in the theme's text color, the only one that keeps a 7:1 contrast (WCAG AAA) in light and dark themes. Color goes to the border and the icons.

Language

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.

Permissions

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.
  • Auto mode: it holds there too, even though in that mode the engine wouldn't ask you.

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.

Install

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

Development

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.

Acknowledgements

The idea comes from Anthropic's blast-radius. impact-radius is written from scratch; see ACKNOWLEDGEMENTS.md.

License

MIT

Source 7 files
hooks/register.tsx 331 lines
1// 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}
331
hooks/classify.ts 176 lines
1// 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}
176
hooks/measure.ts 238 lines
1// 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}
238
hooks/messages.ts 400 lines
1// 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}
400
hooks/shell.ts 256 lines
1// 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}
256
hooks/temp.ts 56 lines
1// 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}
56
hooks/installs.ts 342 lines
1// 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