SLOPSHOPPER

tripwire

Holds a destructive shell command (rm -rf, force push, hard reset…), shows what it would destroy, and lets you cancel or proceed.

newpaneguardcommandtoastprocess
v0.1.0MITupdated 2026-10-14flaviorg/tripwire
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tripwire
│ ┃ tripwire ✕ › fix the failing auth test and add a╭─────────────────────────────╮ │ ┃ ◆ tripwire · Recursive delete │ tripwire │ │ ┃ rm -rf build ⏺ Read(src/auth.ts) │ cancelled · command not run │ │ ┃ 3 paths would be deleted ⎿ Read 6 lines ╰─────────────────────────────╯ │ ┃ package.json ⏺ Update(src/auth.ts) │ ┃ README.md ⎿ Added 2 lines, removed 1 line │ ┃ src ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ ⎿ Denied by tripwire: The user cancelled this command becau │ ┃ Answer in Claude's dialog · Cancel is the │ ┃ default ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tripwire │ ⎿ tripwire: Tripwire holds: │ ⎿ tripwire: ● Recursive delete │ ⎿ tripwire: ● Force push │ ⎿ tripwire: ● Hard reset │ ⎿ tripwire: ○ Git clean │ ⎿ tripwire: ○ Force-delete branch │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · tripwire
◆ tripwire · Recursive delete rm -rf build 3 paths would be deleted package.json README.md src Answer in Claude's dialog · Cancel is the default
README

<img src="docs/media/banner.png" alt="tripwire: a Claude Code mod that holds a destructive command and shows what it would destroy before it runs. The pane lists 17 paths of build/ and the dialog offers Cancel or Proceed" width="100%">

◆ tripwire

A Claude Code mod that holds a destructive command and shows you what it would destroy — before it runs.

Claude Code mod Tested with License: MIT

<img src="docs/media/demo.gif" alt="Claude runs rm -rf build; tripwire opens its pane listing 17 paths · 56K that would be deleted and asks Cancel or Proceed; the user cancels. Claude then force-pushes a branch; tripwire shows the 2 remote commits that would be overwritten and the user proceeds. /tripwire lists both decisions" width="880">

<sub>An animation drawn from the mod's own pane and dialog. A recording from a real session is <a href="#in-a-real-session">below</a>.</sub>

One bad rm -rf, one force push to the wrong branch, one git reset --hard — and hours of work are gone. Claude Code's permission prompt tells you what command is about to run. Tripwire tells you what it would take with it, then waits for your answer.

╭──────────────────────────────────────────────────────────╮
│ ◆ tripwire · Recursive delete                            │
│ rm -rf build                                             │
│ 17 paths · 56K would be deleted                          │
│   build                                                  │
│   build/index.html                                       │
│   build/chunks                                           │
│   build/chunks/list.js.map                               │
│   …                                                      │
│ Answer in Claude's dialog · Cancel is the default        │
╰──────────────────────────────────────────────────────────╯
tripwire · 17 paths · 56K would be deleted
rm -rf build
  1: Cancel   2: Proceed

In a real session

<img src="docs/media/tripwire.gif" alt="Claude runs rm -rf build; tripwire holds it, opens a pane saying 17 paths · 56K would be deleted with the paths listed, and asks Cancel or Proceed; the user picks Cancel and Claude reports the folder is still there" width="800">

Install

Inside Claude Code:

/plugin marketplace add flaviorg/tripwire
/plugin install tripwire@tripwire

Then start a new session, or run /reload-plugins. Tripwire is also listed in my claude-mods marketplace with my other mods.

What it holds

CommandWhat the pane shows
●rm -r / rm -rf / rm with a globHow many paths and how much disk space, with the first 30 paths
●git push --force, -f, --force-with-leaseThe remote commits that would be overwritten
●git reset --hardThe changed files that would be discarded
●git checkout -- ., git restore .The changed files that would be discarded
○git clean -fWhat git clean -n would remove
○git branch -DThe unmerged commits on that branch
●DROP TABLE / DROP DATABASE / TRUNCATEThe statement
○chmod -R 777The path

● high severity · ○ medium. Commands are checked segment by segment, so cd app && sudo rm -rf build is caught and measured inside app/.

How it decides

  1. A tool.call hook sees every Bash command before it runs. Safe commands pass straight through — no delay, no prompt.
  2. A hit runs at most two read-only commands (find, du, git log, git status, git clean -n) with a 2-second timeout each to measure the damage.
  3. The pane opens with the blast list, and Claude's question dialog asks Cancel / Proceed. Cancel is first and is the default.
  4. Proceed lets the command continue to Claude Code's normal permission check. Anything else — Cancel, a typed answer, a dismissed dialog, a headless claude -p run — denies it, and Claude reads why.
  5. If tripwire itself fails before deciding, the command is denied. It fails closed.

Tripwire is a safety net, not a permission system: keep your normal permissions on.

Commands

CommandWhat it does
/tripwireLists the rules and every command held in this session, with your decision

Options

Set them with /plugin configure tripwire@tripwire:

OptionValuesDefaultWhat it does
modeask, deny, offaskdeny refuses hits without asking; off disables the guard
extraPatternscomma-separated regexes(empty)Extra commands to hold, e.g. terraform destroy,kubectl delete
allowPathscomma-separated folder names(empty)Folders rm -rf may delete without a hold, e.g. dist,build,.cache

Privacy

Tripwire reads no files of yours and makes no network requests. On a hit it runs read-only find, du and git commands in your working directory. You can confirm this before installing:

claude plugin validate ./tripwire

The calls: line lists every mods API call it makes.

Compatibility

Tested with Claude Code 2.1.296 in the terminal and the Desktop app's Code tab. Mods need Claude Code 2.1.287 or later. In claude -p runs nobody can answer the dialog, so a hit is denied.

Development

claude plugin validate --strict .   # manifest, hooks and calls
claude plugin test .                 # 26 tests, no session or sign-in needed
claude --plugin-dir .                # try it in a session; edits hot-reload

Inspired by Anthropic's blast-radius sample mod; written from scratch against the public mods API.

License

MIT © flaviorg

Source 4 files
hooks/register.ts 131 lines
1// tripwire — holds a destructive Bash command, shows what it would destroy, asks you.
2// This is the only file that touches the mods API ($).
3import { findMatch, RULES, type Match } from './rules.ts'
4import { formatPreview, previewPlan, type PlanStep, type Preview, type RunResult } from './preview.ts'
5import type { Segment } from './segments.ts'
6
7const PANE = 'tripwire'
8
9type Options = { mode: 'ask' | 'deny' | 'off'; extraPatterns: string; allowPaths: string }
10
11type Held = { command: string; segment: Segment; match: Match; preview: Preview }
12type LogEntry = { at: number; command: string; ruleId: string; decision: 'proceeded' | 'cancelled' | 'denied' }
13
14// The last held command, for the pane and /tripwire; the session's decisions.
15let last: Held | null = null
16const log: LogEntry[] = []
17
18const DENY_CANCELLED = 'The user cancelled this command because it is destructive (tripwire). Ask before trying an alternative.'
19const DENY_MODE = 'tripwire blocked this destructive command (mode: deny). Ask the user before trying an alternative.'
20
21// Runs each preview step with a short timeout; a failure becomes null, never a throw.
22async function runPlan($: any, plan: PlanStep[]): Promise<RunResult[]> {
23  const results: RunResult[] = []
24  for (const step of plan.slice(0, 2)) {
25    try {
26      const r = await $.process.run(step.argv, { timeoutMs: 2000, ...(step.cwd ? { cwd: step.cwd } : {}) })
27      results.push({ exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr })
28    } catch {
29      results.push(null)
30    }
31  }
32  return results
33}
34
35// Shows the pane, asks in the dialog, closes the pane. The wait lives inside $.ui.ask.
36async function holdAndAsk($: any, held: Held): Promise<'proceed' | 'cancel'> {
37  let placed = false
38  try {
39    const opened = await $.ui.open({ id: PANE, title: 'Tripwire', holdToasts: true })
40    placed = opened?.isPlaced === true
41  } catch {}
42  const question = [`tripwire · ${held.preview.hit}`, held.segment.text, ...(placed ? [] : held.preview.summary.slice(1))].join('\n')
43  let answer = 'Cancel'
44  try {
45    answer = await $.ui.ask(question, ['Cancel', 'Proceed'])
46  } catch {}
47  try {
48    await $.ui.close({ id: PANE })
49  } catch {}
50  return answer === 'Proceed' ? 'proceed' : 'cancel'
51}
52
53function remember(held: Held, decision: LogEntry['decision'], at: number) {
54  log.push({ at, command: held.command, ruleId: held.match.ruleId, decision })
55}
56
57export function register(on: any, options: Options) {
58  const mode = options?.mode ?? 'ask'
59  const findOptions = { extraPatterns: options?.extraPatterns ?? '', allowPaths: options?.allowPaths ?? '' }
60
61  on('session.start', async ($: any, e: any, next: any) => {
62    try {
63      await $.command.register({ name: 'tripwire', description: 'What tripwire guards, and what it held this session', immediate: true })
64    } catch {}
65    return next(e)
66  })
67
68  on('tool.call', { tool: 'Bash' }, async ($: any, e: any, next: any) => {
69    if (mode === 'off') return next(e)
70    const found = findMatch(e.command, findOptions)
71    if (!found) return next(e)
72    const held: Held = { command: e.command, segment: found.segment, match: found.match, preview: formatPreview(found.match, found.segment.text, []) }
73    if (mode === 'deny') {
74      last = held
75      remember(held, 'denied', Date.now())
76      return { deny: DENY_MODE }
77    }
78    const results = await runPlan($, previewPlan(found.match, found.segment.cwd))
79    held.preview = formatPreview(found.match, found.segment.text, results)
80    last = held
81    const decision = await holdAndAsk($, held)
82    if (decision === 'proceed') {
83      remember(held, 'proceeded', Date.now())
84      $.ui.toast('proceeding')
85      return next(e)
86    }
87    remember(held, 'cancelled', Date.now())
88    $.ui.toast('cancelled · command not run')
89    return { deny: DENY_CANCELLED }
90  }).catch(async ($: any, e: any, next: any) => {
91    if (next.called) return next(e)
92    return { deny: 'tripwire failed before deciding, so this command was not run: ' + next.error.kind }
93  })
94
95  on('ui.render', { component: 'Pane' }, async ($: any, e: any, next: any) => {
96    if (e.requestId !== PANE) return next(e)
97    const { Box, Text, Code } = $.ui.resolve(e)
98    const width = Math.max(20, (e.props?.bodyColumns ?? 80) - 2)
99    const cut = (s: string) => (s.length > width ? s.slice(0, width - 1) + '…' : s)
100    if (!last) {
101      return Box({
102        flexDirection: 'column',
103        children: [
104          Text({ bold: true, children: ['◆ tripwire'] }),
105          Text({ dimColor: true, children: ['No command held yet. Tripwire holds rm -rf, force pushes, hard resets and more.'] }),
106        ],
107      })
108    }
109    const { preview, segment } = last
110    return Box({
111      flexDirection: 'column',
112      children: [
113        Text({ bold: true, children: ['◆ tripwire · ' + preview.title] }),
114        Code({ source: segment.text, language: 'bash' }),
115        Text({ color: 'red', bold: true, children: [cut(preview.hit)] }),
116        ...preview.lines.map((line) => Text({ dimColor: true, children: [cut('  ' + line)] })),
117        Text({ children: [' '] }),
118        Text({ dimColor: true, children: ["Answer in Claude's dialog · Cancel is the default"] }),
119      ],
120    })
121  })
122
123  on('command.run', { command: 'tripwire' }, async () => {
124    const rules = RULES.map((r) => `  ${r.severity === 'high' ? '●' : '○'} ${r.label}`).join('\n')
125    const history = log.length === 0
126      ? 'No commands held yet.'
127      : log.map((l) => `  ${l.decision === 'proceeded' ? '✓' : '✗'} ${l.decision} · ${l.command}`).join('\n')
128    return { text: `Tripwire holds:\n${rules}\n\nThis session:\n${history}` }
129  })
130}
131
hooks/rules.ts 176 lines
1// The destructive-command rules. Pure: no mods API here.
2import { splitSegments, type Segment } from './segments.ts'
3
4export type Severity = 'high' | 'medium'
5
6export type Match = {
7  ruleId: string
8  label: string
9  severity: Severity
10  /** Paths the command targets (rm, chmod), if any. */
11  paths: string[]
12  branch?: string
13  remote?: string
14  /** The flag letters seen (e.g. "rf", "fd"). */
15  flags: string
16}
17
18type Hit = Omit<Match, 'ruleId' | 'label' | 'severity'>
19
20export type Rule = {
21  id: string
22  label: string
23  severity: Severity
24  test(segment: string): Hit | null
25}
26
27function words(segment: string): string[] {
28  return segment.split(/\s+/).filter(Boolean)
29}
30
31function flagLetters(ws: string[]): string {
32  return ws
33    .filter((w) => /^-[A-Za-z]+$/.test(w))
34    .map((w) => w.slice(1))
35    .join('')
36}
37
38function positional(ws: string[], skip: number): string[] {
39  return ws.slice(skip).filter((w) => !w.startsWith('-'))
40}
41
42export const RULES: Rule[] = [
43  {
44    id: 'rm',
45    label: 'Recursive delete',
46    severity: 'high',
47    test(segment) {
48      const ws = words(segment)
49      if (ws[0] !== 'rm') return null
50      const flags = flagLetters(ws)
51      const paths = positional(ws, 1)
52      const recursive = /r/i.test(flags) || ws.includes('--recursive')
53      const glob = paths.some((p) => p.includes('*'))
54      if (!recursive && !glob) return null
55      return { paths, flags }
56    },
57  },
58  {
59    id: 'git-force-push',
60    label: 'Force push',
61    severity: 'high',
62    test(segment) {
63      const ws = words(segment)
64      if (ws[0] !== 'git' || ws[1] !== 'push') return null
65      const forced = ws.some((w) => w === '-f' || w === '--force' || w.startsWith('--force-with-lease') || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(w))
66      if (!forced) return null
67      const [remote, branch] = positional(ws, 2)
68      return { paths: [], flags: flagLetters(ws), remote: remote ?? 'origin', branch: branch?.split(':')[0] }
69    },
70  },
71  {
72    id: 'git-reset-hard',
73    label: 'Hard reset',
74    severity: 'high',
75    test(segment) {
76      const ws = words(segment)
77      if (ws[0] !== 'git' || ws[1] !== 'reset' || !ws.includes('--hard')) return null
78      return { paths: [], flags: '' }
79    },
80  },
81  {
82    id: 'git-clean',
83    label: 'Git clean',
84    severity: 'medium',
85    test(segment) {
86      const ws = words(segment)
87      if (ws[0] !== 'git' || ws[1] !== 'clean') return null
88      const flags = flagLetters(ws)
89      if (!/f/.test(flags) && !ws.includes('--force')) return null
90      if (/n/.test(flags) || ws.includes('--dry-run')) return null
91      return { paths: [], flags }
92    },
93  },
94  {
95    id: 'git-branch-delete',
96    label: 'Force-delete branch',
97    severity: 'medium',
98    test(segment) {
99      const ws = words(segment)
100      if (ws[0] !== 'git' || ws[1] !== 'branch') return null
101      const forced = ws.includes('-D') || (ws.includes('--delete') && ws.includes('--force')) || (ws.includes('-d') && ws.includes('-f'))
102      if (!forced) return null
103      const branch = positional(ws, 2)[0]
104      return { paths: [], flags: flagLetters(ws), branch }
105    },
106  },
107  {
108    id: 'git-checkout-discard',
109    label: 'Discard working changes',
110    severity: 'high',
111    test(segment) {
112      const ws = words(segment)
113      if (ws[0] !== 'git') return null
114      if (ws[1] === 'checkout' && ws[2] === '--' && ws[3] === '.') return { paths: ['.'], flags: '' }
115      if (ws[1] === 'restore' && positional(ws, 2).includes('.')) return { paths: ['.'], flags: flagLetters(ws) }
116      return null
117    },
118  },
119  {
120    id: 'sql-drop',
121    label: 'Destructive SQL',
122    severity: 'high',
123    test(segment) {
124      return /\b(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE(\s+TABLE)?)\b/i.test(segment) ? { paths: [], flags: '' } : null
125    },
126  },
127  {
128    id: 'chmod-777',
129    label: 'World-writable permissions',
130    severity: 'medium',
131    test(segment) {
132      const ws = words(segment)
133      if (ws[0] !== 'chmod' || !ws.includes('777')) return null
134      return { paths: positional(ws, 1).filter((w) => w !== '777'), flags: flagLetters(ws) }
135    },
136  },
137]
138
139/** Comma-separated regex sources → medium-severity rules. Invalid patterns are skipped. */
140export function customRules(patterns: string): Rule[] {
141  return patterns
142    .split(',')
143    .map((p) => p.trim())
144    .filter(Boolean)
145    .flatMap((source, i) => {
146      try {
147        const re = new RegExp(source, 'i')
148        return [{ id: `custom-${i + 1}`, label: source, severity: 'medium' as const, test: (s: string) => (re.test(s) ? { paths: [], flags: '' } : null) }]
149      } catch {
150        return []
151      }
152    })
153}
154
155export type FindOptions = { extraPatterns?: string; allowPaths?: string }
156
157function isAllowed(hit: Hit, allow: string[]): boolean {
158  if (allow.length === 0 || hit.paths.length === 0) return false
159  return hit.paths.every((p) => allow.includes(p.replace(/\/+$/, '').split('/').pop() ?? p))
160}
161
162/** The first destructive segment of a command, or null. */
163export function findMatch(command: string, options: FindOptions = {}): { match: Match; segment: Segment } | null {
164  const rules = [...RULES, ...customRules(options.extraPatterns ?? '')]
165  const allow = (options.allowPaths ?? '').split(',').map((s) => s.trim()).filter(Boolean)
166  for (const segment of splitSegments(command)) {
167    for (const rule of rules) {
168      const hit = rule.test(segment.text)
169      if (!hit) continue
170      if (rule.id === 'rm' && isAllowed(hit, allow)) continue
171      return { match: { ruleId: rule.id, label: rule.label, severity: rule.severity, ...hit }, segment }
172    }
173  }
174  return null
175}
176
hooks/preview.ts 104 lines
1// Which read-only commands measure a hit, and how their output reads. Pure.
2import type { Match } from './rules.ts'
3
4export type RunResult = { exitCode: number; stdout: string; stderr: string } | null
5
6export type PlanStep = { argv: string[]; cwd?: string }
7
8export type Preview = {
9  title: string
10  /** One sentence with the count in it. */
11  hit: string
12  /** Up to 30 dim rows. */
13  lines: string[]
14  /** `hit` plus the first rows, for the dialog when the pane can't place. */
15  summary: string[]
16}
17
18const MAX_LINES = 30
19
20/** At most two read-only commands that show what the hit would destroy. */
21export function previewPlan(match: Match, cwd?: string): PlanStep[] {
22  const at = (argv: string[]): PlanStep => (cwd ? { argv, cwd } : { argv })
23  switch (match.ruleId) {
24    case 'rm': {
25      const p = match.paths[0]
26      return p ? [at(['find', p, '-maxdepth', '3']), at(['du', '-sh', p])] : []
27    }
28    case 'git-force-push': {
29      const ref = match.branch ? `${match.remote ?? 'origin'}/${match.branch}` : '@{upstream}'
30      return [at(['git', 'log', '--oneline', `HEAD..${ref}`]), at(['git', 'log', '--oneline', `${ref}..HEAD`])]
31    }
32    case 'git-reset-hard':
33    case 'git-checkout-discard':
34      return [at(['git', 'status', '--porcelain'])]
35    case 'git-clean':
36      return [at(['git', 'clean', '-n', ...(match.flags.includes('d') ? ['-d'] : [])])]
37    case 'git-branch-delete':
38      return match.branch ? [at(['git', 'log', '--oneline', `main..${match.branch}`])] : []
39    default:
40      return []
41  }
42}
43
44function rows(result: RunResult): string[] {
45  return (result?.stdout ?? '').split('\n').map((l) => l.trimEnd()).filter(Boolean)
46}
47
48export function formatPreview(match: Match, segmentText: string, results: RunResult[]): Preview {
49  const title = match.label
50  let hit = 'nothing measurable — review the command'
51  let lines: string[] = []
52  const failed = results.length > 0 && results.some((r) => r === null || r.exitCode !== 0)
53
54  switch (match.ruleId) {
55    case 'rm': {
56      const found = rows(results[0])
57      // macOS du pads the size with a leading space (' 56K\tbuild'), so trim before splitting.
58      const size = rows(results[1])[0]?.trim().split(/\s+/)[0]
59      lines = found
60      hit = failed ? 'could not measure — review the command' : `${found.length} path${found.length === 1 ? '' : 's'}${size ? ` · ${size}` : ''} would be deleted`
61      break
62    }
63    case 'git-force-push': {
64      const lost = rows(results[0])
65      const pushed = rows(results[1])
66      lines = [...lost.map((l) => `− ${l}`), ...pushed.slice(0, 5).map((l) => `+ ${l}`)]
67      hit = failed ? 'could not measure — review the command' : lost.length === 0 ? 'no remote commits would be lost' : `${lost.length} remote commit${lost.length === 1 ? '' : 's'} would be overwritten`
68      break
69    }
70    case 'git-reset-hard':
71    case 'git-checkout-discard': {
72      const changed = rows(results[0])
73      lines = changed
74      hit = failed ? 'could not measure — review the command' : `${changed.length} changed file${changed.length === 1 ? '' : 's'} would be discarded`
75      break
76    }
77    case 'git-clean': {
78      const removed = rows(results[0]).map((l) => l.replace(/^Would remove\s+/, ''))
79      lines = removed
80      hit = failed ? 'could not measure — review the command' : `${removed.length} untracked path${removed.length === 1 ? '' : 's'} would be removed`
81      break
82    }
83    case 'git-branch-delete': {
84      const commits = rows(results[0])
85      lines = commits
86      hit = failed ? 'could not measure — review the command' : `${commits.length} unmerged commit${commits.length === 1 ? '' : 's'} on ${match.branch ?? 'the branch'} would be lost`
87      break
88    }
89    case 'sql-drop':
90      hit = 'this SQL destroys data'
91      lines = [segmentText]
92      break
93    case 'chmod-777':
94      hit = `${match.paths.join(', ') || 'the path'} would become world-writable`
95      break
96    default:
97      hit = `matches ${match.label}`
98      lines = [segmentText]
99  }
100
101  if (lines.length > MAX_LINES) lines = [...lines.slice(0, MAX_LINES), `… ${lines.length - MAX_LINES} more`]
102  return { title, hit, lines, summary: [hit, ...lines.slice(0, 4)] }
103}
104
hooks/segments.ts 85 lines
1// Splits a shell command into the segments the guard inspects.
2// Pure: no mods API here.
3
4export type Segment = {
5  /** The segment's text, without a leading sudo/env prefix. */
6  text: string
7  /** The directory a preceding `cd` moved into, relative to the session's cwd. */
8  cwd?: string
9}
10
11const OPERATORS = ['&&', '||', ';', '|', '\n']
12
13/** Splits on &&, ||, ;, | and newlines outside quotes. */
14function splitRaw(command: string): string[] {
15  const out: string[] = []
16  let current = ''
17  let quote: '"' | "'" | null = null
18  for (let i = 0; i < command.length; i++) {
19    const ch = command[i]
20    if (quote) {
21      current += ch
22      if (ch === quote && command[i - 1] !== '\\') quote = null
23      continue
24    }
25    if (ch === '"' || ch === "'") {
26      quote = ch
27      current += ch
28      continue
29    }
30    const op = OPERATORS.find((o) => command.startsWith(o, i))
31    if (op) {
32      out.push(current)
33      current = ''
34      i += op.length - 1
35      continue
36    }
37    current += ch
38  }
39  out.push(current)
40  return out.map((s) => s.trim()).filter(Boolean)
41}
42
43/** Drops a leading `sudo [flags]`, `env`, and `VAR=value` words. */
44function stripPrefixes(text: string): string {
45  const words = text.split(/\s+/)
46  let i = 0
47  while (i < words.length) {
48    const w = words[i]
49    if (w === 'sudo' || w === 'env') {
50      i++
51      while (i < words.length && words[i].startsWith('-') && words[i - 1] === 'sudo') i++
52      continue
53    }
54    if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
55      i++
56      continue
57    }
58    break
59  }
60  return words.slice(i).join(' ')
61}
62
63function joinCwd(base: string | undefined, dir: string): string | undefined {
64  const clean = dir.replace(/^["']|["']$/g, '')
65  if (clean === '' || clean === '~' || clean.startsWith('~/')) return undefined
66  if (clean.startsWith('/')) return clean
67  return base ? `${base}/${clean}` : clean
68}
69
70export function splitSegments(command: string): Segment[] {
71  const segments: Segment[] = []
72  let cwd: string | undefined
73  for (const raw of splitRaw(command)) {
74    const text = stripPrefixes(raw)
75    const cd = /^cd(?:\s+(\S+))?\s*$/.exec(text)
76    if (cd) {
77      cwd = cd[1] === undefined ? undefined : joinCwd(cwd, cd[1])
78      continue
79    }
80    if (!text) continue
81    segments.push(cwd ? { text, cwd } : { text })
82  }
83  return segments
84}
85