No description.

Personal plugin marketplace shared by Claude Code and Codex, with canonical AgentForge definitions, native runtime manifests, and automated validation. Claude supports the full catalog; Codex enrolls 16 packages, with completed fresh-task runtime smoke acceptance for 8.
See Dual-agent operating model for ownership boundaries, runtime mappings, installation, and runtime acceptance.
jdh-agents/
├── MARKETPLACE.yaml # Canonical AgentForge collection definition
├── .claude-plugin/ # Compiler output — the remote-install entry point
│ └── marketplace.json # Root copy of the Claude publication
├── plugins/ # Authoring source — hand-edited, never installed from
│ └── [plugin-name]/
│ ├── PACKAGE.yaml # Canonical AgentForge package definition
│ └── ... # Plugin files (commands, agents, skills, etc.)
├── marketplaces/ # Compiler output — committed, never hand-edited
│ ├── claude/ # Self-contained Claude marketplace root (19 packages)
│ │ ├── .claude-plugin/marketplace.json
│ │ └── plugins/[name]/
│ └── codex/ # Self-contained Codex marketplace root (16 packages)
│ ├── .agents/plugins/marketplace.json
│ └── plugins/[name]/
├── .betterleaks.toml # Privacy-gate rules
├── .betterleaks-baseline.json # Pre-existing findings, accepted once
├── scripts/ # Automation tooling
│ ├── agentforge.sh # Fetches + sha256-verifies the pinned compiler
│ ├── privacy-scan.sh # History secret/privacy gate (pinned Betterleaks)
│ └── tests/ # Gate self-test + attention-workflow behavior
└── .github/workflows/ # CI/CD automation
└── validate.yml # GitHub Actions workflow
Each directory under marketplaces/ is a complete marketplace root, so a runtime is pointed at that directory rather than at the repository. Pointing a runtime at the repository root is what previously let Codex resolve canonical Claude sources instead of its own projection.
The one exception is .claude-plugin/marketplace.json, which exists so that Claude Code can install from the repository remotely -- see Root manifest. It is a compiled copy, not a hand-written one, and it resolves packages back into marketplaces/claude/.
Consuming vs. authoring. Installing and using these plugins needs nothing but this repository —
marketplaces/is compiled output and is committed, so every plugin is ready to install as-is. Authoring (thecompilestep below) additionally needs the AgentForge compiler, whichscripts/agentforge.shfetches and verifies on first use — no manual install, and no credential, since that repository is public.
Nothing here is needed to browse the repo. These are what the plugins and the tooling expect at runtime.
For the marketplace tooling (compile, check):
bash and curl — scripts/agentforge.sh fetches the pinned compiler binary and verifies it against a per-platform sha256 before executing itgit — scripts/privacy-scan.sh scans committed history, so it needs the repository's commits (in CI, actions/checkout with fetch-depth: 0)uv — only to run scripts/tests/test_attention_workflow.py, which declares its own pytest dependency in a PEP 723 header. The repo declares no project, so there is nothing to install and no lockfile to sync.Per plugin. Most plugins are self-contained, but several are inert or misleading without an external account or binary. Check this table before installing one and wondering why it does nothing:
| Plugin | Needs |
|---|---|
librarian, debate | Obsidian vault + obsidian-mcp MCP server; obsidian-cli on PATH |
coach | Obsidian vault + obsidian-cli; Todoist (via the claude.ai connector) |
compass | Obsidian vault + obsidian-cli; Kagi MCP server (optional, for research) |
teach | Obsidian vault + obsidian-cli; DEVONthink MCP server (optional) |
pm | Obsidian vault; Linear MCP server; ndr on PATH |
linear, spec-flow | Linear MCP server (spec-flow also uses Context7) |
attention-workflow | Linear or Fibery MCP server |
em | A tracker (Linear MCP server, Fibery MCP server, or gh) and a VCS; ndr, workspaces, craft, pm optional |
craft | gh, git/jj, ndr; IaC skills additionally want tflint, checkov, trivy, infracost |
langfuse | A Langfuse account + uv on PATH (the Stop hook runs via uv run) |
skillsmith | gh on PATH (for upstream-review) |
pr-watch | gh (GitHub) and/or fj (Forgejo) on PATH, each already logged in; git for Forgejo head commits |
introspect | Local Claude Code transcripts under ~/.claude/projects/; invocable from Claude Code or Codex, but does not parse Codex rollout files |
shake-tune | Klippain Shake Tune PNG output from a Klipper printer |
commit, feedback | Nothing beyond git (commit also supports jj) |
Vault-backed plugins default to a vault named Loose Ends. That is an example, not a requirement — point them at your own vault by editing the paths in the skill bodies.
Claude Code installs remotely, with no clone:
/plugin marketplace add jdh313/jdh-agents
That resolves .claude-plugin/marketplace.json at the repository root, which is a compiled copy of the Claude publication whose package sources point back into marketplaces/claude/. It is generated, never hand-written -- see Root manifest.
A local clone still works, and is what Codex needs:
git clone https://github.com/jdh313/jdh-agents
/plugin marketplace add /path/to/jdh-agents/marketplaces/claude
Codex local marketplace. Fifteen of the nineteen packages declare targets.codex and are therefore enrolled -- enrollment means the package compiled and published for Codex, not that it was exercised on a Codex runtime. Six have a passing fresh-task smoke test on top of that: the four installed below plus librarian and teach.
codex plugin marketplace add /path/to/jdh-agents/marketplaces/codex
codex plugin add commit@jdh-agents
codex plugin add craft@jdh-agents
codex plugin add linear@jdh-agents
codex plugin add spec-flow@jdh-agents
Both publications keep the marketplace name jdh-agents, so an existing install survives the repoint: only the path each runtime resolves changes.
Claude Code's marketplace add <owner>/<repo> form reads .claude-plugin/marketplace.json at the repository root, so remote install needs a manifest there -- but the compiled Claude publication lives under marketplaces/claude/, and its package sources are relative to that directory.
MARKETPLACE.yaml's Claude publication therefore declares root-manifest: true. AgentForge writes a second copy of the same registry at the repository root and rewrites every package source from ./plugins/<name> to ./marketplaces/claude/plugins/<name>, so both copies enrol the same packages and resolve to the same bytes. The Codex publication does not declare it: Codex is installed from a local clone, and a second root file would collide with nothing useful.
Like everything under marketplaces/, the root manifest is generated. Do not hand-edit it -- agentforge compile rewrites it, and agentforge check fails on drift in it, reporting the path with a <root> prefix rather than relative to marketplaces/.
Step 3 requires the AgentForge compiler.
bash mkdir -p plugins/my-plugin ``plugins/my-plugin/PACKAGE.yaml. Declare only the runtimes whose native mappings have been validated.bash scripts/agentforge.sh compile MARKETPLACE.yaml --out marketplaces `` The pinned compiler is fetched and sha256-verified on first use; nothing to install or configure.bash scripts/agentforge.sh check MARKETPLACE.yaml --out marketplaces --claude-native ``bash scripts/privacy-scan.sh ``See docs/agentforge-compatibility.md for the current target matrix, payload handling, and reviewed compatibility limitations.
Two commands drive the registry. There is no repo-specific CLI to install.
Compiles MARKETPLACE.yaml into the committed publication roots under marketplaces/, plus the root manifest beside MARKETPLACE.yaml. AgentForge stages into a temporary directory and publishes by rename, so a failed compile leaves the committed tree untouched and a successful one prunes every stale file.
scripts/agentforge.sh compile MARKETPLACE.yaml --out marketplaces
scripts/agentforge.sh is a ~40-line wrapper that pins the compiler by release version and per-platform sha256, fetches it on first use, verifies the bytes before executing them, and caches it under .cache/agentforge/<version>/. The hash is re-verified on every run, so a corrupted or tampered cache is replaced rather than trusted. CI runs this same script — there is no separate CI pin.
Diffs the compilation plan against the committed tree without writing, and reports missing, extra, changed, and permission drift. It also gates managed output content, parses every managed .json, validates skill frontmatter, checks manifest parity, and resolves every declared plugin path. With --claude-native it cross-checks the Claude publication using claude plugin validate --strict.
scripts/agentforge.sh check MARKETPLACE.yaml --out marketplaces --claude-native
AgentForge owns the cross-runtime translation from Claude disable-model-invocation: true metadata to Codex policy.allow_implicit_invocation: false skill sidecars, and reports it as a translated-construct note during check.
scripts/privacy-scan.sh
Hard-fails on absolute machine-home paths and secret-shaped assignments across the repository's committed history. Every finding fails; there is no advisory tier. The pinned Betterleaks binary fetches and sha256-verifies itself on first use, so there is nothing to install.
This is the one gate AgentForge cannot own: AgentForge only ever sees files a publication declares, so a leak in an undeclared file — a doc, a workflow, a decision atom — is invisible to it.
GitHub Actions runs on every push and pull request:
scripts/tests/test_privacy_gate.sh, then scripts/privacy-scan.sh over committed historyagentforge check --claude-native with AgentForge pinned to release v0.4.0claude plugin validate --strict for the generated Claude publication, using Claude Code 2.1.216jdh313/agentforge publishes per-platform release binaries, so the workflow downloads the pinned agentforge-linux-x64 binary and verifies it against a recorded SHA256 checksum instead of checking out and building the compiler from source.
MARKETPLACE.yaml and plugins/*/PACKAGE.yaml are the only maintained sources of marketplace and package metadata, and plugins/ is the only maintained source of plugin content. Everything under marketplaces/ is committed compiler output — manifests and bodies alike. Edit the source and run scripts/agentforge.sh compile; never hand-edit a file under marketplaces/, because the next compile republishes the whole tree and silently discards the edit.
This is a personal marketplace maintained by one person, published so others can install it. There is no service-level agreement, and new plugin submissions are unlikely to be merged — forking is a first-class answer.
SECURITY.md. Read the threat model there before installing; plugins are instructions and scripts your agent executes with your permissions, and three of them ship hooks that run automatically.CONTRIBUTING.md covers what lands, the pinned-compiler workflow, and the install trap behind most "my copy is stale" reports.Apache-2.0 (see LICENSE).
Portions are derived from third-party work under other terms — notably twelve skills across craft, pm, skillsmith, and teach adapted from mattpocock/skills (MIT), and the langfuse plugin, forked from langfuse/Claude-Observability-Plugin (MIT). Both upstreams' notices are reproduced in full. Required notices, the full MIT text, and a per-skill provenance table are in THIRD-PARTY-NOTICES.md. Each adapted skill also carries upstream: provenance in its frontmatter and an UPSTREAM.md ledger of intentional divergences.
hooks/register.tsx 375 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Check, PullRequest } from '../types'
5
6// Watches the pull requests this session opens and the CI on their heads.
7// A successful `gh pr create` or `fj pr create` adds one; a timer then asks
8// the forge's own CLI for state and checks, so each CLI's login is the only
9// credential involved: GitHub through `gh pr view --json`, Forgejo through
10// `fj pr view` (state, head branch) and `fj actions tasks` (CI per commit).
11const prs = atom({ plugin: 'pr-watch', key: 'prs' } as const, [])
12const PANE = 'pr-watch'
13const TITLE = 'Pull requests'
14const TICK_MS = 20_000
15// A settled PR (CI finished or never reported) is re-asked this often, so a
16// later push that restarts CI is still seen.
17const SETTLED_MS = 5 * 60_000
18// How long a PR with no checks yet counts as waiting for CI to start.
19const WARMUP_MS = 10 * 60_000
20const GLYPH = { pending: '●', pass: '✓', fail: '✗', skip: '–' } as const
21
22type Engine = EngineInterface
23type Parsed = Pick<PullRequest, 'forge' | 'host' | 'repo' | 'number' | 'url'> & Partial<Pick<PullRequest, 'title' | 'head'>>
24
25const keyOf = (p: Pick<PullRequest, 'host' | 'repo' | 'number'>) => `${p.host}/${p.repo}#${p.number}`
26
27// The value of `--name value`, `--name=value` or `-n value` in a command line.
28export const flagOf = (command: string, ...names: string[]): string | undefined => {
29 for (const name of names) {
30 const found = command.match(new RegExp(`(?:^|\\s)${name}(?:=|\\s+)(['"]?)([^\\s'"]+)\\1`))
31 if (found) return found[2]
32 }
33 return undefined
34}
35
36// `host` and `owner/repo` from a remote URL in any of git's spellings.
37export const remoteRepo = (url: string): { host: string; repo: string } | undefined => {
38 const found = url.trim().match(/^(?:[a-z+]+:\/\/)?(?:[^@/\s]+@)?([^:/\s]+)(?::\d+)?[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/)
39 const [, host, repo] = found ?? []
40 return host && repo ? { host, repo } : undefined
41}
42
43// A PR URL on either forge: GitHub's `/pull/N`, Forgejo's `/pulls/N`.
44export const parseUrl = (text: string): Parsed | undefined => {
45 const found = text.match(/https?:\/\/([^/\s]+)\/([^/\s]+\/[^/\s]+)\/(pull|pulls)\/(\d+)/)
46 const [url, host, repo, kind, number] = found ?? []
47 if (!url || !host || !repo || !number) return undefined
48 return { forge: kind === 'pull' ? 'github' : 'forgejo', host, repo, number: Number(number), url }
49}
50
51const GH_CREATE = /\bgh\s+pr\s+create\b/
52const FJ_CREATE = /\bfj\b[^|;&\n]*\bpr\s+create\b/
53
54// A `gh pr create` prints the new PR's URL.
55export const parseGhCreate = (command: string, output: string): Parsed | undefined => {
56 if (!GH_CREATE.test(command)) return undefined
57 const pr = parseUrl(output)
58 return pr?.forge === 'github' ? { ...pr, head: flagOf(command, '--head', '-H') } : undefined
59}
60
61// A `fj pr create` prints `created pull request #N: <title>`, isolates and
62// all (see `plain`); the host and
63// repo come from its flags, or from `remote` when it inferred them.
64export const parseFjCreate = (
65 command: string,
66 output: string,
67 remote?: { host: string; repo: string },
68): Parsed | undefined => {
69 if (!FJ_CREATE.test(command)) return undefined
70 const created = plain(output).match(/created pull request #(\d+): ?(.*)/)
71 if (!created) return undefined
72 const host = flagOf(command, '-H', '--host') ?? remote?.host
73 const repo = flagOf(command, '-r', '--repo') ?? remote?.repo
74 if (!host || !repo) return undefined
75 const number = Number(created[1])
76 return {
77 forge: 'forgejo',
78 host,
79 repo,
80 number,
81 url: `https://${host}/${repo}/pulls/${number}`,
82 title: created[2]?.trim() || undefined,
83 head: flagOf(command, '--head'),
84 }
85}
86
87export const verdictOf = (checks: readonly Check[]): PullRequest['verdict'] =>
88 checks.length === 0
89 ? 'none'
90 : checks.some(c => c.state === 'fail')
91 ? 'fail'
92 : checks.some(c => c.state === 'pending')
93 ? 'pending'
94 : 'pass'
95
96type GhCheck = { __typename?: string; name?: string; context?: string; status?: string; conclusion?: string; state?: string }
97
98// `gh pr view --json title,state,headRefName,statusCheckRollup`: check runs
99// carry status + conclusion, legacy commit statuses carry context + state.
100export const parseGhView = (json: string): Pick<PullRequest, 'title' | 'head' | 'state' | 'checks'> => {
101 const v = JSON.parse(json) as { title?: string; state?: string; headRefName?: string; statusCheckRollup?: GhCheck[] }
102 const checks = (v.statusCheckRollup ?? []).map((c): Check => {
103 if (c.context !== undefined) {
104 const state = c.state === 'SUCCESS' ? 'pass' : c.state === 'PENDING' || c.state === 'EXPECTED' ? 'pending' : 'fail'
105 return { name: c.context, state }
106 }
107 const done = c.status === 'COMPLETED'
108 const state = !done
109 ? 'pending'
110 : c.conclusion === 'SUCCESS'
111 ? 'pass'
112 : c.conclusion === 'NEUTRAL' || c.conclusion === 'SKIPPED'
113 ? 'skip'
114 : 'fail'
115 return { name: c.name ?? '?', state }
116 })
117 return {
118 title: v.title,
119 head: v.headRefName,
120 state: v.state === 'MERGED' ? 'merged' : v.state === 'CLOSED' ? 'closed' : 'open',
121 checks,
122 }
123}
124
125// fj wraps every interpolated value in Unicode isolates (U+2068, U+2069).
126const plain = (text: string) => text.replace(/[-]/g, '').replace(/\u001b\[[0-9;]*m/g, '')
127
128// `fj pr view`: `<title> #N`, then `By <user> — <State> — +a -b`, then
129// "From `<head>` into `<base>`".
130export const parseFjView = (output: string): Pick<PullRequest, 'title' | 'head' | 'state'> => {
131 const lines = plain(output).split('\n')
132 const title = lines[0]?.replace(/\s+#\d+\s*$/, '').trim() || undefined
133 const state = lines[1]?.match(/\b(Open|Draft|Merged|Closed)\b/)?.[1]
134 const head = lines[2]?.match(/From `([^`]+)`/)?.[1]
135 return { title, head, state: state === 'Merged' ? 'merged' : state === 'Closed' ? 'closed' : 'open' }
136}
137
138export type Task = { id: number; sha: string; status: string; name: string; event: string }
139
140// `fj actions tasks`, newest first:
141// `#24 (c968e0637a) success validate 1m13s (pull_request): <commit title>`.
142export const parseFjTasks = (output: string): Task[] =>
143 plain(output)
144 .split('\n')
145 .flatMap(line => {
146 const [, id, sha, status, rest, event] = line.match(/^#(\d+) \(([0-9a-f]+)\) (\S+) (.+?) \(([\w-]+)\): /) ?? []
147 if (!id || !sha || !status || !rest || !event) return []
148 return [{ id: Number(id), sha, status, name: rest.replace(/\s+\S*\d+s$/, ''), event }]
149 })
150
151const taskState = (status: string): Check['state'] =>
152 status === 'success' ? 'pass' : status === 'skipped' ? 'skip' : status === 'failure' || status === 'cancelled' ? 'fail' : 'pending'
153
154// The newest task per workflow job on commit `sha`, as checks.
155export const checksForCommit = (tasks: readonly Task[], sha: string): Check[] => {
156 const seen = new Map<string, Check>()
157 for (const t of tasks) {
158 if (sha.startsWith(t.sha) && !seen.has(t.name)) seen.set(t.name, { name: t.name, state: taskState(t.status) })
159 }
160 return [...seen.values()]
161}
162
163const run = async ($: Engine, argv: string[]) => {
164 try {
165 const out = await $.process.run(argv, { timeoutMs: 20_000 })
166 return out.exitCode === 0 ? out.stdout : undefined
167 } catch {
168 return undefined
169 }
170}
171
172// The commit a branch points at, as last pushed (a jj bookmark is exported
173// to the same refs in a colocated repo).
174const shaOf = async ($: Engine, head: string) => {
175 for (const ref of [`refs/remotes/origin/${head}`, `refs/heads/${head}`]) {
176 const sha = (await run($, ['git', 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`]))?.trim()
177 if (sha) return sha
178 }
179 return undefined
180}
181
182type Fresh = Pick<PullRequest, 'title' | 'head' | 'state' | 'checks'>
183
184const askGitHub = async ($: Engine, pr: PullRequest): Promise<Fresh | undefined> => {
185 const json = await run($, ['gh', 'pr', 'view', pr.url, '--json', 'title,state,headRefName,statusCheckRollup'])
186 if (json === undefined) return undefined
187 try {
188 return parseGhView(json)
189 } catch {
190 return undefined
191 }
192}
193
194const askForgejo = async ($: Engine, pr: PullRequest): Promise<Fresh | undefined> => {
195 const view = await run($, ['fj', '-H', pr.host, 'pr', 'view', `${pr.repo}#${pr.number}`])
196 if (view === undefined) return undefined
197 const seen = parseFjView(view)
198 const head = seen.head ?? pr.head
199 const sha = head && (await shaOf($, head))
200 const tasks = sha ? await run($, ['fj', '-H', pr.host, 'actions', 'tasks', '-r', pr.repo]) : undefined
201 return { ...seen, head, checks: sha && tasks ? checksForCommit(parseFjTasks(tasks), sha) : pr.checks }
202}
203
204const isDue = (pr: PullRequest, now: number) => {
205 if (pr.state !== 'open') return false
206 const waiting = pr.verdict === 'pending' || (pr.verdict === 'none' && now - pr.addedAt < WARMUP_MS)
207 return waiting || now - pr.polledAt >= SETTLED_MS
208}
209
210// What changed between two polls of one PR that is worth a toast.
211export const newsOf = (before: PullRequest, after: PullRequest): string | undefined => {
212 const label = `PR #${after.number}`
213 if (before.state === 'open' && after.state !== 'open') return `${label} ${after.state}`
214 if (after.verdict === before.verdict) return undefined
215 if (after.verdict === 'pass') return `${label}: CI passed`
216 if (after.verdict === 'fail') {
217 const failed = after.checks.filter(c => c.state === 'fail').map(c => c.name)
218 return `${label}: CI failed (${failed.join(', ')})`
219 }
220 return undefined
221}
222
223let isPolling = false
224
225const poll = async ($: Engine, force = false) => {
226 if (isPolling) return
227 isPolling = true
228 try {
229 const now = await $.clock.now()
230 for (const pr of await read($, prs)) {
231 if (!force && !isDue(pr, now)) continue
232 const fresh = pr.forge === 'github' ? await askGitHub($, pr) : await askForgejo($, pr)
233 if (!fresh) continue
234 const after: PullRequest = {
235 ...pr,
236 ...fresh,
237 title: fresh.title ?? pr.title,
238 verdict: verdictOf(fresh.checks),
239 polledAt: now,
240 }
241 await update($, prs, list => list.map(p => (p.key === pr.key ? after : p)))
242 const news = newsOf(pr, after)
243 if (news) $.ui.toast(news, { timeoutMs: 8000 })
244 }
245 } finally {
246 isPolling = false
247 }
248}
249
250let ticker: { cancel: () => void } | undefined
251
252const ensureTicking = ($: Engine) => {
253 ticker ??= $.clock.every(TICK_MS, () => void poll($))
254}
255
256const track = async ($: Engine, found: Parsed) => {
257 const now = await $.clock.now()
258 const key = keyOf(found)
259 const fresh: PullRequest = { ...found, key, state: 'open', checks: [], verdict: 'none', addedAt: now, polledAt: 0 }
260 await update($, prs, list => [list.find(p => p.key === key) ?? fresh, ...list.filter(p => p.key !== key)])
261 void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
262 ensureTicking($)
263 await poll($)
264}
265
266// What a Bash call printed, from its record or from the text the model got.
267const outputOf = (ran: { result?: unknown; text?: string }) => {
268 const record = ran.result as { stdout?: unknown } | undefined
269 return [typeof record?.stdout === 'string' ? record.stdout : '', ran.text ?? ''].join('\n')
270}
271
272const remoteOf = async ($: Engine, command: string) => {
273 const url = await run($, ['git', 'remote', 'get-url', flagOf(command, '-R', '--remote') ?? 'origin'])
274 return url ? remoteRepo(url) : undefined
275}
276
277const describe = (list: readonly PullRequest[]) =>
278 list
279 .map(pr =>
280 [
281 `${pr.state === 'open' ? GLYPH[pr.verdict === 'none' ? 'pending' : pr.verdict] : pr.state} ${pr.repo}#${pr.number}${pr.title ? ` ${pr.title}` : ''}`,
282 ...pr.checks.map(c => ` ${GLYPH[c.state]} ${c.name}`),
283 ].join('\n'),
284 )
285 .join('\n')
286
287export const register: Register = on => {
288 on('session.start', async ($, e, next) => {
289 await $.command.register({
290 name: 'prs',
291 description: 'Show the pull requests this session watches and their CI',
292 argumentHint: '[add <url> | refresh | clear]',
293 })
294 if ((await read($, prs)).some(p => p.state === 'open')) ensureTicking($)
295 return next(e)
296 })
297
298 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
299 const ran = await next(e)
300 if (ran.deny !== undefined || ran.isError) return ran
301 const command = String(e.command ?? '')
302 if (!GH_CREATE.test(command) && !FJ_CREATE.test(command)) return ran
303 const output = outputOf(ran)
304 const needsRemote = FJ_CREATE.test(command) && !(flagOf(command, '-H', '--host') && flagOf(command, '-r', '--repo'))
305 const found =
306 parseGhCreate(command, output) ??
307 parseFjCreate(command, output, needsRemote ? await remoteOf($, command) : undefined)
308 if (found) await track($, found)
309 return ran
310 })
311
312 on('command.run', { command: 'prs' }, async ($, e) => {
313 const [verb = '', arg = ''] = e.args.trim().split(/\s+/)
314 if (verb === 'add') {
315 const found = parseUrl(arg)
316 if (!found) return { text: 'Usage: /prs add <PR URL>' }
317 await track($, found)
318 return { text: `Watching ${found.repo}#${found.number}.` }
319 }
320 if (verb === 'refresh') {
321 await poll($, true)
322 } else if (verb === 'clear') {
323 await update($, prs, list => list.filter(p => p.state === 'open'))
324 return { text: 'Cleared merged and closed pull requests.' }
325 } else if (verb !== '') {
326 return { text: 'Usage: /prs [add <url> | refresh | clear]' }
327 }
328 await $.ui.open({ id: PANE, title: TITLE })
329 const list = await read($, prs)
330 return { text: list.length === 0 ? 'No pull requests yet.' : describe(list) }
331 })
332
333 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
334 const { Box, Text } = $.ui.resolve(e)
335 const list = await read($, prs)
336 return (
337 <Box flexDirection="column">
338 {list.length === 0 && <Text dimColor>No pull requests yet.</Text>}
339 {list.map(pr => {
340 const isOpen = pr.state === 'open'
341 const verdict = pr.verdict === 'none' ? 'pending' : pr.verdict
342 const color = !isOpen ? undefined : verdict === 'pass' ? 'green' : verdict === 'fail' ? 'red' : 'yellow'
343 return (
344 <Box flexDirection="column" marginBottom={1}>
345 <Box>
346 <Text bold color={color} dimColor={!isOpen}>
347 {isOpen ? GLYPH[verdict] : pr.state} #{pr.number}
348 </Text>
349 <Box flexShrink={1}>
350 <Text dimColor={!isOpen} wrap="truncate-end">
351 {' '}
352 {pr.title ?? ''}
353 </Text>
354 </Box>
355 </Box>
356 <Text dimColor wrap="truncate-end">
357 {' '}
358 {pr.repo}
359 {isOpen && pr.checks.length === 0 ? ' · no checks yet' : ''}
360 </Text>
361 {isOpen &&
362 pr.checks.map(c => (
363 <Text dimColor={c.state === 'pass' || c.state === 'skip'} wrap="truncate-end">
364 {' '}
365 {GLYPH[c.state]} {c.name}
366 </Text>
367 ))}
368 </Box>
369 )
370 })}
371 </Box>
372 )
373 })
374}
375types/index.d.ts 33 lines1// One CI check on a PR's head commit, reduced to the four states the pane
2// draws: `pending` covers queued, running and waiting alike.
3export type Check = {
4 name: string
5 state: 'pending' | 'pass' | 'fail' | 'skip'
6}
7
8// One pull request this session watches. `key` is `<host>/<repo>#<number>`.
9// `head` is the source branch, which Forgejo needs to find the commit its
10// tasks ran on. `verdict` is `none` until the forge reports a check. Times
11// are `$.clock.now()` milliseconds.
12export type PullRequest = {
13 key: string
14 forge: 'github' | 'forgejo'
15 host: string
16 repo: string
17 number: number
18 url: string
19 title?: string
20 head?: string
21 state: 'open' | 'merged' | 'closed'
22 checks: Check[]
23 verdict: 'none' | 'pending' | 'pass' | 'fail'
24 addedAt: number
25 polledAt: number
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'pr-watch': { prs: PullRequest[] }
31 }
32}
33