A live GitHub dashboard for every repo across every account you're signed into.

A GitHub dashboard for people who ship Claude Code plugins and small apps. It comes in two parts that share one data file:
/repos pane, toasts, and /repos widget to open the widget.It sweeps every repo of every account the GitHub CLI is signed into.
plugin.json, your marketplace's copy and the latest releaseEach item has a button that opens Claude Code in that repo with the task written out: 🔧 Fix, 👀 Review or 🚢 Ship it. Claude works in a branch and asks before it pushes, comments, merges or publishes. ✓ Done hides an item, and ✓ Close closes an issue.
The widget: download the installer for your system from Releases:
RepoRadar-Setup-x.y.z.exeRepoRadar-x.y.z-arm64.dmg (Apple silicon) or -x64.dmg (Intel)The mod: in Claude Code:
/plugin marketplace add J-M-Pineapple-Express/repo-radar
/plugin install repo-radar@repo-radar
Then type /repos for the pane, or /repos widget to open the widget.
Both need the GitHub CLI signed in: run gh auth login. Sign in to more than one account and Repo Radar shows them all.
Everything stays on your computer. Repo Radar talks only to GitHub, using the tokens gh already has, and never writes them to disk. Your history lives in ~/.claude/repo-radar/data.json.
cd widget
npm install
npm start # run the widget
npm run dist:win # or dist:mac, to build installers
The mod is in mod/. Load it while developing with claude --plugin-dir mod.
MIT
hooks/register.tsx 714 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Account, Health, Ledger, Repo, Snapshot, Status, Traffic } from '../types'
5import { ago, changes, compact, dayKeys, describe, importTracker, lifetime, mergeHistory, shortDate, missingAssets, perDay, record, repoKey, summarize, toLedger } from './lib'
6import type { DayRow, History } from './lib'
7
8const PANE = 'repo-radar'
9const REFRESH_MS = 10 * 60 * 1000
10const TICK_MS = 8 * 1000
11const PAGE_LIMIT = 5
12const PREVIEW = 8
13const STALE_DAYS = 90
14const TRAFFIC_MS = 60 * 60 * 1000 // GitHub updates traffic about hourly
15const TRAFFIC_LIMIT = 300 // per account, most recently pushed first: in practice every repo
16const TRAFFIC_BATCH = 10 // repos fetched at once
17const AWAY_MS = 30 * 60 * 1000
18const TOAST_LIMIT = 3
19
20const snapshot = atom({ plugin: 'repo-radar', key: 'snapshot' } as const, null)
21const status = atom({ plugin: 'repo-radar', key: 'status' } as const, 'idle')
22const message = atom({ plugin: 'repo-radar', key: 'message' } as const, '')
23const expanded = atom({ plugin: 'repo-radar', key: 'expanded' } as const, [])
24const digest = atom({ plugin: 'repo-radar', key: 'digest' } as const, '')
25const hiddenColumns = atom({ plugin: 'repo-radar', key: 'hidden' } as const, [])
26
27const DOT: Record<Health, { glyph: string; color: string }> = {
28 green: { glyph: '●', color: 'green' },
29 yellow: { glyph: '●', color: 'yellow' },
30 red: { glyph: '●', color: 'red' },
31 archived: { glyph: '○', color: 'gray' },
32}
33
34const QUERY = `query($after: String) {
35 viewer {
36 login
37 repositories(first: 100, after: $after,
38 ownerAffiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER],
39 orderBy: { field: PUSHED_AT, direction: DESC }) {
40 pageInfo { hasNextPage endCursor }
41 nodes {
42 name url isPrivate isFork isArchived pushedAt viewerPermission
43 owner { login }
44 stargazerCount forkCount
45 issues(states: OPEN) { totalCount }
46 pullRequests(states: OPEN) { totalCount }
47 latestRelease { tagName }
48 primaryLanguage { name color }
49 defaultBranchRef { target { ... on Commit { statusCheckRollup { state } } } }
50 }
51 }
52 }
53}`
54
55type Login = { host: string; login: string; token: string }
56type Engine = EngineInterface
57
58const graphqlUrl = (host: string) =>
59 host === 'github.com' ? 'https://api.github.com/graphql' : `https://${host}/api/graphql`
60
61const restBase = (host: string) => (host === 'github.com' ? 'https://api.github.com' : `https://${host}/api/v3`)
62
63const restHeaders = (token: string) => ({
64 Authorization: `bearer ${token}`,
65 Accept: 'application/vnd.github+json',
66 'User-Agent': 'repo-radar',
67})
68
69const winPath = (path: string) => path.replaceAll('/', '\\')
70
71/** The file the widget shares: ~/.claude/repo-radar/data.json. */
72async function sharedPath($: Engine): Promise<string | null> {
73 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
74 return home ? `${home}/.claude/repo-radar/data.json` : null
75}
76
77type Shared = { history?: History; ledger?: Ledger | null; watchedSince?: string | null; prefs?: Record<string, unknown> } & Record<string, unknown>
78
79async function loadShared($: Engine): Promise<Shared | null> {
80 const file = await sharedPath($)
81 if (!file || !(await $.fs.exists(file))) return null
82 // Both sides move their writes into place; a second read covers an unlucky moment anyway.
83 for (let i = 0; i < 2; i++) {
84 try {
85 return JSON.parse(await $.fs.read(file)) as Shared
86 } catch {
87 await $.clock.sleep(200)
88 }
89 }
90 return null
91}
92
93/** Merges our history, ledger and prefs into the shared file, leaving the widget's own fields alone. */
94async function saveShared($: Engine, patch: { history?: History; ledger?: Ledger; watchedSince?: string; hiddenColumns?: string[] }): Promise<void> {
95 const file = await sharedPath($)
96 if (!file) return
97 try {
98 const data: Shared = (await loadShared($)) ?? { version: 1, accounts: [], fetchedAt: 0, trafficAt: 0 }
99 if (patch.history) data.history = mergeHistory(data.history ?? {}, patch.history)
100 if (patch.ledger && (!data.ledger || patch.ledger.at > data.ledger.at)) data.ledger = patch.ledger
101 if (patch.watchedSince && (!data.watchedSince || patch.watchedSince < data.watchedSince)) data.watchedSince = patch.watchedSince
102 if (patch.hiddenColumns) data.prefs = { ...data.prefs, hiddenColumns: patch.hiddenColumns }
103 // Write beside it, then move into place, so the widget never reads half a file.
104 const tmp = `${file}.mod.tmp`
105 await $.fs.write(tmp, JSON.stringify(data))
106 const isWindows = file.includes('\\') || /^[A-Za-z]:/.test(file)
107 await $.process.run(isWindows ? ['cmd', '/c', 'move', '/y', winPath(tmp), winPath(file)] : ['mv', '-f', tmp, file])
108 } catch {
109 // the widget is a bonus; the mod's own store still has everything
110 }
111}
112
113/**
114 * Starts the widget without waiting on it. The installed app first; while building,
115 * the widget folder beside this mod. `start` detaches it, so a mod reload never closes it.
116 */
117async function launchWidget($: Engine): Promise<string> {
118 const local = (await $.env.get('LOCALAPPDATA')) ?? ''
119 const root = $.plugin.root.replaceAll('\\', '/').replace(/\/$/, '').replace(/\/\.claude-plugin$/, '')
120 const dev = `${root.slice(0, root.lastIndexOf('/'))}/widget`
121 const installed = `${local}/Programs/Repo Radar/Repo Radar.exe`
122 const devElectron = `${dev}/node_modules/electron/dist/electron.exe`
123 const argv = local && (await $.fs.exists(installed))
124 ? [installed]
125 : (await $.fs.exists(devElectron))
126 ? [devElectron, dev]
127 : null
128 if (!argv) return "Couldn't find the Repo Radar widget. Install it from the AfterRealm releases page, then try /repos widget again."
129 const r = await $.process.run(['cmd', '/c', 'start', 'Repo Radar', ...argv.map(winPath)])
130 return r.exitCode === 0 ? '📡 Repo Radar widget launched.' : `Couldn't launch the widget: ${r.stderr.trim() || `exit ${r.exitCode}`}`
131}
132
133async function ghLogins($: Engine): Promise<{ host: string; login: string }[]> {
134 try {
135 const json = await $.process.run(['gh', 'auth', 'status', '--json', 'hosts'])
136 if (json.exitCode === 0) {
137 const hosts = JSON.parse(json.stdout).hosts as Record<string, { state: string; host: string; login: string }[]>
138 return Object.values(hosts)
139 .flat()
140 .filter(a => a.state === 'success')
141 .map(a => ({ host: a.host, login: a.login }))
142 }
143 // Older gh: no --json on auth status, so read the human output.
144 const text = await $.process.run(['gh', 'auth', 'status'])
145 const out = `${text.stdout}\n${text.stderr}`
146 return [...out.matchAll(/Logged in to (\S+) (?:account|as) ([\w-]+)/g)].map(m => ({ host: m[1]!, login: m[2]! }))
147 } catch {
148 return [] // gh not installed
149 }
150}
151
152async function discover($: Engine, fallbackToken: string): Promise<Login[]> {
153 const logins: Login[] = []
154 for (const { host, login } of await ghLogins($)) {
155 try {
156 const r = await $.process.run(['gh', 'auth', 'token', '--hostname', host, '--user', login])
157 if (r.exitCode === 0 && r.stdout.trim()) logins.push({ host, login, token: r.stdout.trim() })
158 } catch {
159 // skip an account whose token can't be read
160 }
161 }
162 if (logins.length === 0 && fallbackToken) logins.push({ host: 'github.com', login: '', token: fallbackToken })
163 return logins
164}
165
166function toRepo(node: any, now: number): Repo {
167 const ci = node.defaultBranchRef?.target?.statusCheckRollup?.state
168 const stale = node.pushedAt && now - Date.parse(node.pushedAt) > STALE_DAYS * 86400 * 1000
169 const health: Health = node.isArchived
170 ? 'archived'
171 : ci === 'FAILURE' || ci === 'ERROR'
172 ? 'red'
173 : stale
174 ? 'yellow'
175 : 'green'
176 return {
177 name: node.name,
178 owner: node.owner.login,
179 url: node.url,
180 isPrivate: node.isPrivate,
181 isFork: node.isFork,
182 stars: node.stargazerCount,
183 forks: node.forkCount,
184 issues: node.issues.totalCount,
185 prs: node.pullRequests.totalCount,
186 release: node.latestRelease?.tagName ?? null,
187 pushedAt: node.pushedAt,
188 language: node.primaryLanguage?.name ?? null,
189 languageColor: node.primaryLanguage?.color ?? null,
190 health,
191 canPush: ['ADMIN', 'MAINTAIN', 'WRITE'].includes(node.viewerPermission),
192 }
193}
194
195async function fetchAccount($: Engine, who: Login, now: number): Promise<Account> {
196 const repos: Repo[] = []
197 let login = who.login
198 let after: string | null = null
199 for (let page = 0; page < PAGE_LIMIT; page++) {
200 const res = await $.http.fetch(graphqlUrl(who.host), {
201 method: 'POST',
202 headers: { Authorization: `bearer ${who.token}`, 'Content-Type': 'application/json', 'User-Agent': 'repo-radar' },
203 body: JSON.stringify({ query: QUERY, variables: { after } }),
204 })
205 const body = JSON.parse(res.text)
206 if (!res.ok || body.errors) {
207 const why = body.errors?.[0]?.message ?? body.message ?? `HTTP ${res.status}`
208 return { host: who.host, login: login || '?', repos, error: why }
209 }
210 const conn = body.data.viewer.repositories
211 login = body.data.viewer.login
212 repos.push(...conn.nodes.map((n: any) => toRepo(n, now)))
213 if (!conn.pageInfo.hasNextPage) break
214 after = conn.pageInfo.endCursor
215 }
216 return { host: who.host, login, repos }
217}
218
219function tickerLines(snap: Snapshot): string[] {
220 const all = snap.accounts.flatMap(a => a.repos)
221 const live = all.filter(r => r.health !== 'archived')
222 const red = live.filter(r => r.health === 'red')
223 const stars = all.reduce((n, r) => n + r.stars, 0)
224 const forks = all.reduce((n, r) => n + r.forks, 0)
225 const issues = live.reduce((n, r) => n + r.issues, 0)
226 const prs = live.reduce((n, r) => n + r.prs, 0)
227 const lines = [
228 `${all.length} repos · ⭐ ${compact(stars)} · 🍴 ${compact(forks)}`,
229 `🐛 ${issues} open issues · 🔀 ${prs} open PRs`,
230 ]
231 if (red.length) lines.push(`🔴 CI failing: ${red.slice(0, 3).map(r => r.name).join(', ')}${red.length > 3 ? '…' : ''}`)
232 const top = [...all].sort((a, b) => b.stars - a.stars)[0]
233 if (top && top.stars > 0) lines.push(`🏆 Top repo: ${top.name} (⭐ ${compact(top.stars)})`)
234 return lines
235}
236
237async function fetchTraffic($: Engine, who: Login, r: Repo): Promise<{ clones: DayRow[]; views: DayRow[] } | undefined> {
238 const base = `${restBase(who.host)}/repos/${r.owner}/${r.name}/traffic`
239 const [c, v] = await Promise.all([
240 $.http.fetch(`${base}/clones`, { headers: restHeaders(who.token) }),
241 $.http.fetch(`${base}/views`, { headers: restHeaders(who.token) }),
242 ])
243 if (!c.ok || !v.ok) return undefined
244 return { clones: JSON.parse(c.text).clones ?? [], views: JSON.parse(v.text).views ?? [] }
245}
246
247/**
248 * Fetches the freshest 14 days for every repo we can push to and folds them into the lifetime history.
249 * GitHub keeps only 14 days, so a repo skipped here loses days for good. Batched to stay polite.
250 */
251async function addTraffic($: Engine, who: Login, acct: Account, history: History): Promise<void> {
252 const picks = acct.repos.filter(r => r.canPush && r.health !== 'archived').slice(0, TRAFFIC_LIMIT)
253 for (let i = 0; i < picks.length; i += TRAFFIC_BATCH) {
254 await Promise.all(
255 picks.slice(i, i + TRAFFIC_BATCH).map(async r => {
256 try {
257 const rows = await fetchTraffic($, who, r)
258 if (!rows) return
259 const entry = (history[repoKey(r.owner, r.name)] ??= { clones: {}, views: {} })
260 record(entry.clones, rows.clones)
261 record(entry.views, rows.views)
262 } catch {
263 // traffic is a bonus; a failure leaves the row without it
264 }
265 }),
266 )
267 }
268}
269
270/** Gives every repo with history its lifetime totals and recent clone days. */
271function withLifetime(snap: Snapshot, history: History): Snapshot {
272 const keys = dayKeys(snap.fetchedAt)
273 const accounts = snap.accounts.map(a => ({
274 ...a,
275 repos: a.repos.map(r => {
276 const entry = history[repoKey(r.owner, r.name)]
277 if (!entry) return r
278 const life = lifetime(entry)
279 const recent = Object.entries(entry.clones).map(([timestamp, count]) => ({ timestamp, count }))
280 const traffic: Traffic = { clones: perDay(recent, keys), clonesLifetime: life.clones, viewsLifetime: life.views, since: life.since }
281 return { ...r, traffic }
282 }),
283 }))
284 return { ...snap, accounts }
285}
286
287async function importHistory($: Engine, fallbackToken: string, source: string): Promise<string> {
288 const src = source.trim()
289 if (!src) {
290 return [
291 'Usage: /repos-import <file> or /repos-import <owner/repo>:<path>',
292 'The file is JSON: { "by_repo": { "owner/name": { "days": { "YYYY-MM-DD": clones } } } }',
293 ].join('\n')
294 }
295 let text: string
296 const remote = /^([\w.-]+\/[\w.-]+):(.+)$/.exec(src)
297 if (remote) {
298 const logins = await discover($, fallbackToken)
299 const who = logins.find(l => l.host === 'github.com') ?? logins[0]
300 if (!who) return 'No GitHub account found to read that file with.'
301 const res = await $.http.fetch(`${restBase(who.host)}/repos/${remote[1]}/contents/${remote[2]}`, {
302 headers: { ...restHeaders(who.token), Accept: 'application/vnd.github.raw' },
303 })
304 if (!res.ok) return `Couldn't read ${src} (HTTP ${res.status}).`
305 text = res.text
306 } else {
307 text = await $.fs.read(src)
308 }
309 const history = ((await $.store.get('history')) as History | undefined) ?? {}
310 let merged: { repos: number; days: number }
311 try {
312 merged = importTracker(history, JSON.parse(text))
313 } catch (err) {
314 return `Couldn't import ${src}: ${err instanceof Error ? err.message : err}`
315 }
316 await $.store.set('history', history)
317 await saveShared($, { history })
318 const snap = await read($, snapshot)
319 if (snap) await update($, snapshot, () => withLifetime(snap, history))
320 return `📥 Imported ${merged.days} days of clone history across ${merged.repos} repos. Lifetime totals updated.`
321}
322
323async function checkRelease($: Engine, who: Login, r: Repo): Promise<string> {
324 const res = await $.http.fetch(`${restBase(who.host)}/repos/${r.owner}/${r.name}/releases?per_page=5`, {
325 headers: restHeaders(who.token),
326 })
327 if (!res.ok) return `⚠ ${r.name}: couldn't read releases (HTTP ${res.status})`
328 const releases = (JSON.parse(res.text) as any[]).filter(x => !x.draft)
329 if (releases.length === 0) return `· ${r.name}: no published releases`
330 const [latest, previous] = releases
331 const names = (x: any): string[] => (x.assets ?? []).map((a: any) => a.name)
332 const broken = (latest.assets ?? []).filter((a: any) => a.state !== 'uploaded' || a.size === 0).map((a: any) => a.name)
333 const missing = previous ? missingAssets(names(latest), names(previous)) : []
334 const count = names(latest).length
335 if (missing.length === 0 && broken.length === 0) {
336 return `✅ ${r.name} ${latest.tag_name}: ${count} asset${count === 1 ? '' : 's'}${previous ? `, matches ${previous.tag_name}` : ''}`
337 }
338 const lines = [`❌ ${r.name} ${latest.tag_name}: ${count} asset${count === 1 ? '' : 's'}`]
339 for (const n of missing) lines.push(` missing (was in ${previous.tag_name}): ${n}`)
340 for (const n of broken) lines.push(` broken upload: ${n}`)
341 return lines.join('\n')
342}
343
344async function releaseCheck($: Engine, fallbackToken: string, args: string): Promise<string> {
345 const snap = await read($, snapshot)
346 if (!snap) return 'Repo Radar has not finished its first scan yet. Try again in a moment.'
347 const want = args.trim().toLowerCase()
348 const targets = snap.accounts.flatMap(a =>
349 a.repos
350 .filter(r => (want ? r.name.toLowerCase() === want || repoKey(r.owner, r.name).toLowerCase() === want : r.release))
351 .map(r => ({ acct: a, repo: r })),
352 )
353 if (targets.length === 0) return want ? `No repo named "${args.trim()}" in Repo Radar.` : 'None of your repos have releases.'
354 const logins = await discover($, fallbackToken)
355 const results = await Promise.all(
356 targets.map(({ acct, repo }) => {
357 const who = logins.find(l => l.host === acct.host && (l.login === acct.login || !l.login))
358 return who
359 ? checkRelease($, who, repo).catch(err => `⚠ ${repo.name}: ${err}`)
360 : Promise.resolve(`⚠ ${repo.name}: signed out of ${acct.login}`)
361 }),
362 )
363 const bad = results.filter(r => r.startsWith('❌')).length
364 return [`📦 Release check: ${results.length} repo${results.length === 1 ? '' : 's'}, ${bad} with problems`, ...results].join('\n')
365}
366
367async function announce($: Engine, before: Snapshot | null, snap: Snapshot, shared?: Ledger | null): Promise<void> {
368 if (before) {
369 // If the widget swept since our last look, it already notified for what it saw.
370 const base = shared && shared.at > before.fetchedAt ? shared : toLedger(before)
371 const news = changes(base, snap).map(describe)
372 news.slice(0, TOAST_LIMIT).forEach(line => $.ui.toast(line, { timeoutMs: 8000 }))
373 if (news.length > TOAST_LIMIT) $.ui.toast(`📡 +${news.length - TOAST_LIMIT} more updates`, { timeoutMs: 8000 })
374 return
375 }
376 // First scan of this session: compare with where things stood last time.
377 const ledger = (await $.store.get('ledger')) as Ledger | undefined
378 if (!ledger || snap.fetchedAt - ledger.at < AWAY_MS) return
379 const since = new Date(ledger.at).toISOString().slice(0, 10)
380 const keys = dayKeys(snap.fetchedAt)
381 const clones = snap.accounts
382 .flatMap(a => a.repos)
383 .reduce((n, r) => n + (r.traffic?.clones.reduce((m, c, i) => ((keys[i] ?? '') >= since ? m + c : m), 0) ?? 0), 0)
384 const list = changes(ledger, snap)
385 if (list.length === 0) return // nothing new: no "all quiet" card
386 const line = `🌅 While you were away (${ago(new Date(ledger.at).toISOString(), snap.fetchedAt)}): ${summarize(list, clones)}`
387 await update($, digest, () => [line, ...list.slice(0, 6).map(describe)].join('\n'))
388 $.ui.toast(line, { timeoutMs: 10000 })
389}
390
391let isRefreshing = false
392
393async function refresh($: Engine, fallbackToken: string): Promise<void> {
394 if (isRefreshing) return
395 isRefreshing = true
396 try {
397 await update($, status, () => 'loading' as Status)
398 const logins = await discover($, fallbackToken)
399 if (logins.length === 0) {
400 await update($, status, () => 'no-auth' as Status)
401 $.ui.status(undefined)
402 return
403 }
404 const now = await $.clock.now()
405 const before = await read($, snapshot)
406 const isTrafficDue = !before?.trafficAt || now - before.trafficAt > TRAFFIC_MS
407 const oldTraffic = new Map(
408 (before?.accounts ?? []).flatMap(a => a.repos).map(r => [repoKey(r.owner, r.name), r.traffic] as const),
409 )
410 const shared = await loadShared($)
411 const history = mergeHistory(((await $.store.get('history')) as History | undefined) ?? {}, shared?.history)
412 // GitHub hands back 14 days, so the first scan's window is where our own record starts.
413 let watchedSince = (await $.store.get('watchedSince')) as string | undefined
414 if (!watchedSince) {
415 watchedSince = dayKeys(now)[0]!
416 await $.store.set('watchedSince', watchedSince)
417 }
418 const accounts: Account[] = []
419 for (const who of logins) {
420 try {
421 const acct = await fetchAccount($, who, now)
422 if (isTrafficDue) await addTraffic($, who, acct, history)
423 else for (const r of acct.repos) r.traffic = oldTraffic.get(repoKey(r.owner, r.name))
424 accounts.push(acct)
425 } catch (err) {
426 accounts.push({ host: who.host, login: who.login || '?', repos: [], error: String(err) })
427 }
428 }
429 const fresh: Snapshot = { accounts, fetchedAt: now, trafficAt: isTrafficDue ? now : (before?.trafficAt ?? now), watchedSince }
430 const snap = isTrafficDue ? withLifetime(fresh, history) : fresh
431 if (isTrafficDue) await $.store.set('history', history)
432 // The widget may have swept more recently; its ledger then says what's already been announced.
433 const sharedLedger = shared?.ledger
434 const storeLedger = (await $.store.get('ledger')) as Ledger | undefined
435 if (sharedLedger && (!storeLedger || sharedLedger.at > storeLedger.at)) await $.store.set('ledger', sharedLedger)
436 await announce($, before, snap, shared?.ledger)
437 await update($, snapshot, () => snap)
438 await $.store.set('ledger', toLedger(snap))
439 await saveShared($, { history, ledger: toLedger(snap), watchedSince })
440 // First load: open the person's own repos; everything else starts folded.
441 await update($, expanded, list => (list.length ? list : accounts.map(a => `${a.host}/${a.login}`)))
442 await update($, status, () => 'ready' as Status)
443 await update($, message, () => '')
444 } catch (err) {
445 await update($, status, () => 'error' as Status)
446 await update($, message, () => String(err))
447 } finally {
448 isRefreshing = false
449 }
450}
451
452export const register: Register = (on, options) => {
453 const fallbackToken = String((options as Record<string, unknown>)?.github_token ?? '')
454 let tick = 0
455
456 on('session.start', async ($, e, next) => {
457 await $.command.register({ name: 'repos', description: 'Open Repo Radar, your GitHub dashboard (/repos widget opens the desktop widget)' })
458 await $.command.register({
459 name: 'release-check',
460 description: 'Check your latest releases have every asset the release before had (optionally name one repo)',
461 })
462 await $.command.register({
463 name: 'repos-import',
464 description: 'Import older clone history into Repo Radar: a JSON file path, or owner/repo:path on GitHub',
465 })
466 const savedHidden = await $.store.get('hiddenColumns')
467 if (Array.isArray(savedHidden)) await update($, hiddenColumns, () => savedHidden.map(String))
468 void refresh($, fallbackToken)
469 $.clock.every(REFRESH_MS, () => void refresh($, fallbackToken))
470 $.clock.every(TICK_MS, async () => {
471 const snap = await read($, snapshot)
472 if (!snap) return
473 const lines = tickerLines(snap)
474 $.ui.status(lines[tick++ % lines.length])
475 })
476 return next(e)
477 })
478
479 on('command.run', { command: 'repos' }, async ($, e) => {
480 if ((e.args ?? '').trim() === 'widget') return { text: await launchWidget($) }
481 const opened = await $.ui.open({ id: PANE, title: '📡 Repo Radar', focus: true, closeOnEscape: true, rows: 26, columns: 98 })
482 const snap = await read($, snapshot)
483 if (!snap || (await $.clock.now()) - snap.fetchedAt > REFRESH_MS) void refresh($, fallbackToken)
484 const where = e.presentation?.isFullscreen
485 ? 'docked on the right'
486 : 'above the prompt (turn on the fullscreen layout to dock it on the right)'
487 return { text: opened.isPlaced ? `Repo Radar opened ${where}. Esc closes it.` : `Repo Radar couldn't open: ${'reason' in opened ? opened.reason : 'no room'}` }
488 })
489
490 on('command.run', { command: 'repos-import' }, async ($, e) => ({
491 text: await importHistory($, fallbackToken, e.args ?? ''),
492 }))
493
494 on('command.run', { command: 'release-check' }, async ($, e) => ({
495 text: await releaseCheck($, fallbackToken, e.args ?? ''),
496 }))
497
498 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
499 const { Box, Text, Button } = $.ui.resolve(e)
500 const snap = await read($, snapshot)
501 const state = await read($, status)
502 const why = await read($, message)
503 const open = await read($, expanded)
504 const away = await read($, digest)
505 const hidden = await read($, hiddenColumns)
506 const now = await $.clock.now()
507 const width = e.props.bodyColumns ?? e.viewport?.columns ?? 80
508 const isWide = width >= 70
509 const hasTraffic = width >= 88
510
511 const toggle = (k: string) =>
512 update($, expanded, list => (list.includes(k) ? list.filter(x => x !== k) : [...list, k]))
513
514 const header = (
515 <Box flexDirection="column" marginBottom={1}>
516 <Box gap={2}>
517 <Text bold color="cyan">📡 REPO RADAR</Text>
518 <Button key="refresh" label={state === 'loading' ? 'scanning…' : '↻ Refresh'} hotkey="r" onPress={() => void refresh($, fallbackToken)} />
519 </Box>
520 {snap && (
521 <Text dimColor>
522 {snap.accounts.length} account{snap.accounts.length === 1 ? '' : 's'} ·{' '}
523 {snap.accounts.reduce((n, a) => n + a.repos.length, 0)} repos · swept {ago(new Date(snap.fetchedAt).toISOString(), now)} ago
524 {e.props.isFocused ? ' · ↑↓ scroll · r refresh' : ' · Tab here to scroll'}
525 </Text>
526 )}
527 {away !== '' && (
528 <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} marginTop={1}>
529 {away.split('\n').map((line, i) => (
530 <Text key={`away:${i}`} bold={i === 0} color={i === 0 ? 'yellow' : undefined}>
531 {line}
532 </Text>
533 ))}
534 <Button key="dismiss-digest" plain dimColor label="dismiss" onPress={() => void update($, digest, () => '')} />
535 </Box>
536 )}
537 </Box>
538 )
539
540 if (state === 'no-auth') {
541 return (
542 <Box flexDirection="column">
543 {header}
544 <Text bold>No GitHub account found 🔌</Text>
545 <Text>Sign in with the GitHub CLI, then press Refresh:</Text>
546 <Text color="cyan"> gh auth login</Text>
547 <Text dimColor>No gh? Paste a token into Repo Radar's github_token setting in /config.</Text>
548 </Box>
549 )
550 }
551
552 if (!snap) {
553 return (
554 <Box flexDirection="column">
555 {header}
556 <Text dimColor>{state === 'error' ? `Scan failed: ${why}` : 'Sweeping the skies for repos…'}</Text>
557 </Box>
558 )
559 }
560
561 const longest = Math.max(10, ...snap.accounts.flatMap(a => a.repos.map(r => r.name.length + (r.isPrivate ? 3 : 0) + (r.isFork ? 2 : 0))))
562 const zero = (n: number) => n === 0
563 const since = snap.accounts
564 .flatMap(a => a.repos)
565 .map(r => r.traffic?.since)
566 .filter((d): d is string => !!d)
567 .sort()[0]
568
569 // Every column but the name: its width, whether the pane has room, and how a row fills it.
570 const COLUMNS: { id: string; label: string; width: number; fits: boolean; cell: (r: Repo) => RenderChildren }[] = [
571 {
572 id: 'lang', label: '◆', width: 2, fits: isWide,
573 cell: r => <Text color={r.languageColor ?? undefined}>{r.languageColor ? '◆' : ' '}</Text>,
574 },
575 {
576 id: 'stars', label: 'stars', width: 7, fits: true,
577 cell: r => <Text color={zero(r.stars) ? undefined : 'yellow'} dimColor={zero(r.stars)}>★ {compact(r.stars)}</Text>,
578 },
579 {
580 id: 'issues', label: 'issues', width: 7, fits: isWide,
581 cell: r => <Text dimColor={zero(r.issues)}>{zero(r.issues) ? '·' : r.issues}</Text>,
582 },
583 {
584 id: 'prs', label: 'PRs', width: 5, fits: isWide,
585 cell: r => <Text dimColor={zero(r.prs)} color={r.prs ? 'magenta' : undefined}>{zero(r.prs) ? '·' : r.prs}</Text>,
586 },
587 {
588 id: 'release', label: 'release', width: 12, fits: isWide,
589 cell: r => <Text color="green" wrap="truncate">{r.release ?? ''}</Text>,
590 },
591 { id: 'pushed', label: 'pushed', width: 7, fits: true, cell: r => <Text dimColor>{ago(r.pushedAt, now)}</Text> },
592 {
593 id: 'clones', label: 'clones', width: 8, fits: hasTraffic,
594 cell: r => (
595 <Text color={r.traffic?.clonesLifetime ? 'cyan' : undefined} dimColor={!r.traffic?.clonesLifetime}>
596 {r.traffic ? compact(r.traffic.clonesLifetime) : ''}
597 </Text>
598 ),
599 },
600 {
601 id: 'views', label: 'views', width: 7, fits: hasTraffic,
602 cell: r => <Text dimColor={!r.traffic?.viewsLifetime}>{r.traffic ? compact(r.traffic.viewsLifetime) : ''}</Text>,
603 },
604 ]
605 const fitting = COLUMNS.filter(c => c.fits)
606 const shown = fitting.filter(c => !hidden.includes(c.id))
607 const folded = fitting.filter(c => hidden.includes(c.id))
608 const used = shown.reduce((n, c) => n + c.width, 0)
609 const nameWidth = Math.max(12, Math.min(longest + 2, 34, width - 8 - used))
610
611 const toggleColumn = (id: string) =>
612 update($, hiddenColumns, list => {
613 const next = list.includes(id) ? list.filter(x => x !== id) : [...list, id]
614 void $.store.set('hiddenColumns', next)
615 void saveShared($, { hiddenColumns: next })
616 return next
617 })
618
619 // Header buttons hide their column; a hidden column waits at the end as "+name".
620 const columns = (scope: string) => (
621 <Box paddingLeft={4}>
622 <Box width={nameWidth}><Text dimColor>repo</Text></Box>
623 {shown.map(c => (
624 <Box key={`col:${scope}:${c.id}`} width={c.width} justifyContent="flex-end">
625 <Button key={`hide:${scope}:${c.id}`} plain dimColor label={c.label} onPress={() => void toggleColumn(c.id)} />
626 </Box>
627 ))}
628 {folded.map(c => (
629 <Box key={`fold:${scope}:${c.id}`} marginLeft={1}>
630 <Button key={`show:${scope}:${c.id}`} plain dimColor label={`+${c.label}`} onPress={() => void toggleColumn(c.id)} />
631 </Box>
632 ))}
633 </Box>
634 )
635
636 const row = (r: Repo) => (
637 <Box key={`${r.owner}/${r.name}`}>
638 <Text color={DOT[r.health].color}>{DOT[r.health].glyph} </Text>
639 <Box width={nameWidth}>
640 <Text wrap="truncate" dimColor={r.health === 'archived'}>
641 {r.name}
642 {r.isPrivate ? ' 🔒' : ''}
643 {r.isFork ? ' ⑂' : ''}
644 </Text>
645 </Box>
646 {shown.map(c => (
647 <Box key={`${r.owner}/${r.name}:${c.id}`} width={c.width} justifyContent="flex-end">
648 {c.cell(r)}
649 </Box>
650 ))}
651 </Box>
652 )
653
654 return (
655 <Box flexDirection="column">
656 {header}
657 {snap.accounts.map(acct => {
658 const owners = [...new Set(acct.repos.map(r => r.owner))].sort((a, b) =>
659 a === acct.login ? -1 : b === acct.login ? 1 : a.localeCompare(b),
660 )
661 return (
662 <Box key={`acct:${acct.host}/${acct.login}`} flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginBottom={1}>
663 <Text bold>
664 🐙 {acct.login}
665 <Text dimColor>{acct.host === 'github.com' ? '' : ` @ ${acct.host}`}</Text>
666 </Text>
667 {acct.error && <Text color="red">⚠ {acct.error}</Text>}
668 {columns(`${acct.host}/${acct.login}`)}
669 {owners.map(owner => {
670 const k = `${acct.host}/${owner}`
671 const repos = acct.repos.filter(r => r.owner === owner)
672 const isOpen = open.includes(k)
673 const showAll = open.includes(`${k}:all`)
674 const shown = showAll ? repos : repos.slice(0, PREVIEW)
675 const stars = repos.reduce((n, r) => n + r.stars, 0)
676 const reds = repos.filter(r => r.health === 'red').length
677 return (
678 <Box key={k} flexDirection="column">
679 <Box>
680 <Button key={`fold:${k}`} plain label={isOpen ? '▾' : '▸'} onPress={() => void toggle(k)} />
681 <Text bold color={owner === acct.login ? 'cyan' : 'blue'}> {owner}</Text>
682 <Text dimColor> {repos.length} repos · ★{compact(stars)}</Text>
683 {reds > 0 && <Text color="red"> 🔴 {reds}</Text>}
684 </Box>
685 {isOpen && <Box flexDirection="column" paddingLeft={2}>{shown.map(row)}</Box>}
686 {isOpen && repos.length > PREVIEW && (
687 <Box paddingLeft={2}>
688 <Button
689 key={`more:${k}`}
690 plain
691 dimColor
692 label={showAll ? 'show less' : `+${repos.length - PREVIEW} more`}
693 onPress={() => void toggle(`${k}:all`)}
694 />
695 </Box>
696 )}
697 </Box>
698 )
699 })}
700 </Box>
701 )
702 })}
703 {hasTraffic && since && snap.watchedSince && (
704 <Text dimColor>
705 clones since {shortDate(since < snap.watchedSince ? since : snap.watchedSince, now)} · views since{' '}
706 {shortDate(snap.watchedSince, now)} (GitHub only keeps 14 days of views)
707 </Text>
708 )}
709 <Text dimColor><Text color="green">●</Text> active <Text color="yellow">●</Text> quiet {STALE_DAYS}d+ <Text color="red">●</Text> CI failing ○ archived</Text>
710 </Box>
711 )
712 })
713}
714hooks/lib.ts 151 lines1import type { Ledger, Snapshot } from '../types'
2
3const DAY = 86400 * 1000
4
5export const ago = (iso: string | null, now: number): string => {
6 if (!iso) return '—'
7 const s = Math.max(0, (now - Date.parse(iso)) / 1000)
8 if (s < 3600) return `${Math.max(1, Math.round(s / 60))}m`
9 if (s < 86400) return `${Math.round(s / 3600)}h`
10 if (s < 86400 * 60) return `${Math.round(s / 86400)}d`
11 if (s < 86400 * 730) return `${Math.round(s / (86400 * 30))}mo`
12 return `${Math.round(s / (86400 * 365))}y`
13}
14
15export const compact = (n: number): string =>
16 n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n)
17
18/** The last `days` UTC dates ending at `now`, oldest first, as YYYY-MM-DD. */
19export const dayKeys = (now: number, days = 14): string[] =>
20 Array.from({ length: days }, (_, i) => new Date(now - (days - 1 - i) * DAY).toISOString().slice(0, 10))
21
22/** GitHub's traffic rows mapped onto fixed days, zero where a day is missing. */
23export const perDay = (rows: { timestamp: string; count: number }[], keys: string[]): number[] => {
24 const byDay = new Map(rows.map(r => [r.timestamp.slice(0, 10), r.count]))
25 return keys.map(k => byDay.get(k) ?? 0)
26}
27
28/** "2026-04-14" as "Apr 14", with the year when it isn't this year's. */
29export const shortDate = (day: string, now: number): string => {
30 const d = new Date(`${day}T00:00:00Z`)
31 const month = d.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' })
32 const year = d.getUTCFullYear() === new Date(now).getUTCFullYear() ? '' : ` ${d.getUTCFullYear()}`
33 return `${month} ${d.getUTCDate()}${year}`
34}
35
36export const repoKey = (owner: string, name: string) => `${owner}/${name}`
37
38export function toLedger(snap: Snapshot): Ledger {
39 const repos: Ledger['repos'] = {}
40 for (const r of snap.accounts.flatMap(a => a.repos)) {
41 repos[repoKey(r.owner, r.name)] = { stars: r.stars, forks: r.forks, issues: r.issues, release: r.release }
42 }
43 return { at: snap.fetchedAt, repos }
44}
45
46export type Change = { kind: 'star' | 'fork' | 'issue' | 'release'; repo: string; amount: number; tag?: string }
47
48/** What grew between an earlier ledger and a newer snapshot. Repos new to the ledger are not news. */
49export function changes(before: Ledger, after: Snapshot): Change[] {
50 const out: Change[] = []
51 for (const r of after.accounts.flatMap(a => a.repos)) {
52 const was = before.repos[repoKey(r.owner, r.name)]
53 if (!was) continue
54 if (r.stars > was.stars) out.push({ kind: 'star', repo: r.name, amount: r.stars - was.stars })
55 if (r.forks > was.forks) out.push({ kind: 'fork', repo: r.name, amount: r.forks - was.forks })
56 if (r.issues > was.issues) out.push({ kind: 'issue', repo: r.name, amount: r.issues - was.issues })
57 if (r.release && r.release !== was.release) out.push({ kind: 'release', repo: r.name, amount: 1, tag: r.release })
58 }
59 return out
60}
61
62export function describe(c: Change): string {
63 switch (c.kind) {
64 case 'star':
65 return `⭐ +${c.amount} star${c.amount === 1 ? '' : 's'} on ${c.repo}`
66 case 'fork':
67 return `🍴 ${c.repo} was forked${c.amount > 1 ? ` ${c.amount}×` : ''}`
68 case 'issue':
69 return `🐛 ${c.amount} new issue${c.amount === 1 ? '' : 's'} on ${c.repo}`
70 case 'release':
71 return `🚀 ${c.repo} shipped ${c.tag}`
72 }
73}
74
75/** One line summing a batch of changes, for the digest. */
76export function summarize(list: Change[], clones: number): string {
77 const sum = (k: Change['kind']) => list.filter(c => c.kind === k).reduce((n, c) => n + c.amount, 0)
78 const parts = [
79 sum('star') && `+${sum('star')} ⭐`,
80 sum('fork') && `+${sum('fork')} 🍴`,
81 sum('issue') && `${sum('issue')} new issue${sum('issue') === 1 ? '' : 's'}`,
82 sum('release') && `${sum('release')} release${sum('release') === 1 ? '' : 's'} 🚀`,
83 clones && `${compact(clones)} clones`,
84 ].filter(Boolean)
85 return parts.length ? parts.join(' · ') : 'all quiet'
86}
87
88/** An asset name with its version numbers swapped for a placeholder, so releases compare. */
89export const assetShape = (name: string): string =>
90 name.replace(/v?\d+\.\d+\.\d+(?:-(?:alpha|beta|rc|pre)[.\d]*)?/gi, '{v}')
91
92/** Assets the previous release had that the latest lacks, by shape. */
93export function missingAssets(latest: string[], previous: string[]): string[] {
94 const have = new Set(latest.map(assetShape))
95 return previous.filter(n => !have.has(assetShape(n)))
96}
97
98
99export type DayRow = { timestamp: string; count: number }
100
101/** Daily traffic per repo, kept across sessions; zero days are not stored. */
102export type History = Record<string, { clones: Record<string, number>; views: Record<string, number> }>
103
104/** Folds GitHub's daily rows into a repo's days. A day only ever grows, so keep the larger count. */
105export function record(days: Record<string, number>, rows: DayRow[]): void {
106 for (const row of rows) {
107 const day = row.timestamp.slice(0, 10)
108 if (row.count > 0) days[day] = Math.max(days[day] ?? 0, row.count)
109 }
110}
111
112const total = (days: Record<string, number>) => Object.values(days).reduce((n, c) => n + c, 0)
113
114/** A repo's lifetime totals and the first day on record. */
115export function lifetime(entry: History[string] | undefined): { clones: number; views: number; since: string | null } {
116 if (!entry) return { clones: 0, views: 0, since: null }
117 const days = [...Object.keys(entry.clones), ...Object.keys(entry.views)].sort()
118 return { clones: total(entry.clones), views: total(entry.views), since: days[0] ?? null }
119}
120
121/**
122 * Merges a clone tracker's export into the history:
123 * `{ by_repo: { "owner/name": { days: { "YYYY-MM-DD": count } } } }`.
124 */
125export function importTracker(history: History, data: unknown): { repos: number; days: number } {
126 const byRepo = (data as { by_repo?: Record<string, { days?: Record<string, number> }> })?.by_repo
127 if (!byRepo || typeof byRepo !== 'object') throw new Error('expected a JSON object with a "by_repo" field')
128 let repos = 0
129 let days = 0
130 for (const [key, entry] of Object.entries(byRepo)) {
131 const rows = Object.entries(entry?.days ?? {}).map(([timestamp, count]) => ({ timestamp, count: Number(count) || 0 }))
132 if (rows.length === 0) continue
133 const target = (history[key] ??= { clones: {}, views: {} })
134 record(target.clones, rows)
135 repos += 1
136 days += rows.length
137 }
138 return { repos, days }
139}
140
141/** Folds `from` into `into`, day by day, keeping the larger count; either side may hold days the other lacks. */
142export function mergeHistory(into: History, from: History | undefined): History {
143 for (const [key, entry] of Object.entries(from ?? {})) {
144 const target = (into[key] ??= { clones: {}, views: {} })
145 for (const kind of ['clones', 'views'] as const) {
146 for (const [day, n] of Object.entries(entry?.[kind] ?? {})) target[kind][day] = Math.max(target[kind][day] ?? 0, n)
147 }
148 }
149 return into
150}
151types/index.d.ts 50 lines1export type Health = 'green' | 'yellow' | 'red' | 'archived'
2
3export type Repo = {
4 name: string
5 owner: string
6 url: string
7 isPrivate: boolean
8 isFork: boolean
9 stars: number
10 forks: number
11 issues: number
12 prs: number
13 release: string | null
14 pushedAt: string | null
15 language: string | null
16 languageColor: string | null
17 health: Health
18 canPush: boolean
19 traffic?: Traffic
20}
21
22/** `clones` is the last 14 days, oldest first; the lifetime totals count every day on record since `since`. */
23export type Traffic = { clones: number[]; clonesLifetime: number; viewsLifetime: number; since: string | null }
24
25/** Counts per repo at one moment, kept across sessions to say what changed. */
26export type Ledger = {
27 at: number
28 repos: Record<string, { stars: number; forks: number; issues: number; release: string | null }>
29}
30
31export type Account = { host: string; login: string; repos: Repo[]; error?: string }
32
33/** `watchedSince`: the first day Repo Radar's own traffic history covers (views have no older source). */
34export type Snapshot = { accounts: Account[]; fetchedAt: number; trafficAt: number; watchedSince: string }
35
36export type Status = 'idle' | 'loading' | 'ready' | 'no-auth' | 'error'
37
38declare module 'claude-code' {
39 interface PluginState {
40 'repo-radar': {
41 snapshot: Snapshot | null
42 status: Status
43 message: string
44 expanded: string[]
45 digest: string
46 hidden: string[]
47 }
48 }
49}
50