Previews what a risky shell command would touch and asks before it runs

A macOS-focused developer workstation built around Zsh, Powerlevel10k, modern CLI tools, and battle-tested automation. Everything is wired together with bestow (a Go-based GNU Stow successor), encrypted secrets, and a centralized package manifest so each new machine behaves exactly like the last one.
z command), Atuin history (Ctrl+R and up-arrow), and Ghostty compatible key bindingsgw), and curated helper aliases/functions (take, kill_by_port, show_tools, etc.)<leader>d… plus F5/F10/F11) shared by every language. Go (delve) supports debugging the test under the cursor, launching the program with arguments, and attaching to a remote dlv --headless; Python, Java, and Ballerina are wired in too. Adding another debugger is a single drop-in file — see Adding a Debugger<leader>G… for code generation (if err != nil guards, struct tags, interface stubs, test and doc-comment generation), and <leader>T… to run tests from the buffer with a summary tree and watch modev for nvim, g for ripgrep, f for fd, z for zoxide) while keeping original commands for scripts.md → mdcat, .json/.yaml → jless, .py/.sh/.bal → $EDITOR)pam-reattach)ControlEscape.spoon: tap Caps Lock for Escape, hold for Control — a Karabiner alternative that needs only Accessibility/Input Monitoring permissions, no blockable system extensionedit_secrets workflow, and automatic .env handling inside init.shBrewfile with optional category files in packages/ for modular installationtest-zsh integration tests, profile_startup quick timing, and bin/profile-zsh-startup for deep divesgit --version /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install --cask font-fira-code-nerd-font
Set the font once inside your terminal profile (Appearance/Text settings).
init.sh)git clone https://github.com/ThisaruGuruge/dotfiles.git ~/dotfiles
cd ~/dotfiles
./init.sh
The script is interactive; it will:
bestow)Brewfile with Brewpam-reattach).env with SOPS + age (keys stored at ~/.config/sops/age/keys.txt)bestowtest-zsh and show next stepsgit clone https://github.com/ThisaruGuruge/dotfiles.git ~/dotfiles
cd ~/dotfiles
# Install everything that is currently enabled in Brewfile
brew bundle --file=Brewfile
# Install bestow (requires Go)
go install github.com/redpierrot/bestow@latest
# Bootstrap bestow's own config (source: this repo, destination: $HOME)
bestow init --source ~/dotfiles --destination ~/
# Symlink the packages you need (add/remove as desired)
bestow stow zsh git tmux direnv bash
# Copy the env template (will be encrypted later by init/edit_secrets)
cp zsh/.env.example zsh/.env
# Reload configuration
source ~/.zshrc
Edit the Brewfile directly or use category Brewfiles in packages/ before running brew bundle if you want to customize packages.
Already have a preferred dotfiles strategy but want the curated tools? Run brew bundle --file=Brewfile and manually pick pieces (aliases, functions, etc.). Edit the Brewfile directly or use the category files in packages/ to customize your installation.
The main Brewfile contains core packages that are always installed. Optional packages are organized into category files in packages/:
packages/
├── cloud.brewfile # AWS, GCP CLIs
├── containers.brewfile # colima, Docker CLI + Compose + credential helper
├── development.brewfile # pyenv, rbenv, nvm, flutter
├── editors.brewfile # Cursor, VS Code
├── productivity.brewfile # Raycast, Rectangle, etc.
└── terminals.brewfile # iTerm2
brew bundle --file=Brewfile # Core packages
brew bundle --file=packages/development.brewfile # Add dev tools
brew bundle --file=packages/editors.brewfile # Add editors
core – powerlevel10k, zoxide, eza, bat, ripgrep, lazygit, lazydocker, tmux, direnv, atuin, gh, etc.security – sops + age for encrypted secrets (always enabled)development – pyenv, rbenv, nvm, flutter (optional)aws, gcp – cloud CLIs and helperseditors – Cursor, VS Codeterminals – iTerm2 (casks)containers – colima (container runtime), Docker CLI + Compose + credential helperproductivity – Raycast, Rectangle, TablePlus, Alfred, PostmanComment out what you do not need in the Brewfile, then rerun brew bundle.
| Path | Notes |
|---|---|
zsh/ | .zshrc, .zshrc.d/ (modular shell config), .functions.d/ (modular functions), aliases, paths |
zsh/.zshrc.d/ | 7 modules: plugins, completion, keybindings, history, integrations, environment, tmux |
zsh/.functions.d/ | 9 modules: colors, core, navigation, archives, git, system, dotfiles, docs, packages |
<tool>/.config/<tool>/ | XDG configs, one stow package per tool (ghostty, lazygit, nvim, yazi, ripgrep, typos) |
zsh/.p10k.zsh | Powerlevel10k prompt config (Catppuccin Mocha, stowed to ~/.p10k.zsh) |
git/ | .gitconfig, ignore rules, delta settings |
tmux/ | Modern tmux config + keybinds |
direnv/ | Project-specific environment automation |
packages/ | Optional category Brewfiles (cloud, containers, development, etc.) |
bin/ | Helper scripts (test-zsh-config, profile-zsh-startup, audit-configs, adopt-config) |
docs/ | Additional documentation (prompt guide, tmux keybindings, config management) |
Ghostty is configured with:
The optional containers category (packages/containers.brewfile) installs colima as the local container runtime, paired with the plain docker, docker-compose, and docker-credential-helper CLI formulae — no Docker Desktop app/VM required.
brew bundle --file=packages/containers.brewfile # colima + docker CLI + compose + credential helper
colima-start # boots the VM, sets the `colima` docker context
docker ps # talks to colima automatically
lzd # lazydocker also works unchanged — it just follows the active docker context
Colima lifecycle aliases (zsh/.aliases.sh, only defined if colima is installed):
| Alias | Command |
|---|---|
colima-start | colima start |
colima-stop | colima stop |
colima-restart | colima restart |
colima-status | colima status |
colima-list | colima list |
colima-ssh | colima ssh |
Homebrew's docker-compose formula installs as a CLI plugin, and docker-credential-helper installs docker-credential-osxkeychain to store registry credentials in the macOS Keychain instead of plaintext — but Docker only picks either up if ~/.docker/config.json says so:
{
"cliPluginsExtraDirs": ["/opt/homebrew/lib/docker/cli-plugins"],
"credsStore": "osxkeychain"
}
init.sh does not currently write these keys automatically — add them by hand (or merge into an existing ~/.docker/config.json) after installing docker-compose / docker-credential-helper.
Day-to-day package management (adding/removing symlinks after the initial install) uses bestow — a Go-based successor to GNU Stow — instead of stow directly. It replicates stow --no-folding behavior (always on, no flag needed) but is idempotent and conflict-safe with pre-existing absolute symlinks.
bestow stow zsh # Shell config (includes .p10k.zsh)
bestow stow nvim ghostty lazygit ripgrep yazi typos # Per-tool packages
bestow stow git tmux direnv # Git/Tmux/Direnv packages
# Remove a package
bestow unstow zsh
init.sh (the fresh-machine installer) still bootstraps with GNU Stow, since bestow is a personal Go build not yet packaged for distribution.
Alternative: For cross-platform dotfile management, consider Chezmoi.
test-zsh # Full validation (tools, PATH, runtimes)
./bin/profile-zsh-startup # Deep component timing (zinit plugins, SDKMAN, p10k, etc.)
help # Alias documentation entry point
docs # Interactive alias browser (alias_docs)
show_tools # Overview of installed CLI upgrades
alias_search git # Search for aliases by keyword
edit_secrets # Safely edit encrypted ~/.env via SOPS
take my-service && code . # Smart project bootstrap / clone helper
kill_by_port 3000 # Kill whatever binds to port 3000
lg # Launch lazygit with our config
lzd # Launch lazydocker for Docker management
gw build # Run Gradle build from any subdirectory
gwt # Alias for: gw test
gwc # Alias for: gw clean
gwcb # Alias for: gw clean build
Ctrl+R # Atuin search UI (fuzzy search all history)
Up arrow # Atuin prefix search (as you type)
Zsh suffix aliases automatically open files based on their extension. Just type the filename and press Enter:
# Viewing files (rendered markdown)
README.md # Opens in mdless with rendered Markdown
data.json # Opens in jless (interactive JSON viewer)
config.yaml # Opens in jless (interactive YAML viewer)
# Editing files (opens in $EDITOR/nvim)
script.py # Opens Python files in nvim
setup.sh # Opens shell scripts in nvim
service.bal # Opens Ballerina files in nvim
app.conf # Opens config files in nvim
Supported extensions:
| Extension | Tool | Purpose |
|---|---|---|
.md | mdcat -p | View rendered Markdown with paging |
.json | jless | Interactive JSON browsing with folding |
.yaml, .yml | jless | Interactive YAML browsing |
.py | $EDITOR | Edit Python files |
.sh, .bash, .zsh | $EDITOR | Edit shell scripts |
.bal | $EDITOR | Edit Ballerina files |
.conf, .config, .ini | $EDITOR | Edit configuration files |
The default editor is set to nvim via the $EDITOR environment variable in .zshenv.
~/.config/sops/age/keys.txt and are created by init.sh. Backup that file somewhere safe.~/.env (ignored by git). They remain encrypted on disk and are transparently decrypted by the shell when sourced.edit_secrets (wrapper defined in .functions.d/06-dotfiles.zsh) to decrypt, open your $EDITOR, and re-encrypt on save. sops -d ~/.env | less # View decrypted env
sops ~/.env # Edit directly
export SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt
zsh/.env.example is copied when you first run init.sh; extend it if you need new keys for future machines.The installer configures macOS to accept Touch ID (fingerprint) for sudo prompts in the terminal. This uses /etc/pam.d/sudo_local, which persists across macOS system updates (unlike editing /etc/pam.d/sudo directly).
tmux support: The pam-reattach brew package is included so Touch ID also works inside tmux sessions — without it, macOS cannot reach the biometric sensor from a reattached session.
After running init.sh, any sudo command will show the Touch ID prompt first and fall back to password if dismissed.
To enable manually (without init.sh):
brew install pam-reattach
# Create /etc/pam.d/sudo_local (requires sudo)
sudo tee /etc/pam.d/sudo_local <<'EOF'
# sudo_local: local config for sudo (persists across macOS updates)
auth optional /opt/homebrew/lib/pam/pam_reattach.so
auth sufficient pam_tid.so
EOF
Note: On Intel Macs, replace
/opt/homebrew/lib/pam/pam_reattach.sowith/usr/local/lib/pam/pam_reattach.so.
Sample prompt (top line + prompt character on line 2):
~/dotfiles main !+ Go 1.23.4
❯
What you see:
! modified, + staged, ? untracked, * stash, ⇣⇡ ahead/behind) — no colour change on dirty, no blocking git subprocess❯ green on success, red on failure; ❮ in Vim normal mode❯ in scrollbackDesign principle: The prompt shows command context (where you are, git state, language). The tmux status bar shows session context (session name, sysinfo, battery, time). Nothing is duplicated between the two layers.
Customize the prompt at ~/.p10k.zsh (stowed from zsh/.p10k.zsh):
nvim ~/dotfiles/zsh/.p10k.zsh # Edit directly — hot-reloads automatically
p10k configure # Interactive wizard (overwrites the file)
The Catppuccin Mocha palette used by the prompt matches the tmux status bar exactly — same background (#1e1e2e), same blue (#89b4fa), same green (#a6e3a1), same accent colors throughout.
test-zsh – Runs syntax checks, ensures required tools exist, inspects PATH/env vars, and prints a summary with pass/warn/fail counts./bin/profile-zsh-startup – Detailed profiler that times individual components (Homebrew shellenv, zinit plugins, SDKMAN, pyenv, compinit, sourcing files)zsh -n ~/.zshrc – Quick syntax validation if you edit the configzsh -n ~/.p10k.zsh – Validate prompt config syntax without sourcing itbrew bundle check – Confirm Brew dependencies match Brewfile before running Bundle againCommon fixes:
source ~/.zshrc # Reload everything after edits
rm -rf ~/.local/share/zinit && bash -c "$(curl -fsSL https://raw.githubusercontent.com/zdharma-continuum/zinit/HEAD/scripts/install.sh)" # Reinstall zinit if plugins fail
which fzf zoxide atuin direnv # Confirm core binaries are on PATH
p10k configure # Re-run the interactive prompt wizard
Fonts missing? Re-open the terminal and ensure your profile uses a Nerd Font. Icons rendering as boxes means the font doesn't include Nerd Font glyphs — install a patched font (e.g. FiraCode Nerd Font) and set it in your terminal profile.
cd ~/dotfiles
git pull origin main
brew bundle --file=Brewfile # Install any new packages
test-zsh # Sanity check after upgrades
source ~/.zshrc # Reload shell config
Remember to bestow unstow packages you no longer want and re-run bestow stow after pulling to ensure new configs are linked.
Bug reports and PRs are welcome! Please run test-zsh plus any relevant profilers before opening a pull request. See CONTRIBUTING.md for commit conventions, scopes (including prompt for p10k changes), and validation expectations.
Happy hacking!
hooks/register.ts 189 lines1import type { Register } from 'claude-code'
2
3import { LABELS, classify, clip, human, isGlob, rmTargets } from './risk'
4import type { Risk } from './risk'
5
6const RUN = 'Run it'
7const CANCEL = 'Cancel'
8const T = { timeoutMs: 8000 }
9
10// The part of `$` the previews use. `$` must stay named `$` and stay in this file.
11type Sh = {
12 process: {
13 run: (
14 argv: readonly string[],
15 init?: { cwd?: string; timeoutMs?: number },
16 ) => Promise<{ exitCode: number; stdout: string; stderr: string }>
17 }
18 fs: { stat: (path: string) => Promise<{ kind: string; size: number }> }
19 env: { get: (name: string) => Promise<string | undefined> }
20}
21
22async function git($: Sh, args: string[]): Promise<string> {
23 const r = await $.process.run(['git', ...args], T)
24 if (r.exitCode !== 0) {
25 throw new Error(`git ${args[0]} failed: ${r.stderr.trim().slice(0, 200)}`)
26 }
27 return r.stdout.trimEnd()
28}
29
30async function previewRm($: Sh, risk: Risk): Promise<string[]> {
31 const targets = rmTargets(risk.args)
32 if (targets.length === 0) {
33 return [`Could not parse targets from: ${risk.segment.slice(0, 200)}`]
34 }
35 const home = await $.env.get('HOME')
36 const lines: string[] = []
37 for (const raw of targets.slice(0, 8)) {
38 if (/[$`]/.test(raw)) {
39 lines.push(`? ${raw} (contains a variable or substitution, cannot resolve)`)
40 continue
41 }
42 if (isGlob(raw)) {
43 lines.push(`? ${raw} (glob, the shell expands it at run time)`)
44 continue
45 }
46 const path = raw === '~' ? (home ?? raw) : raw.startsWith('~/') && home ? home + raw.slice(1) : raw
47 const kind = await $.process
48 .run(['stat', '-f', '%HT', path], T)
49 .then(r => (r.exitCode === 0 ? r.stdout.trim() : null))
50 if (kind === null) {
51 lines.push(`- ${raw} (does not exist)`)
52 continue
53 }
54 const stat = await $.fs.stat(path)
55 if (stat.kind === 'dir') {
56 const files = await $.process.run(['find', path, '-type', 'f'], T)
57 const count = files.stdout.split('\n').filter(Boolean).length
58 const du = await $.process.run(['du', '-sk', path], T)
59 const kb = Number.parseInt(du.stdout.split('\t')[0] ?? '', 10)
60 lines.push(`- ${raw}/ directory: ${count} files, ${Number.isNaN(kb) ? '?' : human(kb * 1024)}`)
61 if (path === '/' || path === home) {
62 lines.push(' !! this is the filesystem root or your home directory')
63 }
64 } else {
65 lines.push(`- ${raw} ${kind}, ${human(stat.size)}`)
66 }
67 }
68 if (targets.length > 8) {
69 lines.push(`... and ${targets.length - 8} more targets`)
70 }
71 return lines
72}
73
74async function previewChmod($: Sh, risk: Risk): Promise<string[]> {
75 const lines: string[] = []
76 for (const t of rmTargets(risk.args).slice(1, 6)) {
77 if (isGlob(t) || /[$`]/.test(t)) {
78 lines.push(`? ${t} (cannot resolve)`)
79 continue
80 }
81 const files = await $.process.run(['find', t], T)
82 lines.push(`- ${t} ${files.stdout.split('\n').filter(Boolean).length} entries affected`)
83 }
84 return lines.length ? lines : ['Could not parse targets']
85}
86
87async function previewFor($: Sh, risk: Risk): Promise<string[]> {
88 switch (risk.kind) {
89 case 'rm':
90 return previewRm($, risk)
91 case 'git-reset-hard': {
92 const status = await git($, ['status', '--short'])
93 const out = [
94 'Uncommitted changes that would be discarded:',
95 ...(status ? clip(status.split('\n')) : ['(working tree is clean)']),
96 ]
97 const ahead = await git($, ['log', '--oneline', '@{u}..']).catch(() => '')
98 if (ahead) {
99 out.push('Local commits not on upstream (reset may orphan them):', ...clip(ahead.split('\n'), 6))
100 }
101 return out
102 }
103 case 'git-push-force': {
104 const branch = await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])
105 const ahead = await git($, ['log', '--oneline', '@{u}..']).catch(() => '')
106 const behind = await git($, ['log', '--oneline', '..@{u}']).catch(() => '')
107 const out = [`Branch: ${branch}`]
108 out.push(ahead ? 'Commits to be pushed:' : 'No unpushed commits found (or no upstream)')
109 if (ahead) out.push(...clip(ahead.split('\n'), 6))
110 if (behind) {
111 out.push('Remote-only commits that would be OVERWRITTEN:', ...clip(behind.split('\n'), 6))
112 }
113 return out
114 }
115 case 'git-clean': {
116 const dry = await git($, ['clean', '-nd'])
117 return dry ? ['Would remove:', ...clip(dry.split('\n'))] : ['Nothing to remove right now']
118 }
119 case 'adopt': {
120 const status = await git($, ['status', '--short'])
121 return [
122 'Live files get pulled INTO the repo, overwriting curated configs.',
123 'Run `git diff` right after. Current changes:',
124 ...(status ? clip(status.split('\n'), 8) : ['(working tree is clean)']),
125 ]
126 }
127 case 'chmod-r':
128 return previewChmod($, risk)
129 case 'dd': {
130 const of = risk.args.find(a => a.startsWith('of='))
131 const inp = risk.args.find(a => a.startsWith('if='))
132 return [`Raw write: ${inp ?? 'if=?'} -> ${of ?? 'of=?'}`, 'Cannot be previewed or undone.']
133 }
134 case 'mkfs':
135 return [
136 `Formats: ${risk.args.filter(a => !a.startsWith('-')).join(' ') || '(device unknown)'}`,
137 'All data on it is lost.',
138 ]
139 case 'sql':
140 return [
141 'Destructive SQL or migration detected. It cannot be previewed here.',
142 `Command: ${risk.segment.slice(0, 300)}`,
143 ]
144 }
145}
146
147async function buildPreview($: Sh, risks: Risk[]): Promise<string> {
148 const parts: string[] = []
149 for (const r of risks) {
150 parts.push(`[${LABELS[r.kind]}]`, ...(await previewFor($, r)))
151 }
152 return parts.join('\n')
153}
154
155export const register: Register = on => {
156 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
157 const risks = classify(e.command)
158 if (risks.length === 0) {
159 return next(e)
160 }
161
162 // Anything below that fails, or is not answered by a person, denies the call.
163 let preview: string
164 try {
165 preview = await buildPreview($, risks)
166 } catch (err) {
167 const why = err instanceof Error ? err.message : String(err)
168 return { deny: `blast-radius: could not preview "${e.command.slice(0, 120)}" (${why}). Not run.` }
169 }
170
171 let answer: string
172 try {
173 const question = `Risky command: ${e.command.slice(0, 300)}\n\n${preview.slice(0, 1500)}\n\nRun it?`
174 answer = await $.ui.ask(question, { options: [RUN, CANCEL], header: 'Blast radius' })
175 } catch {
176 return {
177 deny: `blast-radius: "${e.command.slice(0, 120)}" needs your confirmation and nobody answered. Not run.\n${preview.slice(0, 800)}`,
178 }
179 }
180
181 if (answer !== RUN) {
182 return { deny: `blast-radius: you cancelled "${e.command.slice(0, 120)}".` }
183 }
184 return next(e)
185 }).catch(($, e, next) =>
186 next.called ? next(e) : { deny: 'blast-radius: its guard failed, so the command was not run.' },
187 )
188}
189hooks/risk.ts 202 lines1export type RiskKind =
2 | 'rm'
3 | 'git-reset-hard'
4 | 'git-push-force'
5 | 'git-clean'
6 | 'sql'
7 | 'adopt'
8 | 'chmod-r'
9 | 'dd'
10 | 'mkfs'
11
12export type Risk = {
13 kind: RiskKind
14 segment: string
15 args: string[]
16}
17
18export const LABELS: Record<RiskKind, string> = {
19 rm: 'recursive delete (rm -r)',
20 'git-reset-hard': 'git reset --hard',
21 'git-push-force': 'forced git push',
22 'git-clean': 'git clean (deletes untracked files)',
23 sql: 'destructive SQL or migration',
24 adopt: 'stow/bestow --adopt (overwrites repo files)',
25 'chmod-r': 'recursive chmod',
26 dd: 'dd (raw block write)',
27 mkfs: 'mkfs (formats a device)',
28}
29
30const WRAPPERS = new Set([
31 'sudo',
32 'doas',
33 'env',
34 'command',
35 'time',
36 'nohup',
37 'exec',
38 'xargs',
39 'nice',
40 'builtin',
41])
42
43const SQL_DESTRUCTIVE =
44 /\b(drop\s+(table|database|schema|index|view)|truncate\s+(table\s+)?[A-Za-z_"`]|delete\s+from\s+[A-Za-z_"`.]+\s*(;|"|'|$))/i
45const MIGRATE =
46 /\b(db:migrate|migrate:(fresh|refresh|reset|rollback)|prisma\s+migrate\s+(reset|deploy|dev)|manage\.py\s+migrate|alembic\s+(upgrade|downgrade)|flyway\s+(migrate|clean)|goose\s+(up|down|reset)|dbmate\s+(up|down|drop)|sqlx\s+migrate|knex\s+migrate|sequelize\S*\s+db:migrate)\b/i
47
48/** Splits on unquoted ; & | and newlines. Quotes and $( ) are not parsed deeply. */
49export function splitSegments(command: string): string[] {
50 const out: string[] = []
51 let cur = ''
52 let quote: string | null = null
53 for (let i = 0; i < command.length; i++) {
54 const c = command.charAt(i)
55 if (quote) {
56 cur += c
57 if (c === quote && command.charAt(i - 1) !== '\\') quote = null
58 continue
59 }
60 if (c === '"' || c === "'") {
61 quote = c
62 cur += c
63 continue
64 }
65 if (c === ';' || c === '&' || c === '|' || c === '\n') {
66 if (cur.trim()) out.push(cur.trim())
67 cur = ''
68 continue
69 }
70 cur += c
71 }
72 if (cur.trim()) out.push(cur.trim())
73 return out
74}
75
76/** Whitespace split that keeps quoted strings together and strips the quotes. */
77export function tokenize(segment: string): string[] {
78 const out: string[] = []
79 let cur = ''
80 let quote: string | null = null
81 let has = false
82 for (let i = 0; i < segment.length; i++) {
83 const c = segment.charAt(i)
84 if (quote) {
85 if (c === quote) quote = null
86 else cur += c
87 continue
88 }
89 if (c === '"' || c === "'") {
90 quote = c
91 has = true
92 continue
93 }
94 if (c === '\\' && i + 1 < segment.length) {
95 cur += segment.charAt(++i)
96 has = true
97 continue
98 }
99 if (/\s/.test(c)) {
100 if (has || cur) out.push(cur)
101 cur = ''
102 has = false
103 continue
104 }
105 cur += c
106 has = true
107 }
108 if (has || cur) out.push(cur)
109 return out
110}
111
112const stripOpen = (t: string) => t.replace(/^(\$\(|\(|`)+/, '')
113const base = (t: string) => stripOpen(t).split('/').pop() ?? t
114
115/** Index of the program word, skipping wrappers, VAR=x assignments and options of wrappers. */
116function programIndex(tokens: string[]): number {
117 let i = 0
118 while (i < tokens.length) {
119 const t = stripOpen(tokens[i] ?? '')
120 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) i++
121 else if (WRAPPERS.has(base(t))) {
122 i++
123 while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) i++
124 } else break
125 }
126 return i
127}
128
129const hasShortFlag = (args: string[], chars: string) =>
130 args.some(a => /^-[A-Za-z]+$/.test(a) && [...chars].some(ch => a.includes(ch)))
131
132function classifySegment(segment: string): Risk[] {
133 const tokens = tokenize(segment)
134 const idx = programIndex(tokens)
135 if (idx >= tokens.length) return []
136 const prog = base(tokens[idx] ?? '')
137 const args = tokens.slice(idx + 1)
138 const out: Risk[] = []
139 const push = (kind: RiskKind, a: string[] = args) => out.push({ kind, segment, args: a })
140
141 if (prog === 'rm') {
142 if (hasShortFlag(args, 'rR') || args.includes('--recursive')) push('rm')
143 } else if (prog === 'git') {
144 const sub = args.find(a => !a.startsWith('-'))
145 if (sub === 'reset' && args.includes('--hard')) push('git-reset-hard')
146 else if (
147 sub === 'push' &&
148 // --force-with-lease refuses to clobber remote commits you have not seen, so it is not flagged.
149 (args.includes('--force') ||
150 args.some(a => /^-[A-Za-z]*f[A-Za-z]*$/.test(a)) ||
151 args.some(a => a.startsWith('+') && a.length > 1))
152 )
153 push('git-push-force')
154 else if (sub === 'clean' && (hasShortFlag(args, 'f') || args.includes('--force')) && !hasShortFlag(args, 'n'))
155 push('git-clean')
156 } else if (prog === 'stow' || prog === 'bestow') {
157 if (args.includes('--adopt')) push('adopt')
158 } else if (prog === 'chmod' || prog === 'chown') {
159 if (hasShortFlag(args, 'R') || args.includes('--recursive')) push('chmod-r')
160 } else if (prog === 'dd') {
161 push('dd')
162 } else if (prog === 'mkfs' || prog.startsWith('mkfs.')) {
163 push('mkfs')
164 }
165 return out
166}
167
168export function classify(command: string): Risk[] {
169 const risks: Risk[] = []
170 for (const seg of splitSegments(command)) risks.push(...classifySegment(seg))
171
172 if (SQL_DESTRUCTIVE.test(command) || MIGRATE.test(command)) {
173 risks.push({ kind: 'sql', segment: command, args: [] })
174 }
175
176 // Fallback for forms the tokenizer misses (rm inside $(...), bash -c '...', heredocs)
177 if (
178 !risks.some(r => r.kind === 'rm') &&
179 /(^|[\s(`;&|])rm\s+(-[A-Za-z]*[rR][A-Za-z]*|--recursive)\b/.test(command)
180 ) {
181 risks.push({ kind: 'rm', segment: command, args: [] })
182 }
183 return risks
184}
185
186export const isGlob = (p: string) => /[*?[\]{}]/.test(p)
187export const rmTargets = (args: string[]): string[] => {
188 const out: string[] = []
189 let afterDashes = false
190 for (const a of args) {
191 if (!afterDashes && a === '--') afterDashes = true
192 else if (afterDashes || !a.startsWith('-')) out.push(a)
193 }
194 return out
195}
196
197export const human = (n: number) =>
198 n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`
199
200export const clip = (lines: string[], max = 12) =>
201 lines.length > max ? [...lines.slice(0, max), `... and ${lines.length - max} more`] : lines
202