SLOPSHOPPER

preflight

A live pre-commit checklist above the prompt: re-runs your typecheck, lint or any command after Claude edits files.

newbandguardprocesstimer
v0.2.1no licenseupdated 2026-10-16Arkboi-GG/claude-mods/mods/preflight
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · preflight
› 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⟩ · types not run ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ · types not run
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 157 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Check } from '../types'
5
6const checksAtom = atom({ plugin: 'preflight', key: 'checks' } as const, [] as Check[])
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
40const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
41const GLYPH = { idle: '·', running: '…', pass: '✓', fail: '✗' } as const
42const COLOR = { idle: '#6b7280', running: '#f59e0b', pass: '#22c55e', fail: '#ef4444' } as const
43
44type Def = { name: string; command: string }
45
46let defs: Def[] = []
47let timer: { cancel: () => void } | null = null
48let isRunning = false
49let isDirty = false
50
51async function shell($: any, command: string) {
52  try {
53    return await $.process.run(['sh', '-c', command], { timeoutMs: 300000 })
54  } catch {
55    return await $.process.run(['cmd', '/c', command], { timeoutMs: 300000 })
56  }
57}
58
59// No setting given: use tsc when there is a tsconfig, and any lint script.
60async function detect($: any): Promise<Def[]> {
61  const found: Def[] = []
62  try {
63    const probe = await shell($, 'test -f tsconfig.json || dir tsconfig.json')
64    if (probe.exitCode === 0) found.push({ name: 'types', command: 'npx tsc -b' })
65  } catch {
66    // no shell: skip
67  }
68  try {
69    const r = await shell($, 'npm pkg get scripts.lint')
70    if (r.exitCode === 0 && r.stdout.trim() && r.stdout.trim() !== '{}') found.push({ name: 'lint', command: 'npm run lint' })
71  } catch {
72    // no npm: skip
73  }
74  return found
75}
76
77async function runAll($: any) {
78  if (isRunning) {
79    isDirty = true
80    return
81  }
82  isRunning = true
83  do {
84    isDirty = false
85    await update($, checksAtom, () => defs.map(d => ({ name: d.name, state: 'running' as const, issues: 0, at: 0 })))
86    for (let i = 0; i < defs.length; i++) {
87      let r
88      try {
89        r = await shell($, defs[i].command)
90      } catch {
91        r = { exitCode: 1, stdout: '', stderr: 'error: could not run' }
92      }
93      const out = `${r.stdout}\n${r.stderr}`
94      const issues = r.exitCode === 0 ? 0 : Math.max(1, (out.match(/\berror\b/gi) ?? []).length)
95      await update($, checksAtom, prev =>
96        prev.map((c, k) => (k === i ? { ...c, state: r.exitCode === 0 ? ('pass' as const) : ('fail' as const), issues, at: Date.now() } : c)),
97      )
98    }
99  } while (isDirty)
100  isRunning = false
101}
102
103export const register: Register = (on, options) => {
104  const delayMs = Math.max(1, Number(options.delaySeconds ?? 8)) * 1000
105
106  const parse = (s: string): Def[] =>
107    s
108      .split(';')
109      .map(p => p.trim())
110      .filter(Boolean)
111      .map(p => {
112        const i = p.indexOf('=')
113        return i > 0 ? { name: p.slice(0, i).trim(), command: p.slice(i + 1).trim() } : { name: p.split(' ')[0], command: p }
114      })
115
116  on('session.start', async ($, e, next) => {
117    defs = String(options.checks ?? '').trim() ? parse(String(options.checks)) : await detect($)
118    await update($, checksAtom, () => defs.map(d => ({ name: d.name, state: 'idle' as const, issues: 0, at: 0 })))
119    const result = await next(e)
120    $.clock.after(1000, () => runAll($))
121    return result
122  })
123
124  on('tool.call', async ($, e, next) => {
125    const result = await next(e)
126    if (EDIT_TOOLS.has(String(e.tool)) && defs.length > 0) {
127      timer?.cancel()
128      timer = $.clock.after(delayMs, () => runAll($))
129    }
130    return result
131  })
132
133  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
134    const below = await next(e)
135    const checks = await read($, checksAtom)
136    if (e.props.hasSurvey || checks.length === 0) return below
137
138    const { Box, Text } = $.ui.resolve(e)
139    const isWide = e.props.bodyColumns >= WIDE
140    const clock = (at: number) => (at ? new Date(at).toTimeString().slice(0, 5) : '')
141    const zone = (
142      <Box gap={2}>
143        {checks.map(c => (
144          <Box key={c.name} gap={1}>
145            <Text color={COLOR[c.state]}>{GLYPH[c.state]}</Text>
146            <Text dimColor={c.state === 'pass' || c.state === 'idle'}>{c.name}</Text>
147            {c.state === 'fail' ? <Text color="#ef4444">{c.issues}</Text> : null}
148            {c.state === 'idle' ? <Text dimColor>not run</Text> : null}
149            {isWide && c.at ? <Text dimColor>{clock(c.at)}</Text> : null}
150          </Box>
151        ))}
152      </Box>
153    )
154    return addZone(below, zone, 3)
155  })
156}
157
types/index.d.ts 8 lines
1export type Check = { name: string; state: 'idle' | 'running' | 'pass' | 'fail'; issues: number; at: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    preflight: { checks: Check[] }
6  }
7}
8