SLOPSHOPPER

search-tools

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

newguardprompttoolprocess
A shopper browsing a rack in a slop shop
README

Claude Code user config

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.

Install on a new machine

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

Tracked

PathWhat
.claude/CLAUDE.mdGlobal instructions
.claude/settings.jsonPermissions, hooks, env, enabled plugins, status line
.claude/statusline.shStatus 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.pyStop 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.

Dependencies

Installed by install.sh:

ToolUsed bySource
codegraphMCP server codegraph, UserPromptSubmit hook codegraph prompt-hookhttps://github.com/colbymchenry/codegraph
ai-memoryMCP server ai-memory (http://127.0.0.1:49374), hooks on most lifecycle eventshttps://github.com/akitaonrails/ai-memory
ponytailPlugin ponytail@ponytailhttps://github.com/DietrichGebert/ponytail
paper-desktopPlugin paper-desktop@paperhttps://github.com/paper-design/agent-plugins
atlassian, typescript-lspPlugins from claude-plugins-officialhttps://github.com/anthropics/claude-plugins-official
maestriThe 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, mobbinHTTP MCP servers (stored in ~/.claude.json, not in this repo)https://mcp.context7.com/mcp, https://api.mobbin.com/mcp

Notes

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