SLOPSHOPPER

startup-factory

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

panecommandstatusprocessnetworktimer
v0.2.4no licenseupdated 2026-10-25Deploy-your-Startup/startup-factory-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · startup-factory
│ ┃ Startup Factory ✕ › fix the failing auth test and add an audit log call │ ┃ 🏭 STARTUP FACTORY ◌ loading… │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ 0 │ ⏺ Update(src/auth.ts) │ ┃ │ live │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ ↻ Aktualisieren ] [ ⚡ Checks ] │ ┃ Lese Registry… ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /factory │ ⎿ startup-factory: Startup factory dashboard opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Startup Factory
🏭 STARTUP FACTORY ◌ loading… ╭──────────────────────────────────────────────────────────╮ │ 0 │ │ live │ ╰──────────────────────────────────────────────────────────╯ [ ↻ Aktualisieren ] [ ⚡ Checks ] Lese Registry…
README

startup-factory-mod

A Claude Code mod that turns a factory workspace into a live dashboard: one pane with every startup and tooling repo from projects.yaml.

  • Tiles: live, in progress, dirty checkouts, problems
  • Cards for live startups: website status and response time (url:), pod health from kubectl --context <kubectl_context>, git branch / dirty / ahead / behind, last commit; border and badge colored by health (LIVE / CHECK / DOWN)
  • Status line: 🏭 5/10 live · 2 dirty · ⚠ 1 down
  • Git is re-read every 2 minutes and after each Claude turn; website and cluster checks run on open and with the ⚡ Checks button

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

Install

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

Registry fields it reads

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.

Develop

claude plugin validate .
claude plugin test .

tsc -p . type-checks once Claude Code has loaded the mod (it writes .claude-plugin/types/).

Source 2 files
hooks/register.tsx 459 lines
1import { 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}
459
types/index.d.ts 45 lines
1export 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