SLOPSHOPPER

repo-radar

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

newpanecommandtoaststatusprocess
v0.1.4MITupdated 2026-10-09J-M-Pineapple-Express/repo-radar/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repo-radar
│ ┃ 📡 Repo Radar ✕ › fix the failing auth test and add an audit log call │ ┃ 📡 REPO RADAR [ ↻ Refresh ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ No GitHub account found 🔌 ⎿ Read 6 lines │ ┃ Sign in with the GitHub CLI, then press ⏺ Update(src/auth.ts) │ ┃ Refresh: ⎿ Added 2 lines, removed 1 line │ ┃ gh auth login ⏺ Bash(bun test) │ ┃ No gh? Paste a token into Repo Radar's ⎿ 3 pass, 1 fail │ ┃ github_token setting in /config. │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /repos │ ⎿ repo-radar: Repo Radar opened docked on the right. Esc closes it │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 📡 Repo Radar
📡 REPO RADAR [ ↻ Refresh ] No GitHub account found 🔌 Sign in with the GitHub CLI, then press Refresh: gh auth login No gh? Paste a token into Repo Radar's github_token setting in /config.
README

📡 Repo Radar

A GitHub dashboard for people who ship Claude Code plugins and small apps. It comes in two parts that share one data file:

  • The widget: a small always-on-top window for Windows and macOS.
  • The mod: a Claude Code plugin with a /repos pane, toasts, and /repos widget to open the widget.

It sweeps every repo of every account the GitHub CLI is signed into.

What it shows

  • ⚠️ Needs Attention, your to-do list:
  • failing CI
  • Dependabot security alerts
  • releases missing files the previous release had
  • plugin versions that don't match across plugin.json, your marketplace's copy and the latest release
  • issues waiting on your reply, longest wait first
  • open PRs
  • work committed since your last release

Each 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.

  • Tiles and a 30-day chart: stars, lifetime clones, release downloads and CI status.
  • 🧩 Plugins · 📦 Apps · 🗂 Other: your repos, sorted by what they are. Each row shows only the numbers that matter for its kind, plus this week's trend. Open a repo for its chart, where visitors came from, and the pages they read.
  • ⭐ Following: repos you've starred or watch. New releases get a NEW badge, and Windows or macOS notifies you about repos you watch. If a repo has a plugin, marketplace or skill, Install shows the exact commands first, then runs them.
  • Lifetime history: GitHub keeps only 14 days of traffic. Repo Radar saves every day of clones, views and referrers, so your totals keep growing.
  • Notifications for new stars, forks, issues and releases, and 🎉 milestones.

Install

The widget: download the installer for your system from Releases:

  • Windows: RepoRadar-Setup-x.y.z.exe
  • macOS: RepoRadar-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.

Privacy

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.

Build it yourself

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.

License

MIT

Source 3 files
hooks/register.tsx 714 lines
1import { 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}
714
hooks/lib.ts 151 lines
1import 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}
151
types/index.d.ts 50 lines
1export 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