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:
168
plugins/compound-engineering/skills/ce-demo-reel/SKILL.md
Normal file
168
plugins/compound-engineering/skills/ce-demo-reel/SKILL.md
Normal 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"
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||

|
||||

|
||||
```
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
653
plugins/compound-engineering/skills/ce-demo-reel/scripts/capture-demo.py
Executable file
653
plugins/compound-engineering/skills/ce-demo-reel/scripts/capture-demo.py
Executable 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()
|
||||
Reference in New Issue
Block a user