SLOPSHOPPER

env-guard

A green/red environment badge above the prompt that turns red when Claude's tool calls touch anything that looks like production.

newbandguardtoasttimer
v0.2.1no licenseupdated 2026-10-16Arkboi-GG/claude-mods/mods/env-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · env-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ⟨Claude Code's own drawing⟩ ● LOCAL ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ● LOCAL
README

claude-mods

A small collection of Claude Code mods.

Install

/plugin install <mod> --marketplace Arkboi-GG/claude-mods

The bars stack above the prompt, so you can install any combination.

Mods

  • task-progress: the band above the prompt. While Claude works on a multi-step task it shows one block per step: the running step fills as "In Progress", finished steps turn green "Completed", and a turn that stops with steps still open turns amber "Paused". When Claude is idle it shows usage rings (context, 5-hour and 7-day limits) with a Compact button. It reads the task list Claude keeps while working, so it appears for multi-step tasks. If you also use the skins mod, run /skin band off once so its own usage band doesn't show twice.
  • repo-status: branch, ahead/behind, changed-file count and the open PR's check state (needs git; gh is optional).
  • stack-health: status dots for your Docker Compose services and any local ports you list (web=5173,api=3000).
  • preflight: a live pre-commit checklist. After Claude edits files it re-runs your typecheck/lint (auto-detected, or set checks) and shows pass/fail with an error count.
  • env-guard: a green LOCAL badge that turns red PROD when a tool call matches your production patterns, and toasts when it happens.
  • agent-crabs: a pixel-art crab for every running subagent, dressed for its role (beret, hard hat, postman cap and more), with the agent's name, what it is working on and a timer. Finished crabs cheer and leave after a few seconds; failed ones turn grey. Name an agent by starting the Agent call's description with Name: , e.g. Olivia: rewrite welcome email; map names to outfits with the outfits option.
Source 2 files
hooks/register.tsx 110 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Touch } from '../types'
5
6const touchAtom = atom({ plugin: 'env-guard', key: 'touch' } as const, null as Touch)
7
8const WIDE = 110
9
10// Shared "dev row" protocol (the same helper sits in every mod of this repo):
11// each mod adds a keyed zone to one row, so the mods fill a single line together
12// and still work alone. Zones sort by their key, `zone-<order>`.
13function addZone(below: any, zone: any, order: number): any {
14  const tagged = { ...zone, props: { ...zone.props, key: `zone-${order}`, flexShrink: 0 } }
15  const byKey = (a: any, b: any) => String(a?.props?.key).localeCompare(String(b?.props?.key))
16  const merge = (node: any): any => {
17    if (!node || typeof node !== 'object') return null
18    if (node.props?.key === 'devrow') return { ...node, children: [...(node.children ?? []), tagged].sort(byKey) }
19    const kids: any[] = node.children ?? []
20    for (let i = 0; i < kids.length; i++) {
21      const hit = merge(kids[i])
22      if (hit) return { ...node, children: kids.map((k, j) => (j === i ? hit : k)) }
23    }
24    return null
25  }
26  const merged = merge(below)
27  if (merged) return merged
28  const row = {
29    type: 'Box',
30    props: { key: 'devrow', paddingX: 1, justifyContent: 'space-between', flexWrap: 'wrap', columnGap: 3 },
31    children: [tagged],
32  }
33  return {
34    type: 'Box',
35    props: { flexDirection: 'column' },
36    children: below ? [below, row] : [row],
37  }
38}
39
40export const register: Register = (on, options) => {
41  const holdMs = Math.max(1, Number(options.holdMinutes ?? 10)) * 60000
42  const patterns: RegExp[] = []
43  for (const p of String(options.patterns ?? '\\bprod\\b,\\bproduction\\b').split(',')) {
44    const s = p.trim()
45    if (!s) continue
46    try {
47      patterns.push(new RegExp(s, 'i'))
48    } catch {
49      // an invalid pattern is skipped
50    }
51  }
52
53  // What a call is judged on: the command, URL or arguments it acts with, never
54  // the text of a file it edits or reads, and not a commit message or heredoc body.
55  const subject = (e: Record<string, unknown>): string => {
56    const tool = String(e.tool)
57    if (tool === 'Bash') {
58      return String(e.command ?? '')
59        .replace(/<<-?\s*['"]?(\w+)['"]?[\s\S]*?\n\s*\1\b/g, '')
60        .replace(/(-m|--message|--body|-b|--title|-t)\s+("[^"]*"|'[^']*')/g, '')
61    }
62    if (tool === 'WebFetch') return String(e.url ?? '')
63    if (tool.startsWith('mcp__')) {
64      const { tool: _t, tool_use_id: _i, ...args } = e
65      return `${tool} ${JSON.stringify(args)}`
66    }
67    return ''
68  }
69
70  const hitOf = (e: Record<string, unknown>): string | null => {
71    const text = subject(e)
72    if (!text) return null
73    for (const re of patterns) if (re.test(text)) return re.source
74    return null
75  }
76
77  on('tool.call', async ($, e, next) => {
78    const tool = String(e.tool)
79    const hit = hitOf(e as Record<string, unknown>)
80    if (hit) {
81      const at = await $.clock.now()
82      await update($, touchAtom, () => ({ at, tool, hit }))
83      $.ui.toast(`Production touched by ${tool} (matched "${hit}")`)
84      // clear the red badge once the hold time has passed
85      $.clock.after(holdMs + 1000, () => update($, touchAtom, t => (t && t.at === at ? { ...t } : t)))
86    }
87    return next(e)
88  })
89
90  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
91    const below = await next(e)
92    if (e.props.hasSurvey) return below
93
94    const touch = await read($, touchAtom)
95    const now = await $.clock.now()
96    const isRed = touch !== null && now - touch.at < holdMs
97
98    const { Box, Text } = $.ui.resolve(e)
99    const zone = (
100      <Box gap={1}>
101        <Text color={isRed ? '#ef4444' : '#22c55e'} bold>
102          {isRed ? '▲ PROD' : '● LOCAL'}
103        </Text>
104        {isRed && touch ? <Text dimColor>{touch.tool} matched "{touch.hit}"</Text> : null}
105      </Box>
106    )
107    return addZone(below, zone, 9)
108  })
109}
110
types/index.d.ts 8 lines
1export type Touch = { at: number; tool: string; hit: string } | null
2
3declare module 'claude-code' {
4  interface PluginState {
5    'env-guard': { touch: Touch }
6  }
7}
8