Stop Claude using cat, grep, find, and sed in Bash when the Read, Grep, and Glob tools fit. Each redirected call saves a permission prompt and keeps raw shell…

Stop Claude using cat, grep, find, and sed in Bash when the Read, Grep, and Glob tools fit. Each of those Bash calls costs you a permission prompt and dumps raw shell output into the context window. This mod refuses the call and tells Claude the exact tool call to make instead; Claude complies on its next step.
The habit is a long-standing, much-upvoted complaint (anthropics/claude-code#19649).
/plugin marketplace add MDmubarak786/claude-mods
/plugin install right-tool@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/right-tool
Nothing to do. These simple forms are redirected; everything else runs:
| Bash | Becomes |
|---|---|
cat file | Read file |
head -n 20 file, head -20 file | Read file with limit=20 |
tail -n 30 file | Read file |
sed -n '10,20p' file | Read file with offset=10, limit=11 |
grep -rn pattern dir, grep -rl pattern . --include=*.ts | Grep with the pattern, path, output mode, and glob |
find dir -name '*.go' [-type f] | Glob dir/**/*.go |
A command with a pipe, &&, ;, a redirection, a shell glob, a substitution, more than one file, or a flag the mod doesn't understand is left alone. So is ls.
What Claude reads when a call is refused:
right-tool: do not use Bash for this. Call the Read with file_path="src/app.ts" tool instead. It needs no permission prompt and its output is formatted for you. Use Bash only for commands that run something.
| Command | What it does |
|---|---|
/right-tool | Show whether it's on and how many calls were redirected since load. |
/right-tool off | Let every Bash command through. Remembered across sessions. |
/right-tool on | Turn it back on. |
After a turn with redirects, a line under Claude's answer gives the count.
From claude plugin validate ./mods/right-tool:
hooks: session.start, command.run{command=right-tool}, tool.call{tool=Bash}, turn.complete
calls: $.command.register, $.store.get, $.store.set, $.ui.log
No files, processes, or network. The on/off flag is the only thing saved.
Failure policy. This is a nudge, not a safety guard, so if the hook throws or times out the command runs and a dim line says so. That's the opposite of fence and circuit-breaker, which fail closed, and it's deliberate.
claude plugin validate --strict and claude plugin test pass. /right-tool answered from a live claude -p session.tail -n N can't become an exact offset without knowing the file length, so it becomes a plain Read of the file.grep -P, find -newer, and the like pass through on purpose.MIT, see the repository root.
hooks/register.ts 169 lines1// right-tool: stop Bash doing the job of Read, Grep, and Glob.
2//
3// /right-tool show whether it's on and how many calls were redirected
4// /right-tool off let every Bash command through
5// /right-tool on turn it back on
6//
7// When Claude runs a plain `cat file`, `head`, `tail`, `sed -n`, `grep`, or
8// `find -name` in Bash, the call is refused with the exact Read, Grep, or Glob
9// call to make instead. Those tools need no permission prompt and keep raw
10// shell output out of context. Anything with a pipe, redirection, chain,
11// substitution, glob, or an unknown flag passes through untouched.
12
13let enabled = true
14let redirected = 0
15let redirectedThisTurn = 0
16
17// Shell syntax that means "this is more than a simple read": leave it alone.
18const COMPLEX = /[|;&><`$(){}\\]|\n/
19// A shell glob in a path means more than one file: not a Read.
20const GLOB = /[*?]/
21
22function tokens(command: string): string[] | null {
23 const out: string[] = []
24 const re = /'([^']*)'|"([^"]*)"|(\S+)/g
25 for (const m of command.trim().matchAll(re)) out.push(m[1] ?? m[2] ?? m[3])
26 return out.length ? out : null
27}
28
29type Redirect = { tool: string; args: Record<string, string | number> }
30
31// Returns the replacement call, or null to let the command run.
32function redirect(command: string): Redirect | null {
33 if (COMPLEX.test(command)) return null
34 const t = tokens(command)
35 if (!t) return null
36 const [cmd, ...rest] = t
37
38 if (cmd === 'cat') {
39 const files = rest.filter((a) => !a.startsWith('-'))
40 const flags = rest.filter((a) => a.startsWith('-'))
41 if (files.length !== 1 || GLOB.test(files[0]) || flags.some((f) => !/^-[nb]*$/.test(f))) return null
42 return { tool: 'Read', args: { file_path: files[0] } }
43 }
44
45 if (cmd === 'head' || cmd === 'tail') {
46 let n: number | null = null
47 const files: string[] = []
48 for (let i = 0; i < rest.length; i++) {
49 const a = rest[i]
50 if (a === '-n' && /^\d+$/.test(rest[i + 1] ?? '')) n = Number(rest[++i])
51 else if (/^-n\d+$/.test(a)) n = Number(a.slice(2))
52 else if (/^-\d+$/.test(a)) n = Number(a.slice(1))
53 else if (a.startsWith('-')) return null
54 else files.push(a)
55 }
56 if (files.length !== 1 || GLOB.test(files[0])) return null
57 if (cmd === 'head') return { tool: 'Read', args: { file_path: files[0], limit: n ?? 10 } }
58 // tail needs the file length to compute an offset; Read with no offset is still the right tool.
59 return { tool: 'Read', args: { file_path: files[0] } }
60 }
61
62 if (cmd === 'sed') {
63 // sed -n 'A,Bp' file or sed -n 'Ap' file
64 if (rest.length !== 3 || rest[0] !== '-n') return null
65 const range = /^(\d+)(?:,(\d+))?p$/.exec(rest[1])
66 if (!range) return null
67 const from = Number(range[1])
68 const to = range[2] ? Number(range[2]) : from
69 if (to < from || GLOB.test(rest[2])) return null
70 return { tool: 'Read', args: { file_path: rest[2], offset: from, limit: to - from + 1 } }
71 }
72
73 if (cmd === 'grep' || cmd === 'rg') {
74 const args: Record<string, string | number> = {}
75 const positional: string[] = []
76 for (const a of rest) {
77 if (a.startsWith('--include=')) args.glob = a.slice('--include='.length)
78 else if (a.startsWith('--glob=') || a.startsWith('-g=')) args.glob = a.split('=')[1]
79 else if (/^-[rRniwlEFh]+$/.test(a)) {
80 if (a.includes('i')) args['-i'] = 'true'
81 if (a.includes('n')) args['-n'] = 'true'
82 if (a.includes('l')) args.output_mode = 'files_with_matches'
83 } else if (a.startsWith('-')) return null
84 else positional.push(a)
85 }
86 if (positional.length < 1 || positional.length > 2) return null
87 args.pattern = positional[0]
88 if (positional[1]) args.path = positional[1]
89 if (args['-n'] && !args.output_mode) args.output_mode = 'content'
90 delete args['-n']
91 return { tool: 'Grep', args }
92 }
93
94 if (cmd === 'find') {
95 // find <path> -name <pattern> [-type f]
96 const i = rest.indexOf('-name') >= 0 ? rest.indexOf('-name') : rest.indexOf('-iname')
97 if (i < 1 || !rest[i + 1]) return null
98 const path = rest.slice(0, i)
99 const after = rest.slice(i + 2)
100 if (path.length !== 1 || !after.every((a, j) => (a === '-type' && after[j + 1] === 'f') || after[j - 1] === '-type')) return null
101 const base = path[0] === '.' ? '' : path[0].replace(/\/$/, '') + '/'
102 return { tool: 'Glob', args: { pattern: base + '**/' + rest[i + 1] } }
103 }
104
105 return null
106}
107
108function describe(r: Redirect): string {
109 const parts = Object.entries(r.args).map(([k, v]) => k + '=' + (typeof v === 'string' ? JSON.stringify(v) : v))
110 return r.tool + ' with ' + parts.join(', ')
111}
112
113async function guard($, e, next) {
114 if (!enabled) return next(e)
115 const r = redirect(e.command)
116 if (!r) return next(e)
117 redirected += 1
118 redirectedThisTurn += 1
119 return {
120 deny:
121 'right-tool: do not use Bash for this. Call the ' + describe(r) + ' tool instead. ' +
122 'It needs no permission prompt and its output is formatted for you. Use Bash only for commands that run something.',
123 }
124}
125
126export function register(on) {
127 on('session.start', async ($, e, next) => {
128 try {
129 enabled = (await $.store.get('enabled')) !== false
130 } catch {
131 enabled = true
132 }
133 try {
134 await $.command.register({
135 name: 'right-tool',
136 description: 'Redirect cat, grep, find, and sed in Bash to Read, Grep, and Glob',
137 argumentHint: '[on | off]',
138 immediate: true,
139 })
140 } catch (error) {
141 $.ui.log('could not register /right-tool: ' + error)
142 }
143 return next(e)
144 })
145
146 on('command.run', { command: 'right-tool' }, async ($, e) => {
147 const args = e.args.trim()
148 if (args === 'on' || args === 'off') {
149 enabled = args === 'on'
150 await $.store.set('enabled', enabled)
151 return { text: enabled ? 'right-tool on.' : 'right-tool off. Every Bash command goes through.' }
152 }
153 return { text: (enabled ? 'right-tool on' : 'right-tool off') + '. Redirected ' + redirected + ' call(s) since load. /right-tool off or /right-tool on' }
154 }).catch(async () => ({ text: 'right-tool: the command failed, so nothing changed.' }))
155
156 // Not a safety guard, so a failure lets the command run rather than blocking it.
157 on('tool.call', { tool: 'Bash' }, guard).catch(async ($, e, next) => {
158 $.ui.log('right-tool skipped a command it could not parse: ' + next.error.kind)
159 return next(e)
160 })
161
162 on('turn.complete', async ($, e, next) => {
163 const n = redirectedThisTurn
164 redirectedThisTurn = 0
165 if (n === 0 || typeof e.agentId === 'string') return next(e)
166 return { text: 'right-tool redirected ' + n + ' Bash call(s) to Read, Grep, or Glob this turn.' }
167 })
168}
169