feat(ce-demo-reel): add local save as alternative to catbox upload (#647)
Some checks failed
CI / pr-title (push) Has been cancelled
CI / test (push) Has been cancelled
Release PR / release-pr (push) Has been cancelled
Release PR / publish-cli (push) Has been cancelled

This commit is contained in:
Luca Henn
2026-04-22 20:28:44 +02:00
committed by GitHub
parent 7ddfbed33b
commit fdf5fe4af5
6 changed files with 348 additions and 15 deletions

View File

@@ -0,0 +1,53 @@
---
date: 2026-04-22
topic: demo-reel-local-save
---
# Demo Reel: Local Evidence Save
## Problem Frame
When `ce-demo-reel` captures evidence (GIFs, screenshots, terminal recordings), the local artifacts are deleted after uploading to catbox.moe. Users who want to keep evidence locally — for offline access, committing to the repo, or archival — have no way to do so without manually copying files from the temp directory before cleanup runs.
---
## Requirements
**Destination choice**
- R1. After capture completes, ask the user whether to upload to catbox (existing behavior) or save locally.
- R2. The question must present the captured artifact(s) and clearly describe both options.
**Local save behavior**
- R3. When the user chooses local save, copy the final artifact(s) (GIF, PNG, or recording) to a stable OS-temp path (`$TMPDIR/compound-engineering/ce-demo-reel/`). Do not upload to catbox.
- R4. Create the destination directory if it does not exist.
- R5. Use a descriptive filename that includes the branch name or PR identifier and a timestamp to avoid collisions across runs.
- R6. After saving, display the local file path(s) to the user for easy reference.
---
## Success Criteria
- A user running `ce-demo-reel` can keep captured evidence on disk without manual intervention.
- The saved artifacts are discoverable in a predictable, stable OS-temp location.
---
## Scope Boundaries
- Catbox upload logic itself is unchanged — only the routing (local vs. upload) is new.
- No automatic git-add or commit of saved artifacts.
- No configurable save path — `$TMPDIR/compound-engineering/ce-demo-reel/` is the fixed default for now.
- No retroactive save of previously captured evidence.
---
## Key Decisions
- **Local save as an alternative to upload, not an addition**: The user chooses one destination per capture — either catbox or local. This keeps the flow simple and avoids redundant artifacts.
- **OS-temp as the local target**: Uses `$TMPDIR/compound-engineering/ce-demo-reel/` per the repo's cross-invocation scratch-space convention. Stable prefix makes files findable without polluting the repo tree.
---
## Next Steps
-> `/ce-plan` for structured implementation planning, or proceed directly to implementation given the small scope.

View File

@@ -0,0 +1,210 @@
---
title: "feat(ce-demo-reel): Add local save as alternative to catbox upload"
type: feat
status: active
date: 2026-04-22
origin: docs/brainstorms/2026-04-22-demo-reel-local-save-requirements.md
---
# feat(ce-demo-reel): Add local save as alternative to catbox upload
## Overview
Add a destination choice to the ce-demo-reel upload flow: after capture, the user picks either "upload to catbox" (existing behavior) or "save locally" (new). Local save copies the final artifact to a stable OS-temp path with a descriptive filename. The catbox upload path is unchanged.
---
## Problem Frame
When ce-demo-reel captures evidence, local artifacts are deleted after uploading to catbox.moe. Users who want to keep evidence locally have no way to do so. (See origin: `docs/brainstorms/2026-04-22-demo-reel-local-save-requirements.md`)
---
## Requirements Trace
- R1. After capture completes, ask the user whether to upload to catbox or save locally
- R2. The question must present the captured artifact(s) and clearly describe both options
- R3. When the user chooses local save, copy artifacts to `$TMPDIR/compound-engineering/ce-demo-reel/`; do not upload to catbox
- R4. Create the destination directory if it does not exist
- R5. Use a descriptive filename with branch name and timestamp to avoid collisions
- R6. After saving, display the local file path(s) to the user
---
## Scope Boundaries
- Catbox upload logic itself is unchanged — only the routing is new
- No automatic git-add or commit of saved artifacts
- No configurable save path — `$TMPDIR/compound-engineering/ce-demo-reel/` is the fixed default
- No retroactive save of previously captured evidence
---
## Context & Research
### Relevant Code and Patterns
- `plugins/compound-engineering/skills/ce-demo-reel/references/upload-and-approval.md` — the 5-step upload flow where the destination choice will be inserted
- `plugins/compound-engineering/skills/ce-demo-reel/scripts/capture-demo.py` — pipeline script with `preview` and `upload` subcommands; will get a new `save-local` subcommand
- `plugins/compound-engineering/skills/ce-demo-reel/SKILL.md` — Step 8 delegates to `upload-and-approval.md`; Output section defines the return format
### Institutional Learnings
- **Script-first architecture** (`docs/solutions/skill-design/script-first-skill-architecture.md`): File manipulation (mkdir, copy, path generation) belongs in the Python script, not inline in SKILL.md
- **Prefer Python over bash** (`docs/solutions/best-practices/prefer-python-over-bash-for-pipeline-scripts-2026-04-09.md`): The `save-local` subcommand should be Python, consistent with the existing script
---
## Key Technical Decisions
- **Destination choice replaces approval gate, not adds to it**: The existing Step 2 approval gate asks "use this / recapture / skip". The new flow asks "upload to catbox / save locally / recapture / skip" — a single merged question, not two sequential prompts.
- **`save-local` as a script subcommand**: Per script-first architecture, the Python script handles directory creation, filename generation, and file copying. The SKILL.md orchestrates the choice and calls the script.
- **Filename format**: `<branch>-<YYYYMMDD-HHMMSS>.<ext>` — branch provides context, timestamp prevents collisions. Branch name is sanitized (slashes to dashes, truncated to 60 chars).
- **Output format for local save**: The existing output uses `URL: [public URL]`. For local saves, use `Path: [local path]` instead, so the caller can distinguish between the two.
---
## Open Questions
### Resolved During Planning
- **Should preview upload still happen before the choice?** Yes — the user needs to see the artifact to decide. The preview is temporary (1h) and costs nothing if they choose local save.
### Deferred to Implementation
- **Exact branch-name sanitization regex**: Implementation detail; follow Python `re.sub` conventions.
---
## Implementation Units
- [ ] U1. **Add `save-local` subcommand to capture-demo.py**
**Goal:** Add a script subcommand that copies an artifact to a target directory with a descriptive filename.
**Requirements:** R3, R4, R5, R6
**Dependencies:** None
**Files:**
- Modify: `plugins/compound-engineering/skills/ce-demo-reel/scripts/capture-demo.py`
**Approach:**
- Add `save-local` subcommand accepting `--file` (artifact path), `--branch` (branch name), and `--output-dir` (target directory, defaults to `$TMPDIR/compound-engineering/ce-demo-reel/`)
- Create output directory with `os.makedirs(exist_ok=True)`
- Sanitize branch name: replace `/` with `-`, strip non-alphanumeric chars except `-`, truncate to 60 chars
- Generate filename: `<sanitized-branch>-<YYYYMMDD-HHMMSS>.<ext>` where ext comes from the source file
- Copy file with `shutil.copy2`
- Print the final absolute path as the last line of output (matching the convention of `preview` and `upload` which print the URL as last line)
- Register the subcommand in the argparse `main()` block
**Patterns to follow:**
- `cmd_preview` and `cmd_upload` in the same file — same structure, same error handling with `die()`
- Argparse registration pattern at bottom of file
**Test scenarios:**
- Happy path: `save-local --file /tmp/demo.gif --branch feat/add-login` creates `$TMPDIR/compound-engineering/ce-demo-reel/feat-add-login-<timestamp>.gif` and prints the path
- Happy path: `save-local --file /tmp/screenshot.png --branch main` creates `$TMPDIR/compound-engineering/ce-demo-reel/main-<timestamp>.png`
- Edge case: branch with deep nesting `feat/team/subsystem/thing` sanitizes to `feat-team-subsystem-thing`
- Edge case: branch name exceeding 60 chars is truncated
- Edge case: output directory does not exist — created automatically
- Error path: source file does not exist — exits with error message
**Verification:**
- `python3 scripts/capture-demo.py save-local --file <test-gif> --branch test-branch` copies the file and prints the destination path
---
- [ ] U2. **Update upload-and-approval.md to add destination choice**
**Goal:** Replace the current approval gate with a combined destination-choice question that includes the local save option.
**Requirements:** R1, R2
**Dependencies:** U1
**Files:**
- Modify: `plugins/compound-engineering/skills/ce-demo-reel/references/upload-and-approval.md`
**Approach:**
- Step 1 (preview upload) stays unchanged — user still sees a preview
- Step 2 becomes "Destination Choice" instead of "Approval Gate"
- The blocking question now offers 4 options:
1. **Upload to catbox (public URL)** — proceeds to Step 3 (promote to permanent, unchanged)
2. **Save locally** — runs `save-local` subcommand, skips Step 3, goes to cleanup
3. **Recapture** — unchanged behavior
4. **Proceed without evidence** — unchanged behavior
- Add a new section "Step 3b: Local Save" that calls `python3 scripts/capture-demo.py save-local --file [ARTIFACT_PATH] --branch [BRANCH]`
- Step 3b captures the printed path and uses it in the output
- Step 5 (cleanup) remains the same — `[RUN_DIR]` is always removed since the artifact has been copied out
**Patterns to follow:**
- Existing Step 2 approval gate structure (question wording, option format, platform blocking tool instructions)
- Existing Step 3 promote structure (script invocation, output capture)
**Test scenarios:**
- Happy path: user selects "Save locally" -> `save-local` runs, local path displayed, `[RUN_DIR]` cleaned up
- Happy path: user selects "Upload to catbox" -> existing promote flow runs unchanged
- Happy path: user selects "Recapture" -> returns to tier execution as before
- Integration: multiple static screenshots — each file is saved locally with the same branch prefix but unique timestamps
**Verification:**
- The approval gate question includes all 4 options with clear descriptions
- "Save locally" branch calls the script and does not invoke catbox upload
- "Upload to catbox" branch is functionally identical to the current behavior
---
- [ ] U3. **Update SKILL.md output format for local saves**
**Goal:** Extend the output contract to support local file paths alongside URLs.
**Requirements:** R6
**Dependencies:** U2
**Files:**
- Modify: `plugins/compound-engineering/skills/ce-demo-reel/SKILL.md`
**Approach:**
- In the Output section, add `Path` as an alternative to `URL`:
- `URL: [public URL]` when uploaded to catbox (unchanged)
- `Path: [local file path]` when saved locally
- One of the two is present, never both
- Update the note about `URL: "none"` to cover the local case: when saved locally, `URL` is `"none"` but `Path` is populated
**Patterns to follow:**
- Existing output block format in SKILL.md
**Test scenarios:**
- Happy path: local save produces output with `Path:` field and `URL: "none"`
- Happy path: catbox upload produces output with `URL:` field and no `Path:` field (unchanged)
**Verification:**
- Output contract is clear about when `Path` vs `URL` is present
- Callers (e.g., ce-commit-push-pr) can distinguish local from remote evidence
---
## System-Wide Impact
- **Interaction graph:** ce-commit-push-pr is the primary caller of ce-demo-reel. It currently expects a `URL` in the output to embed in PR descriptions. With local saves, it will receive `Path` instead — it should handle this gracefully (e.g., skip embedding or note that evidence is local-only).
- **Error propagation:** If `save-local` fails (disk full, permission denied), the artifact still exists in `[RUN_DIR]`. The skill should report the error and offer to retry or fall back to catbox upload.
- **Unchanged invariants:** The catbox preview/upload pipeline, tier selection, and capture logic are entirely untouched.
---
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| ce-commit-push-pr doesn't handle `Path` output | Check how ce-commit-push-pr consumes demo-reel output; update if needed (but scoped out of this plan per scope boundaries) |
| OS-temp files cleaned by system reboot | Acceptable — demo reel artifacts are transient; users can `mv` to repo if they want to commit |
---
## Sources & References
- **Origin document:** [docs/brainstorms/2026-04-22-demo-reel-local-save-requirements.md](docs/brainstorms/2026-04-22-demo-reel-local-save-requirements.md)
- Related code: `plugins/compound-engineering/skills/ce-demo-reel/`
- Learnings: `docs/solutions/skill-design/script-first-skill-architecture.md`, `docs/solutions/best-practices/prefer-python-over-bash-for-pipeline-scripts-2026-04-09.md`

View File

@@ -194,7 +194,7 @@ Use this branch diff (not the working-tree diff) for the evidence decision. If t
**Evidence decision (before delegation).** If the branch diff changes observable behavior (UI, CLI output, API behavior with runnable code, generated artifacts, workflow output) and evidence is not otherwise blocked (unavailable credentials, paid services, deploy-only infrastructure, hardware), ask: "This PR has observable behavior. Capture evidence for the PR description?" **Evidence decision (before delegation).** If the branch diff changes observable behavior (UI, CLI output, API behavior with runnable code, generated artifacts, workflow output) and evidence is not otherwise blocked (unavailable credentials, paid services, deploy-only infrastructure, hardware), ask: "This PR has observable behavior. Capture evidence for the PR description?"
- **Capture now** -- load the `ce-demo-reel` skill with a target description inferred from the branch diff. ce-demo-reel returns `Tier`, `Description`, and `URL`. Note the captured evidence so it can be passed as free-text steering to `ce-pr-description` (e.g., "include the captured demo: <URL> as a `## Demo` section") or spliced into the returned body before apply. If capture returns `Tier: skipped` or `URL: "none"`, proceed with no evidence. - **Capture now** -- load the `ce-demo-reel` skill with a target description inferred from the branch diff. ce-demo-reel returns `Tier`, `Description`, `URL`, and `Path`. Exactly one of `URL` or `Path` contains a real value; the other is `"none"`. If capture returns a public URL, pass it as steering to `ce-pr-description` (e.g., "include the captured demo: <URL> as a `## Demo` section") or splice into the returned body before apply. If capture returns a local `Path` instead (user chose local save), pass steering that notes evidence was captured but is local-only (e.g., "evidence was captured locally — note in the PR that a demo was recorded but is not embedded because the user chose local save"). If capture returns `Tier: skipped` or both `URL` and `Path` are `"none"`, proceed with no evidence.
- **Use existing evidence** -- ask for the URL or markdown embed, then pass it as free-text steering to `ce-pr-description` or splice in before apply. - **Use existing evidence** -- ask for the URL or markdown embed, then pass it as free-text steering to `ce-pr-description` or splice in before apply.
- **Skip** -- proceed with no evidence section. - **Skip** -- proceed with no evidence section.
@@ -202,7 +202,7 @@ When evidence is not possible (docs-only, markdown-only, changelog-only, release
**Delegate title and body generation to `ce-pr-description`.** Load the `ce-pr-description` skill: **Delegate title and body generation to `ce-pr-description`.** Load the `ce-pr-description` skill:
- **For a new PR** (no existing PR found in Step 3): invoke with `base:<base-remote>/<base-branch>` using the already-resolved base from earlier in this step, so `ce-pr-description` describes the correct commit range even when the branch targets a non-default base (e.g., `develop`, `release/*`). Append any captured-evidence context or user focus as free-text steering (e.g., "include the captured demo: <URL> as a `## Demo` section"). - **For a new PR** (no existing PR found in Step 3): invoke with `base:<base-remote>/<base-branch>` using the already-resolved base from earlier in this step, so `ce-pr-description` describes the correct commit range even when the branch targets a non-default base (e.g., `develop`, `release/*`). Append any captured-evidence context or user focus as free-text steering (e.g., "include the captured demo: <URL> as a `## Demo` section", or "evidence captured locally — not embedded" for local saves).
- **For an existing PR** (found in Step 3): invoke with the full PR URL from the Step 3 context (e.g., `https://github.com/owner/repo/pull/123`). The URL preserves repo/PR identity even when invoked from a worktree or subdirectory; the skill reads the PR's own `baseRefName` so no `base:` override is needed. Append any focus steering as free text after the URL. - **For an existing PR** (found in Step 3): invoke with the full PR URL from the Step 3 context (e.g., `https://github.com/owner/repo/pull/123`). The URL preserves repo/PR identity even when invoked from a worktree or subdirectory; the skill reads the PR's own `baseRefName` so no `base:` override is needed. Append any focus steering as free text after the URL.
**Steering discipline.** Pass only what the diff cannot reveal: a user focus ("emphasize the performance win"), a specific framing concern ("this needs to read as a migration not a feature"), or a pointer to institutional knowledge. Do NOT dump an exhaustive scope summary or a numbered list of every change — `ce-pr-description` reads the diff itself. Over-specified steering encourages the downstream skill to cover everything passed in, producing verbose output. Cap steering at roughly 100 words; if a longer framing feels necessary, trust the diff and cut. **Steering discipline.** Pass only what the diff cannot reveal: a user focus ("emphasize the performance win"), a specific framing concern ("this needs to read as a migration not a feature"), or a pointer to institutional knowledge. Do NOT dump an exhaustive scope summary or a numbered list of every change — `ce-pr-description` reads the diff itself. Over-specified steering encourages the downstream skill to cover everything passed in, producing verbose output. Cap steering at roughly 100 words; if a longer framing feels necessary, trust the diff and cut.

View File

@@ -154,12 +154,16 @@ Return these values to the caller (e.g., ce-commit-push-pr):
Tier: [browser-reel / terminal-recording / screenshot-reel / static / skipped] Tier: [browser-reel / terminal-recording / screenshot-reel / static / skipped]
Description: [1 sentence describing what the evidence shows] Description: [1 sentence describing what the evidence shows]
URL: [public URL or "none" (multiple URLs comma-separated for static screenshots)] URL: [public URL or "none" (multiple URLs comma-separated for static screenshots)]
Path: [local file path or "none" (multiple paths comma-separated for static screenshots)]
=== End Evidence === === 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. 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. - `Tier: skipped` means no evidence was captured; both `URL` and `Path` are `"none"`.
- When uploaded to catbox: `URL` has the public URL, `Path` is `"none"`.
- When saved locally: `Path` has the local file path, `URL` is `"none"`.
- For all non-skipped tiers, exactly one of `URL` or `Path` contains a real value; the other is `"none"`.
**Label convention:** **Label convention:**
- Browser reel, terminal recording, screenshot reel: label as "Demo" - Browser reel, terminal recording, screenshot reel: label as "Demo"

View File

@@ -1,6 +1,6 @@
# Upload and Approval # Upload and Approval
Upload a temporary preview for the user to review, then promote to permanent hosting on approval. Upload a temporary preview for the user to review, then either promote to permanent hosting or save locally based on user choice.
## Step 1: Preview Upload (Temporary) ## Step 1: Preview Upload (Temporary)
@@ -14,24 +14,33 @@ The last line of output is the preview URL (e.g., `https://litter.catbox.moe/abc
For multiple files (static screenshots tier), upload each file separately. For multiple files (static screenshots tier), upload each file separately.
**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. **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 destination choice question instead of a URL.
## Step 2: Approval Gate ## Step 2: Destination Choice
Present the preview URL to the user for approval. Use the platform's blocking question tool: `AskUserQuestion` in Claude Code (call `ToolSearch` with `select:AskUserQuestion` first if its schema isn't loaded), `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension). Fall back to presenting options in chat only when no blocking tool exists in the harness or the call errors (e.g., Codex edit modes) — not because a schema load is required. Never silently skip the question. Present the preview URL to the user and ask how to handle the evidence. Use the platform's blocking question tool: `AskUserQuestion` in Claude Code (call `ToolSearch` with `select:AskUserQuestion` first if its schema isn't loaded), `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension). Fall back to presenting options in chat only when no blocking tool exists in the harness or the call errors (e.g., Codex edit modes) — not because a schema load is required. Never silently skip the question.
**Question:** "Evidence preview (1h link): [PREVIEW_URL]" **Question:** "Evidence preview (1h link): [PREVIEW_URL]. Where should the evidence go?"
**Options:** **Options:**
1. **Use this in the PR** -- promote to permanent hosting 1. **Upload to catbox (public URL)** -- promote to permanent hosting for PR embedding
2. **Recapture** -- provide instructions on what to change 2. **Save locally** -- save to a stable OS-temp path ($TMPDIR/compound-engineering/ce-demo-reel/)
3. **Proceed without evidence** -- set evidence to null and proceed 3. **Recapture** -- provide instructions on what to change
4. **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. If the question tool is unavailable (headless/background mode), present the numbered options and wait for the user's reply before proceeding.
### On "Upload to catbox (public URL)"
Proceed to Step 3: Promote to Permanent Hosting.
### On "Save locally"
Proceed to Step 3b: Local Save.
### On "Recapture" ### 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. 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 destination choice.
### On "Proceed without evidence" ### On "Proceed without evidence"
@@ -39,7 +48,7 @@ Set evidence to null and proceed. The preview link expires on its own.
## Step 3: Promote to Permanent Hosting ## 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): After the user selects "Upload to catbox", upload to permanent catbox hosting. The command accepts either the preview URL (preferred) or the local file path (fallback):
```bash ```bash
python3 scripts/capture-demo.py upload [PREVIEW_URL or ARTIFACT_PATH] python3 scripts/capture-demo.py upload [PREVIEW_URL or ARTIFACT_PATH]
@@ -51,10 +60,26 @@ The last line of output is the permanent URL (e.g., `https://files.catbox.moe/ab
For multiple files, promote each separately. For multiple files, promote each separately.
## Step 3b: Local Save
After the user selects "Save locally", save the artifact to the default OS-temp path using the pipeline script:
```bash
python3 scripts/capture-demo.py save-local --file [ARTIFACT_PATH] --branch [BRANCH_NAME]
```
Determine `[BRANCH_NAME]` from `git branch --show-current` or the PR context discovered in Step 0 of the SKILL.md.
The last line of output is the absolute path of the saved file. Use this path in the output.
For multiple files (static screenshots tier), save each file separately.
**If save fails** (permission denied, disk full), report the error and offer to retry or fall back to catbox upload (Step 3).
## Step 4: Return Output ## 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. Return the structured output defined in the SKILL.md Output section: `Tier`, `Description`, and either `URL` (permanent catbox URL) or `Path` (local file path). The caller formats the evidence into the PR description. ce-demo-reel does not generate markdown.
## Step 5: Cleanup ## Step 5: Cleanup
Remove the `[RUN_DIR]` scratch directory and all temporary files. Preserve nothing -- the evidence lives at the permanent URL now. Remove the `[RUN_DIR]` scratch directory and all temporary files. Preserve nothing -- the evidence lives at the permanent URL or has been copied to the local save path.

View File

@@ -11,15 +11,18 @@ Subcommands:
terminal-recording --output OUT --tape TAPE Run VHS tape file terminal-recording --output OUT --tape TAPE Run VHS tape file
preview FILE Upload to litterbox (1h expiry) for preview preview FILE Upload to litterbox (1h expiry) for preview
upload FILE_OR_URL Upload/promote to catbox.moe (permanent) upload FILE_OR_URL Upload/promote to catbox.moe (permanent)
save-local --file F --branch B Save artifact locally instead of uploading
""" """
import argparse import argparse
import json import json
import os import os
import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time import time
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -640,6 +643,35 @@ def cmd_upload(args):
_upload_with_retry(CATBOX_API, source, "catbox.moe") _upload_with_retry(CATBOX_API, source, "catbox.moe")
# --- Save local ---
def _sanitize_branch(branch):
sanitized = branch.replace("/", "-")
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "", sanitized)
sanitized = re.sub(r"-+", "-", sanitized).strip("-")
return sanitized[:60]
def cmd_save_local(args):
src = Path(args.file)
if not src.exists():
die(f"File not found: {src}")
output_dir = Path(args.output_dir)
os.makedirs(output_dir, exist_ok=True)
branch_part = _sanitize_branch(args.branch) if args.branch else "unknown"
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
stem = re.sub(r"[^a-zA-Z0-9_-]", "", src.stem)[:40] or "artifact"
filename = f"{branch_part}-{timestamp}-{stem}{src.suffix}"
dest = output_dir / filename
shutil.copy2(src, dest)
dest_abs = str(dest.resolve())
print(f"Saved: {dest_abs}")
print(dest_abs)
# --- Main --- # --- Main ---
def main(): def main():
@@ -656,6 +688,7 @@ Commands:
terminal-recording --output O --tape T Run VHS tape terminal-recording --output O --tape T Run VHS tape
preview FILE Upload to litterbox (1h expiry) preview FILE Upload to litterbox (1h expiry)
upload FILE_OR_URL Upload/promote to catbox.moe (permanent) upload FILE_OR_URL Upload/promote to catbox.moe (permanent)
save-local --file F --branch B Save artifact locally instead of uploading
""", """,
) )
sub = parser.add_subparsers(dest="command") sub = parser.add_subparsers(dest="command")
@@ -702,6 +735,13 @@ Commands:
p_upload = sub.add_parser("upload", help="Upload or promote to catbox.moe (permanent)") 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") p_upload.add_argument("source", help="Local file path or URL to promote")
# save-local
p_save = sub.add_parser("save-local", help="Save artifact locally instead of uploading")
p_save.add_argument("--file", required=True, help="Artifact file to save")
p_save.add_argument("--branch", default="", help="Branch name for filename")
default_dir = str(Path(os.environ.get("TMPDIR", "/tmp")) / "compound-engineering" / "ce-demo-reel")
p_save.add_argument("--output-dir", default=default_dir, help="Target directory")
args = parser.parse_args() args = parser.parse_args()
if not args.command: if not args.command:
@@ -717,6 +757,7 @@ Commands:
"terminal-recording": cmd_terminal_recording, "terminal-recording": cmd_terminal_recording,
"preview": cmd_preview, "preview": cmd_preview,
"upload": cmd_upload, "upload": cmd_upload,
"save-local": cmd_save_local,
} }
dispatch[args.command](args) dispatch[args.command](args)