SLOPSHOPPER

shire-status

shire index health in the status line, toasts when it changes, and a /shire pane with rebuild buttons

newpaneguardcommandtoaststatus
★ 1v0.1.0Apache-2.0updated 2026-10-03justinjdev/shire/contrib/claude-mod/shire-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shire-status
│ ┃ shire ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ shire status printed something that is not │ shire-status │ │ ┃ JSON: (nothing) ⏺ Read(src/auth.ts) │ shire: shire status printed something that │ │ ┃ Install or update shire, then run /shire ⎿ Read 6 lines │ is not JSON: (nothing) │ │ ┃ again. ⏺ 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 │ │ › /shire │ ⎿ shire-status: shire: shire status printed something that is not │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ shire-status: shire ✗ unavailable (/shire for details)

Draws

Pane · shire
shire status printed something that is not JSON: (nothing) Install or update shire, then run /shire again.
README

shire-status: a Claude Code mod

Shows shire's index health inside Claude Code. It reads shire status --json, which never rebuilds or writes.

  • Status line: shire ● 412 pkgs · 38.2k syms · 4m ago, with ⟳ while a build runs, ▲ for warnings (build failures, an interrupted build, a capped or partial file walk, pending re-checks, HEAD moved since the index was built) and ✗ when the index is missing or unreadable. Polled every 15 s and 3 s after any edit.
  • Toasts only when something changed: symbol/file counts moved, new build failures, an interrupted build, the 500k file cap, the watch daemon stopping. An on-demand rebuild (serve --root) that changed nothing stays quiet.
  • /shire opens a pane with the details and Rebuild / Force rebuild buttons. Rebuild goes through the watch daemon when it is running and listening, otherwise runs shire build. /shire rebuild [--force] does the same from the prompt.

Requirements

A shire on PATH that has the status subcommand.

The files here are compiled into the shire binary (src/claude_mod.rs), so changing one changes what shire init --mod installs.

Install

shire init --mod          # or answer yes to the prompt in `shire init`

This writes the mod, compiled into the shire binary so it always matches its shire status, to ~/.claude/shire-mod/shire-status/ and lists that folder in env.CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json. shire install refreshes its files after an upgrade (without re-listing a folder you took out of the settings to switch it off), and shire uninstall removes it.

To run this folder straight from a checkout instead:

claude --plugin-dir /path/to/shire/contrib/claude-mod/shire-status

Develop

claude plugin validate contrib/claude-mod/shire-status
claude plugin test contrib/claude-mod/shire-status

Claude Code lays the API's type declarations into .claude-plugin/types/ when it loads the folder (git-ignored); after that tsc -p contrib/claude-mod/shire-status type-checks it. The mod API is early access and may change between Claude Code releases.

Source 3 files
hooks/register.tsx 227 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ShireStatus } from '../types'
5import { detailRows, settled, statusLine, transitions, warnings } from './format'
6
7const status = atom({ plugin: 'shire-status', key: 'status' } as const, null)
8const baseline = atom({ plugin: 'shire-status', key: 'baseline' } as const, null)
9const error = atom({ plugin: 'shire-status', key: 'error' } as const, null)
10const busy = atom({ plugin: 'shire-status', key: 'busy' } as const, null)
11
12const PANE = 'shire'
13/** Background poll; `shire status` is read-only and takes a few ms. */
14const POLL_MS = 15_000
15/** Re-poll this long after an edit, so a watch-daemon rebuild shows up. */
16const AFTER_EDIT_MS = 3_000
17const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
18/** A full build of a big monorepo runs for minutes. */
19const BUILD_TIMEOUT_MS = 600_000
20
21// Module variables: a reload starts them over, which is what they should do.
22let soon: Timer | undefined
23/** The poll running now, and the one queued behind it. */
24let current: Promise<void> = Promise.resolve()
25let queued: Promise<void> | null = null
26/** Set synchronously, so two quick presses cannot both start a build. */
27let rebuilding = false
28/** Tells this module load's `busy` entry from one a reloaded module left. */
29const OWNER = `${Date.now()}-${Math.random()}`
30
31/**
32 * Read `shire status`. Polls run one at a time, and a call made while one is
33 * running waits for a fresh poll after it (shared by every such call), so a
34 * caller never gets a snapshot taken before it asked.
35 */
36function poll($: EngineInterface): Promise<void> {
37  if (queued !== null) return queued
38  const run = current.then(() => {
39    queued = null
40    return pollOnce($)
41  })
42  queued = run
43  current = run
44  return run
45}
46
47async function pollOnce($: EngineInterface): Promise<void> {
48  let ran
49  try {
50    ran = await $.process.run(['shire', 'status', '--json'], { timeoutMs: 10_000 })
51  } catch (e) {
52    // Rejects when the command cannot start (no shire on PATH) or times out;
53    // the engine does not pass the cause through, so say both. A non-zero
54    // exit or output that is not JSON are reported separately below.
55    const msg = e instanceof Error ? e.message : String(e)
56    await fail($, `could not run shire status (is shire on PATH?): ${msg}`)
57    return
58  }
59  if (ran.exitCode !== 0) {
60    // An older shire has no `status` subcommand: clap exits 2.
61    const why = ran.stderr.includes('unrecognized subcommand')
62      ? 'this shire has no `status` command; upgrade shire'
63      : ran.stderr.trim().split('\n')[0] || `shire status exited ${ran.exitCode}`
64    await fail($, why)
65    return
66  }
67  let next: ShireStatus
68  try {
69    next = JSON.parse(ran.stdout) as ShireStatus
70  } catch {
71    const first = ran.stdout.trim().split('\n')[0]?.slice(0, 120) ?? ''
72    await fail($, `shire status printed something that is not JSON: ${first || '(nothing)'}`)
73    return
74  }
75  if (settled(next)) {
76    for (const line of transitions(await read($, baseline), next)) $.ui.toast(`shire: ${line}`)
77    await update($, baseline, () => next)
78  }
79  await update($, status, () => next)
80  await update($, error, () => null)
81  $.ui.status(statusLine(next, await $.clock.now()))
82}
83
84async function fail($: EngineInterface, why: string): Promise<void> {
85  const had = await read($, error)
86  await update($, error, () => why)
87  await update($, status, () => null)
88  $.ui.status('shire ✗ unavailable (/shire for details)')
89  if (had !== why) $.ui.toast(`shire: ${why}`)
90}
91
92/**
93 * `shire rebuild` when the watch daemon is up and listening (it debounces and
94 * builds), else a build. Not when it is merely running: `shire rebuild`
95 * exits 0 even when it cannot reach the socket, so a wedged daemon would make
96 * the button report a rebuild that never happened.
97 */
98async function rebuild($: EngineInterface, force: boolean): Promise<string> {
99  if (rebuilding) return 'shire: a rebuild is already running'
100  rebuilding = true
101  const s = await read($, status)
102  const root = s?.root
103  const rootArgs = root ? ['--root', root] : []
104  const viaDaemon = !force && s?.watch.running === true && s.watch.listening
105  const argv = viaDaemon
106    ? ['shire', 'rebuild', ...rootArgs]
107    : ['shire', 'build', ...rootArgs, ...(force ? ['--force'] : [])]
108  const label = force ? 'force rebuild' : viaDaemon ? 'rebuild (watch daemon)' : 'rebuild'
109  await update($, busy, () => ({ label, owner: OWNER }))
110  $.ui.status(`shire ⟳ ${label}…`)
111  try {
112    const ran = await $.process.run(argv, { timeoutMs: BUILD_TIMEOUT_MS })
113    const tail = (ran.stderr.trim().split('\n').pop() ?? '').slice(0, 200)
114    return ran.exitCode === 0
115      ? `shire: ${label} ${viaDaemon ? 'requested' : 'finished'}`
116      : `shire: ${label} failed (exit ${ran.exitCode})${tail ? `: ${tail}` : ''}`
117  } catch (e) {
118    return `shire: ${label} could not run: ${e instanceof Error ? e.message : String(e)}`
119  } finally {
120    rebuilding = false
121    await update($, busy, b => (b?.owner === OWNER ? null : b))
122    await poll($)
123  }
124}
125
126export const register: Register = on => {
127  on('session.start', async ($, e, next) => {
128    await $.command.register({
129      name: 'shire',
130      description: 'shire index status; `/shire rebuild` or `/shire rebuild --force` to rebuild',
131      argumentHint: '[rebuild [--force]]',
132    })
133    void poll($)
134    $.clock.every(POLL_MS, () => void poll($))
135    return next(e)
136  })
137
138  on('command.run', { command: 'shire' }, async ($, e) => {
139    const args = e.args.trim().split(/\s+/).filter(Boolean)
140    if (args[0] === 'rebuild') {
141      return { text: await rebuild($, args.includes('--force')) }
142    }
143    await poll($)
144    await $.ui.open({ id: PANE, title: 'shire' })
145    const s = await read($, status)
146    if (s === null) return { text: `shire: ${(await read($, error)) ?? 'no status yet'}` }
147    const w = warnings(s)
148    return {
149      text: `shire: ${s.state}` + (w.length > 0 ? ` (${w.join(', ')})` : '') + ' — details in the pane.',
150    }
151  })
152
153  // An edit may trigger a watch-daemon rebuild; look again shortly after.
154  on('tool.call', async ($, e, next) => {
155    const ran = await next(e)
156    if (EDIT_TOOLS.has(String(e.tool))) {
157      soon?.cancel()
158      soon = $.clock.after(AFTER_EDIT_MS, () => void poll($))
159    }
160    return ran
161  })
162
163  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
164    const { Box, Text, Button } = $.ui.resolve(e)
165    const s = await read($, status)
166    const err = await read($, error)
167    const b = await read($, busy)
168    const running = b?.owner === OWNER ? b.label : null
169    const now = await $.clock.now()
170
171    if (s === null) {
172      return (
173        <Box flexDirection="column">
174          <Text color="red">{err ?? 'Reading shire status…'}</Text>
175          <Text dimColor>Install or update shire, then run /shire again.</Text>
176        </Box>
177      )
178    }
179
180    const label = Math.max(...detailRows(s, now).map(([k]) => k.length)) + 2
181    return (
182      <Box flexDirection="column">
183        {detailRows(s, now).map(([k, v]) => (
184          <Box key={k}>
185            <Text dimColor>{k.padEnd(label)}</Text>
186            <Text>{v}</Text>
187          </Box>
188        ))}
189        {s.last_build_failures.length > 0 && (
190          <Box flexDirection="column" marginTop={1}>
191            <Text color="yellow">Last build failures</Text>
192            {s.last_build_failures.slice(0, 10).map((f, i) => (
193              <Text key={`f${i}`} wrap="truncate-end">
194                [{f.kind}] {f.target}: {f.error}
195              </Text>
196            ))}
197          </Box>
198        )}
199        <Box marginTop={1}>
200          {running !== null ? (
201            <Text dimColor>{running}…</Text>
202          ) : (
203            <Box>
204              <Button
205                key="rebuild"
206                label="Rebuild"
207                hotkey="r"
208                variant="primary"
209                onPress={async () => $.ui.toast(await rebuild($, false))}
210              />
211              <Text> </Text>
212              <Button
213                key="force"
214                label="Force rebuild"
215                hotkey="f"
216                onPress={async () => $.ui.toast(await rebuild($, true))}
217              />
218              <Text> </Text>
219              <Button key="close" label="Close" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
220            </Box>
221          )}
222        </Box>
223      </Box>
224    )
225  })
226}
227
hooks/format.ts 184 lines
1import type { ShireStatus } from '../types'
2
3export type Health = 'ok' | 'busy' | 'warn' | 'bad'
4
5export function health(s: ShireStatus): Health {
6  switch (s.state) {
7    case 'missing':
8    case 'refused':
9    case 'unreadable':
10      return 'bad'
11    case 'building':
12      return 'busy'
13    case 'interrupted':
14      return 'warn'
15    case 'ok':
16      return warnings(s).length > 0 ? 'warn' : 'ok'
17  }
18}
19
20/** Short reasons the index is degraded, worst first. */
21export function warnings(s: ShireStatus): string[] {
22  const out: string[] = []
23  if (s.state === 'interrupted') out.push('last build interrupted')
24  const n = s.last_build_failures.length
25  if (n > 0) out.push(`${n} build failure${n === 1 ? '' : 's'}`)
26  if (s.file_walk === 'capped') out.push('file walk capped')
27  else if (s.file_walk === 'partial') out.push('unreadable paths')
28  if (s.pending_source_recheck.length > 0 && !s.build_running) {
29    out.push(`${s.pending_source_recheck.length} pending re-check`)
30  }
31  if (s.head_matches === false) out.push('HEAD moved')
32  return out
33}
34
35/** 38211 -> "38.2k"; null -> "?". */
36export function compact(n: number | null): string {
37  if (n === null) return '?'
38  if (n < 1000) return String(n)
39  // From 999_500 up, "k" would round to "1000k".
40  if (n < 999_500) return `${trim(n / 1000)}k`
41  return `${trim(n / 1_000_000)}M`
42}
43
44function trim(x: number): string {
45  return x >= 100 ? String(Math.round(x)) : x.toFixed(1).replace(/\.0$/, '')
46}
47
48/** Age of an RFC 3339 timestamp at `nowMs`: "12s", "4m", "3h", "2d". */
49export function age(iso: string | null, nowMs: number): string | null {
50  if (iso === null) return null
51  const t = Date.parse(iso)
52  if (Number.isNaN(t)) return null
53  const s = Math.max(0, Math.round((nowMs - t) / 1000))
54  if (s < 60) return `${s}s`
55  if (s < 3600) return `${Math.floor(s / 60)}m`
56  if (s < 86400) return `${Math.floor(s / 3600)}h`
57  return `${Math.floor(s / 86400)}d`
58}
59
60const GLYPH: Record<Health, string> = { ok: '●', busy: '⟳', warn: '▲', bad: '✗' }
61
62/** The one line pinned under the prompt. */
63export function statusLine(s: ShireStatus, nowMs: number): string {
64  const h = health(s)
65  const head = `shire ${GLYPH[h]}`
66  if (s.state === 'missing') return `${head} no index (run shire build)`
67  if (h === 'bad') return `${head} index ${s.state}`
68  const parts = [`${compact(s.counts.packages)} pkgs`, `${compact(s.counts.symbols)} syms`]
69  if (s.state === 'building') parts.unshift('building…')
70  else {
71    const a = age(s.indexed_at, nowMs)
72    if (a !== null) parts.push(`${a} ago`)
73  }
74  const [worst] = warnings(s)
75  if (worst !== undefined) parts.push(worst)
76  return `${head} ${parts.join(' · ')}`
77}
78
79/**
80 * Whether a snapshot can be compared against. One taken while a build runs
81 * may hold the last build's metadata or none at all (the build locks readers
82 * out), so toasts compare the last settled snapshot with the next settled
83 * one and skip the polls in between: otherwise a build a poll lands in loses
84 * its "index built" or "reindexed" toast.
85 */
86export function settled(s: ShireStatus): boolean {
87  return s.state !== 'building'
88}
89
90/**
91 * Toasts for what changed between two settled snapshots. Deliberately quiet:
92 * an on-demand rebuild (`serve --root`) rewrites `indexed_at` every few
93 * seconds while nothing changes, so a rebuild is only worth a toast when the
94 * counts moved, and build failures only when one is new.
95 */
96export function transitions(prev: ShireStatus | null, next: ShireStatus): string[] {
97  if (prev === null) return []
98  const out: string[] = []
99  if (prev.state === 'missing' && next.state === 'ok') {
100    out.push(`index built: ${compact(next.counts.symbols)} symbols`)
101  } else if (next.indexed_at !== prev.indexed_at && next.indexed_at !== null) {
102    const ds = delta(prev.counts.symbols, next.counts.symbols)
103    const df = delta(prev.counts.files, next.counts.files)
104    if (ds !== 0 || df !== 0) {
105      const took = next.build_duration_ms === null ? '' : ` in ${next.build_duration_ms}ms`
106      out.push(`reindexed${took}: ${signed(ds)} symbols, ${signed(df)} files`)
107    }
108  }
109  // A manifest that fails to parse fails again on every build, so compare
110  // what failed, not when the build ran.
111  const seen = new Set(prev.last_build_failures.map(failureKey))
112  const fresh = next.last_build_failures.filter(f => !seen.has(failureKey(f)))
113  const nf = next.last_build_failures.length
114  if (fresh[0] !== undefined) {
115    out.push(`build had ${nf} failure${nf === 1 ? '' : 's'}: ${fresh[0].target}`)
116  }
117  if (next.state === 'interrupted' && prev.state !== 'interrupted') {
118    out.push('a build was interrupted; the next build repairs the index')
119  }
120  if (next.file_walk === 'capped' && prev.file_walk !== 'capped') {
121    out.push('file walk hit the 500k cap: exclude directories in shire.toml')
122  }
123  if (prev.watch.running && !next.watch.running) out.push('watch daemon stopped')
124  return out
125}
126
127function failureKey(f: ShireStatus['last_build_failures'][number]): string {
128  return `${f.kind}\u0000${f.target}`
129}
130
131function delta(a: number | null, b: number | null): number {
132  return a === null || b === null ? 0 : b - a
133}
134
135function signed(n: number): string {
136  return n > 0 ? `+${n}` : String(n)
137}
138
139/** The rows the /shire pane shows, label then value. */
140export function detailRows(s: ShireStatus, nowMs: number): [string, string][] {
141  const rows: [string, string][] = [
142    ['state', s.state + (s.error ? ` (${s.error})` : '')],
143    ['root', s.root],
144    ['index', (s.db_path ?? '?') + (s.db_size_bytes === null ? '' : ` · ${bytes(s.db_size_bytes)}`)],
145  ]
146  if (s.indexed_at !== null) {
147    const took = s.build_duration_ms === null ? '' : ` · took ${s.build_duration_ms}ms`
148    rows.push(['indexed', `${age(s.indexed_at, nowMs) ?? '?'} ago${took}`])
149  }
150  if (s.git_commit !== null) {
151    const moved = s.head_matches === false ? ` (HEAD now ${short(s.head_commit)})` : ''
152    rows.push(['commit', short(s.git_commit) + moved])
153  }
154  const c = s.counts
155  rows.push([
156    'counts',
157    `${compact(c.packages)} packages · ${compact(c.symbols)} symbols · ` +
158      `${compact(c.references)} refs · ${compact(c.files)} files · ${compact(c.docs)} docs`,
159  ])
160  if (s.references_enabled !== null) {
161    rows.push(['references', s.references_enabled ? 'on' : 'off'])
162  }
163  if (s.file_walk !== null) rows.push(['file walk', s.file_walk])
164  if (s.pending_source_recheck.length > 0) {
165    rows.push(['pending', s.pending_source_recheck.join(', ')])
166  }
167  const w = s.watch
168  rows.push([
169    'watch',
170    w.running ? `running${w.pid === null ? '' : ` (pid ${w.pid})`}${w.listening ? '' : ', not listening'}` : 'not running',
171  ])
172  return rows
173}
174
175function short(sha: string | null): string {
176  return sha === null ? '?' : sha.slice(0, 8)
177}
178
179function bytes(n: number): string {
180  if (n < 1024) return `${n} B`
181  if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KiB`
182  return `${(n / 1024 / 1024).toFixed(1)} MiB`
183}
184
types/index.d.ts 48 lines
1/** One `shire status --json` object. */
2export type ShireStatus = {
3  shire_version: string
4  root: string
5  /** null when shire.toml could not be read (state is then `unreadable`). */
6  db_path: string | null
7  state: 'missing' | 'refused' | 'unreadable' | 'building' | 'interrupted' | 'ok'
8  error: string | null
9  db_size_bytes: number | null
10  build_running: boolean
11  indexed_at: string | null
12  build_duration_ms: number | null
13  git_commit: string | null
14  head_commit: string | null
15  head_matches: boolean | null
16  counts: {
17    packages: number | null
18    symbols: number | null
19    references: number | null
20    files: number | null
21    docs: number | null
22  }
23  references_enabled: boolean | null
24  file_walk: 'complete' | 'partial' | 'capped' | null
25  pending_source_recheck: string[]
26  last_build_failures: { kind: string; target: string; error: string }[]
27  watch: { running: boolean; listening: boolean; pid: number | null }
28}
29
30declare module 'claude-code' {
31  interface PluginState {
32    'shire-status': {
33      /** The last status read; null before the first poll or when it failed. */
34      status: ShireStatus | null
35      /** Why the last poll failed (shire not installed, an old shire, ...). */
36      error: string | null
37      /** The last snapshot taken while no build ran; toasts compare against it. */
38      baseline: ShireStatus | null
39      /**
40       * What a pane button is running right now, if anything. `owner` names
41       * the module load that started it: state survives a hot reload but the
42       * rebuild's `finally` may not, so another load's entry is stale.
43       */
44      busy: { label: string; owner: string } | null
45    }
46  }
47}
48