From 545405380dba78bc0efd35f7675e8c27d99bf8c9 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Fri, 10 Apr 2026 17:30:39 -0700 Subject: [PATCH] fix(ce-demo-reel): two-stage upload for reviewable approval gate (#546) Co-authored-by: Claude Opus 4.6 (1M context) --- .../references/upload-and-approval.md | 70 ++++++---- .../ce-demo-reel/scripts/capture-demo.py | 124 ++++++++++++++---- 2 files changed, 143 insertions(+), 51 deletions(-) diff --git a/plugins/compound-engineering/skills/ce-demo-reel/references/upload-and-approval.md b/plugins/compound-engineering/skills/ce-demo-reel/references/upload-and-approval.md index 02da205..bbc352d 100644 --- a/plugins/compound-engineering/skills/ce-demo-reel/references/upload-and-approval.md +++ b/plugins/compound-engineering/skills/ce-demo-reel/references/upload-and-approval.md @@ -1,40 +1,60 @@ # Upload and Approval -Get user approval for the local artifact, upload evidence to a public URL, and generate markdown for PR inclusion. +Upload a temporary preview for the user to review, then promote to permanent hosting on approval. -## Step 1: Local Approval Gate +## Step 1: Preview Upload (Temporary) -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: +Upload the evidence file (GIF or PNG) to litterbox for a temporary 1-hour preview: ```bash -python3 scripts/capture-demo.py upload [ARTIFACT_PATH] +python3 scripts/capture-demo.py preview [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." +The last line of output is the preview URL (e.g., `https://litter.catbox.moe/abc123.gif`). This URL expires after 1 hour — no cleanup needed. For multiple files (static screenshots tier), upload each file separately. -## Step 3: Return Output +**If upload fails** after retry, fall back to opening the local file with the platform file-opener (`open` on macOS, `xdg-open` on Linux) so the user can still review it. Include the local path in the approval question instead of a URL. -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 2: Approval Gate -## Step 4: Cleanup +Present the preview URL 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). -Remove the `[RUN_DIR]` scratch directory and all temporary files. Preserve nothing -- the evidence lives at the public URL now. +**Question:** "Evidence preview (1h link): [PREVIEW_URL]" -If the upload failed and the user has not yet manually uploaded, preserve `[RUN_DIR]` so the artifact is still accessible. +**Options:** +1. **Use this in the PR** -- promote to permanent hosting +2. **Recapture** -- provide instructions on what to change +3. **Proceed without evidence** -- 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. + +### On "Recapture" + +Return to the tier execution step. The user's instructions guide what to change in the next capture attempt. After recapture, upload a new preview and repeat the approval gate. + +### On "Proceed without evidence" + +Set evidence to null and proceed. The preview link expires on its own. + +## Step 3: Promote to Permanent Hosting + +After the user approves, upload to permanent catbox hosting. The command accepts either the preview URL (preferred) or the local file path (fallback): + +```bash +python3 scripts/capture-demo.py upload [PREVIEW_URL or ARTIFACT_PATH] +``` + +If Step 1 produced a preview URL, pass it here -- catbox copies directly from litterbox without re-uploading. If Step 1 fell back to local review (no preview URL), pass the local artifact path instead. + +The last line of output is the permanent URL (e.g., `https://files.catbox.moe/abc123.gif`). Use this URL in the output, not the preview URL. + +For multiple files, promote each separately. + +## Step 4: Return Output + +Return the structured output defined in the SKILL.md Output section: `Tier`, `Description`, and `URL` (the permanent catbox URL). The caller formats the evidence into the PR description. ce-demo-reel does not generate markdown. + +## Step 5: Cleanup + +Remove the `[RUN_DIR]` scratch directory and all temporary files. Preserve nothing -- the evidence lives at the permanent URL now. diff --git a/plugins/compound-engineering/skills/ce-demo-reel/scripts/capture-demo.py b/plugins/compound-engineering/skills/ce-demo-reel/scripts/capture-demo.py index fed8c27..95a0acf 100755 --- a/plugins/compound-engineering/skills/ce-demo-reel/scripts/capture-demo.py +++ b/plugins/compound-engineering/skills/ce-demo-reel/scripts/capture-demo.py @@ -9,7 +9,8 @@ Subcommands: 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) + preview FILE Upload to litterbox (1h expiry) for preview + upload FILE_OR_URL Upload/promote to catbox.moe (permanent) """ import argparse import json @@ -27,6 +28,7 @@ from pathlib import Path 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" +LITTERBOX_API = "https://litterbox.catbox.moe/resources/internals/api.php" # --- Helpers --- @@ -527,23 +529,80 @@ def cmd_terminal_recording(args): # --- Upload --- -def cmd_upload(args): - file_path = args.file - if not Path(file_path).exists(): - die(f"File not found: {file_path}") - +def _upload_to(api_url, file_path, extra_fields=None): + """Upload a file to a catbox-family API. Returns the URL or empty string.""" 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...") + cmd = [ + "curl", "-s", "--connect-timeout", "10", + "-F", "reqtype=fileupload", + "-F", f"fileToUpload=@{file_path}", + ] + for field in (extra_fields or []): + cmd += ["-F", field] + cmd.append(api_url) - def _try_upload(): + try: + result = subprocess.run( + cmd, 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 "" + + +def _upload_with_retry(api_url, file_path, label, extra_fields=None): + """Upload with one retry. Prints and returns the URL, or exits on failure.""" + size_mb = file_size_mb(file_path) + print(f"Uploading {file_path} ({size_mb:.1f} MB) to {label}...") + + url = _upload_to(api_url, file_path, extra_fields) + if url.startswith("https://"): + print(f"Uploaded: {url}") + print(url) + return url + + print(f"ERROR: Upload failed. Response: {url[:200]}", file=sys.stderr) + print(f"Local file preserved at: {file_path}", file=sys.stderr) + print("Retrying in 2 seconds...", file=sys.stderr) + time.sleep(2) + + url = _upload_to(api_url, file_path, extra_fields) + if url.startswith("https://"): + print(f"Uploaded (retry): {url}") + print(url) + return url + + print("ERROR: Retry also failed.", file=sys.stderr) + sys.exit(1) + + +# --- Preview (litterbox — temporary, 1h expiry) --- + +def cmd_preview(args): + file_path = args.file + if not Path(file_path).exists(): + die(f"File not found: {file_path}") + _upload_with_retry(LITTERBOX_API, file_path, "litterbox (1h expiry)", ["time=1h"]) + + +# --- Upload (catbox — permanent) --- + +def _promote_url(source_url): + """Promote a URL (e.g., litterbox preview) to permanent catbox hosting.""" + if not check_tool("curl"): + die("curl is not installed") + + print(f"Promoting {source_url} to catbox.moe...") + + def _try(): try: result = subprocess.run( ["curl", "-s", "--connect-timeout", "10", - "-F", "reqtype=fileupload", - "-F", f"fileToUpload=@{file_path}", CATBOX_API], + "-F", "reqtype=urlupload", + "-F", f"url={source_url}", CATBOX_API], capture_output=True, text=True, timeout=30, check=False, ) return result.stdout.strip() @@ -551,27 +610,34 @@ def cmd_upload(args): print("ERROR: Upload timed out after 30s", file=sys.stderr) return "" - url = _try_upload() - + url = _try() if url.startswith("https://"): - print(f"Uploaded: {url}") + print(f"Promoted: {url}") print(url) - return + return url - 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(f"ERROR: Promote failed. Response: {url[:200]}", file=sys.stderr) print("Retrying in 2 seconds...", file=sys.stderr) time.sleep(2) - url = _try_upload() + url = _try() if url.startswith("https://"): - print(f"Uploaded (retry): {url}") + print(f"Promoted (retry): {url}") print(url) + return url + + print("ERROR: Retry also failed.", file=sys.stderr) + sys.exit(1) + + +def cmd_upload(args): + source = args.source + if source.startswith("https://"): + _promote_url(source) else: - print("ERROR: Retry also failed. Upload manually or commit to branch.", file=sys.stderr) - sys.exit(1) + if not Path(source).exists(): + die(f"File not found: {source}") + _upload_with_retry(CATBOX_API, source, "catbox.moe") # --- Main --- @@ -588,7 +654,8 @@ Commands: 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 + preview FILE Upload to litterbox (1h expiry) + upload FILE_OR_URL Upload/promote to catbox.moe (permanent) """, ) sub = parser.add_subparsers(dest="command") @@ -627,9 +694,13 @@ Commands: 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") + # preview + p_preview = sub.add_parser("preview", help="Upload to litterbox (1h expiry) for preview") + p_preview.add_argument("file", help="File to upload") + # upload - p_upload = sub.add_parser("upload", help="Upload to catbox.moe") - p_upload.add_argument("file", help="File to upload") + p_upload = sub.add_parser("upload", help="Upload or promote to catbox.moe (permanent)") + p_upload.add_argument("source", help="Local file path or URL to promote") args = parser.parse_args() @@ -644,6 +715,7 @@ Commands: "stitch": cmd_stitch, "screenshot-reel": cmd_screenshot_reel, "terminal-recording": cmd_terminal_recording, + "preview": cmd_preview, "upload": cmd_upload, } dispatch[args.command](args)