Live dashboard of every startup and tooling repo in the startup-factory registry

A Claude Code mod that turns a factory workspace into a live dashboard: one pane with every startup and tooling repo from projects.yaml.
url:), pod health from kubectl --context <kubectl_context>, git branch / dirty / ahead / behind, last commit; border and badge colored by health (LIVE / CHECK / DOWN)🏭 5/10 live · 2 dirty · ⚠ 1 downThe pane opens by itself in a session started in a factory workspace (any folder with a projects.yaml) (from 144 terminal columns), or with /factory.
Workspaces created from startup-factory-template enable the mod in their .claude/settings.json; Claude Code offers to install it when you trust the folder. Anywhere else, at a Claude Code prompt:
/plugin marketplace add Deploy-your-Startup/startup-factory-mod
/plugin install startup-factory@startup-factory-mod
projects:
- name: example-app
repo: https://github.com/example-owner/example-app.git
deployed: true # card in the "Live" section
kubectl_context: example-app-production # pod check in this context’s namespace
url: app.example.com # website check + link
Pod checks use the namespace configured in each Kubernetes context (or default when none is configured). Startups sharing a cluster need separate contexts with their own namespaces. Only Ready pods and completed jobs count as healthy; an empty namespace is shown as needing attention.
claude plugin validate .
claude plugin test .
tsc -p . type-checks once Claude Code has loaded the mod (it writes .claude-plugin/types/).
hooks/register.tsx 459 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, Cluster, Project, Site } from '../types'
5
6const PANE = 'startup-factory'
7const TITLE = 'Startup Factory'
8const GIT_EVERY_MS = 2 * 60 * 1000
9
10const EMPTY: Board = { root: null, projects: [], refreshedAt: null, isBusy: false }
11const board = atom({ plugin: 'startup-factory', key: 'board' } as const, EMPTY)
12
13type Entry = Record<string, string>
14
15// projects.yaml is flat enough that a line reader covers it: two top-level
16// lists (`projects:`, `tooling:`) of `- name: ...` items with scalar fields.
17function parseRegistry(text: string): { projects: Entry[]; tooling: Entry[] } {
18 const lists: { projects: Entry[]; tooling: Entry[] } = { projects: [], tooling: [] }
19 let list: Entry[] | null = null
20 let item: Entry | null = null
21 for (const raw of text.split('\n')) {
22 const line = raw.replace(/\s+#.*$/, '')
23 if (line.trim() === '' || line.trim().startsWith('#')) continue
24 const top = /^(\w[\w-]*):\s*$/.exec(line)
25 if (top) {
26 list = top[1] === 'projects' ? lists.projects : top[1] === 'tooling' ? lists.tooling : null
27 item = null
28 continue
29 }
30 const field = /^\s*(-\s+)?([\w-]+):\s*(.*)$/.exec(line)
31 if (!field || !list) continue
32 if (field[1]) {
33 item = {}
34 list.push(item)
35 }
36 if (item) item[field[2]!] = field[3]!.trim().replace(/^["']|["']$/g, '')
37 }
38 return lists
39}
40
41function githubUrl(repo: string | undefined): string | undefined {
42 const match = /github\.com[:/](.+?)(\.git)?$/.exec(repo ?? '')
43 return match ? `https://github.com/${match[1]}` : undefined
44}
45
46function projectPath(root: string, project: Project): string {
47 return `${root}/projects/${project.kind === 'startup' ? 'startups' : 'tooling'}/${project.name}`
48}
49
50async function readGit($: EngineInterface, root: string, project: Project): Promise<Project> {
51 const path = projectPath(root, project)
52 if (!(await $.fs.exists(`${path}/.git`))) return { ...project, isCloned: false }
53
54 const [status, log] = await Promise.all([
55 $.process.run(['git', '-C', path, 'status', '--porcelain=v2', '--branch'], { timeoutMs: 10_000 }),
56 $.process.run(['git', '-C', path, 'log', '-1', '--format=%ct%x09%s'], { timeoutMs: 10_000 }),
57 ])
58 let branch: string | undefined
59 let ahead = 0
60 let behind = 0
61 let dirty = 0
62 for (const line of status.stdout.split('\n')) {
63 if (line.startsWith('# branch.head ')) branch = line.slice('# branch.head '.length)
64 else if (line.startsWith('# branch.ab ')) {
65 const [, a, b] = /\+(\d+) -(\d+)/.exec(line) ?? []
66 ahead = Number(a ?? 0)
67 behind = Number(b ?? 0)
68 } else if (line !== '' && !line.startsWith('#')) dirty += 1
69 }
70 const [stamp, subject] = log.stdout.trim().split('\t')
71
72 return {
73 ...project,
74 isCloned: true,
75 branch,
76 ahead,
77 behind,
78 dirty,
79 lastCommitAt: stamp ? Number(stamp) * 1000 : undefined,
80 lastCommit: subject || undefined,
81 }
82}
83
84async function readCluster($: EngineInterface, context: string): Promise<Cluster> {
85 try {
86 const run = await $.process.run(
87 ['kubectl', '--context', context, 'get', 'pods', '-o', 'json'],
88 { timeoutMs: 20_000 },
89 )
90 if (run.exitCode !== 0) {
91 const message = run.stderr.trim().split('\n')[0] ?? `exit ${run.exitCode}`
92 return { state: 'error', message: message.slice(0, 120) }
93 }
94 const result = JSON.parse(run.stdout) as { items: Array<{
95 metadata?: { deletionTimestamp?: string }
96 status?: { phase?: string; conditions?: Array<{ type: string; status: string }> }
97 }> }
98 const pods = result.items.filter(pod => !pod.metadata?.deletionTimestamp)
99 const running = pods.filter(pod => pod.status?.phase === 'Succeeded' ||
100 (pod.status?.phase === 'Running' &&
101 pod.status.conditions?.some(condition => condition.type === 'Ready' && condition.status === 'True'))).length
102 return { state: 'ok', running, total: pods.length }
103 } catch (error) {
104 return { state: 'error', message: String(error).slice(0, 120) }
105 }
106}
107
108async function readSite($: EngineInterface, url: string): Promise<Site> {
109 const startedAt = await $.clock.now()
110 try {
111 const response = await $.http.fetch(url, { method: 'GET' })
112 return { state: 'ok', status: response.status, ms: (await $.clock.now()) - startedAt }
113 } catch (error) {
114 return { state: 'error', message: String(error).slice(0, 120) }
115 }
116}
117
118function siteUrl(raw: string | undefined): string | undefined {
119 if (!raw) return undefined
120 return /^https?:\/\//.test(raw) ? raw : `https://${raw}`
121}
122
123function hostOf(url: string): string {
124 return url.replace(/^https?:\/\//, '').replace(/\/$/, '')
125}
126
127async function findRoot($: EngineInterface): Promise<string | null> {
128 for (const dir of [await $.session.root(), await $.session.cwd()]) {
129 if (await $.fs.exists(`${dir}/projects.yaml`)) return dir
130 }
131 return null
132}
133
134async function refresh($: EngineInterface): Promise<void> {
135 const current = await read($, board)
136 if (current.isBusy) return
137 await update($, board, b => ({ ...b, isBusy: true }))
138
139 try {
140 const root = await findRoot($)
141 if (root === null) {
142 await update($, board, b => ({ ...b, root: null, error: 'Keine projects.yaml in diesem Projekt gefunden.' }))
143 return
144 }
145 const registry = parseRegistry(await $.fs.read(`${root}/projects.yaml`))
146 const previous = new Map(current.projects.map(p => [p.name, p]))
147 const toProject = (kind: Project['kind']) => (entry: Entry): Project => ({
148 name: entry.name ?? '?',
149 kind,
150 description: entry.description,
151 repo: githubUrl(entry.repo),
152 isDeployed: entry.deployed === 'true',
153 context: entry.kubectl_context,
154 url: siteUrl(entry.url),
155 isCloned: false,
156 cluster: previous.get(entry.name ?? '')?.cluster ?? { state: 'unknown' },
157 site: previous.get(entry.name ?? '')?.site ?? { state: 'unknown' },
158 })
159 const listed = [
160 ...registry.projects.map(toProject('startup')),
161 ...registry.tooling.map(toProject('tooling')),
162 ]
163 const projects = await Promise.all(
164 listed.map(p => readGit($, root, p).catch(() => p)),
165 )
166 const now = await $.clock.now()
167 await update($, board, b => ({ ...b, root, projects, refreshedAt: now, error: undefined }))
168 await showStatus($)
169 } finally {
170 await update($, board, b => ({ ...b, isBusy: false }))
171 }
172}
173
174async function runChecks($: EngineInterface): Promise<void> {
175 const { projects } = await read($, board)
176 const patch = (name: string, change: Partial<Project>) =>
177 update($, board, b => ({
178 ...b,
179 projects: b.projects.map(p => (p.name === name ? { ...p, ...change } : p)),
180 }))
181 const clusters = projects
182 .filter(p => p.isDeployed && p.context)
183 .map(async p => {
184 await patch(p.name, { cluster: { state: 'checking' } })
185 await patch(p.name, { cluster: await readCluster($, p.context!) })
186 })
187 const sites = projects
188 .filter(p => p.url)
189 .map(async p => {
190 await patch(p.name, { site: { state: 'checking' } })
191 await patch(p.name, { site: await readSite($, p.url!) })
192 })
193 await Promise.all([...clusters, ...sites])
194 await showStatus($)
195}
196
197async function showStatus($: EngineInterface): Promise<void> {
198 const { projects } = await read($, board)
199 const startups = projects.filter(p => p.kind === 'startup')
200 const live = startups.filter(isLive).length
201 const broken = projects.filter(p => health(p) === 'bad').length
202 const dirty = projects.filter(p => (p.dirty ?? 0) > 0).length
203 const parts = [`🏭 ${live}/${startups.length} live`]
204 if (dirty > 0) parts.push(`${dirty} dirty`)
205 if (broken > 0) parts.push(`⚠ ${broken} down`)
206 $.ui.status(parts.join(' · '))
207}
208
209function ago(ms: number): string {
210 const minutes = Math.max(0, Math.round(ms / 60_000))
211 if (minutes < 1) return 'just now'
212 if (minutes < 60) return `${minutes} min ago`
213 const hours = Math.round(minutes / 60)
214 if (hours < 24) return `${hours} h ago`
215 const days = Math.round(hours / 24)
216 if (days < 30) return `${days} ${days === 1 ? 'day' : 'days'} ago`
217 return `${Math.round(days / 30)} mo ago`
218}
219
220function bar(running: number, total: number, cells = 8): string {
221 if (total === 0) return '▱'.repeat(cells)
222 const filled = Math.round((running / total) * cells)
223 return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
224}
225
226type Health = 'ok' | 'warn' | 'bad' | 'idle'
227
228function isLive(p: Project): boolean {
229 return p.isDeployed || p.url !== undefined
230}
231
232function health(p: Project): Health {
233 if (p.cluster.state === 'error' || p.site.state === 'error') return 'bad'
234 if (p.site.state === 'ok' && p.site.status >= 500) return 'bad'
235 if (p.cluster.state === 'ok' && (p.cluster.total === 0 || p.cluster.running < p.cluster.total)) return 'warn'
236 if (p.site.state === 'ok' && p.site.status >= 400) return 'warn'
237 if ((p.behind ?? 0) > 0) return 'warn'
238 if (isLive(p)) return 'ok'
239 return 'idle'
240}
241
242const HEALTH_COLOR = { ok: 'success', warn: 'warning', bad: 'error', idle: 'subtle' } as const
243const HEALTH_RANK = { bad: 0, warn: 1, ok: 2, idle: 3 } as const
244
245export const register: Register = on => {
246 on('session.start', async ($, e, next) => {
247 const started = await next(e)
248 await $.command.register({
249 name: 'factory',
250 description: 'Open the startup factory dashboard (all startups and their state)',
251 })
252 if ((await findRoot($)) !== null) {
253 void $.ui.open({ id: PANE, title: TITLE })
254 void refresh($).then(() => runChecks($))
255 $.clock.every(GIT_EVERY_MS, () => void refresh($))
256 }
257 return started
258 })
259
260 on('command.run', { command: 'factory' }, async $ => {
261 await $.ui.open({ id: PANE, title: TITLE })
262 void refresh($).then(() => runChecks($))
263 return { text: 'Startup factory dashboard opened.' }
264 })
265
266 // Claude often commits, pulls or deploys during a turn: re-read git after each.
267 on('turn.complete', async ($, e, next) => {
268 const done = await next(e)
269 if ((await read($, board)).root !== null) void refresh($)
270 return done
271 })
272
273 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
274 const { Box, Text, Button, Link } = $.ui.resolve(e)
275 const b = await read($, board)
276 const now = await $.clock.now()
277 const width = Math.max(30, e.props.bodyColumns ?? 60)
278
279 const name = (p: Project, bold = true) =>
280 p.repo ? <Link href={p.repo}>{p.name}</Link> : <Text bold={bold}>{p.name}</Text>
281
282 const gitLine = (p: Project) => {
283 if (!p.isCloned) return <Text dimColor italic>not cloned</Text>
284 return (
285 <Box flexDirection="row" gap={1}>
286 <Text color="suggestion">⎇ {p.branch ?? '?'}</Text>
287 {(p.dirty ?? 0) > 0 && <Text color="warning">✎{p.dirty}</Text>}
288 {(p.ahead ?? 0) > 0 && <Text color="claude">↑{p.ahead}</Text>}
289 {(p.behind ?? 0) > 0 && <Text color="warning">↓{p.behind}</Text>}
290 {!p.dirty && !p.ahead && !p.behind && <Text color="success">✓</Text>}
291 {p.lastCommitAt !== undefined && <Text dimColor>· {ago(now - p.lastCommitAt)}</Text>}
292 </Box>
293 )
294 }
295
296 const pods = (p: Project) => {
297 const c = p.cluster
298 if (c.state === 'ok') {
299 const color = c.running === c.total ? 'success' : 'warning'
300 return (
301 <Text color={color}>
302 {bar(c.running, c.total)} {c.running}/{c.total}
303 </Text>
304 )
305 }
306 if (c.state === 'checking') return <Text color="claude">◌ checking cluster…</Text>
307 if (c.state === 'error') return <Text color="error">✗ cluster unreachable</Text>
308 return <Text dimColor>☸ {p.context}</Text>
309 }
310
311 const site = (p: Project) => {
312 if (!p.url) return null
313 const s = p.site
314 const status =
315 s.state === 'ok' ? (
316 <Text color={s.status >= 500 ? 'error' : s.status >= 400 ? 'warning' : 'success'}>
317 {s.status} · {Math.round(s.ms)} ms
318 </Text>
319 )
320 : s.state === 'checking' ? <Text color="claude">◌</Text>
321 : s.state === 'error' ? <Text color="error">offline</Text>
322 : null
323 return (
324 <Box flexDirection="row" gap={1}>
325 <Text>🌐</Text>
326 <Link href={p.url}>{hostOf(p.url)}</Link>
327 {status}
328 </Box>
329 )
330 }
331
332 const card = (p: Project) => {
333 const state = health(p)
334 return (
335 <Box
336 key={p.name}
337 flexDirection="column"
338 borderStyle="round"
339 borderColor={HEALTH_COLOR[state]}
340 paddingX={1}
341 marginBottom={1}
342 >
343 <Box flexDirection="row" justifyContent="space-between">
344 <Box flexDirection="row" gap={1}>
345 <Text color={HEALTH_COLOR[state]}>●</Text>
346 {name(p)}
347 </Box>
348 <Text backgroundColor={HEALTH_COLOR[state]} color="inverseText" bold>
349 {state === 'bad' ? ' DOWN ' : state === 'warn' ? ' CHECK ' : ' LIVE '}
350 </Text>
351 </Box>
352 {site(p)}
353 {p.context && pods(p)}
354 {gitLine(p)}
355 {p.lastCommit && (
356 <Text dimColor wrap="truncate-end">
357 “{p.lastCommit}”
358 </Text>
359 )}
360 {p.site.state === 'error' && (
361 <Text color="error" dimColor wrap="truncate-end">
362 {p.site.message}
363 </Text>
364 )}
365 {p.cluster.state === 'error' && (
366 <Text color="error" dimColor wrap="truncate-end">
367 {p.cluster.message}
368 </Text>
369 )}
370 </Box>
371 )
372 }
373
374 const line = (p: Project) => (
375 <Box key={p.name} flexDirection="column" marginBottom={0}>
376 <Box flexDirection="row" gap={1}>
377 <Text color={p.isCloned ? 'subtle' : 'inactive'}>○</Text>
378 {name(p, false)}
379 </Box>
380 <Box paddingLeft={2}>{gitLine(p)}</Box>
381 </Box>
382 )
383
384 const heading = (icon: string, label: string, count?: string) => (
385 <Box flexDirection="row" gap={1} marginTop={1} marginBottom={1}>
386 <Text>{icon}</Text>
387 <Text bold color="claude">
388 {label.toUpperCase()}
389 </Text>
390 {count && <Text dimColor>{count}</Text>}
391 <Text dimColor wrap="truncate-end">
392 {'─'.repeat(width)}
393 </Text>
394 </Box>
395 )
396
397 const tile = (key: string, value: number, label: string, color: string) => (
398 <Box
399 key={key}
400 flexDirection="column"
401 alignItems="center"
402 borderStyle="round"
403 borderColor={value > 0 ? color : 'subtle'}
404 flexGrow={1}
405 paddingX={1}
406 >
407 <Text bold color={value > 0 ? color : 'subtle'}>
408 {String(value)}
409 </Text>
410 <Text dimColor>{label}</Text>
411 </Box>
412 )
413
414 const startups = b.projects.filter(p => p.kind === 'startup')
415 const live = startups
416 .filter(isLive)
417 .sort((x, y) => HEALTH_RANK[health(x)] - HEALTH_RANK[health(y)])
418 const building = startups.filter(p => !isLive(p))
419 const tooling = b.projects.filter(p => p.kind === 'tooling')
420 const dirty = b.projects.filter(p => (p.dirty ?? 0) > 0).length
421 const issues = b.projects.filter(p => health(p) === 'bad' || health(p) === 'warn').length
422
423 return (
424 <Box flexDirection="column">
425 <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
426 <Text bold color="claude">
427 🏭 STARTUP FACTORY
428 </Text>
429 <Text dimColor>
430 {b.isBusy ? '◌ loading…' : b.refreshedAt !== null ? `↻ ${ago(now - b.refreshedAt)}` : ''}
431 </Text>
432 </Box>
433
434 <Box flexDirection="row" gap={1}>
435 {tile('t-live', live.length, 'live', 'success')}
436 {tile('t-build', building.length, 'in Arbeit', 'suggestion')}
437 {tile('t-dirty', dirty, 'dirty', 'warning')}
438 {tile('t-issues', issues, 'Probleme', 'error')}
439 </Box>
440
441 <Box flexDirection="row" gap={1} marginTop={1}>
442 <Button key="refresh" label="↻ Aktualisieren" onPress={() => void refresh($)} />
443 <Button key="clusters" label="⚡ Checks" onPress={() => void runChecks($)} />
444 </Box>
445
446 {b.error && <Text color="error">{b.error}</Text>}
447 {b.projects.length === 0 && !b.error && <Text dimColor>Lese Registry…</Text>}
448
449 {live.length > 0 && heading('🚀', 'Live', `${live.length}/${startups.length}`)}
450 {live.map(card)}
451 {building.length > 0 && heading('🧪', 'Building', String(building.length))}
452 {building.map(line)}
453 {tooling.length > 0 && heading('🛠', 'Tooling', String(tooling.length))}
454 {tooling.map(line)}
455 </Box>
456 )
457 })
458}
459types/index.d.ts 45 lines1export type Cluster =
2 | { state: 'unknown' }
3 | { state: 'checking' }
4 | { state: 'ok'; running: number; total: number }
5 | { state: 'error'; message: string }
6
7export type Site =
8 | { state: 'unknown' }
9 | { state: 'checking' }
10 | { state: 'ok'; status: number; ms: number }
11 | { state: 'error'; message: string }
12
13export type Project = {
14 name: string
15 kind: 'startup' | 'tooling'
16 description?: string
17 repo?: string
18 url?: string
19 isDeployed: boolean
20 context?: string
21 isCloned: boolean
22 branch?: string
23 dirty?: number
24 ahead?: number
25 behind?: number
26 lastCommit?: string
27 lastCommitAt?: number
28 cluster: Cluster
29 site: Site
30}
31
32export type Board = {
33 root: string | null
34 projects: Project[]
35 refreshedAt: number | null
36 isBusy: boolean
37 error?: string
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 'startup-factory': { board: Board }
43 }
44}
45