Status line with business, repo, branch and dirty count, plus a warning band when on main or a secret file is staged

Mods that load in every Claude Code session: the terminal, the desktop app's Code tab, and cloud sessions.
| Mod | What it shows |
|---|---|
| repo-sentry | Business, repo, branch, behind or ahead of origin, dirty count. Warns on work sitting on main, a stale repo, or a staged secret. |
| progress-pulse | A live gradient progress band for the session's task list, a cheer on every finished task, /progress for the full view, /progress demo to see it run. |
| usage-meter | The 5-hour and weekly plan limits with reset times, context fill and session cost, right above the message box. /usage-meter hides or shows it. |
setup-mac.js (run once) installs every mod as a Claude Code plugin read straight from this folder, and adds a session-start hook that runs sync-mods.js --quiet. A mod added to the marketplace installs itself at the next session start; an edit to a mod takes effect at the next session start or /reload-plugins.cloud-setup.sh goes into each cloud environment's setup script. Every new container clones this repo and runs sync-mods.js.Put it in its own folder with .claude-plugin/plugin.json, add it to .claude-plugin/marketplace.json, check it with claude plugin validate <folder> and claude plugin test <folder>, commit and push.
mcp-diagnose.js, mcp-where.js, move-mcp-to-repo.js, mcp-set-secret.js, mcp-env-from-dotenv.js: MCP server helpers that never print secret values. servicetrade-mcp/: the read-only ServiceTrade MCP server.
hooks/register.tsx 86 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RepoInfo } from '../types'
5import { buildInfo, statusText } from './logic'
6
7const info = atom({ plugin: 'repo-sentry', key: 'info' } as const, null)
8const hiddenFor = atom({ plugin: 'repo-sentry', key: 'hiddenFor' } as const, '')
9
10async function git($: EngineInterface, args: string[], timeoutMs = 5000): Promise<string | null> {
11 try {
12 const r = await $.process.run(['git', ...args], { timeoutMs })
13 return r.exitCode === 0 ? r.stdout : null
14 } catch {
15 return null
16 }
17}
18
19async function refresh($: EngineInterface): Promise<void> {
20 const top = await git($, ['rev-parse', '--show-toplevel'])
21 let next: RepoInfo | null = null
22 if (top !== null) {
23 const [branch, porcelain, staged, aheadBehind] = await Promise.all([
24 git($, ['rev-parse', '--abbrev-ref', 'HEAD']),
25 git($, ['status', '--porcelain']),
26 git($, ['diff', '--cached', '--name-only']),
27 git($, ['rev-list', '--left-right', '--count', 'HEAD...@{u}']),
28 ])
29 next = buildInfo(top, branch ?? '', porcelain ?? '', staged ?? '', aheadBehind)
30 }
31 await update($, info, () => next)
32 $.ui.status(statusText(next))
33}
34
35export const register: Register = on => {
36 // Behind-origin is only as fresh as the last fetch, and both stale repos found on 8 Oct had
37 // not been fetched in weeks. So: draw from local state at once, then fetch quietly (refs only,
38 // repo hooks off, 15 s cap) and redraw, again every 10 minutes for long sessions. The fetch
39 // runs on a $.clock timer because work that outlives the session.start dispatch belongs there.
40 // A failed fetch (offline, no remote) changes nothing.
41 on('session.start', async ($, e, next) => {
42 const r = await next(e)
43 await refresh($)
44 const fetchAndRefresh = () => {
45 void git($, ['fetch', '--quiet', '--no-tags'], 15000).then(() => refresh($)).catch(() => {})
46 }
47 $.clock.after(1000, fetchAndRefresh)
48 $.clock.every(10 * 60_000, fetchAndRefresh)
49 return r
50 })
51
52 on('turn.complete', async ($, e, next) => {
53 const r = await next(e)
54 await refresh($)
55 return r
56 })
57
58 // Git commands the model runs can change branch or staging mid-turn.
59 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
60 const r = await next(e)
61 if (/\bgit\b/.test(e.command)) await refresh($)
62 return r
63 }).catch(($, e, next) => next(e))
64
65 // Draws above whatever is beneath (another mod's band, the engine's own) rather than in its
66 // place, so two mods on the band both show.
67 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
68 const below = await next(e)
69 const now = await read($, info)
70 if (e.props.hasSurvey || !now || now.warnings.length === 0) return below
71 const sig = now.warnings.join('|')
72 if ((await read($, hiddenFor)) === sig) return below
73
74 const { Box, Button, Text } = $.ui.resolve(e)
75 return (
76 <Box flexDirection="column">
77 {now.warnings.map(w => (
78 <Text color="warning">{now.repo}: {w}</Text>
79 ))}
80 <Button key="hide" label="Hide" onPress={() => update($, hiddenFor, () => sig)} />
81 {below}
82 </Box>
83 )
84 })
85}
86hooks/logic.ts 71 lines1import type { RepoInfo } from '../types'
2
3// Repo folder name -> business label. Edit this map as repos are added.
4export const BUSINESS: Record<string, string> = {
5 'apek-hub': 'APEK',
6 'df-correspondence': 'Desert Fire',
7 jpg: 'JPG',
8 'claude-guard': 'Shared',
9}
10
11const PROTECTED_BRANCHES = new Set(['main', 'master', 'production', 'prod'])
12const SECRET = /(^|\/)(\.env(\..+)?|secrets?\/.*|.*\.pem|.*\.key|credentials\.json|service-account.*\.json)$/i
13
14export function businessFor(repo: string): string {
15 const name = repo.toLowerCase()
16 if (BUSINESS[name]) return BUSINESS[name]
17 if (name.startsWith('apek')) return 'APEK'
18 if (name.startsWith('df-') || name.includes('desert-fire')) return 'Desert Fire'
19 if (name.startsWith('fwd') || name.includes('first-wave')) return 'First Wave Dental'
20 if (name.startsWith('jpg')) return 'JPG'
21 return repo
22}
23
24const lines = (s: string) => s.split('\n').map(l => l.trim()).filter(Boolean)
25
26// aheadBehind is the output of `git rev-list --left-right --count HEAD...@{u}`: "<ahead>\t<behind>",
27// or null when the branch has no upstream.
28export function parseAheadBehind(out: string | null): { ahead: number | null; behind: number | null } {
29 const m = (out ?? '').trim().match(/^(\d+)\s+(\d+)$/)
30 return m ? { ahead: Number(m[1]), behind: Number(m[2]) } : { ahead: null, behind: null }
31}
32
33export function buildInfo(toplevel: string, branch: string, porcelain: string, stagedNames: string, aheadBehind: string | null = null): RepoInfo {
34 const repo = toplevel.trim().split('/').filter(Boolean).pop() ?? toplevel.trim()
35 const b = branch.trim() || 'detached'
36 const staged = lines(stagedNames)
37 const warnings: string[] = []
38 // On a clean main there is nothing to protect yet, and every session opens there, so the
39 // warning only appears once there is work that would land on it.
40 const dirtyCount = lines(porcelain).length
41 if (PROTECTED_BRANCHES.has(b) && (dirtyCount > 0 || staged.length > 0)) warnings.push(`${dirtyCount + staged.length} changes on ${b}. Branch before committing.`)
42 if (b === 'HEAD') warnings.push('Detached HEAD.')
43 const { ahead, behind } = parseAheadBehind(aheadBehind)
44 const dirty = lines(porcelain).length
45 if (behind && behind > 0) {
46 warnings.push(dirty > 0
47 ? `${behind} commits behind origin with ${dirty} uncommitted files. Park them on a branch, then pull.`
48 : `${behind} commits behind origin. Pull before working.`)
49 }
50 const secrets = staged.filter(f => SECRET.test(f))
51 if (secrets.length) warnings.push(`Secret file staged: ${secrets.slice(0, 3).join(', ')}${secrets.length > 3 ? ` (+${secrets.length - 3})` : ''}. Unstage it.`)
52 return {
53 business: businessFor(repo),
54 repo,
55 branch: b,
56 dirty,
57 staged: staged.length,
58 behind,
59 ahead,
60 warnings,
61 }
62}
63
64export function statusText(info: RepoInfo | null): string | undefined {
65 if (!info) return undefined
66 const flag = info.warnings.length ? ' | !' : ''
67 const sync = info.behind === null ? ' | no upstream'
68 : info.behind || info.ahead ? ` | ${info.behind} behind, ${info.ahead} ahead` : ' | in sync'
69 return `${info.business} | ${info.repo}@${info.branch}${sync} | ${info.dirty} dirty, ${info.staged} staged${flag}`
70}
71types/index.d.ts 17 lines1export type RepoInfo = {
2 business: string
3 repo: string
4 branch: string
5 dirty: number
6 staged: number
7 behind: number | null
8 ahead: number | null
9 warnings: string[]
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'repo-sentry': { info: RepoInfo | null; hiddenFor: string }
15 }
16}
17