Grep and Glob tools backed by ripgrep. Structured search without shell quoting.

Portable Claude Code setup. The git root is $HOME. Only ~/.claude and ~/.agents are tracked, through a whitelist in ~/.gitignore.
This file is not loaded into Claude sessions. Only CLAUDE.md, rules/, skills/, agents/, output-styles/ and the hooks in settings.json are.
Using an agent: paste ~/PROMPT.md into it. By hand:
cd ~
git init -b main
git remote add origin <repo-url>
git fetch origin
git reset --hard origin/main # writes only the tracked files; existing untracked files stay
sh ~/.claude/install.sh
| Path | What |
|---|---|
.claude/CLAUDE.md | Global instructions |
.claude/settings.json | Permissions, hooks, env, enabled plugins, status line |
.claude/statusline.sh | Status line (needs git, jq) |
.claude/rules/ | Always-on rules |
.claude/skills/ | Skills. Most are symlinks into ~/.agents/skills |
.claude/agents/ | Custom subagents |
.claude/output-styles/ | Output styles |
.claude/hooks/ | Hook scripts referenced from settings.json. no-bash-file-edits.sh blocks file edits made through Bash so the agent uses Read, Edit and Write |
.agents/skills/ | Skill content shared by all agents (Claude Code, Codex, Cursor) |
.agents/ship/ledger-stop.py | Stop hook for the ship workflow |
.agents/ship/state/ | Only .gitkeep. Ship state is never tracked |
Everything else under $HOME is ignored. Sessions, projects, memories, history, caches, credentials and backups must stay untracked.
Installed by install.sh:
| Tool | Used by | Source |
|---|---|---|
| codegraph | MCP server codegraph, UserPromptSubmit hook codegraph prompt-hook | https://github.com/colbymchenry/codegraph |
| ai-memory | MCP server ai-memory (http://127.0.0.1:49374), hooks on most lifecycle events | https://github.com/akitaonrails/ai-memory |
| ponytail | Plugin ponytail@ponytail | https://github.com/DietrichGebert/ponytail |
| paper-desktop | Plugin paper-desktop@paper | https://github.com/paper-design/agent-plugins |
| atlassian, typescript-lsp | Plugins from claude-plugins-official | https://github.com/anthropics/claude-plugins-official |
| maestri | The maestri-* skills. install.sh exports MAESTRI_CLI and adds the CLI to PATH in your shell rc when /Applications/Maestri.app exists (macOS only) | Maestri desktop app |
| context7, mobbin | HTTP MCP servers (stored in ~/.claude.json, not in this repo) | https://mcp.context7.com/mcp, https://api.mobbin.com/mcp |
ai-memory install-hooks rewrites the ai-memory hook commands in settings.json with absolute paths for the current machine. Commit that diff after running it on a new machine..claude/skills/elixir is a link into a local project and is gitignored.~/.agents/.skill-lock.json records where each skill in ~/.agents/skills came from, for npx skills update.hooks/register.ts 112 lines1import type { Register } from 'claude-code'
2
3const RG = '/opt/homebrew/bin/rg'
4const FD = '/opt/homebrew/bin/fd'
5const LIMIT = 250
6
7const GREP = {
8 name: 'Grep',
9 description: `Search file contents with ripgrep. ALWAYS use this tool to search code or text in files. NEVER run grep, rg, ag, perl -ne, sed -n or awk in Bash for searching: those need shell quoting, which fails on quotes, parentheses, $ and backslashes. Here the pattern is one argument, passed verbatim, so write it exactly as the regex.
10
11When to use: find where a symbol is defined or called, find a string across the codebase, list the files that mention something, check if a pattern exists.
12When not to use: reading a known file (use Read), finding files by name (use Glob), running a program (use Bash).
13
14Pattern syntax is ripgrep (Rust regex): \\( for a literal parenthesis, \\. for a literal dot, \\w+ and \\d+ work, no lookaround. Use glob to restrict file types, e.g. "*.ts" or "*.{rb,erb}". Use files_only to get only paths. Respects .gitignore. Output is file:line:text, at most 50 matches per file and 250 lines in total; narrow with path or glob when it truncates.`,
15 inputSchema: {
16 type: 'object',
17 properties: {
18 pattern: { type: 'string', description: 'Ripgrep regex. Passed as one argument, no shell escaping needed.' },
19 path: { type: 'string', description: 'File or directory to search. Default: project root.' },
20 glob: { type: 'string', description: 'Only search files matching this glob, e.g. "*.tsx" or "*.{js,ts}".' },
21 case_insensitive: { type: 'boolean', description: 'Ignore case. Default false.' },
22 files_only: { type: 'boolean', description: 'Return matching file paths only, one per line.' },
23 context: { type: 'integer', description: 'Lines of context before and after each match.' },
24 },
25 required: ['pattern'],
26 },
27}
28
29const GLOB = {
30 name: 'Glob',
31 description: `Find files by name with a glob pattern. ALWAYS use this tool to locate files by name or extension. NEVER run find, fd, ls -R or tree in Bash for that: find has its own flag syntax and quoting rules, and the model gets them wrong.
32
33When to use: locate a file whose name you know or partly know, list all files of one type, discover the layout of a directory.
34When not to use: searching inside files (use Grep), listing one directory (use Bash ls).
35
36Pattern examples: "**/*.tsx" every .tsx file; "src/**/*.test.ts" tests under src; "**/Dockerfile" by exact name; "**/*user*" by name fragment. Matched against the path relative to path. Respects .gitignore, skips .git. Returns one path per line, at most 250; narrow with path when it truncates.`,
37 inputSchema: {
38 type: 'object',
39 properties: {
40 pattern: { type: 'string', description: 'Glob pattern, e.g. "**/*.rb" or "app/models/*.rb".' },
41 path: { type: 'string', description: 'Directory to search. Default: project root.' },
42 },
43 required: ['pattern'],
44 },
45}
46
47const PROMPT = `# Search tools
48Grep and Glob (mcp__search-tools__Grep, mcp__search-tools__Glob) are the search tools. Bash runs programs; it does not search.
49Bad: Bash grep -rn "useState(" src/ --include="*.tsx"
50Good: Grep { pattern: "useState\\(", path: "src", glob: "*.tsx" }
51Bad: Bash find . -name "*.test.ts" -not -path "*/node_modules/*"
52Good: Glob { pattern: "**/*.test.ts" }
53Bad: Bash perl -ne 'print if /def \\w+\\(.*\\):/' lib/*.py
54Good: Grep { pattern: "def \\w+\\(.*\\):", path: "lib", glob: "*.py" }
55Bad: Bash rg -l "TODO" | head
56Good: Grep { pattern: "TODO", files_only: true }
57Bad: Bash ls -R app/models | grep user
58Good: Glob { pattern: "**/*user*", path: "app/models" }`
59
60export function grepArgs(e: Record<string, unknown>): string[] {
61 const args = ['--no-heading', '--line-number', '--color', 'never', '--max-count', '50']
62 if (e.case_insensitive) args.push('-i')
63 if (e.files_only) args.push('-l')
64 if (e.glob) args.push('-g', String(e.glob))
65 if (typeof e.context === 'number' && e.context > 0) args.push('-C', String(e.context))
66 args.push('-e', String(e.pattern), '--', String(e.path ?? '.'))
67 return args
68}
69
70export function globArgs(e: Record<string, unknown>): string[] {
71 const raw = String(e.pattern)
72 // fd matches the glob against the full relative path; "**/" lets "src/*.ts" match "src/a.ts" and "./src/a.ts" alike.
73 const pattern = raw.startsWith('**') || raw.startsWith('/') ? raw : '**/' + raw
74 return ['--glob', '--full-path', '--color', 'never', '--type', 'f', '--', pattern, String(e.path ?? '.')]
75}
76
77export function cap(text: string): string {
78 const lines = text.split('\n').filter(Boolean)
79 const shown = lines.slice(0, LIMIT)
80 return lines.length > LIMIT
81 ? shown.join('\n') + `\n... ${lines.length - LIMIT} more lines. Narrow with path or glob.`
82 : shown.join('\n')
83}
84
85export function render(run: { exitCode: number; stdout: string; stderr: string }, empty: string): string {
86 if (run.exitCode > 1) return `search failed (${run.exitCode}): ${run.stderr.trim()}`
87 return run.stdout.trim() ? cap(run.stdout) : empty
88}
89
90export const register: Register = on => {
91 on('session.start', async ($, e, next) => {
92 await $.tool.register({ ...GREP, isDeferred: false })
93 await $.tool.register({ ...GLOB, isDeferred: false })
94 return next(e)
95 })
96
97 on('prompt.compose', async ($, e, next) => {
98 const { sections } = await next(e)
99 return { sections: [...sections, { id: 'search-tools:usage', text: PROMPT, scope: 'session' as const }] }
100 })
101
102 on('tool.call', { tool: 'mcp__search-tools__Grep' }, async ($, e) => {
103 const run = await $.process.run([RG, ...grepArgs(e as Record<string, unknown>)], { timeoutMs: 30_000 })
104 return { result: render(run, 'No matches.') }
105 })
106
107 on('tool.call', { tool: 'mcp__search-tools__Glob' }, async ($, e) => {
108 const run = await $.process.run([FD, ...globArgs(e as Record<string, unknown>)], { timeoutMs: 30_000 })
109 return { result: render(run, 'No files match.') }
110 })
111}
112