Fixes for Claude's Bash tool on Windows Git Bash: long commands run from a temp script, rg keeps patterns that start with /, a sed -i that changes nothing…

A few mods for Claude Code that I use every day. They work in the terminal and in the desktop app.
From a shell:
claude plugin marketplace add dante32683/claude-code-mods
claude plugin install agent-crabs@dante32683-mods
Swap agent-crabs for any mod below. In the desktop app, click + next to the prompt box, then Plugins, add the marketplace dante32683/claude-code-mods, and pick the mods you want.
Mods load when a chat starts, so open a new chat after installing.
A little Clawd above the prompt for each subagent that's running, with what it's doing right now. The crab acts it out: reading a book, walking around with a magnifying glass while it searches, writing on paper while it edits, jogging while a command runs, holding a globe while it's on the web, and thinking in between. It throws confetti when the subagent finishes and sits under a rain cloud if it fails. /crabs-demo plays every animation.
The animated crabs show in the desktop app; the terminal gets a small text crab.
A pane with your plan's 5-hour and weekly usage, how fast you're using it, how much per hour or day you can spend and still make the reset, and what's filling the context window. While the pane is closed, the usage bars sit above the prompt. /meter shows or hides the pane. It saves the last reading to statusline-limits.json in your Claude folder.
Better compaction. Before each compaction it saves the whole conversation to a searchable file in ~/.claude/compact-history/ and tells Claude where it is, so details the summary drops can still be found. Your own messages are kept word for word, the summary is written as a handoff, instruction files are re-read afterward, and when a turn ends close to the auto-compact limit it compacts right away instead of mid-task. History older than 30 days is deleted. Options: turnEndPercent and verbatimUserTokens.
Tells Claude when other Claude chats are working in the same repo (same checkout or another worktree) and which files they've edited, so it can coordinate before work collides. Windows only for now.
/aside <task> hands a side task to a background worker while your chat keeps going. Unlike /btw, the worker has tools. It sees the last few messages for context, and its answer opens in a pane where you can send it to the chat or dismiss it. Haiku by default; /aside opus: <task> uses Opus.
Windows Git Bash only. It fixes a few ways Claude's Bash tool breaks there: very long commands get cut off or mangled, rg patterns starting with / get turned into Windows paths, sed -i doesn't say when nothing changed, and rm "${DIR:?}"/... keeps asking for approval.
agent-crabs takes its idea from hamzafer's agent-radar (MIT), and Clawd's shape from Codrops' article on reverse-engineering Claude's mascot animations. Clawd is Anthropic's mascot; this is a fan project, not affiliated with Anthropic. compact-plus's summary style is modeled on OpenAI's Codex CLI.
MIT
hooks/register.ts 67 lines1// bash-fixes: fixes for Claude's Bash tool on Windows Git Bash.
2//
3// tool.check: refuse `pwsh -Command "..."` whose double-quoted body holds a $
4// Bash would expand before PowerShell sees it, and refuse the rm
5// commands the guard below can't make safe (rm in a -c script, an
6// unguardable variable), saying what to write instead.
7// tool.call: source hooks/guards.sh first (rg keeps patterns that start
8// with /, a no-op `sed -i` fails), add the :? guard to rm targets
9// that start with a variable so an empty variable stops the
10// command instead of deleting from /, then run a command the Windows
11// command line would cut off or corrupt from a self-deleting
12// temp script.
13import type { Register, EngineInterface } from 'claude-code'
14import { needsOffload, scriptText, sourceCommand } from './plan'
15import { guardRmTargets, hasExpandedPwshDollar, rmAskAdvice, withGuards } from './guards'
16
17let tempDir: Promise<string | undefined> | undefined
18
19// %TEMP% as C:/... so both $.fs and Git Bash read the same path. Undefined
20// off Windows, where the limit doesn't apply.
21function findTempDir($: EngineInterface): Promise<string | undefined> {
22 tempDir ??= $.process
23 .run(['cmd', '/d', '/c', 'echo %TEMP%'])
24 .then(r => {
25 const dir = r.stdout.trim().replaceAll('\\', '/')
26 return r.exitCode === 0 && /^[A-Za-z]:\//.test(dir) && !dir.includes("'") ? dir : undefined
27 })
28 .catch(() => undefined)
29 return tempDir
30}
31
32export const register: Register = on => {
33 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
34 const command = (e.input as { command?: unknown })?.command
35 if (typeof command === 'string' && hasExpandedPwshDollar(command)) {
36 return {
37 decision: 'deny',
38 reason:
39 'Bash expands $ inside double quotes before pwsh sees it. Put the -Command body in single quotes ' +
40 "('' for a literal quote inside), or pipe a quoted heredoc to pwsh -Command -.",
41 }
42 }
43 const verdict = await next(e)
44 const advice = verdict.decision === 'ask' ? rmAskAdvice(verdict.reason) : undefined
45 return advice === undefined ? verdict : { decision: 'deny', reason: advice }
46 })
47
48 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
49 const command = withGuards(guardRmTargets(e.command), $.plugin.root)
50 if (!needsOffload(command)) return next({ ...e, command })
51 const dir = await findTempDir($)
52 if (dir === undefined) return next({ ...e, command })
53
54 const path = `${dir}/claude-long-bash-${crypto.randomUUID()}.sh`
55 await $.fs.write(path, scriptText(command))
56 const ran = await next({ ...e, command: sourceCommand(path) })
57
58 // The script deletes itself; this catches a parse error on its first line.
59 if (!e.run_in_background && (await $.fs.exists(path))) {
60 await $.process.run(['cmd', '/d', '/c', 'del', '/q', path.replaceAll('/', '\\')]).catch(() => {})
61 }
62 return ran
63 // On a throw, next(e) runs the original command if the call hadn't started,
64 // or replays the result it already produced.
65 }).catch(($, e, next) => next(e))
66}
67hooks/plan.ts 31 lines1// On Windows the Bash tool runs `bash -c "... eval '<command>' ..."` through a
2// command line that corrupts two kinds of command:
3// - Past about 8,191 chars it is cut, and bash fails with "unexpected EOF while
4// looking for matching `''". The command is single-quoted (' becomes '"'"'),
5// then argv quoting turns each " into \", so quotes count extra.
6// - Every \\ reaches bash as \, even inside a quoted heredoc.
7// Running the command from a file skips the command line.
8export const LIMIT = 6000 // leaves room for the wrapper the tool adds
9
10const BACKSLASH = '\\'
11
12export function escapedLength(command: string): number {
13 let n = 2
14 for (const ch of command) n += ch === "'" ? 7 : ch === '"' || ch === BACKSLASH ? 2 : 1
15 return n
16}
17
18export function needsOffload(command: string): boolean {
19 return escapedLength(command) > LIMIT || command.includes(BACKSLASH + BACKSLASH)
20}
21
22// Bash reads a sourced file whole before running it, so the script can delete
23// itself first. Same line, so error line numbers match the original command.
24export function scriptText(command: string): string {
25 return `rm -f -- "\${BASH_SOURCE[0]}"; ${command}`
26}
27
28export function sourceCommand(path: string): string {
29 return `source '${path}'`
30}
31hooks/guards.ts 64 lines1// Command checks and the guards.sh prefix. Pure functions, so tests reach them.
2
3// A pwsh or powershell call with -c or -Command and a double-quoted body.
4const PWSH_BODY = /(?:^|[\s;&|(])(?:pwsh|powershell)(?:\.exe)?\b[^\n;&|]*?\s-(?:c|command)\s+"((?:[^"\\]|\\.)*)"/gi
5
6// A $ Bash expands inside double quotes: unescaped, before a name, {, (, or
7// a special parameter.
8const EXPANDED = /(?<!\\)\$[A-Za-z_{(?!$@*#0-9-]/
9
10export function hasExpandedPwshDollar(command: string): boolean {
11 for (const m of command.matchAll(PWSH_BODY)) {
12 if (EXPANDED.test(m[1] ?? '')) return true
13 }
14 return false
15}
16
17// An rm or rmdir whose target starts with a shell variable is risky: when the
18// variable is empty, "$S"/x turns into /x. Bash's :? guard makes the command
19// stop with an error instead when the variable is empty or unset, and changes
20// nothing when it's set. So add it: "$S"/x and ${S}/x become "${S:?}"/x and
21// ${S:?}/x in the arguments of an rm or rmdir that starts a command, including
22// one inside a -c script string (bash -c "rm ...", wsl.exe -e bash -c '... &&
23// rm ...'). Claude Code's empty-variable rm check accepts the guarded form.
24// Positional parameters and ${S:-...} forms are left alone.
25const RM = /(^|[\n;&|(`]|\b(?:then|do|else)\b|-c[ \t]+["'])([ \t]*)(rm|rmdir)(?=[ \t])([^\n;&|)`]*)/g
26const TARGET = /(^|[ \t])("?)\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))(?="?\/)/g
27
28export function guardRmTargets(command: string): string {
29 return command.replace(RM, (_m, start, space, rm, args: string) =>
30 start + space + rm + args.replace(TARGET, (_t, lead, quote, braced, bare) => `${lead}${quote}\${${braced ?? bare}:?}`),
31 )
32}
33
34// The rm commands the guard above can't make safe: an rm inside a bash -c,
35// sh -c, wsl or eval script, which Claude Code can't inspect, and a variable a
36// guard doesn't protect ($HOME-style, or set in the command from a value that
37// can be / or a top-level folder). Claude Code flags these for review; the hook
38// refuses them instead and says how to rewrite the command with a literal path.
39// Matches Claude Code's own wording (2.1.304), both its dialog line and its
40// longer message.
41const SHELL_C_RM = /shell -c script (?:that )?runs rm|Dangerous rm(?:dir)? operation in a shell -c script/
42const UNGUARDED_RM = /^Dangerous rm(?:dir)? operation[\s\S]*(?:is a shell variable expansion|This command sets \$|on possibly-empty variable path)/
43
44export function rmAskAdvice(reason: string | undefined): string | undefined {
45 if (reason === undefined) return undefined
46 if (SHELL_C_RM.test(reason)) {
47 return (
48 'Claude Code cannot check an rm inside a bash -c, sh -c, wsl or eval script, so it would stop for approval. ' +
49 'Run the rm as its own Git Bash command on the Windows path, e.g. D="C:/.../dir" && rm -f "$D"/*.png ' +
50 '(the $D target is guarded automatically), then run the rest in WSL or the -c script.'
51 )
52 }
53 if (UNGUARDED_RM.test(reason)) {
54 const why = reason.split('\n')[0]!.replace(/ This requires explicit approval[^.]*\./, '')
55 return `This rm would stop for approval: ${why} Write the target as a literal absolute path.`
56 }
57 return undefined
58}
59
60export function withGuards(command: string, root: string): string {
61 const script = root.replaceAll('\\', '/') + '/hooks/guards.sh'
62 return `source '${script}' 2>/dev/null; ${command}`
63}
64