SLOPSHOPPER

right-tool

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…

newguardcommand
v0.1.0MITupdated 2026-10-09MDmubarak786/claude-mods/mods/right-tool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · right-tool
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(cat .env) ⎿ Denied by right-tool: right-tool: do not use Bash for this. Call the Read with file_path=".env" tool inste ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM right-tool redirected 1 Bash call(s) to Read, Grep, or Glob this turn. › /right-tool ⎿ right-tool: right-tool on. Redirected 1 call(s) since load. /right-tool off or /right-tool on ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

right-tool

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).

Install

/plugin marketplace add MDmubarak786/claude-mods
/plugin install right-tool@modhub

Try it for one session without installing:

claude --plugin-dir ./mods/right-tool

Use it

Nothing to do. These simple forms are redirected; everything else runs:

BashBecomes
cat fileRead file
head -n 20 file, head -20 fileRead file with limit=20
tail -n 30 fileRead file
sed -n '10,20p' fileRead file with offset=10, limit=11
grep -rn pattern dir, grep -rl pattern . --include=*.tsGrep 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.
CommandWhat it does
/right-toolShow whether it's on and how many calls were redirected since load.
/right-tool offLet every Bash command through. Remembered across sessions.
/right-tool onTurn it back on.

After a turn with redirects, a line under Claude's answer gives the count.

What it touches

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.

Tested with

  • Claude Code 2.1.295, claude plugin validate --strict and claude plugin test pass. /right-tool answered from a live claude -p session.

Limitations

  • tail -n N can't become an exact offset without knowing the file length, so it becomes a plain Read of the file.
  • Only the listed flags are understood. grep -P, find -newer, and the like pass through on purpose.
  • It redirects subagents' Bash calls too, but the footer counts only the main conversation.

License

MIT, see the repository root.

Source 1 files
hooks/register.ts 169 lines
1// 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