The phone-length asks as slash commands (/ship, /merge, /wrap, /handoff), a local-servers status line with /servers [stop], and a session-start check for the…

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 102 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// What the transcripts kept asking by hand: "stop the local servers", "is ollama
4// still running", and the session-start traps the handoffs warn about (a stale
5// .git/index.lock from a Cowork VM, another session's uncommitted work, stale
6// worktrees under .claude/worktrees/).
7
8type Server = { label: string; port: string; kill: string[][] }
9
10const SERVERS: readonly Server[] = [
11 { label: 'api:8000', port: '8000', kill: [['pkill', '-f', 'uvicorn app.main']] },
12 { label: 'web:3000', port: '3000', kill: [['pkill', '-f', 'next dev'], ['pkill', '-f', 'next-server']] },
13 { label: 'ollama', port: '11434', kill: [['pkill', '-f', 'ollama serve'], ['pkill', '-x', 'Ollama']] },
14]
15
16// A Bash command that may have started or stopped one of them.
17const TOUCHES = /uvicorn|next dev|npm run dev|run dev|ollama|pkill|\bkill\b|studio\.command|dev\.command|serve\.sh|start-studio/
18
19async function run($: EngineInterface, argv: string[], cwd?: string) {
20 try {
21 return await $.process.run(argv, { timeoutMs: 10_000, ...(cwd ? { cwd } : {}) })
22 } catch {
23 return null
24 }
25}
26
27async function listening($: EngineInterface, port: string): Promise<boolean | null> {
28 const r = await run($, ['lsof', '-nP', `-iTCP:${port}`, '-sTCP:LISTEN'])
29 if (r === null) return null // no lsof here (a cloud container): unknown, say nothing
30 return r.exitCode === 0 && r.stdout.trim() !== ''
31}
32
33async function up($: EngineInterface): Promise<string[] | null> {
34 const found: string[] = []
35 for (const s of SERVERS) {
36 const on = await listening($, s.port)
37 if (on === null) return null
38 if (on) found.push(s.label)
39 }
40 return found
41}
42
43async function refresh($: EngineInterface) {
44 const found = await up($)
45 if (found === null) return
46 $.ui.status(found.length ? `local: ${found.join(' · ')}` : undefined)
47}
48
49async function checkRepo($: EngineInterface, cwd: string) {
50 const notes: string[] = []
51 const status = await run($, ['git', 'status', '--porcelain'], cwd)
52 if (status && status.exitCode === 0) {
53 const n = status.stdout.split('\n').filter(l => l.trim()).length
54 if (n > 0) notes.push(`${n} uncommitted path${n === 1 ? '' : 's'} (another session's?)`)
55 }
56 const lock = await run($, ['test', '-e', '.git/index.lock'], cwd)
57 if (lock && lock.exitCode === 0) notes.push('stale .git/index.lock -- rm it before any git command')
58 const trees = await run($, ['git', 'worktree', 'list', '--porcelain'], cwd)
59 if (trees && trees.exitCode === 0) {
60 const stale = trees.stdout.split('\n').filter(l => l.startsWith('worktree ') && l.includes('.claude/worktrees/')).length
61 if (stale > 0) notes.push(`${stale} worktree${stale === 1 ? '' : 's'} under .claude/worktrees/`)
62 }
63 if (notes.length) $.ui.toast(`phantra-ops: ${notes.join(' · ')}`, { timeoutMs: 12_000 })
64}
65
66export const register: Register = on => {
67 on('session.start', async ($, e, next) => {
68 const started = await next(e)
69 await $.command.register({
70 name: 'servers',
71 description: 'Local dev servers on :8000 / :3000 / ollama; `/servers stop` stops them',
72 argumentHint: '[stop]',
73 })
74 if (e.isInteractive) {
75 void checkRepo($, e.cwd)
76 void refresh($)
77 }
78 return started
79 })
80
81 on('command.run', { command: 'servers' }, async ($, e) => {
82 if (e.args.trim() === 'stop') {
83 const before = await up($)
84 for (const s of SERVERS) for (const argv of s.kill) await run($, argv)
85 await refresh($)
86 const after = await up($)
87 const stopped = (before ?? []).filter(l => !(after ?? []).includes(l))
88 return { text: stopped.length ? `Stopped: ${stopped.join(', ')}.` : 'Nothing was running.' }
89 }
90 const found = await up($)
91 if (found === null) return { text: 'phantra-ops: cannot check ports here (no lsof).' }
92 await refresh($)
93 return { text: found.length ? `Up: ${found.join(', ')}. \`/servers stop\` stops them.` : 'No local servers running.' }
94 })
95
96 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
97 const ran = await next(e)
98 if (TOUCHES.test(e.command)) void refresh($)
99 return ran
100 })
101}
102