Quotes glob flags zsh would expand (--include=*.ts) and keeps a grep that finds nothing from failing the command

Function-hook mods for Claude Code. Each folder is one plugin.
| Mod | What it does | ||
|---|---|---|---|
context-watch | One-line gauge above the prompt for context fill, cost and hot rate limits; toasts at 60/80/90%; compaction keeps your instructions and edited files; /ctx opens a context breakdown pane | ||
live-spinner | Spinner shows what is running: the Bash description or a trimmed command, the ssh host, file being read or edited, search, subagent, MCP tool; a tool's own elapsed time after 15s; "Waiting for signing approval" when a signed git commit or tag sits quiet | ||
pr-watch | Tracks PRs from gh pr / gt submit / git push output, shows checks above the prompt, toasts on green/red/merged, blocks foreground CI polling loops; /prs | ||
house-style | No em dashes: system prompt rule, reminder to the model after a write that adds one, toast when a reply uses one | ||
handoff | /carryover [focus] writes a state note from the conversation and clears; the next prompt carries it. /carryover save, show, drop | ||
agent-jobs | /jobs opens a pane of running subagents and background shells with elapsed time and a Stop button; toasts when background work finishes | ||
zsh-safe | Quotes glob flags zsh would expand (--include=*.ts, find -name *.ts) and keeps a grep that finds nothing from failing a cd ... && grep chain | ||
pm-guard | Blocks npm/npx/yarn where the lockfile says bun or pnpm, and steers to bun where there is no lockfile | ||
remote-guard | Asks before a destructive command (restart, rm, kill, reboot, pct/qm stop, zfs destroy) runs over ssh | ||
xcode-status | Only in a folder with an .xcodeproj/.xcworkspace: one line with the last build or test result (flags runs that executed 0 tests) and the booted simulator; blocks a second xcodebuild while one is running; re-checks on /cd and keeps each project's last result | ||
agent-models | Pins the model each subagent type runs on (Explore, general-purpose, Plan, any other, plus custom type=model pairs), set in /config or with /agent-models <type> <model>; a model the caller names wins unless "Override explicit models" is on | ||
worktrees | /worktrees pane: each git worktree with branch, uncommitted changes, ahead/behind and PR state; Remove (after asking) on clean worktrees whose PR is merged or closed, plus "Remove N merged" to clear every merged one at once | ||
pr-rules | Blocks gh pr create / gt submit when the repo keeps an [Unreleased] changelog and the branch adds no entry (skip with --label no-changelog); where the repo's CLAUDE.md says to label every PR (or requireLabels is on), blocks a PR with no --label and lists the repo's labels. Repos with neither are untouched; `/pr-rules on | off | auto` overrides per repo (kept across sessions) |
claude plugin marketplace add narrowstacks/claude-code-mods
claude plugin install context-watch@claude-code-mods
Or load from a checkout without installing:
claude --plugin-dir ./context-watch --plugin-dir ./live-spinner
claude plugin validate ./context-watch
claude plugin test ./context-watch
tsc -p ./context-watch works once Claude Code has loaded the mod and written .claude-plugin/types/.
hooks/register.ts 75 lines1import type { Register } from 'claude-code'
2
3const GLOB_FLAG = /(^|\s)(--?[\w-]+=)([^\s'"]*[*?[][^\s'"]*)/g
4const GLOB_FIND = /(\s-(?:i?name|i?path|i?wholename|regex)\s+)([^\s'"]*[*?[][^\s'"]*)/g
5const SEARCHERS = /^(grep|egrep|fgrep|rg)\b/
6
7// Quotes a glob that zsh would expand (and fail on with "no matches found")
8// where the command means it literally: a flag's value or find's pattern.
9export const quoteGlobs = (command: string) =>
10 command
11 .replace(GLOB_FLAG, (_, lead: string, flag: string, value: string) => `${lead}${flag}'${value}'`)
12 .replace(GLOB_FIND, (_, lead: string, value: string) => `${lead}'${value}'`)
13
14// Where the last top-level command starts and the separator before it,
15// skipping quoted text. Null when the command holds something too tricky
16// to split safely (a heredoc, a subshell, an existing ||).
17export const lastSegment = (command: string): { start: number; separator: string } | null => {
18 if (/<<|\$\(|`|\|\|/.test(command)) {
19 return null
20 }
21
22 let quote: string | null = null
23 let start = 0
24 let separator = ''
25 for (let i = 0; i < command.length; i += 1) {
26 const char = command[i]
27 if (quote !== null) {
28 if (char === quote) quote = null
29 continue
30 }
31 if (char === "'" || char === '"') {
32 quote = char
33 } else if (char === '&' && command[i + 1] === '&') {
34 start = i + 2
35 separator = '&&'
36 i += 1
37 } else if (char === ';' || char === '\n') {
38 start = i + 1
39 separator = ';'
40 } else if (char === '|') {
41 start = i + 1
42 separator = '|'
43 }
44 }
45
46 return { start, separator }
47}
48
49// grep exits 1 when nothing matches, which fails a `cd dir && grep ...`
50// chain as if it had broken. Exit 1 becomes success; 2 (a real error) stays.
51export const tolerateNoMatch = (command: string) => {
52 const segment = lastSegment(command)
53 if (segment === null || segment.separator === '|') {
54 return command
55 }
56
57 const tail = command.slice(segment.start)
58 const body = tail.trim()
59 if (!SEARCHERS.test(body)) {
60 return command
61 }
62
63 const lead = tail.slice(0, tail.length - tail.trimStart().length)
64
65 return `${command.slice(0, segment.start)}${lead}{ ${body.replace(/;\s*$/, '')} || [ $? -eq 1 ]; }`
66}
67
68export const register: Register = on => {
69 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
70 const command = tolerateNoMatch(quoteGlobs(e.command))
71
72 return next(command === e.command ? e : { ...e, command })
73 })
74}
75