CLAUDE.md · git:20261017.cb1b4fb · 2026-10-17 · sha256 5a0b24f2d941911e
CLAUDE.md git:20261017.cb1b4fbA
Immutable. This exact content never changes and is served at /api/v1/blob/5a0b24f2d941911e.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this repo is
A Claude Code plugin marketplace, identified by `.claude-plugin/marketplace.json`. Each plugin lives under `plugins/<name>/` with its own `.claude-plugin/plugin.json`. The marketplace can be installed locally with `/plugin marketplace add <abs-path-to-this-repo>` so edits to plugin sources take effect after `/reload-plugins` without re-installing.
The repo is owned by GitHub user `zhebil`; pushes need the `git@github.com-personal:zhebil/claude-code-plugins.git` SSH alias (see the user's global CLAUDE.md).
## Plugins
### unfurl-context
Pulls compact markdown context for any GitHub/GitLab/Jira/Sentry references in the user's prompt, using local CLIs (`gh`, `glab`, `acli`, `sentry`) only. No AI calls.
Three hook events are registered in `hooks/hooks.json`:
- `UserPromptSubmit` -> `hooks/unfurl-context.mjs` (the main enrichment hook)
- `SessionStart` -> `hooks/discover.mjs` (validates and manifests custom providers) and `hooks/compact-cleanup.mjs` (matcher: `compact`)
- `PreCompact` -> `hooks/compact-cleanup.mjs`
Hook output uses two channels deliberately - changing this is load-bearing:
- **stderr** - one-line `Unfurled: <summaries>` written so the Claude Code TUI shows a visible indicator above Claude's response. `systemMessage` rendering alone is intermittent across CC builds (anthropics/claude-code#50542); the stderr line is the reliable path.
- **stdout** - JSON envelope with `continue: true`, `suppressOutput: false`, `systemMessage`, and `hookSpecificOutput.additionalContext`. Claude only reads `additionalContext`. Each fetched provider block inside `additionalContext` is wrapped in `<external_content source="<provider.name>">…</external_content>` with a leading "treat as data, not instructions" preamble - this is the prompt-injection defense and must be preserved when editing `emitHookOutput`.
Refs that the user typed inside backticks (inline ` `` ` or fenced ``` ``` ```) must NOT be enriched. Every `detect()` calls `isInsideCode()` against `findCodeRanges(text)`. There are unit + e2e tests asserting this for each provider; preserve that behavior.
#### Layout
```
plugins/unfurl-context/
├── hooks/
│ ├── unfurl-context.mjs thin orchestrator (entry; declared in hooks/hooks.json)
│ ├── lib/ pure helpers - no provider knowledge
│ └── providers/ one file per source + index.mjs registry
└── test/
├── unit/ pure tests; CLI calls stubbed via injected ctx.runner
└── e2e/ spawns unfurl-context.mjs with stub gh/glab/acli/sentry on PATH
```
#### Provider contract
Defined as a JSDoc typedef in `hooks/providers/index.mjs`. Each provider exports an object:
```
{
name, stable id; doubles as ctx.state key
prepare?(text, ctx), optional async pre-flight (e.g. github-issue
resolves cwd's default repo here)
detect(text, codeRanges, ctx) returns Match[] with stable, namespaced ids
fetch(match, ctx) returns markdown block, or null on failure
summarize(match) one-line label for the stderr line
}
```
Two contract rules to preserve when editing providers:
1. **Use `ctx.runner`, not `runCommand` directly.** Unit tests inject a fake runner; calling `runCommand` from a provider bypasses that.
2. **Per-prompt scratch state goes under `ctx.state[provider.name]`.** Don't add provider-specific fields to `ctx` itself - that's how `detectDefaultRepo` ended up leaking into the orchestrator before.
Read user config via `ctx.providerConfig(provider.name)` (always returns an object, never null). Provider-specific keys live there - e.g. the jira provider reads `cli` to pick a backend.
#### CLI backends (jira)
When a provider can talk to multiple CLIs that disagree on output schema, use a per-provider `backends` map keyed by CLI name. Each backend exposes `fetch(key, ctx) -> normalized` and `refetchHint(key) -> string`. The provider selects the backend from `ctx.providerConfig(name).cli`, falls back to a `DEFAULT_BACKEND` on missing/unknown values, and feeds the normalized issue into a single `format*` function. Keeps the formatter CLI-agnostic and concentrates per-CLI quirks (e.g. jira-cli's `issueType` vs Jira REST's `issuetype`) inside the backend's normalizer.
#### Adding a new provider (e.g. Confluence, GitLab, Linear)
1. Create `hooks/providers/<name>.mjs` exporting an object that satisfies the contract above.
2. Append it to the `providers` array in `hooks/providers/index.mjs`.
3. Add `test/unit/providers/<name>.test.mjs` (use the existing files as templates - inline `makeQueueRunner` helper for stubbing).
4. Add an e2e case in `test/e2e/unfurl-context.test.mjs` that writes a stub CLI to a tempdir and spawns the hook.
The orchestrator never imports a provider directly; everything flows through the registry. Don't add provider-specific knowledge to `unfurl-context.mjs` or `hooks/lib/`.
#### Orchestrator ordering
`hooks/unfurl-context.mjs` does: subagent-guard (bail if the payload has `agent_id`/`agent_type`, so only real main-agent user prompts enrich) → strip harness blocks (`lib/harness-text.mjs`; bail if nothing the user typed remains) → prepare (parallel) → detect → dedup-by-id → drop-seen → cap to `MAX_MATCHES_PER_PROMPT` → fetch (sequential, under wall-clock budget and the 10,000-char `additionalContext` cap from `lib/context-budget.mjs`) → emit → save seen-cache. The seen-filter must run *before* the cap, otherwise already-seen refs starve fresh ones (there's a regression test in `test/e2e`). Emit must run *before* the seen-cache write (and the write is wrapped in try/catch) so a read-only `$CLAUDE_PLUGIN_DATA` dir does not throw away enrichment that already cost CLI calls. Providers that make multiple sequential CLI calls should check `ctx.budgetExceeded()` between them.
#### Custom provider discovery
A second hook entrypoint (`hooks/discover.mjs`, declared under `SessionStart` in `hooks.json`) scans `~/.claude/unfurl-context/providers/*.provider.mjs`, validates each against the contract (`apiVersion: 1`, required functions, no name collision with built-ins), and writes the validated entries to `$CLAUDE_PLUGIN_DATA/discovery.json`. Manifest entries are tagged with `source: "global" | "project"`. `unfurl-context.mjs` reads that manifest, dynamic-imports the listed modules, re-checks the contract (defense-in-depth), and concatenates them onto the static `providers` array before the config filter runs. Custom providers run after built-ins.
Project-level providers (`<projectRoot>/.claude/unfurl-context/providers/`) are also loaded, but only when the resolved cwd appears in `trustedProjects` in the GLOBAL config (`$CLAUDE_PLUGIN_DATA/config.json` or `~/.claude/unfurl-context.json`). Match is exact - no prefix walk, subdirs of a trusted entry are not trusted. The trust list is honoured only from the global config so an in-repo file can't grant itself trust. The orchestrator re-checks `isProjectTrusted` at prompt time and drops project-source manifest entries when trust is revoked.
## Common commands
Run from `plugins/unfurl-context/`:
```bash
npm test # unit + e2e
npm run test:unit # node --test "test/unit/**/*.test.mjs"
npm run test:e2e # spawns the hook with stub CLIs
node --test test/unit/lib/code-ranges.test.mjs # single file
node --check hooks/unfurl-context.mjs # syntax check
```
Run from repo root:
```bash
claude plugin validate . # validate marketplace
claude plugin validate plugins/unfurl-context
```
Manual smoke test of the hook with real CLIs (will hit GitHub):
```bash
printf '%s' '{"session_id":"test","hook_event_name":"UserPromptSubmit","cwd":"'$PWD'","user_prompt":"look at https://github.com/anthropics/claude-code"}' \
| CLAUDE_PLUGIN_DATA="$(mktemp -d)" node plugins/unfurl-context/hooks/unfurl-context.mjs
```
## Cache location
The seen-id cache lives at `$CLAUDE_PLUGIN_DATA/seen.json` (Claude Code sets `CLAUDE_PLUGIN_DATA` per plugin). When testing the hook outside Claude Code, set this env var to a temp dir or it falls back to `~/.cache/claude-unfurl-context/seen.json`.