/prs: your open PRs across your repos with Vercel preview links, checks, merge state and worktree.

What it does: your open pull requests in one place, with the Vercel preview link ready to click.
Where you see it: /prs toggles a pane. Each of your open PRs in the repos listed in config.json (and whichever repo the session is in) shows:
It refreshes every 3 minutes while open, every 15 minutes in the background (so the badge stays current), or on Refresh.
How it works: runs gh pr list --author @me per repo. The preview link is read from the Vercel bot's PR comment. If none is found, it builds Vercel's branch alias and marks it "(guessed)".
Cost: none. Uses your GitHub CLI login. No model calls.
Data saved: none.
Settings: copy config.example.json to config.json and list your repos (owner/name) and your Vercel team slug (for preview links when the Vercel bot comment isn't found). config.json stays out of git, so private repo names never reach the public repo.
Notes: only PRs authored by you are listed, so outside PRs (like forks) never show up here.
hooks/register.tsx 259 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Desk, DeskChecks, DeskPr } from '../types'
5
6// Your repos and Vercel team come from config.json beside this mod (kept out of
7// git: copy config.example.json). Without it, only the session's own repo is listed.
8type DeskConfig = { repos: string[]; vercelTeam: string | null }
9const PANE = 'prs'
10const REFRESH_MS = 180_000
11
12const desk = atom({ plugin: 'pr-desk', key: 'desk' } as const, null)
13const isLoading = atom({ plugin: 'pr-desk', key: 'isLoading' } as const, false)
14const isOpen = atom({ plugin: 'pr-desk', key: 'isOpen' } as const, false)
15
16type GhCheck = { conclusion?: string | null; status?: string | null; state?: string | null }
17type GhPr = {
18 number: number
19 title: string
20 headRefName: string
21 url: string
22 mergeable: string
23 statusCheckRollup: GhCheck[] | null
24 updatedAt: string
25 isDraft: boolean
26}
27
28const repoFromRemote = (remote: string | null) => {
29 const m = remote ? /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(remote) : null
30 return m ? `${m[1]}/${m[2]}` : null
31}
32
33const checksOf = (rollup: GhCheck[] | null): DeskChecks => {
34 if (!rollup || rollup.length === 0) return 'none'
35 const states = rollup.map(c => (c.conclusion ?? c.state ?? c.status ?? '').toUpperCase())
36 if (states.some(s => ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(s))) return 'failing'
37 if (states.some(s => ['', 'PENDING', 'QUEUED', 'IN_PROGRESS', 'WAITING', 'EXPECTED', 'REQUESTED'].includes(s))) return 'pending'
38 return 'passing'
39}
40
41// Vercel's branch alias: release/v0.10.2 -> release-v0102
42const branchSlug = (branch: string) =>
43 branch.toLowerCase().replace(/\//g, '-').replace(/\./g, '').replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-')
44
45const ago = (iso: string, now: number) => {
46 const m = Math.max(0, Math.round((now - Date.parse(iso)) / 60000))
47 if (m < 60) return `${m}m ago`
48 const h = Math.round(m / 60)
49 return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
50}
51
52const gh = async ($: EngineInterface, args: string[]) => {
53 const run = await $.process.run(['gh', ...args], { timeoutMs: 30_000 })
54 if (run.exitCode !== 0) throw new Error(run.stderr.trim().split('\n')[0] || `gh exited ${run.exitCode}`)
55 return run.stdout
56}
57
58const loadConfig = async ($: EngineInterface): Promise<DeskConfig> => {
59 try {
60 const raw = JSON.parse(await $.fs.read(`${$.plugin.root}/config.json`)) as Partial<DeskConfig>
61 return {
62 repos: Array.isArray(raw.repos) ? raw.repos.filter((r): r is string => typeof r === 'string') : [],
63 vercelTeam: typeof raw.vercelTeam === 'string' && raw.vercelTeam !== '' ? raw.vercelTeam : null,
64 }
65 } catch {
66 return { repos: [], vercelTeam: null }
67 }
68}
69
70const previewFor = async ($: EngineInterface, repo: string, pr: GhPr, vercelTeam: string | null) => {
71 try {
72 const out = JSON.parse(await gh($, ['pr', 'view', String(pr.number), '-R', repo, '--json', 'comments'])) as {
73 comments: { author?: { login?: string }; body: string }[]
74 }
75 for (const c of out.comments) {
76 const m = /([a-z0-9-]+-git-[a-z0-9-]+\.vercel\.app)/i.exec(c.body)
77 if (m) return { preview: `https://${m[1]!.toLowerCase()}`, isPreviewGuess: false }
78 }
79 } catch {
80 // fall through to the alias guess
81 }
82 const project = repo.split('/')[1]!.toLowerCase()
83 if (!vercelTeam) return { preview: null, isPreviewGuess: false }
84 return { preview: `https://${project}-git-${branchSlug(pr.headRefName)}-${vercelTeam}.vercel.app`, isPreviewGuess: true }
85}
86
87const worktrees = async ($: EngineInterface) => {
88 const byBranch = new Map<string, string>()
89 const run = await $.process.run(['git', 'worktree', 'list', '--porcelain']).catch(() => null)
90 if (!run || run.exitCode !== 0) return byBranch
91 let path = ''
92 for (const line of run.stdout.split(/\r?\n/)) {
93 if (line.startsWith('worktree ')) path = line.slice(9)
94 if (line.startsWith('branch refs/heads/')) byBranch.set(line.slice(18), path.split(/[\\/]/).pop() ?? path)
95 }
96 return byBranch
97}
98
99const refresh = async ($: EngineInterface) => {
100 if (await read($, isLoading)) return
101 await update($, isLoading, () => true)
102 try {
103 const here = repoFromRemote((await $.session.repo())?.remote ?? null)
104 const config = await loadConfig($)
105 const repos = [...new Map([here, ...config.repos].filter((r): r is string => !!r).map(r => [r.toLowerCase(), r])).values()]
106 const trees = await worktrees($)
107 const prs: DeskPr[] = []
108 const errors: string[] = []
109 await Promise.all(
110 repos.map(async repo => {
111 try {
112 const list = JSON.parse(
113 await gh($, [
114 'pr', 'list', '-R', repo, '--author', '@me', '--state', 'open', '--limit', '30',
115 '--json', 'number,title,headRefName,url,mergeable,statusCheckRollup,updatedAt,isDraft',
116 ]),
117 ) as GhPr[]
118 for (const pr of list) {
119 const isHere = here !== null && repo.toLowerCase() === here.toLowerCase()
120 prs.push({
121 repo,
122 number: pr.number,
123 title: pr.title,
124 branch: pr.headRefName,
125 url: pr.url,
126 ...(await previewFor($, repo, pr, config.vercelTeam)),
127 mergeable: pr.mergeable,
128 checks: checksOf(pr.statusCheckRollup),
129 isDraft: pr.isDraft,
130 updatedAt: pr.updatedAt,
131 worktree: isHere ? trees.get(pr.headRefName) ?? null : null,
132 })
133 }
134 } catch (err) {
135 errors.push(`${repo}: ${err instanceof Error ? err.message : String(err)}`)
136 }
137 }),
138 )
139 prs.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
140 const fresh: Desk = { at: await $.clock.now(), prs, errors }
141 await update($, desk, () => fresh)
142 } finally {
143 await update($, isLoading, () => false)
144 }
145}
146
147const togglePane = async ($: EngineInterface) => {
148 if ((await $.ui.panes()).some(p => p.id === PANE)) {
149 await $.ui.close({ id: PANE })
150 // A plugin's own close does not reach its own ui.close hook: reset the flag here.
151 await update($, isOpen, () => false)
152 return 'PR Desk closed.'
153 }
154 const opened = await $.ui.open({ id: PANE, title: 'PRs' })
155 if (opened.isPlaced) {
156 await update($, isOpen, () => true)
157 void refresh($).catch(err => $.ui.toast(`PR Desk: ${String(err)}`))
158 } else {
159 $.ui.toast(`PR Desk could not open (${opened.reason}).`)
160 }
161 return opened.isPlaced ? 'PR Desk opened.' : `Could not open the pane (${opened.reason}).`
162}
163
164let ticks = 0
165
166export const register: Register = on => {
167 on('session.start', async ($, e, next) => {
168 await $.command.register({ name: 'prs', description: 'Open PR Desk: your open PRs, preview links, checks and merge state' })
169 // Every 3 minutes while the pane is open; every 15 minutes in the background,
170 // so the dock's PR badge stays current.
171 $.clock.every(REFRESH_MS, async () => {
172 ticks += 1
173 const open = (await $.ui.panes()).some(p => p.id === PANE)
174 if (open || ticks % 5 === 0) await refresh($).catch(() => undefined)
175 })
176 $.clock.after(5000, () => void refresh($).catch(() => undefined))
177 return next(e)
178 })
179
180 on('command.run', { command: 'prs' }, async $ => ({ text: await togglePane($) }))
181
182 // A press on the dock's PRs button.
183 on('state.set', { plugin: 'mod-hub', key: 'signal' }, async ($, e, next) => {
184 const done = await next(e)
185 // Only a write that landed: update() retries a missed one, which would toggle twice.
186 const sig = e.value as { target?: string; action?: string } | null
187 if (done.value?.isSet !== true || !sig) return done
188 if (sig.target === 'pr-desk') await togglePane($)
189 // Review: show the panel, never close it.
190 else if (sig.action === 'review' && !(await $.ui.panes()).some(p => p.id === PANE)) await togglePane($)
191 return done
192 })
193
194 on('ui.close', { id: PANE }, async ($, e, next) => {
195 const done = await next(e)
196 await update($, isOpen, () => false)
197 return done
198 })
199
200 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
201 const { Box, Text, Button, Link } = $.ui.resolve(e)
202 const d = await read($, desk)
203 const loading = await read($, isLoading)
204 const now = await $.clock.now()
205 const repos = [...new Set((d?.prs ?? []).map(p => p.repo))]
206
207 return (
208 <Box flexDirection="column" gap={1}>
209 <Box flexDirection="row" gap={1}>
210 <Text bold>Open PRs</Text>
211 <Text dimColor>
212 {loading ? 'refreshing…' : d ? `${d.prs.length} open · checked ${ago(new Date(d.at).toISOString(), now)}` : 'loading…'}
213 </Text>
214 <Button key="refresh" label="Refresh" plain onPress={() => refresh($)} />
215 <Button key="close" label="Close" role="dismiss" onPress={() => togglePane($)} />
216 </Box>
217 {d && d.prs.length === 0 && d.errors.length === 0 && <Text dimColor>No open PRs of yours.</Text>}
218 {repos.map(repo => (
219 <Box flexDirection="column">
220 <Text bold color="claude">{repo.split('/')[1]}</Text>
221 {d!.prs
222 .filter(p => p.repo === repo)
223 .map(p => (
224 <Box flexDirection="column" marginBottom={1}>
225 <Text bold wrap="truncate-end">
226 #{p.number} {p.title}
227 {p.isDraft ? ' (draft)' : ''}
228 </Text>
229 <Box flexDirection="row" gap={1}>
230 <Text color={p.checks === 'failing' ? 'error' : p.checks === 'passing' ? 'success' : 'warning'}>
231 {p.checks === 'failing' ? '✗ checks failing' : p.checks === 'passing' ? '✓ checks' : p.checks === 'pending' ? '… checks running' : '· no checks'}
232 </Text>
233 <Text color={p.mergeable === 'CONFLICTING' ? 'error' : undefined} dimColor={p.mergeable !== 'CONFLICTING'}>
234 {p.mergeable === 'MERGEABLE' ? 'mergeable' : p.mergeable === 'CONFLICTING' ? 'conflicts with base' : 'merge state unknown'}
235 </Text>
236 <Text dimColor>· {ago(p.updatedAt, now)}</Text>
237 </Box>
238 <Text dimColor wrap="truncate-end">
239 {p.branch}
240 {p.worktree ? ` · worktree ${p.worktree}` : ''}
241 </Text>
242 <Box flexDirection="row" gap={2}>
243 {p.preview && <Link href={p.preview} label={p.isPreviewGuess ? 'preview (guessed)' : 'preview'} />}
244 <Link href={p.url} label="PR" />
245 </Box>
246 </Box>
247 ))}
248 </Box>
249 ))}
250 {(d?.errors ?? []).map(err => (
251 <Text color="warning" wrap="truncate-end">
252 {err}
253 </Text>
254 ))}
255 </Box>
256 )
257 })
258}
259types/index.d.ts 25 lines1export type DeskChecks = 'passing' | 'failing' | 'pending' | 'none'
2
3export type DeskPr = {
4 repo: string
5 number: number
6 title: string
7 branch: string
8 url: string
9 preview: string | null
10 isPreviewGuess: boolean
11 mergeable: string
12 checks: DeskChecks
13 isDraft: boolean
14 updatedAt: string
15 worktree: string | null
16}
17
18export type Desk = { at: number; prs: DeskPr[]; errors: string[] }
19
20declare module 'claude-code' {
21 interface PluginState {
22 'pr-desk': { desk: Desk | null; isLoading: boolean; isOpen: boolean }
23 }
24}
25