Your homelab conventions as rules on Claude's tool calls: a note, an ask or a refusal when a call breaks one (Unraid appdata paths, CLAUDE.local.md staged…

Eleven Claude Code mods (function-hook plugins) built around one workflow: many parallel side sessions, upstream PRs behind CodeRabbit, Codecov and Codex reviews, an Unraid server with about 90 containers, Home Assistant, and a local LLM on an RTX 3070. They are siblings of session-crew and borrow its savvy-progress crabs.
The mod API is early access. These mods were built and tested against Claude Code 2.1.295 (the desktop app's CLI); the declaration file that version ships is the authority.
| Mod | What you get | Commands | |
|---|---|---|---|
| where-am-i | Status line: 🍎 Mac · ha-unraid-management-agent · main · fork of tinkerologie. A note for Claude when, on the Mac, it SSHes to the Unraid host or the container, or runs the tests of a repo that is developed in the container. | /where | |
| roll-call | A pane where each side session's crab holds a sign: one line on where it stands, and a "needs you" lane (questions, approvals, boxes to tick). Lines come from Haiku, the local LLM, or the last message. | `/rollcall [open\ | linked] [text], tool report` |
| fuel-gauge | ⛽ ctx 14% · 5h 26% (34m) · wk 36% in the status line; toasts at 80% and 95% of a plan window and at 85% context; a pane with extra usage and every running side session's context. A band appears only while something runs hot. | /fuel [text] | |
| gpu-crab | While a local-llm tool runs: a crab on a treadmill above the prompt with the model, VRAM, GPU/CPU split (amber when it spills to CPU) and elapsed time; afterwards, a tally of what local models read today instead of the cloud. | /gpu | |
| vitals-strip | Unraid and HA health above the prompt (array and pools, CPU and memory, unhealthy or missing containers, unread alerts, parity, HA repairs). Quiet by default: only what isn't green. | `/vitals [show\ | quiet]` |
| reef | An aquarium pane: each container is a fish coloured by stack, unhealthy ones float belly-up, the GPU is a heater, HA is a lighthouse, side sessions are crabs on the sand. Hover a fish for its name. | /reef [text] | |
| keymaster | Masks API keys, tokens, passwords and private keys in tool output before Claude, the transcript or the screen sees them (‹masked arr-api-key 1a2b3c4d›), with a toast and a count. Withholds output it can't scan. | /keymaster, /keymaster allow <tag> | |
| trailer-guard | Refuses a git commit, gh pr create/edit or git push that would carry Co-Authored-By: Claude or the Claude Code footer into a repo whose remotes include tinkerologie, esphome or home-assistant (configurable). | /trailers | |
| gatekeeper | A pane of your open PRs by lane: needs you (boxes only you tick, green drafts), needs work (red checks, Codecov, CodeRabbit threads, conflicts), waiting. A band when something needs you. | /gates [text], tool gates | |
| codex-corner | A boxing ring above the prompt for Codex adversarial reviews: each run is a round with findings by severity, until the verdict is approve and the bot goes down. Kept per repo and branch. | /codex-card | |
| house-rules | Notes, asks and refusals on tool calls: appdata paths (/mnt/user/appdata vs the nvme8tb pool), CLAUDE.local.md in git, force-push to upstream, HA restarts. Add your own. | /rules, /rules add …, /rules off <id> |
Everything is read-only.
| Mod | Reads |
|---|---|
| roll-call, fuel-gauge, reef | The desktop app's session server (ccd_session_mgmt: list_sessions, list_events, get_usage) |
| vitals-strip | The HA-MCP connector: one ha_eval_template over the Unraid Management Agent integration's entities, and ha_get_overview. Allow both tools in permissions so auto mode doesn't review each read |
| reef | unraid-mcp (system array/metrics, docker list, notification overview, array parity_status) and the HA-MCP connector (ha_get_overview) |
| gpu-crab, reef | Ollama GET /api/ps at the ollamaUrl option (default http://localhost:11434) |
| gatekeeper | gh api graphql, one search query every 5 minutes and shortly after a push |
| where-am-i, trailer-guard, codex-corner | git in the session's checkout |
Server names, URLs and thresholds are options (userConfig) on each mod. The defaults assume nothing about your network: Ollama on localhost, no Home Assistant connector (vitals-strip and reef's lighthouse stay off until you name one), and no repo or SSH rules for where-am-i. Set your values in ~/.claude/settings.json:
"pluginConfigs": {
"gpu-crab": { "options": { "ollamaUrl": "http://192.168.1.50:11434" } },
"vitals-strip": { "options": { "haServer": "<your HA-MCP connector, as /mcp lists it>" } },
"where-am-i": { "options": { "homes": ["my-integration=container"], "containerTargets": ["my-nas"] } }
}
For one terminal session:
claude --plugin-dir ~/dev/crab-workshop/plugins/where-am-i --plugin-dir ~/dev/crab-workshop/plugins/fuel-gauge
For desktop-app sessions, add the folders to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (colon-separated, next to session-crew's), then start a new session:
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/session-crew:/path/to/crab-workshop/plugins/where-am-i:/path/to/crab-workshop/plugins/keymaster"
}
Or install from this folder as a marketplace, so /reload-plugins picks up edits:
claude plugin marketplace add ~/dev/crab-workshop
Then, in a terminal session, /plugin install keymaster@crab-workshop (and so on). Sessions on the UnRAID SSH connection run Claude Code inside the claude-code container, so the mods need the same setup there.
sh scripts/check.sh # every mod: type-check, claude plugin validate, claude plugin test
sh scripts/check.sh gatekeeper # one mod
shared/ holds the crab art (copied from savvy-progress through session-crew; see THIRD_PARTY_NOTICES.md) and the drawing helpers. scripts/sync-shared.sh copies them into each mod's hooks/shared/, because a mod can only import its own files. Edit shared/, never the copies.
AbovePrompt bands and Svg was checked with the test kit only, not by eye in the app.:8043) refused connections from the Mac, so vitals-strip and reef read through unraid-mcp instead./keymaster allow its tag.MIT, see LICENSE. The crab art is third-party; see THIRD_PARTY_NOTICES.md.
hooks/register.ts 95 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HouseRule } from '../types'
5import { BUILT_IN, broken, describe, parseAdd } from './rules'
6
7const hits = atom({ plugin: 'house-rules', key: 'hits' } as const, {})
8
9const STORE_RULES = 'house-rules.rules'
10const STORE_OFF = 'house-rules.off'
11const STORE_HITS = 'house-rules.hits'
12
13let disabled = new Set<string>()
14let rules: HouseRule[] = BUILT_IN
15
16async function load($: EngineInterface): Promise<HouseRule[]> {
17 const mine = ((await $.store.get(STORE_RULES)) as HouseRule[] | undefined) ?? []
18 const off = new Set([...disabled, ...(((await $.store.get(STORE_OFF)) as string[] | undefined) ?? [])])
19 rules = [...BUILT_IN, ...mine].filter(r => !off.has(r.id))
20 return rules
21}
22
23async function count($: EngineInterface, ids: string[]): Promise<void> {
24 const next = await update($, hits, h => {
25 const c = { ...h }
26 for (const id of ids) c[id] = (c[id] ?? 0) + 1
27 return c
28 })
29 await $.store.set(STORE_HITS, next)
30}
31
32export const register: Register = (on, options) => {
33 disabled = new Set(((options.disabled as readonly string[] | undefined) ?? []).map(String))
34
35 on('session.start', async ($, e, next) => {
36 const started = await next(e)
37 await load($)
38 const stored = (await $.store.get(STORE_HITS)) as Record<string, number> | undefined
39 if (stored) await update($, hits, () => stored)
40 await $.command.register({ name: 'rules', description: 'House rules on tool calls: list, add, turn off or on', argumentHint: '[add <tools> <note|ask|deny> /regex/ <message> | off <id> | on <id>]' })
41 return started
42 })
43
44 // ask: put the call to the person (or the mode's decider) with the rule's reason.
45 on('tool.check', async ($, e, next) => {
46 const asks = broken(rules, String(e.tool), e as unknown as Record<string, unknown>).filter(r => r.action === 'ask')
47 if (!asks.length) return next(e)
48 const verdict = await next(e)
49 if (verdict.decision === 'deny') return verdict
50 await count($, asks.map(r => r.id))
51 return { decision: 'ask', reason: `House rule ${asks[0]?.id}: ${asks[0]?.message}` }
52 }).catch(($, e, next) => next(e))
53
54 // deny refuses before the call runs; note rides along after it.
55 on('tool.call', async ($, e, next) => {
56 const found = broken(rules, String(e.tool), e as unknown as Record<string, unknown>)
57 const deny = found.find(r => r.action === 'deny')
58 if (deny) {
59 await count($, [deny.id])
60 $.ui.toast(`House rule ${deny.id}: refused`)
61 return { deny: `house-rules (${deny.id}): ${deny.message}` }
62 }
63 const notes = found.filter(r => r.action === 'note')
64 if (!notes.length) return next(e)
65 const r = await next(e)
66 if (r.deny !== undefined) return r
67 await count($, notes.map(n => n.id))
68 return { ...r, context: [...(r.context ?? []), ...notes.map(n => `house-rules (${n.id}): ${n.message}`)] }
69 }).catch(($, e, next) => next(e))
70
71 on('command.run', { command: 'rules' }, async ($, e) => {
72 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
73 if (verb === 'add') {
74 const mine = ((await $.store.get(STORE_RULES)) as HouseRule[] | undefined) ?? []
75 const rule = parseAdd(rest.join(' '), `mine-${mine.length + 1}`)
76 if (typeof rule === 'string') return { text: rule }
77 await $.store.set(STORE_RULES, [...mine, rule])
78 await load($)
79 return { text: `Added ${rule.id}: ${rule.action} on ${rule.tools.join(',')} matching /${rule.match[0]}/.` }
80 }
81 if (verb === 'off' || verb === 'on') {
82 const id = rest[0] ?? ''
83 const off = new Set(((await $.store.get(STORE_OFF)) as string[] | undefined) ?? [])
84 if (verb === 'off') off.add(id)
85 else off.delete(id)
86 await $.store.set(STORE_OFF, [...off])
87 await load($)
88 return { text: `${id} is ${verb}.` }
89 }
90 const h = await read($, hits)
91 const all = await load($)
92 return { text: ['House rules:', ...all.map(r => describe(r, h[r.id] ?? 0))].join('\n') }
93 })
94}
95hooks/rules.ts 79 lines1// Pure logic for house-rules: the built-in rules and matching a call against them.
2
3import type { HouseRule, RuleAction } from '../types'
4
5export const BUILT_IN: HouseRule[] = [
6 {
7 id: 'appdata-path',
8 tools: ['Write', 'Edit', 'MultiEdit', 'Bash'],
9 // A container mapping or template that names the FUSE path for appdata.
10 match: ['/mnt/user/appdata/[A-Za-z0-9]', '(docker[^"]*\\s(run|create)\\b|\\.xml"|compose|"Type":"Path"|<Config[^>]*Type=\\\\?"Path|\\s-v\\s)'],
11 action: 'note',
12 message:
13 "This server's appdata share lives on the nvme8tb pool and is exclusive: new container paths go to /mnt/nvme8tb/appdata/<app> (no FUSE), as with Prismarr. " +
14 'The Appdata Backup plugin only covers sources under /mnt/user/appdata, so check that whichever path you pick is backed up (zerobyte snapshots the ZFS dataset either way).',
15 isBuiltIn: true,
16 },
17 {
18 id: 'claude-local',
19 tools: ['Bash'],
20 match: ['\\bgit\\s+(add|commit)\\b[^;&|]*CLAUDE\\.local\\.md'],
21 action: 'deny',
22 message: 'CLAUDE.local.md holds environment notes and stays uncommitted in every repo. Leave it out of git add and commits.',
23 isBuiltIn: true,
24 },
25 {
26 id: 'upstream-force-push',
27 tools: ['Bash'],
28 match: ['\\bgit\\s+push\\b[^;&|]*(--force\\b|--force-with-lease|\\s-f\\b|\\s\\+)', '\\bgit\\s+push\\b[^;&|]*\\bupstream\\b'],
29 action: 'deny',
30 message: "Never force-push to an upstream remote (someone else's repo). Push to your fork (origin) and update the PR from there.",
31 isBuiltIn: true,
32 },
33 {
34 id: 'ha-restart',
35 tools: ['mcp__*__ha_restart', 'mcp__*__ha_call_service', 'mcp__*__ha_reload_core'],
36 match: ['(ha_restart|"domain":"homeassistant".*"service":"(restart|stop)"|"service":"homeassistant\\.(restart|stop)"|ha_reload_core)'],
37 action: 'ask',
38 message: 'Restarting Home Assistant interrupts the house (lights, automations, dashboards). David decides when.',
39 isBuiltIn: true,
40 },
41]
42
43export const glob = (pattern: string, s: string): boolean =>
44 new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$').test(s)
45
46/** The rules a call breaks, strictest first. `input` is the call's arguments. */
47export function broken(rules: HouseRule[], tool: string, input: Record<string, unknown>): HouseRule[] {
48 const text = JSON.stringify(input) + (tool.includes('__') ? ` ${tool}` : '')
49 const rank: Record<RuleAction, number> = { deny: 0, ask: 1, note: 2 }
50 return rules
51 .filter(r => r.tools.some(t => glob(t, tool)))
52 .filter(r => r.match.every(m => safeTest(m, text)))
53 .filter(r => !r.unless || !safeTest(r.unless, text))
54 .sort((a, b) => rank[a.action] - rank[b.action])
55}
56
57function safeTest(source: string, text: string): boolean {
58 try {
59 return new RegExp(source, 'i').test(text)
60 } catch {
61 return false
62 }
63}
64
65/** `/rules add <tools> <note|ask|deny> /regex/ message` into a rule, or why not. */
66export function parseAdd(args: string, id: string): HouseRule | string {
67 const m = args.match(/^\s*(\S+)\s+(note|ask|deny)\s+\/(.+?)\/\s+(.+)$/)
68 if (!m || !m[1] || !m[2] || !m[3] || !m[4]) return 'Usage: /rules add <tool-glob[,glob]> <note|ask|deny> /regex/ <message>'
69 try {
70 new RegExp(m[3], 'i')
71 } catch (err) {
72 return `That regex doesn't compile: ${err instanceof Error ? err.message : String(err)}`
73 }
74 return { id, tools: m[1].split(','), match: [m[3]], action: m[2] as RuleAction, message: m[4].trim() }
75}
76
77export const describe = (r: HouseRule, hits: number): string =>
78 `${r.id.padEnd(20)} ${r.action.padEnd(5)} ${String(hits).padStart(3)} hits ${r.tools.join(',')} ${r.isBuiltIn ? '(built in)' : ''}\n ${r.message.slice(0, 140)}`
79types/index.d.ts 23 lines1export type RuleAction = 'note' | 'ask' | 'deny'
2
3export type HouseRule = {
4 id: string
5 /** Tool-name globs the rule looks at (Bash, Write, mcp__*__ha_restart). */
6 tools: string[]
7 /** Regexes (source, flags i) on the call's input as JSON; every one must match. */
8 match: string[]
9 /** A regex that, matching, lets the call through. */
10 unless?: string
11 action: RuleAction
12 message: string
13 isBuiltIn?: boolean
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'house-rules': {
19 hits: Record<string, number>
20 }
21 }
22}
23