SLOPSHOPPER

zsh-safe

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

newguard
v0.1.0no licenseupdated 2026-10-04narrowstacks/claude-code-mods/zsh-safe
A shopper browsing a rack in a slop shop
README

claude-code-mods

Function-hook mods for Claude Code. Each folder is one plugin.

ModWhat it does
context-watchOne-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-spinnerSpinner 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-watchTracks 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-styleNo 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-safeQuotes glob flags zsh would expand (--include=*.ts, find -name *.ts) and keeps a grep that finds nothing from failing a cd ... && grep chain
pm-guardBlocks npm/npx/yarn where the lockfile says bun or pnpm, and steers to bun where there is no lockfile
remote-guardAsks before a destructive command (restart, rm, kill, reboot, pct/qm stop, zfs destroy) runs over ssh
xcode-statusOnly 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-modelsPins 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-rulesBlocks 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 onoffauto` overrides per repo (kept across sessions)

Install

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

Check

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

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