After a git push or a PR merge, follows the GitHub Actions runs on that commit in the status line and toasts green or red, so a session never sleeps in a loop…

An AI production studio. Bring an idea and your own photos; get stills and video made on current image and video models, held to those photos, and pay in credits only for what you approve. Live at phantra.ai. (Named Zero Page until 2026-10-13.)
https://api.phantra.ai/mcp and https://api.phantra.ai/mcp/claude. See phantra.ai/connector.| Part | Where |
|---|---|
| Site and studio | Next.js in web/, on Vercel |
| API | FastAPI in app/ over the library in src/, on Fly |
| Data and sign-in | Postgres and Auth on Supabase |
| Media | Cloudflare R2, served at media.phantra.ai |
| Video and image models | fal.ai (Kling, Veo, Seedance, LTX, Wan, FLUX and others) and Gemini |
| Billing | Stripe plans that grant credits; a ledger hold before every provider call |
venv/bin/pip install -r requirements.txt
venv/bin/pip install -e .
venv/bin/uvicorn app.main:app --reload # the API on 127.0.0.1:8000
cd web && npm install && npm run dev # the studio on localhost:3000
.env.example and web/.env.example list the settings. START_SERVER.md covers the local server and the MCP mount; CLAUDE.md is the working guide to the code, commands and conventions; docs/ holds the task files, the billing design and the runbook.
venv/bin/python -m pytest tests/ -q
venv/bin/ruff check .
cd web && node --test tests/*.test.mjs && npm run typecheck && npm run build
The test suite blocks all network access, so a test cannot make a billed call.
The README this replaced is kept at docs/retired/README-before-phantra.md.
hooks/register.ts 125 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Every push in the transcripts was followed by a wait for CI (6-7 minutes of
4// tests) and a Fly deploy on main, and more than once by a `sleep 90` chain the
5// harness refused. This watches `gh run list` for the pushed commit instead.
6
7type Run = { name: string; status: string; conclusion: string | null; headSha: string }
8
9const POLL_MS = 30_000
10const GIVE_UP_MS = 25 * 60_000
11const PUSH = /\bgit\s+push\b/
12const MERGE = /\bgh\s+pr\s+merge\b/
13
14const ok = (c: string | null) => c === 'success' || c === 'skipped' || c === 'neutral'
15const mark = (r: Run) =>
16 r.status !== 'completed' ? '…' : ok(r.conclusion) ? '✓' : '✗'
17
18async function sh($: EngineInterface, argv: string[]): Promise<string | null> {
19 try {
20 const r = await $.process.run(argv, { timeoutMs: 20_000 })
21 return r.exitCode === 0 ? r.stdout : null
22 } catch {
23 return null
24 }
25}
26
27async function runsFor($: EngineInterface, branch: string, sha: string): Promise<Run[] | null> {
28 const out = await sh($, [
29 'gh', 'run', 'list', '--branch', branch, '--limit', '12',
30 '--json', 'name,status,conclusion,headSha',
31 ])
32 if (out === null) return null
33 try {
34 const all = JSON.parse(out) as Run[]
35 return all.filter(r => r.headSha === sha)
36 } catch {
37 return null
38 }
39}
40
41function line(runs: Run[]): string {
42 return runs.map(r => `${r.name} ${mark(r)}`).join(' · ')
43}
44
45let timer: { cancel: () => void } | null = null
46
47function stop() {
48 timer?.cancel()
49 timer = null
50}
51
52async function watch($: EngineInterface, branchArg?: string) {
53 stop()
54 const branch = branchArg ?? (await sh($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD']))?.trim()
55 const sha =
56 branchArg === undefined
57 ? (await sh($, ['git', 'rev-parse', 'HEAD']))?.trim()
58 : (await sh($, ['git', 'rev-parse', `origin/${branchArg}`]))?.trim()
59 if (!branch || !sha) return
60 if ((await sh($, ['gh', '--version'])) === null) return // no gh here: nothing to watch
61
62 const startedAt = await $.clock.now()
63 $.ui.status(`CI ${branch}: waiting for runs on ${sha.slice(0, 7)}`)
64
65 timer = $.clock.every(POLL_MS, () => {
66 void (async () => {
67 const runs = await runsFor($, branch, sha)
68 if (runs === null) {
69 stop()
70 $.ui.status(undefined)
71 return
72 }
73 if (runs.length === 0) {
74 if ((await $.clock.now()) - startedAt > GIVE_UP_MS) {
75 stop()
76 $.ui.status(undefined)
77 }
78 return
79 }
80 $.ui.status(`CI ${branch}: ${line(runs)}`)
81 if (runs.every(r => r.status === 'completed')) {
82 stop()
83 const red = runs.filter(r => !ok(r.conclusion)).map(r => r.name)
84 $.ui.toast(
85 red.length === 0 ? `CI green on ${branch}` : `CI RED on ${branch}: ${red.join(', ')}`,
86 { timeoutMs: 15_000 },
87 )
88 } else if ((await $.clock.now()) - startedAt > GIVE_UP_MS) {
89 stop()
90 $.ui.status(`CI ${branch}: still running after 25 min, stopped watching`)
91 }
92 })()
93 })
94}
95
96export const register: Register = on => {
97 on('session.start', async ($, e, next) => {
98 await $.command.register({
99 name: 'ci',
100 description: 'GitHub Actions on this branch\'s HEAD, once; `/ci main` for main',
101 argumentHint: '[branch]',
102 })
103 return next(e)
104 })
105
106 on('command.run', { command: 'ci' }, async ($, e) => {
107 const branchArg = e.args.trim() || undefined
108 const branch = branchArg ?? (await sh($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD']))?.trim()
109 const sha = (await sh($, ['git', 'rev-parse', branchArg ? `origin/${branchArg}` : 'HEAD']))?.trim()
110 if (!branch || !sha) return { text: 'phantra-ci-watch: not in a git checkout.' }
111 const runs = await runsFor($, branch, sha)
112 if (runs === null) return { text: 'phantra-ci-watch: `gh run list` failed (is gh installed and signed in?).' }
113 if (runs.length === 0) return { text: `No runs yet on ${branch} @ ${sha.slice(0, 7)}.` }
114 return { text: `${branch} @ ${sha.slice(0, 7)}: ${line(runs)}` }
115 })
116
117 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
118 const ran = await next(e)
119 if (ran.deny !== undefined || ran.isError) return ran
120 if (MERGE.test(e.command)) void watch($, 'main')
121 else if (PUSH.test(e.command)) void watch($)
122 return ran
123 })
124}
125