SLOPSHOPPER

repo-context

Shows the repo and branch Claude is working in, and names new sessions from their first prompt

newguardstatusmodelprocess
A shopper browsing a rack in a slop shop
README

Claude Code mods

Six small mods for Claude Code, written in TypeScript against its plugin hooks API. They add guard rails on tool calls, status line items, panes and slash commands: blocking risky git and shell commands, tracking your pull requests, gathering a standup, pacing session limits, showing which repo Claude is in, and keeping the Mac awake while work runs. Each mod is its own plugin, so you can install only the ones you want.

Install

Add the marketplace from GitHub or from a local clone:

/plugin marketplace add AbdAlrahmanAlQaroot/claude-code-mods
/plugin marketplace add ~/path/to/claude-code-mods

Then install the mods you want:

/plugin install rules-guard@claude-code-mods
/plugin install pr-radar@claude-code-mods

The same works from a shell with claude plugin marketplace add <source> and claude plugin install <name>@claude-code-mods. Options are set with /plugin (pick the plugin, then configure) or claude plugin configure <name>. Restart the session after installing or changing options.

pr-radar and standup call the GitHub CLI, so gh must be installed and logged in. stay-awake and the pr-radar desktop notification are macOS only.

rules-guard

Checks Bash, Write and Edit calls before they run and holds the ones that break a rule. Each block names the rule, the problem, where it was found, the fix, and when to ask the user, so Claude can correct itself and retry.

Always on:

  • No direct push to a protected branch, including --all, --mirror and a bare git push from a protected branch. A bare push whose branch cannot be told is held until the branch is named.
  • No plain force push. --force-with-lease is fine.
  • No kubectl or aws calls aimed at production (a context, cluster or profile with "prod" in its name).

Optional (off by default):

  • noAiAttribution: block AI attribution lines (co-author trailers, session links, "generated with" lines) in commits, PRs, comments and files.
  • noTestPlan: block a "Test plan" section in PR descriptions.
  • prBulletsOnly: PR descriptions must be 20 lines or fewer of short bullet points, with no prose paragraphs.
  • noCodeComments: block new code comments in source files inside a git repo. Tool directives such as @ts- or eslint- lines and shebangs are allowed.
  • noTestFiles: block a git commit that would include test files.
  • noSlackSend: block Claude from sending or scheduling Slack messages. Drafts are allowed.
  • liquibaseAuthor: the only author allowed on Liquibase changesets. Empty turns the check off.
  • vpnProcess: process name that runs while your VPN is up, such as openvpn. When set, kubectl calls are held while it is not running.
  • awsSsoCheck: hold kubectl and aws calls when no AWS SSO session in ~/.aws/sso/cache is fresh.

Other options:

  • protectedBranches: comma-separated branch names. Default main,master.

pr-radar

Keeps the state of your open GitHub pull requests across every repo: review decision, CI checks, unresolved threads, conflicts, and PRs merged in the last three days. It shows a summary in the status line, refreshes every five minutes and after gh pr or git push commands, and tells you when a PR is approved, gets changes requested, starts or stops failing, gets new comments, merges or closes.

  • /prs lists your PRs with links. /prs pane opens a live pane. /prs refresh fetches now.
  • /unreleased compares main (or master) with your integration branch in each repo and lists the PRs and commits not yet released.
  • Claude gets two tools, my_prs and my_unreleased, and prompts about PRs carry the live state as context.

Options:

  • alerts: toast and macOS notification on PR changes. Default true.
  • sound: play a sound with the notification. Default true.
  • repos: comma-separated owner/repo list for /unreleased. Default empty, which uses only repos where you have a PR into the integration branch.
  • integrationBranch: the branch work merges into before a release. Default staging.
  • login: your GitHub login, used to mark your own commits in /unreleased. Default empty, which reads it from gh api user.

standup

/standup opens a pane with what you did on a work day: PRs you opened, merged and reviewed, the commits you made, and the Claude Code sessions you worked in, grouped by repo. With no argument it shows the previous work day. It also accepts today, yesterday or a date like 2026-10-06. The Draft button asks Claude to write the update.

Options:

  • repos: comma-separated local repo paths to read commits from, including unpushed branches. Paths can be absolute, start with ~/, or be relative to your home folder. Default empty, which uses GitHub search only.
  • utcOffset: your time zone as an offset from UTC, such as +03:00. Default +00:00.
  • workDays: comma-separated work days. Default Mon,Tue,Wed,Thu,Fri.
  • draftCommand: a slash command the Draft button runs with the day as its argument. Default empty, which sends Claude a prompt with the day's activity instead.

usage-pacer

Shows session-limit usage in the status line with the time until each window resets, and shows a toast once at 80% and once at 95% of each window.

Options:

  • sonnetSubagentsAbove: when usage of any window is above this percent, general-purpose subagents with no model of their own run on Sonnet. Default 0, which turns it off.
  • handoffAt: when the 5-hour window reaches this percent, run your own /handoff command once in the session you are actively working in. Default 0, which turns it off.

repo-context

Shows the repo and branch Claude is working in on the status line, following cd and git -C in its commands. It also names a new session from its first real prompt, using a short Haiku call.

No options.

stay-awake

Keeps the Mac from idle-sleeping while a turn or a background agent runs, using caffeinate, and lets it sleep again once everything is done.

No options.

License

MIT. See LICENSE.

Source 2 files
hooks/register.ts 113 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RepoSpot } from '../types'
5
6const FRESH_MS = 30_000
7const PATH = String.raw`(?:"([^"]+)"|'([^']+)'|([^\s;&|()]+))`
8const LEADING_CD = new RegExp(String.raw`^\s*(?:cd|pushd)\s+${PATH}`)
9const GIT_DASH_C = new RegExp(String.raw`\bgit\s+-C\s+${PATH}`)
10const MAY_MOVE_BRANCH = /\b(git|gh)\b/
11const SHORT_REPLY = /^(y|yes|n|no|ok|okay|sure|go|go on|do it|continue|retry|again|done|thanks|thank you)\W*$/i
12const NOISE = /\[(?:Image|Pasted text)[^\]]*\]|https?:\/\/\S+/g
13
14const spot = atom({ plugin: 'repo-context', key: 'spot' } as const, null as RepoSpot | null)
15const titled = atom({ plugin: 'repo-context', key: 'titled' } as const, false)
16
17const pathIn = (command: string, pattern: RegExp) => {
18  const found = pattern.exec(command)
19  return found === null ? undefined : (found[1] ?? found[2] ?? found[3])
20}
21
22const resolve = (path: string, base: string, home: string | undefined) => {
23  if (home !== undefined && (path === '~' || path.startsWith('~/'))) return home + path.slice(1)
24  if (path.startsWith('/')) return path
25  return `${base.replace(/\/+$/, '')}/${path}`
26}
27
28const probe = async ($: EngineInterface, dir: string) => {
29  const head = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel', '--abbrev-ref', 'HEAD'], { timeoutMs: 5000 })
30  if (head.exitCode !== 0) return undefined
31  const [top = '', branch = ''] = head.stdout.trim().split('\n')
32  const repo = top.split('/').filter(Boolean).at(-1)
33  if (repo === undefined || branch === '') return undefined
34  if (branch !== 'HEAD') return `${repo}:${branch}`
35  const sha = await $.process.run(['git', '-C', dir, 'rev-parse', '--short', 'HEAD'], { timeoutMs: 5000 })
36  return `${repo}:detached@${sha.stdout.trim()}`
37}
38
39const follow = async ($: EngineInterface, command: string, before: string) => {
40  const home = await $.env.get('HOME')
41  const named = pathIn(command, GIT_DASH_C) ?? pathIn(command, LEADING_CD)
42  const dir = named === undefined ? await $.session.cwd() : resolve(named, before, home)
43  const now = await $.clock.now()
44  const last = await read($, spot)
45  const isFresh = last !== null && last.dir === dir && now - last.at < FRESH_MS
46  if (isFresh && !MAY_MOVE_BRANCH.test(command)) return
47  const label = await probe($, dir)
48  if (label === undefined) return
49  await update($, spot, () => ({ dir, label, at: now }))
50  if (last?.label !== label) $.ui.status(label)
51}
52
53const fallbackTitle = (prompt: string) =>
54  prompt
55    .replace(NOISE, ' ')
56    .replace(/[^\p{L}\p{N}\s/#.:_-]/gu, ' ')
57    .split(/\s+/)
58    .filter(Boolean)
59    .slice(0, 6)
60    .join(' ')
61
62const cleanTitle = (text: string) =>
63  (text.trim().split('\n')[0] ?? '')
64    .replace(/^[\s"'`*#]+|[\s"'`*.!?]+$/g, '')
65    .split(/\s+/)
66    .slice(0, 6)
67    .join(' ')
68    .slice(0, 60)
69
70const nameSession = async ($: EngineInterface, prompt: string) => {
71  const reply = await $.model.complete({
72    model: 'haiku',
73    system: 'You name coding sessions. Reply with a 3 to 6 word title in plain words and nothing else: no quotes, no trailing punctuation.',
74    prompt: `Name the session that starts with this request:\n\n${prompt.slice(0, 2000)}`,
75    maxTokens: 30,
76    effort: 'low',
77    timeoutMs: 6000,
78  })
79  if (!reply.isAnswered) return undefined
80  const title = cleanTitle(reply.text)
81  return title === '' ? undefined : title
82}
83
84export const register: Register = on => {
85  on('session.start', async ($, e, next) => {
86    const started = await next(e)
87    const last = await read($, spot)
88    if (last !== null) $.ui.status(last.label)
89    return started
90  })
91
92  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
93    if (e.agentId !== undefined) return next(e)
94    const before = await $.session.cwd()
95    const ran = await next(e)
96    try {
97      await follow($, e.command, before)
98    } catch {}
99    return ran
100  })
101
102  on('classic.UserPromptSubmit', async ($, e, next) => {
103    const result = await next(e)
104    const isTyped = e.agent_id === undefined && (e.source === undefined || e.source === 'user')
105    if (!isTyped || e.session_title || result.sessionTitle || (await read($, titled))) return result
106    const prompt = e.prompt.trim()
107    const words = fallbackTitle(prompt)
108    if (prompt.startsWith('/') || SHORT_REPLY.test(prompt) || words.split(' ').length < 3) return result
109    await update($, titled, () => true)
110    return { ...result, sessionTitle: (await nameSession($, prompt)) ?? words }
111  })
112}
113
types/index.d.ts 8 lines
1export type RepoSpot = { dir: string; label: string; at: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'repo-context': { spot: RepoSpot | null; titled: boolean }
6  }
7}
8