Asks you before any package install (npm, pnpm, pip, uv, npx, winget and more) and shows a registry link for each package.

A small, language-neutral scaffold you copy into a repo so coding agents start with sensible guardrails. It is Claude Code–native, with AGENTS.md as the cross-tool contract that Cursor, Codex, Copilot, and others read too.
It is deliberately small. It leans on Claude Code's built-in controls where they exist, and adds one hook for the part that has to be project-specific: running your formatter and your tests.
scaffold/ ← copy this into your repo
├── AGENTS.md project facts, commands, conventions, security (fill in)
├── CLAUDE.md imports AGENTS.md, plus a few Claude-specific notes
├── .gitignore lines to add to yours
└── .claude/
├── settings.json permission rules and hook wiring
├── hooks/
│ ├── checks.mjs runs your formatter after edits, your tests before Claude finishes
│ └── checks.json the commands it runs (empty until you fill it in)
└── skills/zenn/ /zenn: optional spec-first workflow for larger work
providers.md notes for Cursor / Copilot / Codex / Gemini / Devin
mods/ user-level Claude Code mods (see mods/README.md)
tests/ node --test "tests/*.test.mjs": the hook, the scaffold rules, the mods
cp -r /path/to/agents/scaffold/. /path/to/your-repo/
The trailing /. copies the contents, including the dot-directories. If the repo already has a .gitignore, AGENTS.md, or CLAUDE.md, merge those by hand instead of overwriting them. Node on PATH is the only requirement.
Replace each <!-- placeholder --> with the project's name, stack, and real commands, and delete the sections you don't need.
Edit .claude/hooks/checks.json. Both lists are empty by default, which turns the hook off.
{
"format": {
"py": "ruff format {file}",
"ts,tsx,js": "npx --no-install prettier --write {file}"
},
"verify": ["pytest -q"]
}
format maps file extensions to a command that runs after Claude edits a file of that type. {file} is the edited file's path, already quoted. If the command fails, its output goes back to Claude to fix.verify commands run when Claude finishes a turn in which it edited files. If one fails, Claude keeps working until it passes.CLAUDE_SKIP_CHECKS=1 claude turns the hook off for a session.On macOS, Linux, or WSL2, run /sandbox in Claude Code. It confines shell commands to the project directory and to network hosts you approve. The secret paths denied in settings.json apply inside the sandbox too.
| Layer | What it covers | | :- | :- | | deny rules | Secrets are never read or written: .env*, .dev.vars*, key files, ~/.ssh, ~/.gnupg, cloud credentials (AWS, GCP, Azure, Kubernetes), tool and registry credentials (GitHub CLI, Docker, npm, PyPI, RubyGems, ~/.git-credentials, ~/.netrc). .env.example stays usable. Lockfiles (package-lock.json, pnpm-lock.yaml, *.lock, *.lockb, go.sum) are not read either: they are large, and they only change through the package manager. | | File search | settings.json sets CLAUDE_CODE_GLOB_NO_IGNORE=false, so Claude's file search respects .gitignore. By default it also lists ignored files such as node_modules and build output. | | ask rules | A person approves git push, git reset --hard, git clean, gh pr merge, CI workflow edits, and any command retried outside the sandbox. These prompt in every permission mode, including auto. | | Built into Claude Code | Writes to .claude/, .git/, .mcp.json, and shell startup files are never auto-approved. rm -rf on the project, home, or root is always stopped. settings.json also disables bypass-permissions mode. | | Checks hook | Your formatter and tests run without anyone remembering to. | | AGENTS.md | Conventions and intent. It shapes what agents try; it enforces nothing. |
There are no allow rules: nothing is pre-approved. Claude Code already runs read-only commands without asking, and saves your own "don't ask again" choices to .claude/settings.local.json.
No permission mode is pinned either. Use Manual, auto, or plan as you prefer; the deny and ask rules hold in all of them.
Bash(git push *) catches git push origin main but not git -C . push. The rules stop the usual form, not a determined workaround. For anything that must never happen, protect the branch on the remote.cat. A script that opens a file itself is only stopped by the OS sandbox.*.pem and *.key are denied wholesale.** Delete those two lines from settings.json if your repo keeps non-secret files with those extensions.npm ls <pkg>, pnpm why <pkg>), or delete the lockfile lines from settings.json.mods/ holds Claude Code mods that load into every session on the machine, whatever repo it runs in, the Desktop Code tab included. Each one fixes friction that kept repeating across real sessions:
| Mod | In short | | :- | :- | | shell-sense | Denies shell commands that are certain to fail on Windows, and says what works instead. | | package-gate | Asks you before any package install, with a registry link per package. | | secret-shield | Keeps secret files and token values out of shell reads and the transcript. | | repo-lock | Denies dependency changes with the wrong package manager or from the wrong folder. | | context-meter | A band above the prompt: context fill against quality marks (30% fading, 40% "dumb zone"), rate limits, and model and effort switchers. | | session-context | Tells Claude the repo, branch and uncommitted work with each prompt. |
Setup, the full behaviour of each, and how to work on them: mods/README.md.
node --test "tests/*.test.mjs"
The tests exercise checks.mjs, enforce the scaffold's own rules (file size limits, valid hook paths, rule syntax), and check that the mods' shared files match. Each mod also has its own tests: claude plugin test mods/<name>. Using another agent? See providers.md.
Earlier versions (per-language standards skills, command-guard hooks, the multi-provider scaffolds) live in git history.
hooks/register.ts 88 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { LOOKS_LIKE_INSTALL, type Pkg, fromCommand, fromManifestEdit, isManifest } from './rules'
4
5const INSTALL = 'Install'
6const DECLINE = "Don't install"
7
8// Packages the user approved in this session, by "manager:name". A reload asks again.
9const approved = new Set<string>()
10const key = (p: Pkg) => `${p.manager}:${p.name}`
11
12// Asks the user in Claude Code's own dialog. Answers a deny reason, or undefined
13// when every package is approved. Anything but "Install" is a refusal.
14async function gate($: EngineInterface, what: string, pkgs: Pkg[]): Promise<string | undefined> {
15 const fresh = pkgs.filter(p => !approved.has(key(p)))
16 if (!fresh.length) return undefined
17 const list = fresh.map(p => `• ${p.name} (${p.manager})\n ${p.link}`).join('\n')
18 const question = `${what} Check each one first:\n${list}\nInstall ${fresh.length === 1 ? 'it' : 'them'}?`
19 let answer = ''
20 try {
21 answer = await $.ui.ask(question, { header: 'Packages', options: [INSTALL, DECLINE] })
22 } catch {
23 // dismissed, or nobody to ask (claude -p): treat as not approved
24 }
25 if (answer === INSTALL) {
26 fresh.forEach(p => approved.add(key(p)))
27 // The engine already heads a toast with the plugin's name.
28 $.ui.toast(`Approved ${fresh.map(p => `${p.name} (${p.manager})`).join(', ')}`)
29 return undefined
30 }
31 const names = fresh.map(p => `${p.name} (${p.link})`).join(', ')
32 return answer && answer !== DECLINE
33 ? `package-gate: the user did not approve installing ${names}. They answered: "${answer}". Follow that answer.`
34 : `package-gate: the user did not approve installing ${names}. Do not install it another way. ` +
35 'Tell the user why you wanted it and what you can do without it.'
36}
37
38// Runners like npx use the project's own copy when one is installed.
39async function localBins($: EngineInterface, command: string): Promise<(name: string) => boolean> {
40 const found = new Set<string>()
41 try {
42 const cwd = await $.session.cwd()
43 for (const m of command.matchAll(/\b(?:npx|bunx|npm\s+exec)\s+(?:-\S+\s+)*((?:@[\w.-]+\/)?[\w.-]+)/g)) {
44 const bin = m[1].replace(/^@[\w.-]+\//, '')
45 if (await $.fs.exists(`${cwd}/node_modules/.bin/${bin}`)) found.add(m[1])
46 }
47 } catch {
48 // no file system here: assume nothing is installed, so the user is asked
49 }
50 return name => found.has(name)
51}
52
53async function manifestDeny($: EngineInterface, path: string, edit: { old?: string; new: string; whole: boolean }) {
54 if (!isManifest(path)) return undefined
55 const before = await $.fs.read(path).catch(() => undefined)
56 const pkgs = fromManifestEdit(path, { ...edit, before: typeof before === 'string' ? before : undefined })
57 return pkgs.length ? gate($, `Claude wants to add dependencies to ${path}.`, pkgs) : undefined
58}
59
60// If a gate hook throws or times out, the engine would skip it and run the call.
61// Fail closed for anything that looks like an install; let the rest through.
62const FAILED = 'package-gate could not check this call, so it did not run. Ask the user before installing anything.'
63
64export const register: Register = on => {
65 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
66 const pkgs = fromCommand(e.command, await localBins($, e.command))
67 const deny = pkgs.length ? await gate($, 'Claude wants to install packages on this machine.', pkgs) : undefined
68 return deny ? { deny } : next(e)
69 }).catch(($, e, next) => (LOOKS_LIKE_INSTALL.test(e.command) ? { deny: FAILED } : next(e)))
70
71 on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
72 const pkgs = fromCommand(e.command, await localBins($, e.command))
73 const deny = pkgs.length ? await gate($, 'Claude wants to install packages on this machine.', pkgs) : undefined
74 return deny ? { deny } : next(e)
75 }).catch(($, e, next) => (LOOKS_LIKE_INSTALL.test(e.command) ? { deny: FAILED } : next(e)))
76
77 // The side door: add a dependency to a manifest, then run a plain install.
78 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
79 const deny = await manifestDeny($, e.file_path, { old: e.old_string, new: e.new_string, whole: false })
80 return deny ? { deny } : next(e)
81 }).catch(($, e, next) => (isManifest(e.file_path) ? { deny: FAILED } : next(e)))
82
83 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
84 const deny = await manifestDeny($, e.file_path, { new: e.content, whole: true })
85 return deny ? { deny } : next(e)
86 }).catch(($, e, next) => (isManifest(e.file_path) ? { deny: FAILED } : next(e)))
87}
88hooks/rules.ts 250 lines1// Pure parsing for package-gate: which registry packages a command or a manifest
2// edit would bring onto this machine, each with a link the user can check.
3// Restores of what a lockfile or requirements file already declares pass; new
4// names, one-off runners (npx, dlx, uvx) and global installs are asked about.
5
6import { segments } from './shell'
7
8export type Pkg = { manager: string; name: string; link: string }
9
10// ── registries ────────────────────────────────────────────────────────────────
11const enc = encodeURIComponent
12const LINKS: Record<string, (n: string) => string> = {
13 npm: n => `https://www.npmjs.com/package/${n}`,
14 pypi: n => `https://pypi.org/project/${n}/`,
15 crates: n => `https://crates.io/crates/${n}`,
16 go: n => `https://pkg.go.dev/${n}`,
17 gem: n => `https://rubygems.org/gems/${n}`,
18 nuget: n => `https://www.nuget.org/packages/${n}`,
19 psgallery: n => `https://www.powershellgallery.com/packages/${n}`,
20 winget: n => `https://winstall.app/apps/${n}`,
21 choco: n => `https://community.chocolatey.org/packages/${n}`,
22 scoop: n => `https://scoop.sh/#/apps?q=${enc(n)}`,
23 brew: n => `https://formulae.brew.sh/formula/${n}`,
24}
25
26const isUrl = (s: string) => /^(https?:|git\+|git:|github:|ssh:)/.test(s)
27const isLocal = (s: string) => /^(\.{1,2}([\\/]|$)|[\\/]|[A-Za-z]:[\\/]|file:|link:|workspace:)/.test(s)
28
29// left-pad@1.3.0 → left-pad, @scope/pkg@^2 → @scope/pkg, npm:alias@x keeps alias target
30const npmName = (spec: string) => {
31 const s = spec.replace(/^npm:/, '')
32 const at = s.indexOf('@', 1)
33 return at > 0 ? s.slice(0, at) : s
34}
35// requests[socks]>=2.0 ; python_version>"3" → requests
36const pyName = (spec: string) => spec.split(/[\s;\[<>=!~@]/)[0]
37const atName = (spec: string) => spec.split('@')[0]
38
39function pkg(manager: string, registry: string, spec: string, name: string): Pkg {
40 return isUrl(spec)
41 ? { manager, name: spec, link: spec }
42 : { manager, name, link: LINKS[registry](name) }
43}
44
45// Flags that take a value, so the value is not mistaken for a package name.
46const VALUE_FLAGS = new Set([
47 '--prefix', '--registry', '--filter', '-F', '--dir', '-C', '--cache', '--target', '-t',
48 '--index-url', '-i', '--extra-index-url', '--find-links', '-f', '--python', '-p',
49 '--source', '-s', '--version', '-v', '--scope', '--workspace', '--root', '--path',
50 '--constraint', '-c', '--platform', '--only-binary', '--no-binary', '--tag', '--group', '-G',
51])
52
53function args(words: string[]): string[] {
54 const out: string[] = []
55 for (let i = 0; i < words.length; i++) {
56 const w = words[i]
57 if (w.startsWith('-')) {
58 if (VALUE_FLAGS.has(w)) i++
59 continue
60 }
61 // `*>` is PowerShell's all-streams redirect.
62 if (/^(\d*|&|\*)[<>]/.test(w)) {
63 if (/^(\d*|&|\*)[<>]{1,2}$/.test(w)) i++ // `> log.txt`: its target too
64 continue // 2>&1, >log.txt, *>$null
65 }
66 // A shell variable ($null, $env:PKG) is never a package name we can link to.
67 if (w.startsWith('$')) continue
68 out.push(w)
69 }
70 return out
71}
72
73const hasFlag = (words: string[], ...flags: string[]) => words.some(w => flags.includes(w))
74
75// One segment → the packages it would fetch. `hasLocalBin(name)` says whether a
76// runner like npx would use a binary already installed in the project.
77export function fromSegment(raw: string[], hasLocalBin: (name: string) => boolean): Pkg[] {
78 let w = raw
79 while (w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0])) w = w.slice(1) // FOO=1 cmd
80 if (w[0] === 'sudo') w = w.slice(1)
81 if (!w.length) return []
82 const tool = w[0].toLowerCase().replace(/\.(exe|cmd)$/, '').replace(/^.*[\\/]/, '')
83 const verb = (w[1] ?? '').toLowerCase()
84 const rest = w.slice(2)
85
86 // python -m pip install ... / py -m pip ...
87 if (/^(python3?|py)$/.test(tool) && w[1] === '-m' && /^pip3?$/.test(w[2] ?? '')) {
88 return fromSegment(['pip', ...w.slice(3)], hasLocalBin)
89 }
90
91 switch (tool) {
92 case 'npm':
93 case 'pnpm':
94 case 'yarn':
95 case 'bun': {
96 // Flags may come before the verb: pnpm --filter web add zod
97 const [sub = '', ...specs] = args(w.slice(1))
98 const v = sub.toLowerCase()
99 if (v === 'dlx') {
100 const spec = specs[0]
101 return spec ? [pkg(`${tool} dlx`, 'npm', spec, npmName(spec))] : []
102 }
103 if (v === 'exec' && tool === 'npm') {
104 const spec = specs[0]
105 return spec && !hasLocalBin(npmName(spec)) ? [pkg('npm exec', 'npm', spec, npmName(spec))] : []
106 }
107 const adds = tool === 'npm' ? ['install', 'i', 'add', 'isntall', 'in'] : ['add', 'install', 'i']
108 if (!adds.includes(v)) return []
109 return specs.filter(s => !isLocal(s)).map(s => pkg(`${tool} ${v}`, 'npm', s, npmName(s)))
110 }
111 case 'npx':
112 case 'bunx': {
113 if (hasFlag(w, '--no-install', '--no')) return []
114 const all = args(w.slice(1))
115 const packages = w.flatMap((x, i) => (x === '-p' || x === '--package' ? [w[i + 1]] : []))
116 const spec = packages[0] ?? all[0]
117 if (!spec || isLocal(spec)) return []
118 const name = npmName(spec)
119 return hasLocalBin(name) && !packages.length ? [] : [pkg(tool, 'npm', spec, name)]
120 }
121 case 'pip':
122 case 'pip3': {
123 if (verb !== 'install') return []
124 const specs = args(rest).filter(s => !isLocal(s) && !/\.(txt|whl|tar\.gz|zip)$/.test(s))
125 return specs.map(s => pkg('pip install', 'pypi', s, pyName(s)))
126 }
127 case 'uv': {
128 if (verb === 'add' || (verb === 'pip' && w[2] === 'install') || (verb === 'tool' && w[2] === 'install')) {
129 const from = verb === 'add' ? rest : w.slice(3)
130 return args(from)
131 .filter(s => !isLocal(s) && !/\.(txt|whl)$/.test(s))
132 .map(s => pkg(`uv ${verb}`, 'pypi', s, pyName(s)))
133 }
134 if (verb === 'run' && hasFlag(w, '--with')) {
135 return w.flatMap((x, i) => (x === '--with' ? [pkg('uv run --with', 'pypi', w[i + 1], pyName(w[i + 1]))] : []))
136 }
137 return []
138 }
139 case 'uvx': {
140 const spec = args(w.slice(1))[0]
141 return spec ? [pkg('uvx', 'pypi', spec, pyName(spec))] : []
142 }
143 case 'pipx': {
144 if (verb !== 'install' && verb !== 'run') return []
145 const spec = args(rest)[0]
146 return spec ? [pkg(`pipx ${verb}`, 'pypi', spec, pyName(spec))] : []
147 }
148 case 'poetry':
149 case 'pdm':
150 case 'conda':
151 case 'mamba': {
152 const ok = verb === 'add' || (tool !== 'poetry' && tool !== 'pdm' && verb === 'install')
153 return ok ? args(rest).filter(s => !isLocal(s)).map(s => pkg(`${tool} ${verb}`, 'pypi', s, pyName(s))) : []
154 }
155 case 'cargo':
156 return verb === 'install' || verb === 'add'
157 ? args(rest).filter(s => !isLocal(s)).map(s => pkg(`cargo ${verb}`, 'crates', s, atName(s)))
158 : []
159 case 'go':
160 return verb === 'install' || verb === 'get'
161 ? args(rest).filter(s => !isLocal(s)).map(s => pkg(`go ${verb}`, 'go', s, atName(s)))
162 : []
163 case 'gem':
164 return verb === 'install' ? args(rest).map(s => pkg('gem install', 'gem', s, s)) : []
165 case 'dotnet':
166 return verb === 'add' && w[2] === 'package' && w[3] ? [pkg('dotnet add package', 'nuget', w[3], w[3])] : []
167 case 'install-module':
168 case 'install-package':
169 case 'install-script': {
170 const named = w.flatMap((x, i) => (/^-name$/i.test(x) ? [w[i + 1]] : []))
171 const names = named.length ? named : args(w.slice(1)).slice(0, 1)
172 return names.map(n => pkg(w[0], 'psgallery', n, n))
173 }
174 case 'winget':
175 case 'choco':
176 case 'scoop':
177 case 'brew': {
178 if (verb !== 'install' && verb !== 'upgrade' && verb !== 'add') return []
179 const ids = w.flatMap((x, i) => (x === '--id' ? [w[i + 1]] : []))
180 const names = ids.length ? ids : args(rest)
181 return names.map(n => pkg(`${tool} ${verb}`, tool, n, n))
182 }
183 }
184 return []
185}
186
187// The .catch fallback's test, when the parse itself failed. At most 8 words between
188// the tool and the verb: an unbounded (\S+\s+)* is quadratic on "go go go ...",
189// and the .catch has only a 1 s budget before the call runs unchecked.
190export const LOOKS_LIKE_INSTALL =
191 /\b(npm|pnpm|yarn|bun|pip3?|uv|pipx|poetry|cargo|go|gem|winget|choco|scoop|brew)\s+(\S+\s+){0,8}(install|i|add|dlx|get)\b|\b(npx|bunx|uvx)\s|install-(module|package|script)/i
192
193export function fromCommand(command: string, hasLocalBin: (name: string) => boolean = () => false): Pkg[] {
194 const seen = new Set<string>()
195 return segments(command)
196 .flatMap(s => fromSegment(s, hasLocalBin))
197 .filter(p => p.name && !seen.has(`${p.manager}:${p.name}`) && seen.add(`${p.manager}:${p.name}`))
198}
199
200// ── manifest edits ────────────────────────────────────────────────────────────
201const DEP_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
202const NOT_DEPS = new Set(['name', 'version', 'node', 'npm', 'pnpm', 'yarn', 'bun', 'packageManager', 'type', 'license', 'main', 'module', 'types'])
203const VERSIONISH = /^(\^|~|>=?|<=?|=|\*|latest|next|workspace:|npm:|catalog:|\d|git\+|github:|https?:)/
204
205function depKeys(json: string): Set<string> {
206 try {
207 const p = JSON.parse(json)
208 return new Set(DEP_SECTIONS.flatMap(s => Object.keys(p?.[s] ?? {})))
209 } catch {
210 return new Set()
211 }
212}
213// "left-pad": "^1.3.0" pairs in an Edit fragment (not valid JSON on its own)
214function fragmentDeps(text: string): Set<string> {
215 const out = new Set<string>()
216 for (const m of text.matchAll(/"((?:@[\w.-]+\/)?[\w.-]+)"\s*:\s*"([^"]*)"/g)) {
217 if (!NOT_DEPS.has(m[1]) && VERSIONISH.test(m[2])) out.add(m[1])
218 }
219 return out
220}
221function reqNames(text: string): Set<string> {
222 return new Set(
223 text
224 .split(/\r?\n/)
225 .map(l => l.trim())
226 .filter(l => l && !l.startsWith('#') && !l.startsWith('-'))
227 .map(l => pyName(l).toLowerCase())
228 .filter(Boolean),
229 )
230}
231
232const base = (p: string) => p.replace(/^.*[\\/]/, '')
233export const isManifest = (path: string) => /^package\.json$|^requirements[\w.-]*\.txt$/i.test(base(path))
234
235// Names an edit adds. `before` is the file's current text (undefined when new).
236export function fromManifestEdit(path: string, edit: { old?: string; new: string; before?: string; whole: boolean }): Pkg[] {
237 const npm = /package\.json$/i.test(base(path))
238 let added: string[]
239 if (npm) {
240 const had = edit.whole ? depKeys(edit.before ?? '{}') : fragmentDeps(edit.old ?? '')
241 const has = edit.whole ? depKeys(edit.new) : fragmentDeps(edit.new)
242 const known = edit.before ? depKeys(edit.before) : new Set<string>()
243 added = [...has].filter(n => !had.has(n) && !known.has(n))
244 } else {
245 const had = reqNames(edit.whole ? edit.before ?? '' : `${edit.before ?? ''}\n${edit.old ?? ''}`)
246 added = [...reqNames(edit.new)].filter(n => !had.has(n))
247 }
248 return added.map(n => (npm ? pkg('package.json', 'npm', n, n) : pkg(base(path), 'pypi', n, n)))
249}
250hooks/shell.ts 115 lines1// The shell tokenizer package-gate, repo-lock and secret-shield share. Each mod is
2// its own plugin folder, so each holds a copy of this file: change one, then copy
3// it to the other two. tests/mods.test.mjs fails while the copies differ.
4//
5// Splits a shell command into segments (at && || ; | & ( ) $( ` and newlines
6// outside quotes) of whitespace-separated words with their quotes removed, and
7// `<` as a word of its own. A wrapper (time, env, sudo, corepack, ...) is
8// dropped, and the script of a `bash -c` or `powershell -Command` is split in
9// turn. Heredoc bodies are skipped unless a shell runs them. Good enough for the
10// commands an agent writes; it is a gate on intent, not a shell parser.
11const WRAPPERS = new Set(['time', 'nohup', 'exec', 'sudo', 'nice', 'env', 'xargs', 'corepack', 'timeout', 'stdbuf'])
12const SHELLS = /^(bash|sh|zsh|dash|cmd|powershell|pwsh)(\.exe)?$/i
13const RUN_FLAG = /^(-[a-z]*c|\/c|-command)$/i
14const HEREDOC = /<<-?[ \t]*(['"]?)([\w.-]+)\1/y
15
16function expand(words: string[]): string[][] {
17 let w = words.filter(x => x !== '{' && x !== '}')
18 while (w.length > 1) {
19 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0])) w = w.slice(1) // FOO=1 cmd
20 else if (w[0].startsWith('$') && w[1] === '=') w = w.slice(2) // $x = cmd
21 else if (WRAPPERS.has(w[0].toLowerCase())) {
22 w = w.slice(1)
23 while (w.length > 1 && /^-|^\d+[smhd]?$/.test(w[0])) w = w.slice(1) // sudo -E, timeout 30
24 } else break
25 }
26 const run = w.findIndex(x => RUN_FLAG.test(x))
27 if (w.length && SHELLS.test(w[0]) && run > 0 && run < w.length - 1) return segments(w.slice(run + 1).join(' '))
28 return w.length ? [w] : []
29}
30
31export function segments(command: string): string[][] {
32 const out: string[][] = []
33 let words: string[] = []
34 let word = ''
35 let quote: string | null = null
36 let has = false
37 const subs: (string | null)[] = [] // the quote each open ( or $( returns to at its )
38 let bodies: string[] = [] // heredoc end tags whose bodies start at the next newline
39 // A heredoc body is data (cat > notes.md <<EOF): skip to the line after its end tag.
40 const skipBodies = (at: number) => {
41 for (const tag of bodies) {
42 while (at < command.length) {
43 const end = command.indexOf('\n', at + 1)
44 const line = command.slice(at + 1, end < 0 ? command.length : end)
45 at = end < 0 ? command.length : end
46 if (line.trim() === tag) break
47 }
48 }
49 bodies = []
50 return at
51 }
52 const endWord = () => {
53 if (has) words.push(word)
54 word = ''
55 has = false
56 }
57 const endSegment = () => {
58 endWord()
59 if (words.length) out.push(...expand(words))
60 words = []
61 }
62 for (let i = 0; i < command.length; i++) {
63 const c = command[i]
64 const next = command[i + 1]
65 if (quote === '"' && c === '$' && next === '(') {
66 endSegment() // "$(cat x)" still runs cat x
67 subs.push(quote)
68 quote = null
69 i++
70 } else if (quote) {
71 if (c === quote) quote = null
72 else word += c
73 } else if (c === '"' || c === "'") {
74 quote = c
75 has = true
76 } else if (c === '(' || (c === '$' && next === '(')) {
77 if (c === '$') i++
78 endSegment()
79 subs.push(null)
80 } else if (c === ')') {
81 endSegment()
82 quote = subs.pop() ?? null
83 } else if (c === '<' && next === '<') {
84 HEREDOC.lastIndex = i
85 const m = command[i + 2] === '<' ? null : HEREDOC.exec(command)
86 if (m) {
87 endWord()
88 if (!SHELLS.test(words[0] ?? '')) bodies.push(m[2]) // bash <<EOF runs its body: read it
89 i = HEREDOC.lastIndex - 1
90 } else {
91 while (command[i + 1] === '<') word += command[i++] // <<< here-string
92 word += c
93 has = true
94 }
95 } else if (c === '&' && (next === '>' || command[i - 1] === '>' || command[i - 1] === '<')) {
96 word += c // 2>&1 and &> are redirects, not separators
97 has = true
98 } else if (c === '\n' || c === ';' || c === '|' || c === '&' || c === '`') {
99 if ((c === '&' || c === '|') && next === c) i++
100 endSegment()
101 if (c === '\n' && bodies.length) i = skipBodies(i)
102 } else if (c === '<' && next !== '<') {
103 endWord()
104 words.push('<')
105 } else if (/\s/.test(c)) {
106 endWord()
107 } else {
108 word += c
109 has = true
110 }
111 }
112 endSegment()
113 return out
114}
115