The BeSpunky house standard for any repo, as a stack of detected layers on an Nx floor: the agent DX (composed devcontainer, Claude settings, window identity…

BeSpunky's one place for Claude Code skills, subagents, and commands. Develop them here once, install them into any project, and upgrade everywhere with a single update.
This is a Claude Code plugin marketplace (a git repo). It currently ships these plugins:
| Plugin | Provides | Purpose | ||
|---|---|---|---|---|
bespunky | skills index, tips · hook | The toolkit's front door. /bespunky:index is a self-maintaining catalog: it lists every installed toolkit skill — grouped by plugin, each with its /plugin:skill invocation and a one-line "use when" — by reading the live available-skills set in the session, so it can never go stale. Every plugin shares the bespunky- prefix, so typing /bespunky in the slash menu filters the autocomplete to the whole toolkit at once. Toolkit tips: a SessionStart hook slips a small rotating handful of toolkit tips into Claude Code's own "working…" spinner, mixed in with its built-in ones. They sit outside the conversation (the model never sees them), there's nothing to dismiss, and they only cover plugins installed for the project. Each plugin owns its tips in a tips.txt at its root. A plugin can't set spinner tips itself, so the hook keeps them in the user's ~/.claude/settings.json (spinnerTipsOverride.tips), touching only the entries it wrote. /bespunky:tips off takes them out and keeps them out, on brings them back, and list shows them all. There is no uninstall event, so run off before uninstalling if you want them gone. | ||
browser-automation | skills playwright, shared-browser | Two ways to drive a real browser. playwright — headless Chromium (pre-installed) for solo automated work: verify a change end-to-end, reproduce a bug, capture before/after screenshots, scrape the rendered DOM, watch console + network, codegen a test. shared-browser — one live browser you and the human drive together: they watch and click it in a normal host tab (over noVNC, on a per-container allocated port so parallel devcontainers never collide) while Claude attaches over loopback CDP to the same instance — for co-debugging, in-place CSS/DOM verification with measured proof (getComputedStyle, getBoundingClientRect), a real login (OAuth/captcha) completed by the human while Claude observes, and pairing on a flow. A decision tree keeps them distinct: no human watching → playwright. A status-line mod shows the noVNC URL under the prompt while the shared browser is up (⚠ when the host-side forward isn't confirmed), read from shared-browser status --json. | ||
house | commands upgrade, add-layer, skills new, firebase-app-hosting · hook, the house band mod | The house standard: create a project, or bring any repo onto it, as a stack of layers. /bespunky-house:new creates a project; by default the agent preset: the house DX (composed devcontainer with the Claude CLI & VS Code extension, Claude settings, window identity, HOUSE.md + a tailored CLAUDE.md) on the Nx floor — no package.json, no framework. Everything else is something to wear: --preset=node for a Node workspace, --preset=angular for the house web app (a clean --minimal Angular app, the dev loop, a design system from moment zero), --firebase, or any layer via --add-layer=<csv> — in the workspace shape you pick (`--layout=apps-libs | packages, --linking=paths | workspaces). /bespunky-house:upgrade brings a project up to the current house standard (newer toolkit, the migrations in between, regenerated house artifacts — it adds no layer); /bespunky-house:add-layer <layers> is an upgrade that also brings layers into being. A SessionStart hook notices when an upgrade is due, and the house band (a hooks module, so a mod) shows the same notice as one row above the prompt — the reason, a button that asks Claude to run /bespunky-house:upgrade (or to update the toolkit, or fix a failed post-create), and Dismiss. It runs the hook's own check (--json), never the upgrade; with mods disabled the hook alone remains. firebase-app-hosting is the operational truth for deploying a house app to Firebase App Hosting: the backend's Root Directory (required for Nx), GitHub rollouts vs firebase deploy from source and how to tell which is live, where apphosting*.yaml` is read from (a walk up from the Root Directory — a nearer file shadows the root one), staging's Environment name, and moving the GitHub link between accounts or orgs (Developer Connect). |
product-ux | skills keep-users-oriented, astonishing-to-use, redesign-means-rethink, distill-the-brief, envision-the-experience, stage-the-vision, mock-to-choose, realize-the-vision, model-intent-not-data | Experience design. keep-users-oriented — whenever you make someone wait or move them through a process, answer the three questions — expected result? where am I? next step? — and pick the right feedback (deterministic → steps/progress; nondeterministic → estimate + notify). A universal service-design principle, expressed primarily through software UI (loading/progress, async, multi-step flows, long-running jobs, notifications, optimistic UI). astonishing-to-use — the UX co-equal force the trio was missing: a design must be astonishing to use, not only to look at (effortless, understandable, no hoops, respectful of attention, a joy in the hand, built for how people really hold devices — thumbs, one-handed, distracted, bad signal). The use and the look are two forces that ping-pong until both are great — never a one-way check — with the mission setting who leads (utility → UX leads; brand/art → concept leads, then pressure-tested). Hard bar: never below great UX; keep-users-oriented is one facet. A router (friction & flow; clarity & cognitive load; embodied & contextual use; reconciling art & use; joy of use). redesign-means-rethink — the entry gate: when asked to redesign a UI, treat it as a complete creative reconception from scratch, never a reskin of the existing code; the existing implementation has zero design authority — read it only after the new design exists, to plan teardown/migration — and a redesign runs the trio below from scratch. (A targeted tweak is not a redesign.) The experiential trio — feeling → Staging → build, one altitude ladder (sensory feeling → web-native art → engineering): envision-the-experience (the feeling) — imagine the world an interface lives in before any layout, grounded in the real situation; interrogate every element (a "menu" might become a sunflower whose petals you pick), name no implementation, restraint over spectacle, produce a Vision. stage-the-vision (the web-native art — the visual architect) — the answer to "grounding stops the bad, but what makes it ART?" It invents the bold, web-native moments that turn the feeling into something you'd screenshot, staying at the art level: it speaks the web's language (parallax, cinematic scroll, a character that turns to camera, type-as-image) but says what artful thing happens and how it feels, never how to build it. Because a model isn't a native artist, it reaches art by inventing several bold concepts and choosing the striking-yet-true, stealing from specific great work and adapting its moves, composing with craft (focal point, scale, negative space, the cinematic moment), decomposing each moment to physical truth (light, shadow, material, depth, texture as they really behave — refusing the lone primitive that only symbolizes a phenomenon: warm light is never just a gradient), and sourcing genuine art — grounded so it's not generic, restrained so it's not garish (bold ≠ loud), judged by an outside eye for beauty (never self-certified). Produces the Staging (a bold concept + concrete described moments + a visual system). mock-to-choose (the verdict — the decision instrument between the art and the build) — a person cannot approve a look by reading a description of it, so this puts the concepts in front of their eyes: it builds the cheapest throwaway thing that makes each concept judgeable, mocks every option on the table (one mock asks "is this OK?" and gets a weak yes; three ask "which one?" and get a real verdict), and shows them side by side in a Compare wall — with a phone/desktop toggle and a true-size Focus view for judging and commenting up close. A mock is shell and presentation only — layout, composition, palette, type, atmosphere, dressed in plausible dummy records — with zero functionality (dead controls, no state, no routing, no data, no build step, no deps, no app integration). Heavy concepts (a scroll cinematic, a 3D scene, a living background, physically-decomposed light) are suggested, never rendered — one representative frame, a still, a flat approximation — vivid enough that the atmosphere is unmistakable, because the point is a fast verdict, not a faithful build. Every variant shares the same dummy content, so the only difference the eye sees is the design. Every review runs on a shared harness — a mini-app shipped with the skill (assets/mock-harness/: a Compare wall + a true-size Focus view + a random-port serve.sh that hot-reloads on edit) copied verbatim into the mocks folder, so Claude authors only mocks.json (the question, what's faked, the variants) and one file per concept: every mock experience is identical and only the mocks change — the user learns the review once. Because bare low fidelity reads as low quality, each mock carries an intent layer — floating notes and hover popovers that narrate the empty house the way an architect walks a site: "the sofa goes here, sideways, facing the window", "this dot is the light — it'll float and breathe; here it's a static glow, so judge where it sits and how much of the frame it owns" — so the user judges the intent, never the shortcut. And the mocks are commentable in place: the user presses c and clicks the exact spot to pin a comment right where they point, written to comments.json on disk with full DOM context (tag, text, rect, styles, ancestor path) — so Claude reads them from a file (exact words, exact element, exact point, exact variant, exact viewport). Comments run draft → submitted → handled: the user sends them to Claude (a Submit review batch, or an auto-send toggle firing each on save), Claude acts on the submitted inbox and checks each off — a handled pin vanishes from the live mock (which only ever shows the current round's open pins) and shows resolved (a green ✓ + reply) in the Focus side-list, so the user watches their notes get checked off while the mock stays uncluttered — which means an asynchronous review works as well as a co-driven one in the shared browser (noVNC, over its own allocated port). The mock iterates in internal rounds (v1 → v2 → …): every comment is version-bound to the round it was made against, Claude commits a round (snapshotting the mock's HTML) right before re-mocking, and past rounds stay viewable read-only and comparable side by side on a History timeline — a built-in, self-ignoring record of what changed and why. The side-list also manages each comment in place (inline edit, per-row send, remove with Undo, row↔pin linking). The verdict is a real gate, not a poll: "none of these" and "a hybrid of A and B" are first-class outcomes (route upstream to re-conceive), and a mock yes is provisional — it picks a direction, it does not certify the finished art (realize-the-vision still owes the outside-eye pass on the real result). Comments are copied verbatim into DECISION.md. Mocks live in a standard dated, feature-scoped package (docs/features/<YYYY-MM-DD>-<slug>/mocks/ — inside the effort's feature package, the slug shared with the git branch) that is self-ignoring and completely throwable (nothing outside may depend on it; one rm erases every trace; the user may choose to keep any of it) — while the decision is recorded durably (DECISION.md), so the conclusion outlives the evidence. Feedback travels upstream (re-conceive in stage-the-vision) and the mock is re-made cheaply — never polished into a prototype — and its code never becomes the build. realize-the-vision (the build) — the craftsman that turns a Vision and a confirmed Staging into a real interface by researching the truest means before writing any code — engineers each staged moment, surveys the field (GSAP, Motion, three.js/R3F/angular-three, Web Animations, scroll-driven CSS, View Transitions, Lottie/Rive, Canvas/SVG/WebGL, Web Audio, haptics) and its caveats, build-vs-source (figurative art is generated/licensed, never hand-coded into path-soup), requires a confirmed Staging (else invokes stage-the-vision first), never self-certifies aesthetics, fans out across subagents against the shared contract with a coherence pass, and verifies against the feeling and the Staging in the running app. Both stage-the-vision and realize-the-vision are routers over reference libraries. | ||
design-system | skills design-system-first, design-tokens-and-theming | Styling as a system. design-system-first — the discipline: before you build any feature UI, go to the design system; never hardcode a style value (every colour, space, radius, type step, elevation, border, duration and easing is a token — a CSS custom property at runtime, consumed through the DS's zero-output author-time API — the house uses SASS; components read semantic tokens only, never a raw primitive); feature components compose DS components and tokens, they never invent appearance; the second occurrence of a UI pattern is a promotion, not a copy-paste — lift it into the DS as a reusable component (nx g @bespunky/nx-tools:ds-component <name>, one secondary entry point each), migrate both sites, delete the copies; and when the DS lacks the concept, model it (add the token, the semantic alias, the scale step, the component) — never a local override, !important, a style reach-in across a component boundary (::ng-deep, :global, :deep()), a duplicated token, or a one-off variant boolean. The DS is the single source of visual truth, so a re-theme or rebrand is a change of tokens, not a thousand component files (the styling twin of redesign-means-rethink: re-token, don't re-hardcode; the styling flavour of architecture-first: never a patch — and worse there, because CSS has no compiler to catch the drift). Ships an always-on policy the scaffold bakes into every project's imported HOUSE.rules.md. design-tokens-and-theming — the techniques, a router: two layers, one truth — CSS custom properties are the runtime layer (cascading, themeable; a mode is a re-binding of tokens, live, never a swapped stylesheet) and the SASS API is the author-time layer (zero-output functions/mixins/placeholders, summoned with @use, never a global side-effect). Clusters: token taxonomy & naming (primitive → semantic → component; a value not on a scale is a design bug, not a missing token); CSS custom properties as the runtime layer; the SASS API layer & how it's summoned (the public @forward … show barrel over _-prefixed private folders named for what they are; how it resolves in-repo vs published); theming & modes (light/dark/brand, where a mode lives, persisting it without a flash, contrast that holds in every mode); component styling & encapsulation (scoped vs shadow encapsulation, tokens in / parts out, the reach-in ban, variants as data not booleans — with Angular adapters for :host / ViewEncapsulation / ::ng-deep and ng-packagr entry points); the DS library's structure & entry points (one component = one entry point, generator-first). Encodes the visual system stage-the-vision produces — it doesn't invent the look, it makes the look live in one place. |
| workflow | skills branch-and-release, feature-package, delegate-and-parallelize, local-server-isolation, session-handoff, project-standing · hooks SessionStart, PreCompact · mods branch-status, /standing, merge-gate | Ways of working — the process, order, and methodology of how work moves from idea to production, independent of what is being built. branch-and-release — the house git methodology over the project's declared branch model (.bespunky/branches.json — one integration line, ordered stages, optional release and hotfix lines; presets from trunk through development → staging → main to gitflow, chosen after an investigation of how the repo actually works, never assumed): unrelated work isolated in per-feature worktrees off the integration line, small committed increments, rebase-and-re-verify at the single divergence point, every move onto a protected line human-gated. Until a model is declared, every existing long-lived branch is protected and the skill investigates and asks once per session. feature-package — a feature is a package, not a scatter of files: one effort, one slug (the same one that names the branch and worktree), one folder — docs/features/<YYYY-MM-DD>-<slug>/ — holding everything durable the effort produces that isn't code: BRIEF.md, VISION.md, STAGING.md, DECISION.md, the throwaway self-ignoring mocks/, and the effort's handoffs/ batons. Born with the worktree and filled as the work happens (a doc written at the end is a memory, and memories are where the reasons go missing); every artifact-producing skill writes into it instead of inventing a private home. Two rules: the conclusion is durable, the evidence is disposable (decisions and roads-not-taken are committed and permanent; mocks and scratch are self-ignoring, depended on by nothing, binned by default), and the user's own words are the most valuable line in the package — quote the sentence that settled it, never paraphrase. It answers "six months on, why was it done this way, and what did we already rule out?" delegate-and-parallelize — the session is an orchestrator, not a worker: decompose the goal into units, delegate every unit that isn't atomic to a subagent, and run the independent ones at once — recursively, an agent handed a still-decomposable unit splitting it again until a unit is atomic, trivial, strictly serial, or contended. One move settles two bills: context (everything read inline is permanent, and permanent cost is what forces compaction, which degrades every turn after it — a subagent reads forty files and hands back fifteen lines) and wall-clock (independent units cost the slowest, not the sum), so the default inverts to work inline only when delegating would cost more than it saves. Two halves decide whether it works in practice: the delegated-task contract (a subagent shares none of your context, so its prompt is self-contained and its return shape is specified — a distillation, never a transcript, or the context you delegated to avoid lands in your window anyway) and supervision (a parent never exits while a child it spawned is still running; it checks on long-running children, because silence reads the same whether an agent is working or wedged, and accounts for every one before it closes — no zombies left burning tokens toward a result nobody will read). And the third pillar, resumability — the tree must outlive the session that started it, because a crash, a dropped connection, a stop (deliberate or mistaken), a permissions error or a container rebuild evaporates the orchestrator's context and with it the plan, what's still outstanding, and every result already paid for. So nothing is dispatched before the plan is on disk, and each state change is written as it happens into a ledger in the effort's package (handoffs/<ts>-fanout.md): stable unit ids, per-unit status, whether each unit is safe to re-run, the returned distillations stored inline (a result that lives only in a context window dies with it), any Workflow runId verbatim — one unrecorded string is the difference between a near-free resume and a full re-run — and what was not covered. Every agent at every depth leaves traces, so a fresh session resumes the outstanding work instead of redoing the expensive work that already succeeded. Plus write-contention isolation, what is never delegated (the decision, the user's intent, the final synthesis, the human-gated promotions), and adversarial verification of what comes back. An agent budget — the total agents the tree may create (default 12, set per request or per project), estimated before anything is dispatched — and you're asked to confirm when the estimate needs more — then conserved and carved down the tree as each child's share — caps spend against your usage limits without capping depth: fewer, fatter units with share enough to recurse, batched leaves, the share worded as an allowance never a ban, and inline fallback when it runs out. Subagents are the everyday tier within that budget; Workflows need the user's explicit opt-in, since a skill that auto-fired cannot authorize its own spending. local-server-isolation — bind a random free port, never the default/forwarded one the user's own server owns. session-handoff — carry a live effort across a context boundary into a fresh session: capture writes a distilled relay baton (into the effort's package), resume re-grounds against reality; the user's corrections are captured first-class, and durable ones promoted to persistent memory. project-standing — the cold pick-up: orient in a project you've been away from, derived from git + the feature packages (never a hand-maintained status doc — that's the first thing to rot) — which efforts are live, stalled, or concluded, which baton to read first, live efforts in full and concluded ones collapsed to a one-line conclusion so orienting costs the same at effort #300 as at #3; scopes for on-demand history search and additive archive-sweep. Two hooks make continuity reliable rather than hoped-for: a SessionStart hook that stays silent unless in-flight work has gone dormant, then relays a fact (detect-don't-execute), and a PreCompact hook that writes a mechanical checkpoint into the live effort's package before context is lost — even headless — then asks the model to distill it. And a mod (hooks/branch-status.ts) pins where this checkout sits in the declared model to the status line — feat/x → development → main, a warning on a protected line, a violations count from verify, or an honest undeclared / unreadable — read from the engine (branches.mjs status --json), never re-derived; it displays and never acts. Another mod (hooks/standing.tsx): /standing opens a pane (never unasked) that leads with the answer (Nothing in flight, or the live / dormant packages across every worktree, each with its newest baton and a Resume button) and collapses finished work to one line (N concluded · latest: …, expandable to the five most recent); Resume only queues a prompt for Claude. All of it is drawn from the same derivation engine (project-standing/scripts/standing.mjs) the SessionStart notice reads, so the two can never disagree. And the merge gate (hooks/merge-gate.tsx): when Claude is done and proposes to land its branch, it calls the plugin's propose_move tool instead of asking "shall I merge?" in prose, and a band above the prompt offers Land on <integration>, Land & promote to <next stage> (only where the engine plans that promotion), Push branch and Not yet — every name and op
hooks/band.tsx 180 lines1// bespunky-house — the HOUSE BAND: a row above the prompt, shown only
2// while this project's house tooling has something to say (the toolkit moved
3// on, a layer was never applied, the container's post-create failed), with one
4// button that asks Claude to deal with it and one that hides the band.
5//
6// WHY IT ASKS THE SCRIPT. Every rule about when a project is behind — order the
7// stamp against the install, a project AHEAD of this machine is not staleness,
8// silence when the plugin root lives inside the project, the snoozes — lives in
9// check-house-version.sh, the SessionStart hook. The band runs that same script
10// with `--json` and draws what it records, so the two surfaces cannot disagree
11// and no rule is written twice.
12//
13// DETECT, DON'T EXECUTE. The band never runs an upgrade, an update or a chown. Its
14// button submits a prompt — the person's own request, made by pressing it — and
15// Claude then runs the consented path (the /bespunky-house:upgrade command's own
16// gates included).
17//
18// THE PLUGIN WORKS WITHOUT IT. The SessionStart hook still relays the notice to
19// the model; with mods disabled nothing here is missed but the view.
20
21import type { EngineInterface, Register } from 'claude-code'
22
23import type { HouseAction, HouseBand, HouseNotice } from '../types/index.d.ts'
24
25import { BrandFrame, brandLine } from './_brand.tsx'
26
27/** The one value the band draws from. */
28const BAND = { plugin: 'bespunky-house', key: 'band' } as const
29
30const CHECK_TIMEOUT_MS = 10_000
31/** A dismissed band cannot say how it comes back, so the dismissal does. */
32export const DISMISSED_TOAST = 'House notice hidden for this session; the next session checks again'
33const ACTIONS: readonly HouseAction[] = ['upgrade', 'update-toolkit', 'fix-mounts']
34
35/** Each action's button and the prompt it submits. */
36const BUTTONS: Record<HouseAction, { label: string; hotkey: string; prompt: (summary: string) => string }> = {
37 upgrade: {
38 label: 'Upgrade',
39 hotkey: 'u',
40 prompt: summary =>
41 `Run /bespunky-house:upgrade to bring this project up to the current house standard. The house band says: ${summary}`,
42 },
43 'update-toolkit': {
44 label: 'Update toolkit',
45 hotkey: 't',
46 prompt: summary =>
47 `Help me update the claude-toolkit plugins on this machine. The house band says: ${summary} Do not run an upgrade as part of this.`,
48 },
49 'fix-mounts': {
50 label: 'Fix',
51 hotkey: 'f',
52 prompt: summary =>
53 `This container's post-create looks like it failed. The house band says: ${summary} Show me the fix and ask before running anything.`,
54 },
55}
56
57/**
58 * The notices in the script's `--json` output; anything else (no output, a
59 * failed run, an unknown action) is no notice. Pure: the whole parsing policy.
60 */
61export function parseNotices(stdout: string): HouseNotice[] {
62 try {
63 const parsed: unknown = JSON.parse(stdout)
64 const notices = (parsed as { notices?: unknown }).notices
65
66 return Array.isArray(notices) ? notices.filter(isNotice) : []
67 } catch {
68 return []
69 }
70}
71
72function isNotice(value: unknown): value is HouseNotice {
73 const n = value as Partial<HouseNotice> | null
74
75 return (
76 typeof n?.kind === 'string' &&
77 typeof n.summary === 'string' &&
78 n.summary !== '' &&
79 ACTIONS.includes(n.action as HouseAction)
80 )
81}
82
83/** The prompt one notice's button submits. */
84export function promptFor(notice: HouseNotice): string {
85 return BUTTONS[notice.action].prompt(notice.summary)
86}
87
88export const register: Register = on => {
89 on('session.start', async ($, e, next) => {
90 if (e.isInteractive) {
91 const notices = await check($, e.cwd)
92 await $.state.set(BAND, { projectDir: e.cwd, notices, dismissed: false })
93 }
94
95 return next(e)
96 })
97
98 // While the band is up, re-ask after each main-thread turn: an upgrade (or a fix)
99 // that ran in it takes the band down. Nothing shown, nothing re-run.
100 on('turn.complete', async ($, e, next) => {
101 const result = await next(e)
102 const { value: band, version } = await $.state.get(BAND)
103
104 if (e.agentId === undefined && band !== undefined && !band.dismissed && band.notices.length > 0) {
105 const notices = await check($, band.projectDir)
106 if (JSON.stringify(notices) !== JSON.stringify(band.notices)) {
107 await $.state.set(BAND, { ...band, notices }, { ifVersion: version })
108 }
109 }
110
111 return result
112 })
113
114 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
115 const { value: band } = await $.state.get(BAND)
116
117 if (e.props.hasSurvey || band === undefined || band.dismissed || band.notices.length === 0) {
118 return next(e)
119 }
120
121 const ui = $.ui.resolve(e)
122 const { Box, Button, Text } = ui
123
124 return (
125 <BrandFrame ui={ui} site={e}>
126 {band.notices.map((notice, index) => {
127 const button = BUTTONS[notice.action]
128
129 return (
130 <Box key={`house-row-${notice.kind}`} flexDirection="row" gap={1}>
131 <Box flexGrow={1} flexShrink={1}>
132 <Text wrap="truncate-end">{notice.summary}</Text>
133 </Box>
134 <Button
135 key={`house-${notice.kind}`}
136 label={button.label}
137 hotkey={button.hotkey}
138 variant="primary"
139 onPress={() => void $.prompt.submit({ text: promptFor(notice), asUser: true })}
140 />
141 {index === 0 && (
142 <Button key="house-dismiss" label="Dismiss" hotkey="d" role="dismiss" onPress={() => void dismiss($)} />
143 )}
144 </Box>
145 )
146 })}
147 </BrandFrame>
148 )
149 })
150}
151
152/** Runs the SessionStart hook's own detection in `--json` mode; silent on any failure. */
153async function check($: EngineInterface, projectDir: string): Promise<HouseNotice[]> {
154 const root = $.plugin.root
155 try {
156 const ran = await $.process.run(['bash', `${root}/hooks/check-house-version.sh`, '--json'], {
157 cwd: projectDir,
158 env: { CLAUDE_PLUGIN_ROOT: root, CLAUDE_PROJECT_DIR: projectDir },
159 timeoutMs: CHECK_TIMEOUT_MS,
160 })
161
162 return ran.exitCode === 0 ? parseNotices(ran.stdout) : []
163 } catch (error) {
164 $.ui.log(`bespunky-house: house check failed: ${error instanceof Error ? error.message : String(error)}`, {
165 to: 'debug',
166 })
167
168 return []
169 }
170}
171
172/** Hides the band for the rest of the session. */
173async function dismiss($: EngineInterface) {
174 const { value: band, version } = await $.state.get(BAND)
175 if (band !== undefined) {
176 await $.state.set(BAND, { ...band, dismissed: true } satisfies HouseBand, { ifVersion: version })
177 $.ui.toast(brandLine(DISMISSED_TOAST))
178 }
179}
180types/index.d.ts 45 lines1// bespunky-house — the house band's state contract.
2//
3// The band (hooks/band.tsx) is a VIEW over `check-house-version.sh --json`: the
4// SessionStart hook's own detection, recorded as notices instead of relayed as
5// text. Its poller writes this ONE session value and the drawing reads only it.
6// Declared here because `$.state` values are typed by the owner's `PluginState`
7// entry, and anyone may read them.
8
9/**
10 * What a notice asks of a human — the script decides, the band only maps it to
11 * a button:
12 * - `upgrade` an upgrade is the fix (`/bespunky-house:upgrade`).
13 * - `update-toolkit` this machine's toolkit is behind; an upgrade here would be wrong or refuse.
14 * - `fix-mounts` the container's post-create failed on an unwritable mount point.
15 */
16export type HouseAction = 'upgrade' | 'update-toolkit' | 'fix-mounts'
17
18/** One notice, as `check-house-version.sh --json` prints it. */
19export type HouseNotice = {
20 /** Which check fired (`toolkit-moved`, `layer-drift`, `dependency-ahead`, `machine-behind`, `post-create-failed`). */
21 kind: string
22 action: HouseAction
23 /** One line, for a person. */
24 summary: string
25}
26
27/**
28 * The band's whole state for the session.
29 *
30 * - `projectDir` the directory the check ran against (the session's start cwd).
31 * - `notices` what the check said last; empty draws nothing.
32 * - `dismissed` the person closed the band; it stays closed for the session.
33 */
34export type HouseBand = {
35 projectDir: string
36 notices: readonly HouseNotice[]
37 dismissed: boolean
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 'bespunky-house': { band: HouseBand }
43 }
44}
45hooks/_brand.tsx 148 lines1// GENERATED from tools/mod-brand/brand.tsx by `node tools/mod-brand/project.mjs --write` — DO NOT EDIT.
2// Change the brand at its source; CI fails when this copy drifts from it.
3
4// ✦ bespunky — THE TOOLKIT'S MOD BRAND: one mark, one accent, one frame, so every toolkit mod (a pane, a band
5// above the prompt, a status entry, a toast) reads as one family — and never as Claude's own UI or another
6// plugin's.
7//
8// THE SINGLE SOURCE. Plugins cannot import each other's files, so this file is PROJECTED, verbatim under a
9// generated header, into `hooks/_brand.tsx` of every plugin whose hooks.json names a module
10// (`node tools/mod-brand/project.mjs --write`); CI fails on any drift. Change the brand HERE, never in a
11// projection, and never re-type the glyph or the colour in a mod.
12//
13// THE RULES IT ENCODES.
14// - Plain-text surfaces (status line, toast) carry the GLYPH alone: Claude Code titles both with the plugin's
15// name (`bespunky-workflow: …`), which already says bespunky — spelling it again reads
16// `bespunky-workflow: ✦ bespunky · …`. `brandLine(text)`.
17// - A mod's slash command answers in the transcript under the PLUGIN's name (`bespunky-workflow: …`), which
18// reads as any plugin's. Its output row is redrawn as the toolkit's: `✦ bespunky · <command>` then the
19// text. `<BrandCommandRow>`, from a `ui.render` hook on `{ component: 'CommandOutput', props: { command } }`.
20// - Anything drawn BELOW the transcript (every band; a pane seated inline) starts one blank row down, so it
21// never reads as the tail of Claude's reply.
22// - The accent is a raw colour, not a theme key: theme keys are Claude's palette, and the point is to look
23// like something that is not Claude. A mid-tone violet keeps its contrast on light and dark themes; with no
24// colour at all the glyph and the bold wordmark still carry the mark.
25//
26// Pure: no `$`, no state, no I/O — the mods pass in their surface's element table and render argument.
27
28import type { BoxProps, ElementConstructor, RenderChildren, RenderSurface, TextProps } from 'claude-code'
29
30export const BRAND = {
31 glyph: '✦',
32 name: 'bespunky',
33 /** Violet-500: distinct from Claude's clay, readable on light and dark. */
34 accent: '#8b5cf6',
35} as const
36
37/** The wordmark: `✦ bespunky`. */
38export const WORDMARK = `${BRAND.glyph} ${BRAND.name}`
39
40/** A status entry or toast, marked as the toolkit's: `✦ <text>` (the engine titles both with the plugin's name). */
41export function brandLine(text: string) {
42 return `${BRAND.glyph} ${text}`
43}
44
45/** A drawn site's title: `✦ bespunky · <mod>`, or the wordmark alone. */
46export function brandTitle(mod?: string) {
47 return mod === undefined ? WORDMARK : `${WORDMARK} · ${mod}`
48}
49
50/** Cells a band's brand gutter takes from its row: the wordmark and the gap after it. */
51export const BAND_GUTTER_CELLS = WORDMARK.length + 1
52
53/** The two elements every surface's table carries, which is all the frame draws with. */
54export type BrandUi = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
55
56/** The parts of a `ui.render` argument the frame reads: where it draws, and how wide. */
57export type BrandSite =
58 | { surface: RenderSurface; component: 'AbovePrompt'; props: { bodyColumns: number } }
59 | { surface: RenderSurface; component: 'Pane'; props: { bodyColumns: number; placement: 'dock' | 'inline' } }
60
61export type BrandFrameProps = {
62 /** `$.ui.resolve(e)`, the surface's own elements. */
63 ui: BrandUi
64 /** The render argument `e` itself. */
65 site: BrandSite
66 /** The mod's short name for a pane's title (`standing`); a band shows the wordmark alone. */
67 mod?: string
68 children?: RenderChildren
69}
70
71/**
72 * The toolkit's frame for a drawn site. A band: one blank row down from the transcript, the wordmark in a
73 * gutter, the mod's rows beside it. A pane: the branded title over a dim rule (the rule on the terminal only —
74 * the other surfaces frame their panes natively, and a rule of box-drawing glyphs is a terminal idiom), then
75 * the body; one blank row down when seated inline under the transcript.
76 */
77export function BrandFrame({ ui, site, mod, children }: BrandFrameProps) {
78 const { Box, Text } = ui
79 const columns = site.props.bodyColumns
80
81 if (site.component === 'AbovePrompt') {
82 return (
83 <Box flexDirection="row" gap={1} marginTop={1}>
84 <Text color={BRAND.accent} bold>
85 {WORDMARK}
86 </Text>
87 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
88 {children}
89 </Box>
90 </Box>
91 )
92 }
93
94 const title = brandTitle(mod)
95 const rule = site.surface === 'terminal' ? Math.max(0, columns - title.length - 1) : 0
96
97 return (
98 <Box flexDirection="column" gap={1} marginTop={site.props.placement === 'inline' ? 1 : 0}>
99 <Box key="brand-title" flexDirection="row" gap={1}>
100 <Text color={BRAND.accent} bold>
101 {title}
102 </Text>
103 {rule > 0 && <Text dimColor>{'─'.repeat(rule)}</Text>}
104 </Box>
105 {children}
106 </Box>
107 )
108}
109
110/**
111 * A thin dim rule between items of a list, sized to the site (`columns` cells). On the terminal a line of
112 * `─`; elsewhere a one-row gap does the same job without a glyph the surface's font may not tile.
113 */
114export function BrandDivider({ ui, site, columns }: { ui: BrandUi; site: BrandSite; columns?: number }) {
115 const { Box, Text } = ui
116 const width = Math.max(0, columns ?? site.props.bodyColumns)
117
118 return site.surface === 'terminal' ? (
119 <Text dimColor wrap="truncate-end">
120 {'─'.repeat(width)}
121 </Text>
122 ) : (
123 <Box height={1} />
124 )
125}
126
127/**
128 * A toolkit command's output row in the transcript: `✦ bespunky · <command>` in the accent, then the text the
129 * command answered. `text` is the row's own (`e.props.text`); `plugin` is the answering plugin's manifest name
130 * (`$.plugin.name`), whose `name: ` lead — the engine's attribution of a hook-answered row — the brand replaces.
131 */
132export function BrandCommandRow({ ui, command, text, plugin }: { ui: BrandUi; command: string; text: string; plugin: string }) {
133 const { Box, Text } = ui
134 const lead = `${plugin}: `
135 const body = text.startsWith(lead) ? text.slice(lead.length) : text
136
137 return (
138 <Box flexDirection="row" gap={1}>
139 <Text color={BRAND.accent} bold>
140 {brandTitle(command)}
141 </Text>
142 <Box flexGrow={1} flexShrink={1}>
143 <Text dimColor>{body}</Text>
144 </Box>
145 </Box>
146 )
147}
148