SLOPSHOPPER

where-am-i

Shows which machine and repo a session runs in (Mac or the claude-code container, repo, branch, fork) in the status line, and nudges Claude when it reaches for…

newguardcommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · where-am-i
› 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 › /where ⎿ where-am-i: host 🐧 dev (dev, linux) ⎿ where-am-i: folder /work/app ⎿ where-am-i: repo app on feat/auth-refresh ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ where-am-i: 🐧 dev · app · feat/auth-refresh
README

crab-workshop

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.

ModWhat you getCommands
where-am-iStatus 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-callA 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-crabWhile 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-stripUnraid 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]`
reefAn 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]
keymasterMasks 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-guardRefuses 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
gatekeeperA 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-cornerA 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-rulesNotes, 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>

Data sources

Everything is read-only.

ModReads
roll-call, fuel-gauge, reefThe desktop app's session server (ccd_session_mgmt: list_sessions, list_events, get_usage)
vitals-stripThe 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
reefunraid-mcp (system array/metrics, docker list, notification overview, array parity_status) and the HA-MCP connector (ha_get_overview)
gpu-crab, reefOllama GET /api/ps at the ollamaUrl option (default http://localhost:11434)
gatekeepergh api graphql, one search query every 5 minutes and shortly after a push
where-am-i, trailer-guard, codex-cornergit 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"] } }
}

Trying them

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.

Developing

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.

Known limits

  • The desktop Code tab's drawing of AbovePrompt bands and Svg was checked with the test kit only, not by eye in the app.
  • The Unraid Management Agent's own API (:8043) refused connections from the Mac, so vitals-strip and reef read through unraid-mcp instead.
  • keymaster masks what looks like a secret. A real value that slips past its patterns is not caught, and a fake one in a test fixture is masked until you /keymaster allow its tag.
  • gatekeeper reads the first 50 PRs of its search (newest first).

License

MIT, see LICENSE. The crab art is third-party; see THIRD_PARTY_NOTICES.md.

Source 2 files
hooks/register.ts 104 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { describe, hostKind, hostLabel, movesCheckout, nudge, ownerRepo, parseRemotes, parseRules, shortPath } from './place'
4import type { Place, RepoInfo } from './place'
5
6const git = async ($: EngineInterface, cwd: string, args: string[]): Promise<string> => {
7  try {
8    const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 5000 })
9    return r.exitCode === 0 ? r.stdout.trim() : ''
10  } catch {
11    return ''
12  }
13}
14
15async function readRepo($: EngineInterface, cwd: string): Promise<RepoInfo | undefined> {
16  const top = await git($, cwd, ['rev-parse', '--show-toplevel'])
17  if (!top) return undefined
18  const [branch, gitDir, common, remotesText] = await Promise.all([
19    git($, cwd, ['rev-parse', '--abbrev-ref', 'HEAD']),
20    git($, cwd, ['rev-parse', '--absolute-git-dir']),
21    git($, cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']),
22    git($, cwd, ['remote', '-v']),
23  ])
24  const remotes = parseRemotes(remotesText)
25  const origin = remotes.origin ? ownerRepo(remotes.origin) : null
26  const upstream = remotes.upstream ? ownerRepo(remotes.upstream) : null
27  return {
28    name: origin?.name ?? upstream?.name ?? top.split('/').pop() ?? top,
29    branch: branch === 'HEAD' ? 'detached' : branch || '?',
30    ...(origin ? { origin: origin.owner } : {}),
31    ...(upstream ? { upstream: upstream.owner } : {}),
32    isWorktree: !!gitDir && !!common && gitDir !== common,
33  }
34}
35
36// Kept per load: a reload re-reads the place at start.
37let place: Place | null = null
38let host = ''
39let kind: Place['kind'] = 'linux'
40let hostRules: [string, string][] = []
41const said = new Set<string>()
42
43async function refresh($: EngineInterface): Promise<Place> {
44  const cwd = await $.session.cwd()
45  const repo = await readRepo($, cwd)
46  place = { kind, host, label: hostLabel(kind, host, hostRules), cwd, ...(repo ? { repo } : {}) }
47  $.ui.status(describe(place))
48  return place
49}
50
51export const register: Register = (on, options) => {
52  hostRules = parseRules(options.hosts as readonly string[] | undefined)
53  const homes = parseRules(options.homes as readonly string[] | undefined)
54  const targets = ((options.containerTargets as readonly string[] | undefined) ?? []).map(String)
55
56  on('session.start', async ($, e, next) => {
57    const started = await next(e)
58    try {
59      const [uname, name, isDocker] = await Promise.all([
60        $.process.run(['uname', '-s'], { timeoutMs: 3000 }).then(r => r.stdout.trim()).catch(() => ''),
61        $.process.run(['hostname'], { timeoutMs: 3000 }).then(r => r.stdout.trim()).catch(() => ''),
62        $.fs.exists('/.dockerenv').catch(() => false),
63      ])
64      host = name || 'unknown'
65      kind = hostKind(uname, isDocker === true)
66      await $.command.register({ name: 'where', description: 'Show which machine, folder, repo, branch and remotes this session runs in' })
67      await refresh($)
68    } catch {
69      // The status line is a nicety; a failure here changes nothing else.
70    }
71    return started
72  })
73
74  on('command.run', { command: 'where' }, async $ => {
75    const p = await refresh($)
76    const cwd = p.cwd
77    const remotes = parseRemotes(await git($, cwd, ['remote', '-v']))
78    const lines = [
79      `host     ${p.label} (${p.host}, ${p.kind})`,
80      `folder   ${shortPath(cwd)}`,
81      ...(p.repo
82        ? [
83            `repo     ${p.repo.name} on ${p.repo.branch}${p.repo.isWorktree ? ' (a worktree)' : ''}`,
84            ...Object.entries(remotes).map(([n, u]) => `remote   ${n.padEnd(9)}${u}`),
85          ]
86        : ['repo     (not a git checkout)']),
87    ]
88    return { text: lines.join('\n') }
89  })
90
91  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
92    const r = await next(e)
93    const command = String(e.command ?? '')
94    if (movesCheckout(command)) await refresh($).catch(() => undefined)
95    if (!place || r.deny !== undefined) return r
96    const n = nudge(place, command, homes, targets)
97    // Once per kind per load: a nudge, not a nag.
98    if (!n || said.has(n.kind)) return r
99    said.add(n.kind)
100    $.ui.toast(n.note.replace(/^where-am-i: /, '').split('. ')[0] + '.')
101    return { ...r, context: [...(r.context ?? []), n.note] }
102  }).catch(($, e, next) => next(e))
103}
104
hooks/place.ts 128 lines
1// Pure logic for where-am-i: what kind of host this is, how to describe the
2// repo, and when a command deserves a nudge.
3
4export type HostKind = 'mac' | 'container' | 'linux'
5
6export type Place = {
7  kind: HostKind
8  host: string
9  label: string
10  cwd: string
11  repo?: RepoInfo
12}
13
14export type RepoInfo = {
15  /** Repo name, from origin's URL or the folder. */
16  name: string
17  branch: string
18  /** owner of origin (davidcoulson) and of upstream (tinkerologie), when present. */
19  origin?: string
20  upstream?: string
21  isWorktree: boolean
22}
23
24/** `pattern=value` lines into pairs; `*` and `?` globs. */
25export function parseRules(raw: readonly string[] | string | undefined): [string, string][] {
26  const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(/[,\n]/) : []
27  return list
28    .map(s => String(s).trim())
29    .filter(s => s.includes('='))
30    .map(s => [s.slice(0, s.indexOf('=')).trim(), s.slice(s.indexOf('=') + 1).trim()] as [string, string])
31    .filter(([p, v]) => p && v)
32}
33
34export const glob = (pattern: string, s: string): boolean =>
35  new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i').test(s)
36
37export function hostKind(uname: string, isDocker: boolean): HostKind {
38  if (/darwin/i.test(uname)) return 'mac'
39  return isDocker ? 'container' : 'linux'
40}
41
42export function hostLabel(kind: HostKind, host: string, rules: [string, string][]): string {
43  const hit = rules.find(([p]) => glob(p, host))
44  if (hit) return hit[1]
45  const short = host.replace(/\.local$/, '')
46  return kind === 'mac' ? '🍎 Mac' : kind === 'container' ? `🐳 ${short}` : `🐧 ${short}`
47}
48
49/** owner/name from a git remote URL (ssh or https); null when it isn't one. */
50export function ownerRepo(url: string): { owner: string; name: string } | null {
51  const m = url.trim().match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/)
52  return m && m[1] && m[2] ? { owner: m[1], name: m[2] } : null
53}
54
55/** `git remote -v` output into name → url (fetch lines). */
56export function parseRemotes(text: string): Record<string, string> {
57  const out: Record<string, string> = {}
58  for (const line of text.split('\n')) {
59    const m = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)/)
60    if (m && m[1] && m[2] && !out[m[1]]) out[m[1]] = m[2]
61  }
62  return out
63}
64
65export function describe(p: Place): string {
66  const parts = [p.label]
67  if (p.repo) {
68    const r = p.repo
69    let repo = `${r.name} · ${r.branch}`
70    if (r.isWorktree) repo += ' (worktree)'
71    parts.push(repo)
72    if (r.upstream && r.origin && r.upstream !== r.origin) parts.push(`fork of ${r.upstream}`)
73  } else {
74    parts.push(shortPath(p.cwd))
75  }
76  return parts.join(' · ')
77}
78
79export const shortPath = (path: string): string =>
80  path.replace(/^\/Users\/[^/]+/, '~').replace(/^\/home\/[^/]+/, '~').replace(/^~\/Library\/Application Support\/Claude\/scratch-workspaces\/.*\/(scratch-[^/]+)/, 'scratch/$1')
81
82const DEV = /(^|[\s;&|(])(make\s+(test|check|lint)|go\s+(test|vet|build)|pytest|\.venv\/bin\/pytest|script\/(test|setup|hassfest)|ruff\s|mypy\s|npm\s+(test|run)|pnpm\s|uv\s+run)/
83
84export const isDevCommand = (command: string): boolean => DEV.test(command)
85
86/** The container or Unraid word a remote command on the Mac names, or null. */
87export function sshTarget(command: string, targets: readonly string[]): string | null {
88  const m = command.match(/(^|[\s;&|(])(ssh|scp|rsync|sftp)\s+([^\n;&|]*)/)
89  if (!m || !m[3]) return null
90  const rest = m[3]
91  return targets.find(t => t && new RegExp(`(^|[\\s@:/])${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[\\s:/])`, 'i').test(rest)) ?? null
92}
93
94export function homeFor(repo: string | undefined, rules: [string, string][]): HostKind | undefined {
95  if (!repo) return undefined
96  const v = rules.find(([p]) => glob(p, repo))?.[1]?.toLowerCase()
97  return v === 'mac' || v === 'container' ? v : undefined
98}
99
100/** The note Claude reads after the command, or null when the command is fine here. */
101export function nudge(p: Place, command: string, homes: [string, string][], targets: readonly string[]): { kind: string; note: string } | null {
102  if (p.kind === 'mac') {
103    const t = sshTarget(command, targets)
104    if (t)
105      return {
106        kind: `ssh:${t}`,
107        note:
108          `where-am-i: this session runs on the Mac (${p.host}), and that command reaches ${t}. ` +
109          `Work on the Unraid host or in the claude-code dev container belongs in a session on the "UnRAID" SSH connection (folder ~/dev), ` +
110          `where the keys and host keys are set up. If it failed with "Host key verification failed" or a permission denial, ` +
111          `stop and ask David to move the work there (or propose a side session there) instead of working around it.`,
112      }
113  }
114  const home = homeFor(p.repo?.name, homes)
115  if (home && home !== p.kind && p.kind !== 'linux' && isDevCommand(command))
116    return {
117      kind: `dev:${p.repo?.name}`,
118      note:
119        `where-am-i: ${p.repo?.name} is developed ${home === 'container' ? 'in the claude-code container (UnRAID SSH connection, ~/dev)' : 'on the Mac'}, ` +
120        `but this session runs ${p.kind === 'mac' ? 'on the Mac' : 'in the container'} (${shortPath(p.cwd)}). ` +
121        `This checkout may be stale or lack the environment (venv, CLAUDE.local.md, keys). Say so to David before relying on results from here.`,
122    }
123  return null
124}
125
126/** Whether a command may have moved the checkout (branch or folder). */
127export const movesCheckout = (command: string): boolean => /\bgit\s+(checkout|switch|worktree|clone|rebase|merge|pull)\b|(^|[\s;&|])cd\s/.test(command)
128