SLOPSHOPPER

phantra-ci-watch

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…

newguardcommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · phantra-ci-watch
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ci ⎿ phantra-ci-watch: phantra-ci-watch: `gh run list` failed (is gh installed and signed in?). ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ phantra-ci-watch: CI feat/auth-refresh: waiting for runs on a1b2c3d
README

Phantra

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

What it does

  • Create. One chat box. The assistant talks an idea through, proposes a plan, and makes a still or writes a scene when asked. Every step that costs credits waits on a card that shows the price.
  • Scenes held to your photos. A scene is written as timed shots and rendered shot by shot against the photos of your characters, products and places. A scene with no photographs does not render.
  • Projects. A brief, a look and a memory per piece of work.
  • Library and effects. Everything you made, with image edits, camera moves, upscaling, sound, reframing, draft-then-finish and real downloads.
  • Editor. A timeline over your rendered clips, with an agent that proposes edits you keep or undo, and export to MP4, audio, a still or OpenTimelineIO.
  • Connector. An MCP server so Claude or ChatGPT can work in your workspace: https://api.phantra.ai/mcp and https://api.phantra.ai/mcp/claude. See phantra.ai/connector.

How it is built

PartWhere
Site and studioNext.js in web/, on Vercel
APIFastAPI in app/ over the library in src/, on Fly
Data and sign-inPostgres and Auth on Supabase
MediaCloudflare R2, served at media.phantra.ai
Video and image modelsfal.ai (Kling, Veo, Seedance, LTX, Wan, FLUX and others) and Gemini
BillingStripe plans that grant credits; a ledger hold before every provider call

Running it

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.

Checks

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.

Source 1 files
hooks/register.ts 125 lines
1import 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