feat(ce-demo-reel): add demo reel skill with Python capture pipeline (#541)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trevin Chow
2026-04-09 21:29:51 -07:00
committed by GitHub
parent f3cc7545e5
commit b979143ad0
18 changed files with 1796 additions and 751 deletions

View File

@@ -0,0 +1,168 @@
---
name: ce-demo-reel
description: "Capture a visual demo reel (GIF, terminal recording, screenshots) for PR descriptions. Use when shipping UI changes, CLI features, or any work with observable behavior that benefits from visual proof. Also use when asked to add a demo, record a GIF, screenshot a feature, show what changed visually, create a demo reel, capture evidence, add proof to a PR, or create a before/after comparison."
argument-hint: "[what to capture, e.g. 'the new settings page' or 'CLI output of the migrate command']"
---
# Demo Reel
Detect project type, recommend a capture tier, record visual evidence, upload to a public URL, and return markdown for PR inclusion.
**Evidence means USING THE PRODUCT, not running tests.** "I ran npm test" is test evidence. Evidence capture is running the actual CLI command, opening the web app, making the API call, or triggering the feature. The distinction is absolute -- test output is never labeled "Demo" or "Screenshots."
If real product usage is impractical (requires API keys, cloud deploy, paid services, bot tokens), say so explicitly: "Real evidence would require [X]. Recommending [fallback approach] instead." Do not silently skip to "no evidence needed" or substitute test output.
Never generate fake or placeholder image/GIF URLs. If upload fails, report the failure.
## Arguments
Parse `$ARGUMENTS`:
- **What to capture**: A description of the feature or behavior to demonstrate. If provided, use it to guide which pages to visit, commands to run, or states to capture.
- If blank, infer what to capture from recoverable branch or PR context. If the target remains ambiguous after that, ask the user what they want to demonstrate before proceeding.
## Step 0: Discover Capture Target
Treat target discovery as stateless and branch-aware. The agent may be invoked in a fresh session after the work was already done, so do not rely on conversation history or assume the caller knows the right artifact.
If invoked by another skill, treat the caller-provided target as a hint, not proof. Rerun target discovery and validation before capturing anything.
Use the lightest available context to identify the best evidence target:
- Current branch name
- Open PR title and description, if one exists
- Changed files and diff against the base branch
- Recent commits
- A plan file only when it is obviously referenced by the branch, PR, arguments, or caller context
Form a capture hypothesis: "The best evidence appears to be [behavior]."
Proceed without asking only when there is exactly one high-confidence observable behavior and a plausible way to exercise it from the workspace. Ask the user what to demonstrate when multiple behaviors are plausible, the diff does not reveal how to exercise the behavior, or the requested target cannot be mapped to a product surface.
Skip evidence with a clear reason when the diff is docs-only, markdown-only, config-only, CI-only, test-only, or a pure internal refactor with no observable output change.
## Step 1: Exercise the Feature
Before capturing anything, verify the feature works by actually using it:
- **CLI tool**: Run the new/changed command and confirm the output is correct
- **Web app**: Navigate to the new/changed page and confirm it renders correctly
- **Library**: Run example code using the new/changed API
- **Bug fix**: Reproduce the original bug scenario and confirm it's fixed
Use the workspace where the feature was built. Do not reinstall from scratch. If setup requires credentials or services, use the platform's blocking question tool (`AskUserQuestion` in Claude Code, `request_user_input` in Codex, `ask_user` in Gemini) to ask the user.
## Step 2: Detect Project Type
Use the capture target from Step 0 to decide which directory to classify. If the diff touches a specific subdirectory with its own package manifest (e.g., `packages/cli/`, `apps/web/`), pass that as the root. Otherwise use the repo root.
```bash
python3 scripts/capture-demo.py detect --repo-root [TARGET_DIR]
```
This outputs JSON with `type` and `reason`. The result is a signal, not a gate. If the agent's understanding from Step 0 contradicts the script's classification (e.g., the diff clearly changes CLI behavior but the repo root classifies as `web-app` because of a sibling Next.js app), the agent's judgment wins.
## Step 3: Assess Change Type
Step 0 already handled the "no observable behavior" early exit. This step classifies changes that DO have observable behavior into `motion` or `states` to guide tier selection.
If arguments describe what to capture, classify based on the description. Otherwise, use the diff context from Step 0.
**Change classification:**
1. **Involves motion or interaction?** (animations, typing flows, drag-and-drop, real-time updates, continuous CLI output) -> classify as `motion`.
2. **Involves discrete states?** (before/after UI, new page, command with output, API response) -> classify as `states`.
| Change characteristic | Classification |
|---|---|
| Animations, typing, drag-and-drop, streaming output | `motion` |
| New UI, before/after, command output, API responses | `states` |
**Feature vs bug fix -- what to demonstrate:**
- **New feature (`feat`)**: Demonstrate the feature working. Show the hero moment -- the feature doing its thing.
- **Bug fix (`fix`)**: Show before AND after. Reproduce the original broken state (if possible) then show the fix. If the broken state can't be reproduced (already fixed in the workspace), capture the fixed state and describe what was broken.
Infer feat vs fix from commit messages, branch name, or plan file frontmatter (`type: feat` or `type: fix`). If unclear, ask.
## Step 4: Tool Preflight
Run the preflight check:
```bash
python3 scripts/capture-demo.py preflight
```
This outputs JSON with boolean availability for each tool: `agent_browser`, `vhs`, `silicon`, `ffmpeg`, `ffprobe`. Print a human-readable summary for the user based on the result, noting install commands for missing tools (e.g., `brew install charmbracelet/tap/vhs` for vhs, `brew install silicon` for silicon, `brew install ffmpeg` for ffmpeg).
## Step 5: Create Run Directory
Create a per-run scratch directory in the OS temp location:
```bash
mktemp -d -t demo-reel-XXXXXX
```
Use the output as `RUN_DIR`. Pass this concrete run directory to every tier reference. Evidence artifacts are ephemeral — they get uploaded to a public URL and then discarded. The OS temp directory is the right place for them, not the repo tree.
## Step 6: Recommend Tier and Ask User
Run the recommendation script with the project type from Step 2, change classification from Step 3, and preflight JSON from Step 4:
```bash
python3 scripts/capture-demo.py recommend --project-type [TYPE] --change-type [motion|states] --tools '[PREFLIGHT_JSON]'
```
This outputs JSON with `recommended` (the best tier), `available` (list of tiers whose tools are present), and `reasoning`.
Present the available tiers to the user via the platform's blocking question tool (`AskUserQuestion` in Claude Code, `request_user_input` in Codex, `ask_user` in Gemini). Mark the recommended tier. Always include "No evidence needed" as a final option.
**Question:** "How should evidence be captured for this change?"
**Options** (show only tiers from the `available` list, order by recommendation):
1. **Browser reel** -- Agent-browser screenshots stitched into animated GIF. Best for web apps.
2. **Terminal recording** -- VHS terminal recording to GIF. Best for CLI tools with interaction/motion.
3. **Screenshot reel** -- Styled terminal frames stitched into animated GIF. Best for discrete CLI steps.
4. **Static screenshots** -- Individual PNGs. Fallback when other tools are unavailable.
5. **No evidence needed** -- The diff speaks for itself. Best for text-only or config changes.
If the question tool is unavailable (background agent, batch mode), present the numbered options and wait for the user's reply before proceeding.
## Step 7: Execute Selected Tier
Carry the capture hypothesis from Step 0 and the feature exercise results from Step 1 into tier execution — these determine which specific pages to visit, commands to run, or states to screenshot. Substitute `[RUN_DIR]` in the tier reference with the concrete path from Step 5.
Load the appropriate reference file for the selected tier:
- **Browser reel** -> Read `references/tier-browser-reel.md`
- **Terminal recording** -> Read `references/tier-terminal-recording.md`
- **Screenshot reel** -> Read `references/tier-screenshot-reel.md`
- **Static screenshots** -> Read `references/tier-static-screenshots.md`
- **No evidence needed** -> Skip to output. Set `evidence_url` to null, `evidence_label` to null.
**Runtime failure fallback:** If the selected tier fails during execution (tool crashes, server not accessible, recording produces empty output), fall back to the next available tier rather than failing entirely. The fallback order is: browser reel -> static screenshots, terminal recording -> screenshot reel -> static screenshots, screenshot reel -> static screenshots. Static screenshots is the terminal fallback -- if even that fails, report the failure and let the user decide.
## Step 8: Upload and Approval
After the selected tier produces an artifact, read `references/upload-and-approval.md` for upload to a public host, user approval gate, and markdown embed generation.
## Output
Return these values to the caller (e.g., git-commit-push-pr):
```
=== Evidence Capture Complete ===
Tier: [browser-reel / terminal-recording / screenshot-reel / static / skipped]
Description: [1 sentence describing what the evidence shows]
URL: [public URL or "none" (multiple URLs comma-separated for static screenshots)]
=== End Evidence ===
```
The `Description` is a 1-line summary derived from the capture hypothesis in Step 0 (e.g., "CLI detect command classifying 3 project types and recommending capture tiers"). The caller decides how to format the URL(s) into the PR description.
- `Tier: skipped` or `URL: "none"` means no evidence was captured.
**Label convention:**
- Browser reel, terminal recording, screenshot reel: label as "Demo"
- Static screenshots: label as "Screenshots"
- The caller applies the label when formatting. ce-demo-reel does not generate markdown.
- Test output is never labeled "Demo" or "Screenshots"

View File

@@ -0,0 +1,105 @@
# Tier: Browser Reel
Capture 3-5 browser screenshots at key UI states and stitch into an animated GIF.
**Best for:** Web apps, desktop apps accessible via localhost or CDP.
**Output:** GIF (PNG screenshots stitched via ffmpeg two-pass palette)
**Label:** "Demo"
**Required tools:** agent-browser, ffmpeg
## Step 1: Connect to the Application
**For web apps** -- verify the dev server is accessible:
- Read `package.json` `scripts` for `dev`, `start`, `serve` commands
- Check `Procfile`, `Procfile.dev`, or `bin/dev` if they exist
- Check `Gemfile` for Rails (`bin/rails server`) or Sinatra
- Check for running processes on common ports (3000, 5000, 8080)
If the server is not running, tell the user what start command was detected and ask them to start it. Do not start it automatically (it may require environment variables, database setup, etc.).
If the server cannot be reached after the user confirms it should be running, fall back to static screenshots tier.
Once accessible, note the base URL (e.g., `http://localhost:3000`).
**For Electron/desktop apps** -- connect via Chrome DevTools Protocol (CDP):
1. Check if the app is already running with CDP enabled by probing common ports:
```bash
curl -s http://localhost:9222/json/version
```
If that returns a JSON response, the app is ready -- connect agent-browser to it:
```bash
agent-browser connect 9222
```
2. If not running, the app needs to be launched with `--remote-debugging-port`. Detect the entry point from `package.json` (look for the `main` field or `electron` in scripts), then ask the user to launch it with:
```
your-electron-app --remote-debugging-port=9222
```
If port 9222 is busy, try 9223-9230.
3. Poll until CDP is ready (timeout after 30 seconds):
```bash
curl -s http://localhost:9222/json/version
```
4. Connect agent-browser:
```bash
agent-browser connect 9222
```
**CDP advantages:** Screenshots come from the renderer's frame buffer, not macOS screen capture -- no Accessibility or Screen Recording permissions needed.
**If CDP connection fails:** Fall back to static screenshots tier. Tell the user: "Could not connect to the app via CDP. Falling back to static screenshots."
## Step 2: Capture Screenshots
Navigate to the relevant pages and capture 3-5 screenshots at key UI states:
1. **Initial/empty state** -- Before the feature is used
2. **Navigation** -- How the user reaches the feature (if not the landing page)
3. **Feature in action** -- The hero shot showing the feature working
4. **Result state** -- After interaction (data present, items created, success message)
5. **Detail view** (optional) -- Expanded item, settings panel, modal
For each screenshot, write to the concrete `RUN_DIR` created by the parent skill:
```bash
agent-browser open [URL]
```
```bash
agent-browser wait 2000
```
```bash
agent-browser screenshot [RUN_DIR]/frame-01-initial.png
```
**Capture tips:**
- Use URL navigation (`agent-browser open URL`) rather than clicking SPA elements (clicks often fail on React/Vue/Svelte SPAs)
- Wait 2-3 seconds after navigation for the page to settle
- Capture the full viewport (sidebar, header give reviewers context)
## Step 3: Stitch into GIF
Use the capture pipeline script to normalize frame dimensions, stitch with two-pass palette, and auto-reduce if over 10 MB:
```bash
python3 scripts/capture-demo.py stitch [RUN_DIR]/demo.gif [RUN_DIR]/frame-*.png
```
The script handles dimension normalization (via ffprobe + ffmpeg padding), concat demuxer stitching, palette generation, and automatic frame reduction if the GIF exceeds GitHub's 10 MB inline limit. Default is 3 seconds per frame. To adjust:
```bash
python3 scripts/capture-demo.py stitch --duration 2.0 [RUN_DIR]/demo.gif [RUN_DIR]/frame-*.png
```
**If stitching fails:** Fall back to static screenshots tier using the individual PNGs already captured. If no PNGs were captured, report the failure.
## Step 4: Cleanup
After successful GIF creation, remove individual PNG frames. Keep only the final GIF for upload.
Proceed to `references/upload-and-approval.md`.

View File

@@ -0,0 +1,61 @@
# Tier: Screenshot Reel
Render styled terminal frames from text and stitch into an animated GIF. Each frame shows one step of a CLI demo (command + output).
**Best for:** CLI tools shown as discrete steps (command -> output -> next command -> output). Also useful when VHS breaks on quoting or special characters.
**Output:** GIF (silicon PNGs stitched via ffmpeg)
**Label:** "Demo"
**Required tools:** silicon, ffmpeg
## Step 1: Write Demo Content
Create a text file with `---` delimiters between frames. Each frame shows the terminal state for one step:
Write to `[RUN_DIR]/demo-steps.txt`:
```
$ your-cli-command --flag value
Output line 1
Output line 2
Success: feature works correctly
---
$ your-cli-command --another-flag
Different output showing another aspect
Result: 42 items processed
---
$ your-cli-command --verify
All checks passed
```
**Tips:**
- Include the `$` prompt to show what the user types
- Keep each frame under ~80 characters wide for readability
- 3-5 frames is ideal -- enough to tell the story, not so many the GIF is huge
- Strip unicode characters that silicon's default font can't render (checkmarks, fancy arrows)
## Step 2: Split into Frame Files
Split the demo content on `---` lines into separate text files, one per frame:
- `[RUN_DIR]/frame-001.txt`
- `[RUN_DIR]/frame-002.txt`
- `[RUN_DIR]/frame-003.txt`
- etc.
## Step 3: Render and Stitch
Use the capture pipeline script to render each text frame through silicon and stitch into an animated GIF in a single call:
```bash
python3 scripts/capture-demo.py screenshot-reel --output [RUN_DIR]/demo.gif --duration 2.5 --text [RUN_DIR]/frame-001.txt [RUN_DIR]/frame-002.txt [RUN_DIR]/frame-003.txt
```
The script handles silicon rendering, dimension normalization, two-pass palette generation, and automatic frame reduction if the GIF exceeds limits. Default duration is 2.5 seconds per frame (faster than browser reels since terminal frames are quicker to read).
**If the script fails** (silicon rendering error, stitching error, empty output): fall back to static screenshots tier. Include the raw terminal output as a code block in the PR description instead. Label as "Terminal output", not "Screenshots".
## Step 4: Cleanup
Remove individual PNGs and text files. Keep only the final GIF for upload.
Proceed to `references/upload-and-approval.md`.

View File

@@ -0,0 +1,55 @@
# Tier: Static Screenshots
Capture individual PNG screenshots. No animation, no stitching.
**Best for:** Fallback when other tools are unavailable, library demos, or features where animation doesn't add value.
**Output:** PNG files
**Label:** "Screenshots"
**Required tools:** Varies (agent-browser for web, silicon for CLI, or native screenshot)
## Capture by Project Type
### Web app or desktop app (agent-browser available)
```bash
agent-browser open [URL]
```
```bash
agent-browser wait 2000
```
```bash
agent-browser screenshot [RUN_DIR]/screenshot-01.png
```
Capture 1-3 screenshots: before state, feature in action, result state.
### CLI tool (silicon available)
Run the command, capture its output to a text file, then render with silicon:
```bash
silicon [RUN_DIR]/output.txt -o [RUN_DIR]/screenshot-01.png --theme Dracula -l bash --pad-horiz 20 --pad-vert 20
```
### CLI tool (no silicon)
Run the command and capture the raw terminal output. Include the output as a code block in the PR description instead of an image. Label it as "Terminal output", never "Screenshot".
### Library
Run example code that exercises the new API. Capture the output as above (silicon if available, code block if not).
## Upload
Each PNG is uploaded individually. Proceed to `references/upload-and-approval.md` for each file, or upload all and present them together for approval.
For multiple screenshots, the markdown embed uses multiple image lines:
```markdown
## Screenshots
![Before](url-1)
![After](url-2)
```

View File

@@ -0,0 +1,88 @@
# Tier: Terminal Recording
Record a terminal session using VHS (charmbracelet/vhs) to produce a GIF demo.
**Best for:** CLI tools, scripts, command-line features with interaction or motion (typing, streaming output, progressive rendering).
**Output:** GIF (direct from VHS)
**Label:** "Demo"
**Required tools:** vhs
## Step 1: Plan the Recording
Before generating a .tape file, determine:
- **What command(s) to run** -- The actual product command, not test commands. "I ran npm test" is test evidence, not a demo.
- **Expected output** -- What the terminal should show when the command succeeds.
- **Terminal dimensions** -- Wide enough for the longest output line, tall enough to avoid scrolling.
- **Timing** -- Target 5-10 seconds total. Enough sleep after each command for output to render.
## Step 2: Generate .tape File
Write a VHS tape file to `[RUN_DIR]/demo.tape`:
```tape
Output [RUN_DIR]/demo.gif
Set FontSize 16
Set Width 800
Set Height 500
Set Theme "Catppuccin Mocha"
Set TypingSpeed 40ms
# Hide boring setup
Hide
Type "cd /path/to/project"
Enter
Sleep 500ms
Show
# The demo
Type "your-cli-command --flag value"
Sleep 500ms
Enter
Sleep 3s
# Let viewer read the output
Sleep 2s
```
**Key .tape directives:**
- `Output [path]` -- Where to write the GIF (must be first line)
- `Set FontSize [14-18]` -- Larger for readability
- `Set Width/Height [pixels]` -- Match content needs
- `Set Theme [name]` -- "Catppuccin Mocha" or "Dracula" are readable defaults
- `Set TypingSpeed [ms]` -- 30-50ms feels natural
- `Hide`/`Show` -- Skip boring setup (cd, source, npm install)
- `Type [text]` -- Types characters (does not execute)
- `Enter` -- Presses enter (executes the typed command)
- `Sleep [duration]` -- Wait for output to render
**Avoid:**
- Non-deterministic output (random IDs, timestamps that change between runs)
- Commands that require interactive input (prompts, password entry)
- Very long output that scrolls off screen
## Step 3: Run VHS
Use the capture pipeline script to execute the tape file and validate output:
```bash
python3 scripts/capture-demo.py terminal-recording --output [RUN_DIR]/demo.gif --tape [RUN_DIR]/demo.tape
```
The script runs VHS, validates the output exists, and reports the file size. If the GIF exceeds 10 MB, reduce by adjusting the .tape: smaller terminal dimensions (`Set Width/Height`), shorter recording (fewer sleeps), or lower font size. Re-run.
## Step 4: Quality Check
Read the generated GIF to verify:
1. Commands are visible and readable
2. Output renders completely (not cut off)
3. The feature being demonstrated is clearly shown
4. No secrets, credentials, or sensitive paths are visible
If quality is poor, revise the .tape file and re-record.
**If VHS fails** (crashes, produces empty GIF, or the command being demonstrated fails): fall back to the screenshot reel tier. Write the same commands and expected output as text frames and stitch via silicon + ffmpeg. If silicon is also unavailable, fall back to static screenshots.
Proceed to `references/upload-and-approval.md`.

View File

@@ -0,0 +1,40 @@
# Upload and Approval
Get user approval for the local artifact, upload evidence to a public URL, and generate markdown for PR inclusion.
## Step 1: Local Approval Gate
Before uploading anywhere public, present the local artifact path to the user for approval. Use the platform's blocking question tool (`AskUserQuestion` in Claude Code, `request_user_input` in Codex, `ask_user` in Gemini).
**Question:** "Evidence captured at [RUN_DIR]/[artifact]. Review it locally and decide:"
**Options:**
1. **Looks good, upload for PR** -- proceed to upload
2. **Not good enough, try again** -- return to the tier execution step and re-capture
3. **Skip evidence for this PR** -- set evidence to null and proceed
If the question tool is unavailable (headless/background mode), present the numbered options and wait for the user's reply before proceeding.
## Step 2: Upload to catbox.moe
After the user approves the local artifact, upload the evidence file (GIF or PNG) using the capture pipeline script. Set `ARTIFACT_PATH` to the approved GIF or PNG path:
```bash
python3 scripts/capture-demo.py upload [ARTIFACT_PATH]
```
The script uploads to catbox.moe, validates the response starts with `https://`, and retries once on failure. The last line of output is the public URL (e.g., `https://files.catbox.moe/abc123.gif`).
**If upload fails** after retry, report the failure and the local artifact path. Do not commit evidence files to the repo — they are ephemeral artifacts, not source material. Tell the user: "Upload failed. Local artifact preserved at [ARTIFACT_PATH]. You can upload it manually or retry later."
For multiple files (static screenshots tier), upload each file separately.
## Step 3: Return Output
Return the structured output defined in the SKILL.md Output section: `Tier`, `Description`, and `URL`. The caller formats the evidence into the PR description. ce-demo-reel does not generate markdown.
## Step 4: Cleanup
Remove the `[RUN_DIR]` scratch directory and all temporary files. Preserve nothing -- the evidence lives at the public URL now.
If the upload failed and the user has not yet manually uploaded, preserve `[RUN_DIR]` so the artifact is still accessible.

View File

@@ -0,0 +1,653 @@
#!/usr/bin/env python3
"""
Evidence capture pipeline — deterministic helpers for the demo-reel skill.
Subcommands:
preflight Check tool availability (JSON output)
detect [--repo-root PATH] Detect project type from manifests (JSON output)
recommend --project-type T --change-type T --tools JSON Recommend capture tier (JSON output)
stitch [--duration N] OUTPUT FRAME [FRAME ...] Stitch frames into animated GIF
screenshot-reel --output OUT [--duration N] [--lang L] [--theme T] --text F [F ...] Render text frames via silicon + stitch
terminal-recording --output OUT --tape TAPE Run VHS tape file
upload FILE Upload to catbox.moe (retries once)
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
# --- Config ---
MAX_GIF_SIZE = 10 * 1024 * 1024 # 10 MB — GitHub inline render limit
TARGET_GIF_SIZE = 5 * 1024 * 1024 # 5 MB — preferred target
CATBOX_API = "https://catbox.moe/user/api.php"
# --- Helpers ---
def die(msg):
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1)
def check_tool(name):
return shutil.which(name) is not None
def run_cmd(cmd, timeout=120):
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
except subprocess.TimeoutExpired:
print(f"ERROR: Command timed out after {timeout}s: {' '.join(cmd)}", file=sys.stderr)
return subprocess.CompletedProcess(cmd, returncode=1, stdout="", stderr=f"Timed out after {timeout}s")
if result.returncode != 0:
print(f"ERROR: Command failed (exit {result.returncode}): {' '.join(cmd)}", file=sys.stderr)
if result.stderr:
print(result.stderr.strip(), file=sys.stderr)
return result
def file_size_mb(path):
return Path(path).stat().st_size / (1024 * 1024)
# --- Preflight ---
def cmd_preflight(_args):
tools = {
"agent_browser": check_tool("agent-browser"),
"vhs": check_tool("vhs"),
"silicon": check_tool("silicon"),
"ffmpeg": check_tool("ffmpeg"),
"ffprobe": check_tool("ffprobe"),
}
print(json.dumps(tools))
# --- Detect ---
ELECTRON_DEPS = {"electron", "electron-builder", "electron-forge", "electron-vite", "electron-packager"}
WEB_NODE_DEPS = {
"react", "vue", "svelte", "astro", "next", "nuxt", "@angular/core", "solid-js",
"@remix-run/react", "gatsby", "express", "fastify", "koa", "hono", "@hono/node-server",
}
WEB_RUBY_DEPS = {"rails", "sinatra", "hanami", "roda"}
WEB_GO_DEPS = {
"github.com/gin-gonic/gin", "github.com/labstack/echo", "github.com/gofiber/fiber",
"github.com/go-chi/chi", "github.com/gorilla/mux",
}
# Note: net/http is stdlib and won't appear in go.mod. The agent detects stdlib web
# servers from source imports in the diff and overrides the classification (Step 2).
WEB_PYTHON_DEPS = {"flask", "django", "fastapi", "starlette", "tornado", "sanic", "litestar"}
WEB_RUST_DEPS = {"actix-web", "axum", "rocket", "warp", "poem", "tide"}
CLI_RUBY_DEPS = {"thor", "gli", "dry-cli"}
CLI_PYTHON_DEPS = {"click", "typer", "argparse"}
def _read_file(path):
try:
return Path(path).read_text(encoding="utf-8", errors="replace")
except (OSError, IOError):
return None
def _has_any_dep(pkg_json, dep_names):
deps = set(pkg_json.get("dependencies", {}).keys())
dev_deps = set(pkg_json.get("devDependencies", {}).keys())
all_deps = deps | dev_deps
return bool(all_deps & dep_names)
def _detect_project_type(repo_root):
root = Path(repo_root)
# Try package.json first (used by multiple checks)
pkg_json = None
pkg_text = _read_file(root / "package.json")
if pkg_text:
try:
pkg_json = json.loads(pkg_text)
except json.JSONDecodeError:
pass
# 1. Desktop app (Electron)
if pkg_json and _has_any_dep(pkg_json, ELECTRON_DEPS):
return {"type": "desktop-app", "reason": "package.json contains Electron dependency"}
# 2. Web app
if pkg_json and _has_any_dep(pkg_json, WEB_NODE_DEPS):
return {"type": "web-app", "reason": "package.json contains web framework dependency"}
# Check vite with framework deps (vite alone could be anything)
if pkg_json and _has_any_dep(pkg_json, {"vite"}):
all_deps = set(pkg_json.get("dependencies", {}).keys()) | set(pkg_json.get("devDependencies", {}).keys())
if all_deps & WEB_NODE_DEPS:
return {"type": "web-app", "reason": "package.json contains vite with framework dependency"}
gemfile = _read_file(root / "Gemfile")
if gemfile:
for dep in WEB_RUBY_DEPS:
if dep in gemfile:
return {"type": "web-app", "reason": f"Gemfile contains {dep}"}
go_mod = _read_file(root / "go.mod")
if go_mod:
for dep in WEB_GO_DEPS:
if dep in go_mod:
return {"type": "web-app", "reason": f"go.mod contains {dep}"}
for pyfile in ["pyproject.toml", "requirements.txt"]:
content = _read_file(root / pyfile)
if content:
for dep in WEB_PYTHON_DEPS:
if dep in content:
return {"type": "web-app", "reason": f"{pyfile} contains {dep}"}
cargo = _read_file(root / "Cargo.toml")
if cargo:
for dep in WEB_RUST_DEPS:
if dep in cargo:
return {"type": "web-app", "reason": f"Cargo.toml contains {dep}"}
# 3. CLI tool
if pkg_json:
if "bin" in pkg_json:
return {"type": "cli-tool", "reason": "package.json has bin field"}
if (root / "bin").is_dir():
return {"type": "cli-tool", "reason": "bin/ directory exists"}
if go_mod and (root / "cmd").is_dir():
return {"type": "cli-tool", "reason": "go.mod with cmd/ directory"}
if cargo and "[[bin]]" in cargo:
return {"type": "cli-tool", "reason": "Cargo.toml has [[bin]] section"}
pyproject = _read_file(root / "pyproject.toml")
if pyproject:
if "[project.scripts]" in pyproject or "[tool.poetry.scripts]" in pyproject:
return {"type": "cli-tool", "reason": "pyproject.toml has script entry points"}
for dep in CLI_PYTHON_DEPS:
if dep in pyproject:
return {"type": "cli-tool", "reason": f"pyproject.toml contains {dep}"}
if gemfile:
for dep in CLI_RUBY_DEPS:
if dep in gemfile:
return {"type": "cli-tool", "reason": f"Gemfile contains {dep}"}
if (root / "bin").is_dir() or (root / "exe").is_dir():
return {"type": "cli-tool", "reason": "Ruby project with bin/ or exe/ directory"}
if go_mod and (root / "main.go").exists():
return {"type": "cli-tool", "reason": "main.go exists without web framework"}
# 4. Library
manifests = ["package.json", "Gemfile", "go.mod", "Cargo.toml", "pyproject.toml", "setup.py"]
has_manifest = any((root / m).exists() for m in manifests)
if not has_manifest:
# Check for gemspec
has_manifest = bool(list(root.glob("*.gemspec")))
if has_manifest:
return {"type": "library", "reason": "package manifest exists but no web/CLI signals"}
# 5. Text-only
return {"type": "text-only", "reason": "no recognized package manifest"}
def cmd_detect(args):
repo_root = args.repo_root or os.getcwd()
result = _detect_project_type(repo_root)
print(json.dumps(result))
# --- Recommend ---
def _recommend_tier(project_type, change_type, tools):
has_browser = tools.get("agent_browser", False)
has_vhs = tools.get("vhs", False)
has_silicon = tools.get("silicon", False)
has_ffmpeg = tools.get("ffmpeg", False)
has_ffprobe = tools.get("ffprobe", False)
has_stitch = has_ffmpeg and has_ffprobe # stitching requires both
recommended = None
reasoning = ""
if project_type == "web-app":
if has_browser and has_stitch:
recommended = "browser-reel"
reasoning = "Web app with agent-browser and ffmpeg available"
elif has_browser:
recommended = "static-screenshots"
reasoning = "Web app with agent-browser but no ffmpeg/ffprobe for stitching"
else:
recommended = "static-screenshots"
reasoning = "Web app without agent-browser"
elif project_type == "cli-tool":
if change_type == "motion":
if has_vhs:
recommended = "terminal-recording"
reasoning = "CLI tool with motion, VHS available"
elif has_silicon and has_stitch:
recommended = "screenshot-reel"
reasoning = "CLI tool with motion, silicon + ffmpeg available (no VHS)"
else:
recommended = "static-screenshots"
reasoning = "CLI tool with no capture tools available"
else: # states
if has_silicon and has_stitch:
recommended = "screenshot-reel"
reasoning = "CLI tool with discrete states, silicon + ffmpeg available"
elif has_vhs:
recommended = "terminal-recording"
reasoning = "CLI tool with discrete states, VHS available (no silicon)"
else:
recommended = "static-screenshots"
reasoning = "CLI tool with no capture tools available"
elif project_type == "desktop-app":
if has_browser and has_stitch:
recommended = "browser-reel"
reasoning = "Desktop app with agent-browser and ffmpeg (via localhost/CDP)"
else:
recommended = "static-screenshots"
reasoning = "Desktop app without agent-browser"
elif project_type == "library":
recommended = "static-screenshots"
reasoning = "Library projects use static screenshots"
else: # text-only or unknown
recommended = "static-screenshots"
reasoning = "Fallback to static screenshots"
# Build available tiers list
available = []
if has_browser and has_stitch:
available.append("browser-reel")
if has_vhs:
available.append("terminal-recording")
if has_silicon and has_stitch:
available.append("screenshot-reel")
available.append("static-screenshots") # always available
return {
"recommended": recommended,
"available": available,
"reasoning": reasoning,
}
def cmd_recommend(args):
try:
tools = json.loads(args.tools)
except json.JSONDecodeError:
die("--tools must be valid JSON")
result = _recommend_tier(args.project_type, args.change_type, tools)
print(json.dumps(result))
# --- Stitch ---
def _get_frame_dimensions(path):
result = run_cmd([
"ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height", "-of", "csv=p=0", str(path),
])
if result.returncode != 0:
die(f"ffprobe failed on {path}")
parts = result.stdout.strip().split(",")
return int(parts[0]), int(parts[1])
def _stitch_frames(output, frames, duration=3.0):
if not frames:
die("No input frames provided")
for f in frames:
if not Path(f).exists():
die(f"Frame not found: {f}")
if not check_tool("ffmpeg"):
die("ffmpeg is not installed. Install with: brew install ffmpeg")
if not check_tool("ffprobe"):
die("ffprobe is not installed. Install with: brew install ffmpeg")
print(f"Stitching {len(frames)} frames into GIF ({duration}s per frame)...")
tmpdir = tempfile.mkdtemp(prefix="evidence-stitch-")
try:
# Detect max dimensions
max_w, max_h = 0, 0
for f in frames:
w, h = _get_frame_dimensions(f)
max_w = max(max_w, w)
max_h = max(max_h, h)
# Even dimensions
if max_w % 2 != 0:
max_w += 1
if max_h % 2 != 0:
max_h += 1
print(f" Target dimensions: {max_w}x{max_h}")
# Normalize frames
normalized = []
for i, f in enumerate(frames):
out = os.path.join(tmpdir, f"frame_{i:03d}.png")
result = run_cmd([
"ffmpeg", "-y", "-v", "error", "-i", f,
"-vf", f"scale={max_w}:{max_h}:force_original_aspect_ratio=decrease,"
f"pad={max_w}:{max_h}:(ow-iw)/2:0:color=#0d1117",
out,
])
if result.returncode != 0:
die(f"ffmpeg failed to normalize frame: {f}")
normalized.append(out)
print(f" Normalized {len(normalized)} frames")
# Write concat file
concat_file = os.path.join(tmpdir, "concat.txt")
with open(concat_file, "w") as fh:
for f in normalized:
fh.write(f"file '{os.path.basename(f)}'\n")
fh.write(f"duration {duration}\n")
# Last file repeated without duration (concat demuxer requirement)
fh.write(f"file '{os.path.basename(normalized[-1])}'\n")
# Two-pass palette generation
palette = os.path.join(tmpdir, "palette.png")
result = run_cmd([
"ffmpeg", "-y", "-v", "error",
"-f", "concat", "-safe", "0", "-i", concat_file,
"-vf", "palettegen=stats_mode=diff",
palette,
])
if result.returncode != 0:
die("ffmpeg palette generation failed")
# Generate GIF with palette
result = run_cmd([
"ffmpeg", "-y", "-v", "error",
"-f", "concat", "-safe", "0", "-i", concat_file,
"-i", palette,
"-lavfi", "paletteuse=dither=bayer:bayer_scale=3",
"-loop", "0",
output,
])
if result.returncode != 0:
die("ffmpeg GIF encoding failed")
if not Path(output).exists():
die("GIF creation failed: no output file")
size = Path(output).stat().st_size
size_mb = size / (1024 * 1024)
print(f" Created: {output} ({size_mb:.1f} MB, {len(frames)} frames)")
# Auto-reduce if over limit
if size > MAX_GIF_SIZE:
print(" GIF exceeds 10 MB limit. Reducing...")
if len(frames) > 2:
print(" Dropping middle frame(s) and re-stitching...")
reduced = [frames[0]]
step = max(2, (len(frames) - 1) // 2)
for j in range(step, len(frames) - 1, step):
reduced.append(frames[j])
reduced.append(frames[-1])
if len(reduced) < len(frames):
print(f" Reduced from {len(frames)} to {len(reduced)} frames")
shutil.rmtree(tmpdir, ignore_errors=True)
_stitch_frames(output, reduced, duration)
return
print(" WARNING: Could not reduce below 10 MB. GIF may not render inline on GitHub.")
elif size > TARGET_GIF_SIZE:
print(" Note: GIF is over 5 MB preferred target but under 10 MB limit. Acceptable.")
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def cmd_stitch(args):
_stitch_frames(args.output, args.frames, args.duration)
# --- Screenshot Reel ---
def cmd_screenshot_reel(args):
if not check_tool("silicon"):
die("silicon is not installed. Install with: brew install silicon")
if not check_tool("ffmpeg"):
die("ffmpeg is not installed. Install with: brew install ffmpeg")
tmpdir = tempfile.mkdtemp(prefix="evidence-reel-")
try:
frame_pngs = []
for i, text_file in enumerate(args.text):
if not Path(text_file).exists():
die(f"Text file not found: {text_file}")
out_png = os.path.join(tmpdir, f"frame_{i:03d}.png")
result = run_cmd([
"silicon", text_file,
"-o", out_png,
"--theme", args.theme,
"-l", args.lang,
"--pad-horiz", "20",
"--pad-vert", "40",
"--no-line-number",
"--no-round-corner",
"--background", args.background,
])
if result.returncode != 0 or not Path(out_png).exists():
die(f"silicon failed to render {text_file}")
frame_pngs.append(out_png)
print(f"Rendered {len(frame_pngs)} frames via silicon")
_stitch_frames(args.output, frame_pngs, args.duration)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
# --- Terminal Recording ---
def cmd_terminal_recording(args):
if not check_tool("vhs"):
die("vhs is not installed. Install with: brew install charmbracelet/tap/vhs")
tape_path = args.tape
if not Path(tape_path).exists():
die(f"Tape file not found: {tape_path}")
# Parse Output directive from tape file
output_path = args.output
tape_content = Path(tape_path).read_text()
tape_has_output = False
for line in tape_content.splitlines():
stripped = line.strip()
if stripped.startswith("Output "):
tape_has_output = True
if not output_path:
output_path = stripped.split(None, 1)[1].strip().strip('"').strip("'")
break
if not output_path:
die("No output path: use --output or set Output in the tape file")
# If --output differs from tape's Output directive, rewrite to a temp tape
actual_tape = tape_path
tmp_tape = None
if output_path and tape_has_output:
# Rewrite the Output line to use the requested path
lines = tape_content.splitlines()
rewritten = []
for line in lines:
if line.strip().startswith("Output "):
rewritten.append(f'Output "{output_path}"')
else:
rewritten.append(line)
fd, tmp_tape = tempfile.mkstemp(suffix=".tape", prefix="vhs-")
os.close(fd)
Path(tmp_tape).write_text("\n".join(rewritten) + "\n")
actual_tape = tmp_tape
elif output_path and not tape_has_output:
# No Output in tape — prepend one
fd, tmp_tape = tempfile.mkstemp(suffix=".tape", prefix="vhs-")
os.close(fd)
Path(tmp_tape).write_text(f'Output "{output_path}"\n{tape_content}')
actual_tape = tmp_tape
print(f"Running VHS tape: {tape_path}")
result = run_cmd(["vhs", actual_tape], timeout=300)
if tmp_tape and Path(tmp_tape).exists():
Path(tmp_tape).unlink()
if result.returncode != 0:
die(f"VHS failed (exit {result.returncode})")
if not Path(output_path).exists():
die(f"VHS produced no output at {output_path}")
size = Path(output_path).stat().st_size
size_mb = size / (1024 * 1024)
print(f"Recording: {output_path} ({size_mb:.1f} MB)")
print(json.dumps({"gif_path": str(output_path), "size_mb": round(size_mb, 1)}))
# --- Upload ---
def cmd_upload(args):
file_path = args.file
if not Path(file_path).exists():
die(f"File not found: {file_path}")
if not check_tool("curl"):
die("curl is not installed")
size_mb = file_size_mb(file_path)
print(f"Uploading {file_path} ({size_mb:.1f} MB) to catbox.moe...")
def _try_upload():
try:
result = subprocess.run(
["curl", "-s", "--connect-timeout", "10",
"-F", "reqtype=fileupload",
"-F", f"fileToUpload=@{file_path}", CATBOX_API],
capture_output=True, text=True, timeout=30, check=False,
)
return result.stdout.strip()
except subprocess.TimeoutExpired:
print("ERROR: Upload timed out after 30s", file=sys.stderr)
return ""
url = _try_upload()
if url.startswith("https://"):
print(f"Uploaded: {url}")
print(url)
return
print(f"ERROR: Upload failed. Response: {url[:200]}", file=sys.stderr)
print(f"Local file preserved at: {file_path}", file=sys.stderr)
# Retry once
print("Retrying in 2 seconds...", file=sys.stderr)
time.sleep(2)
url = _try_upload()
if url.startswith("https://"):
print(f"Uploaded (retry): {url}")
print(url)
else:
print("ERROR: Retry also failed. Upload manually or commit to branch.", file=sys.stderr)
sys.exit(1)
# --- Main ---
def main():
parser = argparse.ArgumentParser(
description="Evidence capture pipeline",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Commands:
preflight Check tool availability (JSON)
detect [--repo-root PATH] Detect project type (JSON)
recommend --project-type T ... Recommend capture tier (JSON)
stitch [--duration N] OUTPUT FRAMES Stitch frames into animated GIF
screenshot-reel --output O --text F Render text via silicon + stitch
terminal-recording --output O --tape T Run VHS tape
upload FILE Upload to catbox.moe
""",
)
sub = parser.add_subparsers(dest="command")
# preflight
sub.add_parser("preflight", help="Check tool availability")
# detect
p_detect = sub.add_parser("detect", help="Detect project type")
p_detect.add_argument("--repo-root", help="Repository root (default: cwd)")
# recommend
p_rec = sub.add_parser("recommend", help="Recommend capture tier")
p_rec.add_argument("--project-type", required=True,
choices=["web-app", "cli-tool", "library", "desktop-app", "text-only"])
p_rec.add_argument("--change-type", required=True, choices=["motion", "states"])
p_rec.add_argument("--tools", required=True, help="JSON object of tool availability")
# stitch
p_stitch = sub.add_parser("stitch", help="Stitch frames into animated GIF")
p_stitch.add_argument("--duration", type=float, default=3.0, help="Seconds per frame")
p_stitch.add_argument("output", help="Output GIF path")
p_stitch.add_argument("frames", nargs="+", help="Input frame PNGs")
# screenshot-reel
p_reel = sub.add_parser("screenshot-reel", help="Render text frames via silicon + stitch")
p_reel.add_argument("--output", required=True, help="Output GIF path")
p_reel.add_argument("--duration", type=float, default=2.5, help="Seconds per frame")
p_reel.add_argument("--lang", default="bash", help="Language for syntax highlighting")
p_reel.add_argument("--theme", default="Dracula", help="Silicon theme")
p_reel.add_argument("--background", default="#0d1117", help="Background color for frame border")
p_reel.add_argument("--text", nargs="+", required=True, help="Text files (one per frame)")
# terminal-recording
p_term = sub.add_parser("terminal-recording", help="Run VHS tape file")
p_term.add_argument("--output", help="Output GIF path (overrides tape Output directive)")
p_term.add_argument("--tape", required=True, help="VHS tape file path")
# upload
p_upload = sub.add_parser("upload", help="Upload to catbox.moe")
p_upload.add_argument("file", help="File to upload")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
dispatch = {
"preflight": cmd_preflight,
"detect": cmd_detect,
"recommend": cmd_recommend,
"stitch": cmd_stitch,
"screenshot-reel": cmd_screenshot_reel,
"terminal-recording": cmd_terminal_recording,
"upload": cmd_upload,
}
dispatch[args.command](args)
if __name__ == "__main__":
main()