mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
ee52834701
The demo recorder kept its own copies of the docs site's colors, and they had drifted from `docs/src/styles/custom.css`: - The VHS terminal's ANSI colors were still the Zola site's palette, which #3866 deleted and #3936 replaced with `--wt-terminal-*`. Light yellow had 2.65:1 contrast against the page background; the site's browser test requires 4.5:1 for its own dim terminal text. - `brightWhite`, the background of Worktrunk's gutter, used the ink color, so dark GIFs drew the gutter as a near-white bar. - Zellij, the starship prompt, and Claude Code were configured for light in both themes, so dark GIFs showed a light Zellij tab bar and Claude Code's light theme. `docs/demos/shared/themes.py` now reads the hex `--wt-*` properties from `custom.css` when the build runs. The VHS theme maps ANSI colors the way the site renders snapshot output (`terminal_color_class` and `terminal_background_class` in `tests/integration_tests/readme_sync.rs`). `DemoEnv` carries the recording's theme, so Zellij, the starship palette, and Claude Code follow it. This replaces the `(pre-Starlight; site is #…)` comments the PR first added. Recording from inside a Claude Code session also leaked into the Claude demos. The demo's Claude Code inherited that session's `CLAUDE*` variables and warned that transcript saving was off, and it started Remote Control, which printed a live claude.ai session URL into the GIF. The recorder now drops inherited `CLAUDE*` variables except `CLAUDE_CODE_OAUTH_TOKEN`, and the demo's `settings.json` sets `remoteControlAtStartup` to false. The GIFs are re-recorded locally (`./docs/demos/build docs` and `./docs/demos/build social`) and published in max-sixty/worktrunk-assets@bb3bfba. ## Verification - `pytest docs/demos/tests`: 4 passed. - OCR of every 20th frame of the Claude demos (354 frames) finds no `remote-control`, `claude.ai/code`, `rc active`, or `Transcript saving`. The same check matches on frames from the leaking recording. - Frames checked by eye in both themes for the ANSI colors, the gutter, and the Zellij chrome. - TUI validation passes for all nine Claude demo GIFs. The `wt-switch` and `wt-statusline` checkpoints named absolute frames past the end of these faster recordings (274–318 frames, against 344–465 in the previously published GIFs), so a negative checkpoint frame now counts back from the last frame; the omnibus checkpoint on its closing `wt list` uses the same anchoring. The previously published GIFs pass the new checkpoints too. Those checkpoints also forbid `claude.ai/code` and `Transcript saving`, and a GIF made from a leaking frame fails on them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01TQPDxs2csvGFGBr2j4MtrA > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-authored-by: Maximilian Roos <m@maxroos.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1450 lines
49 KiB
Python
1450 lines
49 KiB
Python
"""Shared infrastructure for demo recording scripts."""
|
|
|
|
import json
|
|
import os
|
|
import platform
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
from .themes import PALETTES, THEMES, format_theme_for_vhs
|
|
|
|
REAL_HOME = Path.home()
|
|
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
|
DEPS_DIR = Path(__file__).parent.parent / ".deps" # Downloaded dependencies
|
|
|
|
# External dependency URLs
|
|
_GCS_BUCKET = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases"
|
|
_ZELLIJ_PLUGIN_URL = "https://github.com/Cynary/zellij-tab-name/releases/download/v0.4.1/zellij-tab-name.wasm"
|
|
_VHS_FORK_REPO = "https://github.com/max-sixty/vhs.git"
|
|
_VHS_FORK_BRANCH = "keypress-overlay"
|
|
|
|
|
|
def _detect_platform() -> str:
|
|
"""Detect platform for Claude Code binary download."""
|
|
system = platform.system().lower()
|
|
machine = platform.machine().lower()
|
|
|
|
if system == "darwin":
|
|
os_name = "darwin"
|
|
elif system == "linux":
|
|
os_name = "linux"
|
|
else:
|
|
raise RuntimeError(f"Unsupported OS: {system}")
|
|
|
|
if machine in ("x86_64", "amd64"):
|
|
arch = "x64"
|
|
elif machine in ("arm64", "aarch64"):
|
|
arch = "arm64"
|
|
else:
|
|
raise RuntimeError(f"Unsupported architecture: {machine}")
|
|
|
|
# Check for musl on Linux x64
|
|
if os_name == "linux" and arch == "x64":
|
|
try:
|
|
result = subprocess.run(
|
|
["ldd", "--version"], capture_output=True, text=True, check=False
|
|
)
|
|
if "musl" in result.stderr.lower() or "musl" in result.stdout.lower():
|
|
return "linux-x64-musl"
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
return f"{os_name}-{arch}"
|
|
|
|
|
|
def _download_file(url: str, dest: Path) -> None:
|
|
"""Download a file from URL to destination (parallel-safe).
|
|
|
|
Uses PID-unique temp file to avoid races when multiple processes download
|
|
simultaneously. Only moves to dest if dest doesn't exist at move time.
|
|
"""
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
print(f"Downloading {dest.name}...")
|
|
temp = dest.with_suffix(f".{os.getpid()}.tmp")
|
|
try:
|
|
urllib.request.urlretrieve(url, temp)
|
|
# Only move if dest doesn't exist (another process may have finished first)
|
|
if not dest.exists():
|
|
temp.rename(dest)
|
|
finally:
|
|
temp.unlink(missing_ok=True)
|
|
|
|
|
|
def _ensure_claude_binary() -> Path:
|
|
"""Ensure Claude Code binary is downloaded, return path."""
|
|
claude_binary = DEPS_DIR / "claude"
|
|
if claude_binary.exists():
|
|
return claude_binary
|
|
|
|
plat = _detect_platform()
|
|
print(f"Fetching Claude Code for {plat}...")
|
|
|
|
# Get stable version
|
|
with urllib.request.urlopen(f"{_GCS_BUCKET}/stable") as resp:
|
|
version = resp.read().decode().strip()
|
|
print(f"Claude Code version: {version}")
|
|
|
|
# Download binary
|
|
_download_file(f"{_GCS_BUCKET}/{version}/{plat}/claude", claude_binary)
|
|
claude_binary.chmod(0o755)
|
|
|
|
# Verify
|
|
result = subprocess.run(
|
|
[str(claude_binary), "--version"], capture_output=True, text=True, check=False
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(
|
|
f"Claude binary downloaded but --version failed: {result.stderr or result.stdout}"
|
|
)
|
|
|
|
print(f"✓ Claude Code {version} ready")
|
|
return claude_binary
|
|
|
|
|
|
def claude_auth_available() -> bool:
|
|
"""Return whether a Claude demo can authenticate without interactive login."""
|
|
if os.environ.get("ANTHROPIC_API_KEY") or os.environ.get(
|
|
"CLAUDE_CODE_OAUTH_TOKEN"
|
|
):
|
|
return True
|
|
if platform.system() != "Darwin":
|
|
return False
|
|
|
|
try:
|
|
with tempfile.TemporaryDirectory(prefix="wt-claude-auth-") as auth_dir:
|
|
auth_home = Path(auth_dir)
|
|
_forward_macos_keychains(auth_home)
|
|
result = subprocess.run(
|
|
[str(_ensure_claude_binary()), "auth", "status", "--json"],
|
|
env=_isolated_claude_env(auth_home),
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
timeout=10,
|
|
)
|
|
if result.returncode != 0:
|
|
return False
|
|
return bool(json.loads(result.stdout).get("loggedIn"))
|
|
except (
|
|
OSError,
|
|
RuntimeError,
|
|
subprocess.TimeoutExpired,
|
|
json.JSONDecodeError,
|
|
):
|
|
return False
|
|
|
|
|
|
def _forward_macos_keychains(home: Path) -> None:
|
|
"""Make the user's Keychain search list visible from an isolated HOME."""
|
|
if (
|
|
platform.system() != "Darwin"
|
|
or os.environ.get("ANTHROPIC_API_KEY")
|
|
or os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
|
|
):
|
|
return
|
|
|
|
real_home_env = {**os.environ, "HOME": str(REAL_HOME)}
|
|
result = subprocess.run(
|
|
["security", "list-keychains", "-d", "user"],
|
|
env=real_home_env,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(
|
|
f"Failed to read the macOS Keychain search list: {result.stderr}"
|
|
)
|
|
|
|
# `security` has no structured output for the search list. Its output is a
|
|
# shell-like sequence of quoted paths, one per line.
|
|
keychains = [path for path in shlex.split(result.stdout) if Path(path).exists()]
|
|
if not keychains:
|
|
raise RuntimeError("The macOS user Keychain search list is empty")
|
|
|
|
(home / "Library" / "Preferences").mkdir(parents=True, exist_ok=True)
|
|
run(
|
|
["security", "list-keychains", "-d", "user", "-s", *keychains],
|
|
env=_isolated_claude_env(home),
|
|
)
|
|
|
|
|
|
def recorder_env() -> dict[str, str]:
|
|
"""The recorder's environment without the calling Claude Code session's state.
|
|
|
|
A build started from inside Claude Code inherits that session's ``CLAUDE*``
|
|
variables, and a demo's Claude Code reads them as its own: the inherited
|
|
child-session marker, for one, makes it warn that transcript saving is off.
|
|
Only ``CLAUDE_CODE_OAUTH_TOKEN``, the documented way to authenticate demos,
|
|
passes through.
|
|
"""
|
|
return {
|
|
name: value
|
|
for name, value in os.environ.items()
|
|
if not name.startswith("CLAUDE") or name == "CLAUDE_CODE_OAUTH_TOKEN"
|
|
}
|
|
|
|
|
|
def _isolated_claude_env(home: Path) -> dict[str, str]:
|
|
"""Build an environment whose Claude state stays under an isolated HOME."""
|
|
env = recorder_env()
|
|
env["HOME"] = str(home)
|
|
env["XDG_CONFIG_HOME"] = str(home / ".config")
|
|
return env
|
|
|
|
|
|
def _ensure_zellij_plugin() -> Path:
|
|
"""Ensure Zellij tab-name plugin is downloaded, return path."""
|
|
plugin_path = DEPS_DIR / "zellij-tab-name.wasm"
|
|
if plugin_path.exists():
|
|
return plugin_path
|
|
|
|
_download_file(_ZELLIJ_PLUGIN_URL, plugin_path)
|
|
print("✓ Zellij plugin ready")
|
|
return plugin_path
|
|
|
|
|
|
def ensure_vhs_binary() -> Path:
|
|
"""Ensure VHS binary is cloned and built, return path.
|
|
|
|
Uses a custom VHS fork with keystroke overlay support.
|
|
Requires Go to be installed.
|
|
"""
|
|
vhs_dir = DEPS_DIR / "vhs"
|
|
vhs_binary = vhs_dir / "vhs"
|
|
|
|
if vhs_binary.exists():
|
|
return vhs_binary
|
|
|
|
# Check Go is available
|
|
if not shutil.which("go"):
|
|
raise RuntimeError(
|
|
"Go is required to build VHS. Install from https://go.dev/dl/"
|
|
)
|
|
|
|
# Clone if needed
|
|
if not vhs_dir.exists():
|
|
print("Cloning VHS fork...")
|
|
DEPS_DIR.mkdir(parents=True, exist_ok=True)
|
|
result = subprocess.run(
|
|
["git", "clone", "-b", _VHS_FORK_BRANCH, "--depth=1", _VHS_FORK_REPO, str(vhs_dir)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"Failed to clone VHS fork: {result.stderr}")
|
|
|
|
# Build
|
|
print("Building VHS...")
|
|
result = subprocess.run(
|
|
["go", "build", "-o", "vhs", "."],
|
|
cwd=vhs_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"Failed to build VHS: {result.stderr}")
|
|
|
|
# Verify
|
|
result = subprocess.run(
|
|
[str(vhs_binary), "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"VHS built but --version failed: {result.stderr}")
|
|
|
|
print("✓ VHS ready")
|
|
return vhs_binary
|
|
|
|
|
|
# Shared content for demos
|
|
VALIDATION_RS = """//! Input validation utilities.
|
|
|
|
/// Validates that a number is positive.
|
|
pub fn is_positive(n: i32) -> bool {
|
|
n > 0
|
|
}
|
|
|
|
/// Validates that a string is not empty.
|
|
pub fn is_non_empty(s: &str) -> bool {
|
|
!s.trim().is_empty()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_is_positive() {
|
|
assert!(is_positive(1));
|
|
assert!(!is_positive(0));
|
|
assert!(!is_positive(-1));
|
|
}
|
|
}
|
|
"""
|
|
|
|
|
|
@dataclass
|
|
class DemoEnv:
|
|
"""Isolated demo environment with its own repo and home directory."""
|
|
|
|
name: str
|
|
out_dir: Path
|
|
theme: str
|
|
repo_name: str = "worktrunk"
|
|
|
|
@property
|
|
def root(self) -> Path:
|
|
return self.out_dir / f".demo-{self.name}"
|
|
|
|
@property
|
|
def home(self) -> Path:
|
|
return self.root
|
|
|
|
@property
|
|
def work_base(self) -> Path:
|
|
return self.home / "w"
|
|
|
|
@property
|
|
def repo(self) -> Path:
|
|
return self.work_base / self.repo_name
|
|
|
|
@property
|
|
def bare_remote(self) -> Path:
|
|
return self.root / "remote.git"
|
|
|
|
@property
|
|
def starship_config(self) -> Path:
|
|
return self.out_dir / "starship.toml"
|
|
|
|
|
|
def run(cmd, cwd=None, env=None, check=True, capture=False):
|
|
"""Run a command."""
|
|
result = subprocess.run(
|
|
cmd, cwd=cwd, env=env, check=check, capture_output=capture, text=True
|
|
)
|
|
return result.stdout if capture else None
|
|
|
|
|
|
def git(args, cwd=None, env=None):
|
|
"""Run git command."""
|
|
run(["git"] + args, cwd=cwd, env=env)
|
|
|
|
|
|
def render_tape(template_path: Path, replacements: dict, repo_root: Path) -> str | None:
|
|
"""Render a VHS tape template with Source inlining and variable substitutions.
|
|
|
|
Args:
|
|
template_path: Path to the .tape template file
|
|
replacements: Dict of {{VAR}} -> value replacements
|
|
repo_root: Root of the repository (for resolving Source paths)
|
|
|
|
Returns:
|
|
Rendered tape content, or None if template doesn't exist
|
|
"""
|
|
if not template_path.exists():
|
|
print(f"Warning: {template_path} not found, skipping VHS recording")
|
|
return None
|
|
|
|
rendered = template_path.read_text()
|
|
|
|
# Inline Source directives (VHS doesn't support them, we handle it)
|
|
def inline_source(match):
|
|
source_path = repo_root / match.group(1).strip().strip('"')
|
|
return source_path.read_text()
|
|
|
|
rendered = re.sub(r"^Source\s+(.+)$", inline_source, rendered, flags=re.MULTILINE)
|
|
|
|
# Apply template variable replacements
|
|
for key, value in replacements.items():
|
|
rendered = rendered.replace(f"{{{{{key}}}}}", str(value))
|
|
return rendered
|
|
|
|
|
|
def record_vhs(
|
|
tape_path: Path, vhs_binary: str = "vhs", expected_output: Path = None
|
|
):
|
|
"""Record a demo GIF using VHS."""
|
|
env = recorder_env()
|
|
# GIF assets include ANSI styling independent of the recorder's shell.
|
|
env.pop("NO_COLOR", None)
|
|
env["CLICOLOR_FORCE"] = "1"
|
|
run([vhs_binary, str(tape_path)], check=True, env=env)
|
|
|
|
if expected_output and not expected_output.exists():
|
|
raise RuntimeError(
|
|
f"VHS exited 0 but {expected_output.name} was not created. "
|
|
"Check ffmpeg output above — likely missing libass support."
|
|
)
|
|
|
|
|
|
def build_wt(repo_root: Path):
|
|
"""Build the wt binary."""
|
|
print("Building wt binary...")
|
|
run(["cargo", "build", "--quiet"], cwd=repo_root)
|
|
|
|
|
|
def commit_dated(repo: Path, message: str, offset: str, env_extra: dict = None):
|
|
"""Commit with a date offset like '7d' or '2H'."""
|
|
now = datetime.now()
|
|
if offset.endswith("d"):
|
|
delta = timedelta(days=int(offset[:-1]))
|
|
elif offset.endswith("H"):
|
|
delta = timedelta(hours=int(offset[:-1]))
|
|
else:
|
|
raise ValueError(f"Unknown offset format: {offset}")
|
|
|
|
date_str = (now - delta).strftime("%Y-%m-%dT%H:%M:%S")
|
|
env = os.environ.copy()
|
|
env["GIT_AUTHOR_DATE"] = date_str
|
|
env["GIT_COMMITTER_DATE"] = date_str
|
|
env["SKIP_DEMO_HOOK"] = "1"
|
|
if env_extra:
|
|
env.update(env_extra)
|
|
git(["-C", str(repo), "commit", "-qm", message], env=env)
|
|
|
|
|
|
def prepare_base_repo(env: DemoEnv, repo_root: Path):
|
|
"""Set up the base demo repository with Rust project.
|
|
|
|
Creates:
|
|
- Git repo with initial commit
|
|
- Rust project (Cargo.toml, lib.rs, Cargo.lock)
|
|
- Mock gh CLI for CI status
|
|
- bat wrapper for syntax highlighting
|
|
- User config directory
|
|
|
|
Demos should call this first, then add their own:
|
|
- Project hooks config (.config/wt.toml)
|
|
- Branches and worktrees
|
|
- Additional mock CLIs
|
|
- Approved commands in user config
|
|
"""
|
|
# Clean previous (exist_ok=True handles cases where rmtree silently fails,
|
|
# e.g., stale processes holding files open)
|
|
shutil.rmtree(env.root, ignore_errors=True)
|
|
|
|
env.root.mkdir(parents=True, exist_ok=True)
|
|
env.work_base.mkdir(parents=True, exist_ok=True)
|
|
env.repo.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Init bare remote
|
|
run(["git", "init", "--bare", "-q", str(env.bare_remote)])
|
|
|
|
# Init main repo
|
|
git(["-C", str(env.repo), "init", "-q"])
|
|
git(["-C", str(env.repo), "config", "user.name", "Worktrunk Demo"])
|
|
git(["-C", str(env.repo), "config", "user.email", "demo@example.com"])
|
|
git(["-C", str(env.repo), "config", "commit.gpgsign", "false"])
|
|
# Suppress wt hints in demo output (hints are stored in git config)
|
|
git(["-C", str(env.repo), "config", "worktrunk.hints.worktree-path", "true"])
|
|
|
|
# Initial commit
|
|
(env.repo / "README.md").write_text("# Acme App\n\nA demo application.\n")
|
|
git(["-C", str(env.repo), "add", "README.md"])
|
|
commit_dated(env.repo, "Initial commit", "7d")
|
|
git(["-C", str(env.repo), "branch", "-m", "main"])
|
|
# Use local bare repo as remote (GitHub URLs cause VHS to hang waiting for SSH)
|
|
git(["-C", str(env.repo), "remote", "add", "origin", str(env.bare_remote)])
|
|
git(["-C", str(env.repo), "push", "-u", "origin", "main", "-q"])
|
|
|
|
# Rust project
|
|
(env.repo / "Cargo.toml").write_text(
|
|
'[package]\nname = "acme"\nversion = "0.1.0"\nedition = "2021"\n\n[workspace]\n'
|
|
)
|
|
(env.repo / "src").mkdir()
|
|
shutil.copy(FIXTURES_DIR / "lib.rs", env.repo / "src" / "lib.rs")
|
|
(env.repo / ".gitignore").write_text("/target\n")
|
|
git(["-C", str(env.repo), "add", ".gitignore", "Cargo.toml", "src/"])
|
|
commit_dated(env.repo, "Add Rust project with tests", "6d")
|
|
|
|
# Build to create Cargo.lock
|
|
run(["cargo", "build", "--release", "-q"], cwd=env.repo, check=False)
|
|
git(["-C", str(env.repo), "add", "Cargo.lock"])
|
|
commit_dated(env.repo, "Add Cargo.lock", "6d")
|
|
git(["-C", str(env.repo), "push", "-q"])
|
|
|
|
# Mock CLI tools
|
|
bin_dir = env.home / ".local" / "bin"
|
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# bat wrapper for syntax highlighting (alias cat to bat for toml files)
|
|
bat_wrapper = bin_dir / "cat"
|
|
bat_wrapper.write_text("""#!/bin/bash
|
|
# Use bat for syntax highlighting if file is toml
|
|
if [[ "$1" == *.toml ]]; then
|
|
exec bat --style=plain --paging=never "$@"
|
|
else
|
|
exec /bin/cat "$@"
|
|
fi
|
|
""")
|
|
bat_wrapper.chmod(0o755)
|
|
|
|
# Build wt binary
|
|
build_wt(repo_root)
|
|
|
|
# Install fish completions (needed for tab completion in demos)
|
|
# Note: Demos using tab completion also need to source the completions
|
|
# in the tape's hidden section (VHS doesn't trigger fish's lazy loading)
|
|
wt_bin = repo_root / "target" / "debug" / "wt"
|
|
install_env = os.environ.copy()
|
|
install_env["HOME"] = str(env.home)
|
|
run([str(wt_bin), "config", "shell", "install", "fish", "--yes"], env=install_env)
|
|
|
|
# Fish syntax highlighting colors — use ANSI names so the VHS terminal
|
|
# theme controls the actual rendered RGB values (light vs dark).
|
|
# Two mechanisms: fish_variables for --shell/--snapshot mode (fish reads at
|
|
# startup), colors.fish for VHS recordings (VHS applies Env HOME after fish
|
|
# starts, so fish_variables from the demo home isn't read).
|
|
fish_config_dir = env.home / ".config" / "fish"
|
|
fish_config_dir.mkdir(parents=True, exist_ok=True)
|
|
color_vars = {
|
|
"fish_color_autosuggestion": "brblack",
|
|
"fish_color_command": "yellow", # amber — matches website .cmd color
|
|
"fish_color_comment": "brblack",
|
|
"fish_color_end": "normal",
|
|
"fish_color_error": "red",
|
|
"fish_color_escape": "cyan",
|
|
"fish_color_keyword": "yellow",
|
|
"fish_color_normal": "normal",
|
|
"fish_color_operator": "normal",
|
|
"fish_color_param": "normal",
|
|
"fish_color_quote": "green",
|
|
"fish_color_redirection": "normal",
|
|
}
|
|
|
|
# fish_variables file (for --shell and --snapshot modes)
|
|
lines = [
|
|
"# This file contains fish universal variable definitions.",
|
|
"# VERSION: 3.0",
|
|
]
|
|
lines.extend(f"SETUVAR {k}:{v}" for k, v in color_vars.items())
|
|
lines.extend([
|
|
"SETUVAR fish_color_cancel:\\x2d\\x2dreverse",
|
|
"SETUVAR fish_color_search_match:\\x2d\\x2dbackground\\x3d111",
|
|
"SETUVAR fish_color_selection:\\x2d\\x2dbackground\\x3dbrblack",
|
|
"SETUVAR fish_color_valid_path:\\x2d\\x2dunderline",
|
|
"SETUVAR fish_greeting:",
|
|
])
|
|
(fish_config_dir / "fish_variables").write_text("\n".join(lines) + "\n")
|
|
|
|
# colors.fish script (sourced in VHS tape hidden section)
|
|
color_lines = [f"set -g {k} {v}" for k, v in color_vars.items()]
|
|
(fish_config_dir / "colors.fish").write_text("\n".join(color_lines) + "\n")
|
|
|
|
# User config directory (demos add their own config.toml)
|
|
config_dir = env.home / ".config" / "worktrunk"
|
|
config_dir.mkdir(parents=True)
|
|
|
|
# Project config directory
|
|
(env.repo / ".config").mkdir(exist_ok=True)
|
|
|
|
|
|
def setup_claude_code_config(
|
|
env: DemoEnv,
|
|
worktree_paths: list[str],
|
|
allowed_tools: list[str] = None,
|
|
) -> None:
|
|
"""Set up Claude Code configuration to skip first-run dialogs.
|
|
|
|
Args:
|
|
env: Demo environment
|
|
worktree_paths: List of worktree paths to pre-approve for trust
|
|
allowed_tools: List of tools to pre-approve (default: none, Claude will ask)
|
|
"""
|
|
api_key_suffix = (
|
|
os.environ.get("ANTHROPIC_API_KEY", "")[-20:]
|
|
if os.environ.get("ANTHROPIC_API_KEY")
|
|
else ""
|
|
)
|
|
|
|
# Build projects config - pre-approve trust for all worktree paths
|
|
# Use resolved paths to handle macOS symlinks (/var -> /private/var)
|
|
projects_config = {}
|
|
for path in worktree_paths:
|
|
resolved_path = str(Path(path).resolve())
|
|
projects_config[resolved_path] = {
|
|
"allowedTools": [],
|
|
"hasTrustDialogAccepted": True,
|
|
}
|
|
|
|
claude_json = env.home / ".claude.json"
|
|
claude_json.write_text(
|
|
json.dumps(
|
|
{
|
|
"numStartups": 100,
|
|
"installMethod": "global",
|
|
"theme": env.theme,
|
|
"firstStartTime": "2025-01-01T00:00:00.000Z",
|
|
"hasCompletedOnboarding": True,
|
|
"hasCompletedClaudeInChromeOnboarding": True,
|
|
"claudeInChromeDefaultEnabled": False,
|
|
"sonnet45MigrationComplete": True,
|
|
"opus45MigrationComplete": True,
|
|
"thinkingMigrationComplete": True,
|
|
"hasShownOpus45Notice": {},
|
|
"hasShownOpus46Notice": {},
|
|
"opusProMigrationComplete": True,
|
|
"opus46FeedSeenCount": 100,
|
|
"unpinFable5LaunchEffort": True,
|
|
"sonnet1m45MigrationComplete": True,
|
|
"lastReleaseNotesSeen": "99.0.0",
|
|
"lastOnboardingVersion": "99.0.0",
|
|
"announcementImpressions": {
|
|
"fable-5-promo-2": 100,
|
|
"fable-5-promo-2-2": 100,
|
|
"fable-5-promo-2-3": 100,
|
|
"fable-5-promo-2-4-max": 100,
|
|
"opus-5-launch": 100,
|
|
},
|
|
"oauthAccount": {
|
|
"displayName": "wt",
|
|
"emailAddress": "demo@example.com",
|
|
},
|
|
"customApiKeyResponses": {
|
|
"approved": [api_key_suffix] if api_key_suffix else [],
|
|
"rejected": [],
|
|
},
|
|
"officialMarketplaceAutoInstalled": True,
|
|
"effortCalloutDismissed": True,
|
|
"lspRecommendationDisabled": True,
|
|
"passesUpsellSeenCount": 100,
|
|
"passesLastSeenRemaining": 999,
|
|
"hasVisitedPasses": True,
|
|
"tipsHistory": {
|
|
"new-user-warmup": 100,
|
|
"terminal-setup": 100,
|
|
"theme-command": 100,
|
|
"fast-mode-2026-02-01": 100,
|
|
"adaptive-thinking-2026-01-28": 100,
|
|
"prompt-caching-scope-2026-01-05": 100,
|
|
"plan-mode-for-complex-tasks": 100,
|
|
"memory-command": 100,
|
|
"todo-list": 100,
|
|
"stickers-command": 100,
|
|
"status-line": 100,
|
|
"custom-commands": 100,
|
|
"custom-agents": 100,
|
|
"permissions": 100,
|
|
"git-worktrees": 100,
|
|
"guest-passes": 100,
|
|
},
|
|
"projects": projects_config,
|
|
},
|
|
indent=2,
|
|
)
|
|
)
|
|
|
|
# Copy claude binary (downloaded automatically if missing)
|
|
# Claude Code detects native install by checking if ~/.local/bin/claude exists
|
|
local_bin = env.home / ".local" / "bin"
|
|
local_bin.mkdir(parents=True, exist_ok=True)
|
|
claude_binary = _ensure_claude_binary()
|
|
shutil.copy(claude_binary, local_bin / "claude")
|
|
|
|
# Claude settings.json
|
|
claude_dir = env.home / ".claude"
|
|
claude_dir.mkdir(exist_ok=True)
|
|
settings = {
|
|
"permissions": {"allow": allowed_tools or [], "deny": [], "ask": []},
|
|
"model": "claude-opus-4-6",
|
|
# Accounts in the Remote Control rollout otherwise start it, which
|
|
# prints a live claude.ai session URL into the GIF.
|
|
"remoteControlAtStartup": False,
|
|
"statusLine": {
|
|
"type": "command",
|
|
"command": "wt list statusline --format=claude-code",
|
|
},
|
|
}
|
|
(claude_dir / "settings.json").write_text(json.dumps(settings, indent=2))
|
|
_forward_macos_keychains(env.home)
|
|
|
|
|
|
def setup_zellij_config(env: DemoEnv, default_cwd: str = None) -> None:
|
|
"""Set up Zellij configuration for demo recording.
|
|
|
|
Creates config with the site palette for the recording's theme, minimal
|
|
keybinds, and tab-rename plugin. Plugin is downloaded automatically if
|
|
missing.
|
|
|
|
Args:
|
|
env: Demo environment
|
|
default_cwd: Optional default working directory for new panes
|
|
"""
|
|
palette = PALETTES[env.theme]
|
|
zellij_config_dir = env.home / ".config" / "zellij"
|
|
zellij_config_dir.mkdir(parents=True, exist_ok=True)
|
|
zellij_plugins_dir = zellij_config_dir / "plugins"
|
|
zellij_plugins_dir.mkdir(exist_ok=True)
|
|
|
|
# Copy Zellij plugin (downloaded automatically if missing)
|
|
plugin_path = _ensure_zellij_plugin()
|
|
shutil.copy(plugin_path, zellij_plugins_dir / "zellij-tab-name.wasm")
|
|
|
|
default_cwd_line = f'default_cwd "{default_cwd}"' if default_cwd else ""
|
|
|
|
# Pre-populate Zellij permissions cache to avoid permission dialog
|
|
# On macOS, cache is at $HOME/Library/Caches/org.Zellij-Contributors.Zellij/
|
|
# On Linux, it would be at $HOME/.cache/zellij/
|
|
plugin_dest = zellij_plugins_dir / "zellij-tab-name.wasm"
|
|
if platform.system() == "Darwin":
|
|
zellij_cache_dir = env.home / "Library" / "Caches" / "org.Zellij-Contributors.Zellij"
|
|
else:
|
|
zellij_cache_dir = env.home / ".cache" / "zellij"
|
|
zellij_cache_dir.mkdir(parents=True, exist_ok=True)
|
|
permissions_file = zellij_cache_dir / "permissions.kdl"
|
|
permissions_file.write_text(f'''"{plugin_dest}" {{
|
|
ReadApplicationState
|
|
ChangeApplicationState
|
|
}}
|
|
''')
|
|
|
|
zellij_config = zellij_config_dir / "config.kdl"
|
|
zellij_config.write_text(f"""// Demo Zellij config
|
|
default_shell "fish"
|
|
{default_cwd_line}
|
|
pane_frames false
|
|
show_startup_tips false
|
|
show_release_notes false
|
|
theme "worktrunk"
|
|
|
|
// Load the tab-name plugin
|
|
load_plugins {{
|
|
"file:{zellij_plugins_dir}/zellij-tab-name.wasm"
|
|
}}
|
|
|
|
themes {{
|
|
worktrunk {{
|
|
fg "{palette['--wt-ink']}"
|
|
bg "{palette['--wt-paper']}"
|
|
black "{palette['--wt-paper-soft']}"
|
|
red "{palette['--wt-terminal-red']}"
|
|
green "{palette['--wt-terminal-green']}"
|
|
yellow "{palette['--wt-terminal-yellow']}"
|
|
blue "{palette['--wt-terminal-blue']}"
|
|
magenta "{palette['--wt-terminal-magenta']}"
|
|
cyan "{palette['--wt-terminal-cyan']}"
|
|
white "{palette['--wt-ink-muted']}"
|
|
orange "{palette['--wt-copper']}"
|
|
}}
|
|
}}
|
|
|
|
keybinds clear-defaults=true {{
|
|
normal {{
|
|
bind "Ctrl Space" {{ SwitchToMode "tmux"; }}
|
|
}}
|
|
tmux {{
|
|
bind "o" {{ SwitchToMode "pane"; }}
|
|
bind "p" {{ SwitchToMode "pane"; }}
|
|
bind "t" {{ SwitchToMode "tab"; }}
|
|
bind "q" {{ Quit; }}
|
|
}}
|
|
tab {{
|
|
bind "n" {{ NewTab; SwitchToMode "Normal"; }}
|
|
bind "h" "Left" {{ GoToPreviousTab; SwitchToMode "Normal"; }}
|
|
bind "l" "Right" {{ GoToNextTab; SwitchToMode "Normal"; }}
|
|
bind "1" {{ GoToTab 1; SwitchToMode "Normal"; }}
|
|
bind "2" {{ GoToTab 2; SwitchToMode "Normal"; }}
|
|
bind "3" {{ GoToTab 3; SwitchToMode "Normal"; }}
|
|
bind "4" {{ GoToTab 4; SwitchToMode "Normal"; }}
|
|
}}
|
|
pane {{
|
|
bind "n" {{ NewPane; SwitchToMode "Normal"; }}
|
|
}}
|
|
shared_except "locked" {{
|
|
bind "Ctrl t" {{ NewTab; }}
|
|
bind "Ctrl n" {{ NewPane; }}
|
|
}}
|
|
shared_except "normal" {{
|
|
bind "Ctrl Space" "Ctrl c" {{ SwitchToMode "normal"; }}
|
|
bind "Esc" {{ SwitchToMode "normal"; }}
|
|
}}
|
|
}}
|
|
""")
|
|
|
|
|
|
def setup_fish_config(env: DemoEnv, wsl_create: bool = False) -> None:
|
|
"""Set up Fish shell configuration for demo recording.
|
|
|
|
Creates config with wsl abbreviation, starship, wt shell integration,
|
|
Zellij tab auto-rename, and completion pre-loading.
|
|
|
|
Note: Completion files are installed by prepare_base_repo(). This function
|
|
creates config.fish that sources them (needed because VHS doesn't trigger
|
|
fish's lazy completion loading reliably).
|
|
|
|
Args:
|
|
env: Demo environment
|
|
wsl_create: If True, wsl abbreviation includes --create flag
|
|
"""
|
|
fish_config_dir = env.home / ".config" / "fish"
|
|
fish_config_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
wsl_cmd = (
|
|
"wt switch --execute=claude --create"
|
|
if wsl_create
|
|
else "wt switch --execute=claude"
|
|
)
|
|
|
|
fish_config = fish_config_dir / "config.fish"
|
|
fish_config.write_text(f"""# Demo fish config
|
|
# wsl abbreviation: switch to worktree and launch Claude
|
|
abbr --add wsl '{wsl_cmd}'
|
|
# New Zellij tabs start new fish processes, so disable suggestions here rather
|
|
# than only in the tape's initial shell.
|
|
set -g fish_autosuggestion_enabled 0
|
|
starship init fish | source
|
|
# Pre-load wt completions (VHS doesn't trigger lazy loading reliably)
|
|
source ~/.config/fish/completions/wt.fish 2>/dev/null
|
|
|
|
# Disable cursor blinking for VHS recording
|
|
set fish_cursor_default block
|
|
# Send escape sequences to disable cursor blink
|
|
printf '\\e[?12l' # Disable cursor blink mode
|
|
printf '\\e[2 q' # Set steady block cursor (non-blinking)
|
|
|
|
# Auto-rename Zellij tabs based on git branch (for demo)
|
|
function __zellij_tab_rename --on-variable PWD
|
|
if set -q ZELLIJ
|
|
# Get git branch name, fallback to directory basename
|
|
set -l branch (git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
|
if test -n "$branch"
|
|
zellij action rename-tab $branch
|
|
end
|
|
end
|
|
end
|
|
""")
|
|
|
|
|
|
def setup_mock_clis(env: DemoEnv) -> None:
|
|
"""Set up comprehensive mock CLIs for all demo scenarios.
|
|
|
|
Creates mocks for: npm, docker, flyctl, llm, cargo.
|
|
Each mock handles all cases - demos just use the branches they need.
|
|
"""
|
|
bin_dir = env.home / ".local" / "bin"
|
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# npm mock - handles install, build, dev (with optional port)
|
|
npm_mock = bin_dir / "npm"
|
|
npm_mock.write_text("""#!/bin/bash
|
|
if [[ "$1" == "install" ]]; then
|
|
echo "added 847 packages in 3.2s"
|
|
elif [[ "$1" == "run" && "$2" == "build" ]]; then
|
|
echo "vite v5.4.2 building for production..."
|
|
echo "✓ 142 modules transformed"
|
|
echo "dist/index.js 45.2 kB │ gzip: 14.8 kB"
|
|
elif [[ "$1" == "run" && "$2" == "dev" ]]; then
|
|
# Extract port from args if provided (e.g., npm run dev -- --port 3001)
|
|
port=3000
|
|
for arg in "$@"; do
|
|
if [[ "$prev" == "--port" ]]; then
|
|
port="$arg"
|
|
fi
|
|
prev="$arg"
|
|
done
|
|
echo ""
|
|
echo " VITE v5.4.2 ready in 342 ms"
|
|
echo ""
|
|
echo " ➜ Local: http://localhost:$port/"
|
|
echo " ➜ Network: http://192.168.1.42:$port/"
|
|
fi
|
|
""")
|
|
npm_mock.chmod(0o755)
|
|
|
|
# docker mock - handles compose up
|
|
docker_mock = bin_dir / "docker"
|
|
docker_mock.write_text("""#!/bin/bash
|
|
if [[ "$1" == "compose" && "$2" == "up" ]]; then
|
|
echo "[+] Running 1/1"
|
|
echo " ✔ Container postgres Started"
|
|
fi
|
|
""")
|
|
docker_mock.chmod(0o755)
|
|
|
|
# flyctl mock - handles scale
|
|
flyctl_mock = bin_dir / "flyctl"
|
|
flyctl_mock.write_text("""#!/bin/bash
|
|
if [[ "$1" == "scale" ]]; then
|
|
echo "Scaling app to 0 machines"
|
|
fi
|
|
""")
|
|
flyctl_mock.chmod(0o755)
|
|
|
|
# llm mock - simulates both commit message and summary generation.
|
|
# Reads stdin to detect prompt type: summary prompts contain "summary",
|
|
# commit prompts don't. For summaries, returns branch-appropriate one-liners
|
|
# based on filenames in the diff.
|
|
llm_mock = bin_dir / "llm"
|
|
llm_mock.write_text(r"""#!/bin/bash
|
|
input=$(cat)
|
|
|
|
if echo "$input" | grep -qi "summary"; then
|
|
# Summary generation — return branch-appropriate one-liner
|
|
if echo "$input" | grep -q "utils\.rs"; then
|
|
echo "Add utility functions module with string and math helpers"
|
|
elif echo "$input" | grep -q "notes\.txt"; then
|
|
echo "Add TODO notes for caching improvements"
|
|
elif echo "$input" | grep -q "multiply\|subtract\|math"; then
|
|
echo "Add math operations and consolidate tests"
|
|
elif echo "$input" | grep -q "User settings"; then
|
|
echo "Add user settings module placeholder"
|
|
else
|
|
echo "Expand README with contributing and license sections"
|
|
fi
|
|
else
|
|
# Commit message generation
|
|
sleep 0.5
|
|
if echo "$input" | grep -q "test_add"; then
|
|
echo "test: expand add coverage"
|
|
echo ""
|
|
echo "Add another test case for the add function."
|
|
else
|
|
echo "feat: add user settings module"
|
|
echo ""
|
|
echo "Add placeholder module for user profile settings."
|
|
fi
|
|
fi
|
|
""")
|
|
llm_mock.chmod(0o755)
|
|
|
|
# cargo mock - handles nextest run
|
|
cargo_mock = bin_dir / "cargo"
|
|
cargo_mock.write_text(r"""#!/bin/bash
|
|
if [[ "$1" == "nextest" && "$2" == "run" ]]; then
|
|
sleep 0.3
|
|
echo " Finished \`test\` profile [unoptimized + debuginfo] target(s) in 0.02s"
|
|
echo " Starting 2 tests across 1 binary"
|
|
echo " PASS [ 0.001s] acme::tests::test_add"
|
|
echo " PASS [ 0.001s] acme::tests::test_add_zeros"
|
|
echo "------------"
|
|
echo " Summary [ 0.002s] 2 tests run: 2 passed, 0 skipped"
|
|
fi
|
|
""")
|
|
cargo_mock.chmod(0o755)
|
|
|
|
|
|
def prepare_demo_repo(env: DemoEnv, repo_root: Path, hooks_config: str = None):
|
|
"""Set up a full demo repository with varied branches.
|
|
|
|
Creates a rich repo for great `wt list` output:
|
|
- Git repo with Rust project
|
|
- Mock CLIs (npm, docker, flyctl, llm, cargo, gh)
|
|
- bat wrapper for syntax highlighting
|
|
- Extra branches without worktrees (docs/readme, spike/search)
|
|
- alpha: large diff, unpushed commits, behind main
|
|
- beta: staged changes, behind main
|
|
- hooks: no remote, staged+unstaged changes
|
|
|
|
Args:
|
|
env: Demo environment
|
|
repo_root: Path to worktrunk repo for building wt
|
|
hooks_config: Optional project hooks (.config/wt.toml) content.
|
|
If None, uses default pre-merge hook.
|
|
|
|
After calling this, main is at the latest commit and worktrees exist for
|
|
alpha, beta, hooks. Demos can then add their own config.
|
|
"""
|
|
# Base setup: git repo, Rust project, bat wrapper, wt binary
|
|
prepare_base_repo(env, repo_root)
|
|
|
|
# Set up all mock CLIs - demos use what they need
|
|
setup_mock_clis(env)
|
|
|
|
# Project hooks
|
|
if hooks_config is None:
|
|
hooks_config = '[pre-merge]\ntest = "cargo nextest run"\n'
|
|
(env.repo / ".config" / "wt.toml").write_text(hooks_config)
|
|
claude_md_dir = env.repo / ".claude"
|
|
claude_md_dir.mkdir(exist_ok=True)
|
|
(claude_md_dir / "CLAUDE.md").write_text("# Acme App\n\nRust project. Run `cargo test` for tests.\n")
|
|
git(["-C", str(env.repo), "add", ".config/wt.toml", ".claude/CLAUDE.md"])
|
|
commit_dated(env.repo, "Add project hooks", "5d")
|
|
git(["-C", str(env.repo), "push", "-q"])
|
|
|
|
# Mock gh CLI with varied CI status per branch
|
|
bin_dir = env.home / ".local" / "bin"
|
|
gh_mock = bin_dir / "gh"
|
|
shutil.copy(FIXTURES_DIR / "gh-mock.sh", gh_mock)
|
|
gh_mock.chmod(0o755)
|
|
|
|
# Extra branches without worktrees (for --branches view)
|
|
git(["-C", str(env.repo), "branch", "docs/readme"])
|
|
git(["-C", str(env.repo), "branch", "spike/search"])
|
|
|
|
# Create beta first (from current main, so it will be behind after main commit)
|
|
_create_branch_beta(env)
|
|
|
|
# Commit to main so beta is behind
|
|
readme = env.repo / "README.md"
|
|
readme.write_text(
|
|
readme.read_text() + "\n## Development\n\nSee CONTRIBUTING.md for guidelines.\n"
|
|
)
|
|
(env.repo / "notes.md").write_text("# Notes\n")
|
|
git(["-C", str(env.repo), "add", "README.md", "notes.md"])
|
|
commit_dated(env.repo, "docs: add development section", "1d")
|
|
git(["-C", str(env.repo), "push", "-q"])
|
|
|
|
# Create alpha and hooks after the main commit (so they're only ahead, not diverged)
|
|
_create_branch_alpha(env)
|
|
_create_branch_hooks(env)
|
|
|
|
|
|
def _create_branch_alpha(env: DemoEnv):
|
|
"""Create alpha branch with large diff and unpushed commits."""
|
|
branch = "alpha"
|
|
path = env.work_base / f"acme.{branch}"
|
|
|
|
git(["-C", str(env.repo), "checkout", "-q", "-b", branch, "main"])
|
|
|
|
# Initial README changes
|
|
(env.repo / "README.md").write_text("""# Acme App
|
|
|
|
A demo application for showcasing worktrunk features.
|
|
|
|
## Features
|
|
|
|
- Fast worktree switching
|
|
- Integrated merge workflow
|
|
- Pre-merge test hooks
|
|
- LLM commit messages
|
|
|
|
## Getting Started
|
|
|
|
Run `wt list` to see all worktrees.
|
|
""")
|
|
git(["-C", str(env.repo), "add", "README.md"])
|
|
commit_dated(env.repo, "docs: expand README", "3d")
|
|
|
|
# More commits
|
|
readme = env.repo / "README.md"
|
|
readme.write_text(readme.read_text() + "\n## Contributing\n\nPRs welcome!\n")
|
|
git(["-C", str(env.repo), "add", "README.md"])
|
|
commit_dated(env.repo, "docs: add contributing section", "3d")
|
|
|
|
readme.write_text(readme.read_text() + "\n## License\n\nMIT\n")
|
|
git(["-C", str(env.repo), "add", "README.md"])
|
|
commit_dated(env.repo, "docs: add license", "3d")
|
|
|
|
# Add utils module with substantial content
|
|
shutil.copy(FIXTURES_DIR / "alpha-utils.rs", env.repo / "src" / "utils.rs")
|
|
# Update lib.rs to include the module
|
|
lib_rs = env.repo / "src" / "lib.rs"
|
|
lib_content = lib_rs.read_text()
|
|
lib_rs.write_text("pub mod utils;\n\n" + lib_content)
|
|
git(["-C", str(env.repo), "add", "src/utils.rs", "src/lib.rs"])
|
|
commit_dated(env.repo, "feat: add utility functions module", "3d")
|
|
|
|
git(["-C", str(env.repo), "push", "-u", "origin", branch, "-q"])
|
|
git(["-C", str(env.repo), "checkout", "-q", "main"])
|
|
git(["-C", str(env.repo), "worktree", "add", "-q", str(path), branch])
|
|
|
|
# Unpushed commit
|
|
readme = path / "README.md"
|
|
readme.write_text(readme.read_text() + "## FAQ\n\n")
|
|
git(["-C", str(path), "add", "README.md"])
|
|
commit_dated(path, "docs: add FAQ section", "3d")
|
|
|
|
# Working tree changes - large diff using shared fixture
|
|
shutil.copy(FIXTURES_DIR / "alpha-readme.md", path / "README.md")
|
|
(path / "scratch.rs").write_text("// scratch\n")
|
|
|
|
|
|
def _create_branch_beta(env: DemoEnv):
|
|
"""Create beta branch with staged changes and remote tracking."""
|
|
branch = "beta"
|
|
path = env.work_base / f"acme.{branch}"
|
|
|
|
git(["-C", str(env.repo), "checkout", "-q", "-b", branch, "main"])
|
|
git(["-C", str(env.repo), "push", "-u", "origin", branch, "-q"])
|
|
git(["-C", str(env.repo), "checkout", "-q", "main"])
|
|
git(["-C", str(env.repo), "worktree", "add", "-q", str(path), branch])
|
|
|
|
# Staged new file
|
|
(path / "notes.txt").write_text("# TODO\n- Add caching\n")
|
|
git(["-C", str(path), "add", "notes.txt"])
|
|
|
|
|
|
def _create_branch_hooks(env: DemoEnv):
|
|
"""Create hooks branch with refactored lib.rs, no remote."""
|
|
branch = "hooks"
|
|
path = env.work_base / f"acme.{branch}"
|
|
|
|
git(["-C", str(env.repo), "checkout", "-q", "-b", branch, "main"])
|
|
shutil.copy(FIXTURES_DIR / "lib-hooks.rs", env.repo / "src" / "lib.rs")
|
|
git(["-C", str(env.repo), "add", "src/lib.rs"])
|
|
commit_dated(env.repo, "feat: add math operations, consolidate tests", "2H")
|
|
|
|
# No push - no upstream
|
|
git(["-C", str(env.repo), "checkout", "-q", "main"])
|
|
git(["-C", str(env.repo), "worktree", "add", "-q", str(path), branch])
|
|
|
|
# Staged then modified
|
|
lib_rs = path / "src" / "lib.rs"
|
|
lib_rs.write_text(lib_rs.read_text() + "// Division coming soon\n")
|
|
git(["-C", str(path), "add", "src/lib.rs"])
|
|
lib_rs.write_text(lib_rs.read_text() + "// TODO: add division\n")
|
|
|
|
|
|
# =============================================================================
|
|
# Demo recording infrastructure
|
|
# =============================================================================
|
|
|
|
|
|
def check_dependencies(commands: list[str]):
|
|
"""Check that required commands are available, exit if not."""
|
|
for cmd in commands:
|
|
if not shutil.which(cmd):
|
|
raise SystemExit(f"Missing dependency: {cmd}")
|
|
|
|
|
|
def check_ffmpeg_libass():
|
|
"""Check that ffmpeg has libass support (required for keystroke overlay)."""
|
|
if not shutil.which("ffmpeg"):
|
|
raise SystemExit(
|
|
"Missing dependency: ffmpeg\n"
|
|
"Install with: brew install ffmpeg-full\n"
|
|
'Then add it to PATH: export PATH="$(brew --prefix ffmpeg-full)/bin:$PATH"'
|
|
)
|
|
result = subprocess.run(
|
|
["ffmpeg", "-filters"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if " ass " not in result.stdout:
|
|
raise SystemExit(
|
|
"ffmpeg missing libass support (required for keystroke overlay).\n"
|
|
"Install with: brew install ffmpeg-full\n"
|
|
'Then add it to PATH: export PATH="$(brew --prefix ffmpeg-full)/bin:$PATH"'
|
|
)
|
|
|
|
|
|
def write_starship_config(path: Path, theme: str) -> None:
|
|
"""Write the starship fixture plus its ``site`` palette for ``theme``."""
|
|
palette = PALETTES[theme]
|
|
path.write_text(f"""{(FIXTURES_DIR / "starship.toml").read_text()}
|
|
[palettes.site]
|
|
craft = "{palette['--wt-craft']}"
|
|
muted = "{palette['--wt-ink-muted']}"
|
|
red = "{palette['--wt-terminal-red']}"
|
|
""")
|
|
|
|
|
|
def setup_demo_output(env: DemoEnv) -> None:
|
|
"""Set up demo output directory and write the starship config."""
|
|
env.out_dir.mkdir(parents=True, exist_ok=True)
|
|
write_starship_config(env.starship_config, env.theme)
|
|
|
|
|
|
def record_text(
|
|
demo_env: DemoEnv,
|
|
tape_path: Path,
|
|
output_txt: Path,
|
|
replacements: dict,
|
|
repo_root: Path,
|
|
vhs_binary: str = "vhs",
|
|
) -> None:
|
|
"""Record text output by rendering tape via VHS.
|
|
|
|
Uses VHS with .txt output to capture authentic shell session including
|
|
real prompts and command output.
|
|
|
|
Args:
|
|
demo_env: Demo environment
|
|
tape_path: Path to VHS tape file
|
|
output_txt: Path to write text output
|
|
replacements: Template variable replacements
|
|
repo_root: Root of the repository (for resolving Source paths)
|
|
vhs_binary: VHS binary to use
|
|
|
|
Raises:
|
|
subprocess.CalledProcessError: If VHS fails
|
|
RuntimeError: If VHS succeeds but output file not created
|
|
"""
|
|
|
|
# Render tape with variable substitution
|
|
rendered = render_tape(tape_path, replacements, repo_root)
|
|
if not rendered:
|
|
raise RuntimeError(f"Failed to render tape: {tape_path}")
|
|
|
|
# Modify for text output
|
|
temp_txt = (demo_env.out_dir / ".text-output.txt").resolve()
|
|
rendered = re.sub(
|
|
r'^Output\s+"[^"]+"', f'Output "{temp_txt}"', rendered, flags=re.MULTILINE
|
|
)
|
|
rendered = re.sub(r"^Set Width .*$", "Set Width 120", rendered, flags=re.MULTILINE)
|
|
rendered = re.sub(
|
|
r"^Set Height .*$", "Set Height 120", rendered, flags=re.MULTILINE
|
|
)
|
|
for setting in ["FontSize", "Theme", "Padding"]:
|
|
rendered = re.sub(rf"^Set {setting} .*$\n?", "", rendered, flags=re.MULTILINE)
|
|
|
|
# Write and run
|
|
tape_rendered = (demo_env.out_dir / ".text-rendered.tape").resolve()
|
|
tape_rendered.write_text(rendered)
|
|
try:
|
|
run(
|
|
[vhs_binary, str(tape_rendered)],
|
|
check=True,
|
|
env=_isolated_claude_env(demo_env.home),
|
|
)
|
|
finally:
|
|
tape_rendered.unlink(missing_ok=True)
|
|
|
|
if not temp_txt.exists():
|
|
raise RuntimeError(f"VHS succeeded but output file not created: {temp_txt}")
|
|
shutil.copy(temp_txt, output_txt)
|
|
temp_txt.unlink()
|
|
|
|
|
|
def extract_commands_from_tape(
|
|
tape_path: Path, repo_root: Path, command_prefixes: tuple[str, ...] = ("wt", "git")
|
|
) -> list[str]:
|
|
"""Extract shell commands from a VHS tape file.
|
|
|
|
Parses the tape looking for Type "command" followed by Enter patterns,
|
|
filtering to commands that start with specified prefixes (default: wt, git).
|
|
|
|
Only extracts commands executed after a Show directive (visible part of
|
|
demo). A command may be typed while hidden to make a complete command the
|
|
first frame, provided its Enter occurs after Show. Skips commands executed
|
|
in Hide blocks.
|
|
|
|
Args:
|
|
tape_path: Path to the .tape template file
|
|
repo_root: Root of the repository (for resolving Source paths)
|
|
command_prefixes: Tuple of command prefixes to extract (default: wt, git)
|
|
|
|
Returns:
|
|
List of commands in order they appear in the tape
|
|
"""
|
|
# Render tape with dummy replacements to inline Source directives
|
|
rendered = render_tape(tape_path, {}, repo_root)
|
|
if not rendered:
|
|
return []
|
|
|
|
commands = []
|
|
in_visible_section = False
|
|
pending_command = None
|
|
lines = rendered.split("\n")
|
|
for raw_line in lines:
|
|
line = raw_line.strip()
|
|
|
|
# Track visibility
|
|
if line == "Show":
|
|
in_visible_section = True
|
|
continue
|
|
elif line == "Hide":
|
|
in_visible_section = False
|
|
continue
|
|
|
|
if line.startswith("Type "):
|
|
match = re.match(r'Type\s+["\'](.+)["\']', line)
|
|
if match:
|
|
pending_command = match.group(1)
|
|
continue
|
|
|
|
if line == "Enter":
|
|
if (
|
|
in_visible_section
|
|
and pending_command
|
|
and any(
|
|
pending_command.startswith(prefix) for prefix in command_prefixes
|
|
)
|
|
):
|
|
commands.append(pending_command)
|
|
pending_command = None
|
|
continue
|
|
|
|
if line and not line.startswith(("#", "Sleep ")):
|
|
# Interactive editing or completion means the literal Type text is
|
|
# not the command that Enter will execute. Snapshot mode cannot
|
|
# replay those terminal interactions faithfully.
|
|
pending_command = None
|
|
|
|
return commands
|
|
|
|
|
|
def record_snapshot(
|
|
demo_env: "DemoEnv",
|
|
tape_path: Path,
|
|
output_snap: Path,
|
|
repo_root: Path,
|
|
) -> None:
|
|
"""Record command output snapshot for regression testing.
|
|
|
|
Extracts commands from the tape and runs them in a fish shell with full
|
|
shell integration (just like the GIF demos). The snapshot format is:
|
|
|
|
$ wt list
|
|
<output>
|
|
|
|
$ wt switch alpha
|
|
<output>
|
|
|
|
This captures semantic output (what commands produce) rather than visual
|
|
rendering (how it looks in a terminal). Small diffs are expected when
|
|
output formats change; the goal is catching unexpected regressions like
|
|
new hints or warnings creeping in.
|
|
|
|
Args:
|
|
demo_env: Demo environment with repo and home paths
|
|
tape_path: Path to the .tape template file
|
|
output_snap: Path to write snapshot output
|
|
repo_root: Root of the repository
|
|
|
|
Raises:
|
|
RuntimeError: If no commands found in tape
|
|
"""
|
|
commands = extract_commands_from_tape(tape_path, repo_root)
|
|
if not commands:
|
|
raise RuntimeError(f"No snapshotable commands found in {tape_path.name}")
|
|
|
|
# Build environment matching the GIF demos
|
|
env = _isolated_claude_env(demo_env.home)
|
|
env.update(
|
|
{
|
|
"PATH": f"{repo_root / 'target' / 'debug'}:{demo_env.home / '.local' / 'bin'}:{os.environ.get('PATH', '')}",
|
|
"TERM": "xterm-256color",
|
|
"LANG": "en_US.UTF-8",
|
|
"LC_ALL": "en_US.UTF-8",
|
|
"GIT_PAGER": "", # Plain text output, no delta formatting
|
|
}
|
|
)
|
|
|
|
# Generate a fish script that:
|
|
# 1. Initializes shell integration (like shared-commands.tape)
|
|
# 2. Runs each command, printing "$ cmd" before and blank line after
|
|
script_lines = [
|
|
"# Initialize shell integration",
|
|
"wt config shell init fish | source",
|
|
"source ~/.config/fish/completions/wt.fish",
|
|
f"cd {demo_env.repo}",
|
|
"",
|
|
]
|
|
|
|
for i, cmd in enumerate(commands):
|
|
# Echo the command prompt, run command (merging stderr)
|
|
script_lines.append(f"echo '$ {cmd}'")
|
|
script_lines.append(f"{cmd} 2>&1")
|
|
# Blank line between commands (not after last)
|
|
if i < len(commands) - 1:
|
|
script_lines.append("echo ''")
|
|
|
|
script_content = "\n".join(script_lines)
|
|
script_path = demo_env.out_dir / ".snapshot-script.fish"
|
|
script_path.write_text(script_content)
|
|
|
|
# Run the script in fish
|
|
result = subprocess.run(
|
|
["fish", str(script_path)],
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
# Combine stdout and stderr
|
|
output = (result.stdout + result.stderr).rstrip()
|
|
|
|
# Normalize temp paths to stable placeholder
|
|
temp_path = str(demo_env.out_dir)
|
|
temp_path_real = str(demo_env.out_dir.resolve())
|
|
output = output.replace(temp_path_real, "<DEMO_DIR>")
|
|
output = output.replace(temp_path, "<DEMO_DIR>")
|
|
|
|
# Write snapshot
|
|
output_snap.parent.mkdir(parents=True, exist_ok=True)
|
|
output_snap.write_text(output + "\n")
|
|
|
|
|
|
@dataclass
|
|
class DemoSize:
|
|
"""Canvas and font size for demo recording."""
|
|
|
|
width: int
|
|
height: int
|
|
fontsize: int
|
|
|
|
|
|
# Predefined sizes for different contexts
|
|
SIZE_SOCIAL = DemoSize(width=1200, height=700, fontsize=26) # Big text for mobile
|
|
SIZE_DOCS = DemoSize(width=1600, height=900, fontsize=24) # More content for docs
|
|
SIZE_DOCS_MOBILE = DemoSize(width=576, height=432, fontsize=20)
|
|
|
|
|
|
def build_tape_replacements(demo_env: DemoEnv, repo_root: Path) -> dict:
|
|
"""Build template variable replacements for tape rendering.
|
|
|
|
Used by both GIF recording and text recording to ensure consistency.
|
|
All paths are resolved to absolute paths for VHS compatibility.
|
|
|
|
Tapes use Source directives for shared content:
|
|
- Source shared-setup.tape: VHS Set directives (at top, before Output)
|
|
- Source shared-commands.tape: Env vars and shell setup (after Require)
|
|
"""
|
|
return {
|
|
"DEMO_REPO": demo_env.repo.resolve(),
|
|
"DEMO_HOME": demo_env.home.resolve(),
|
|
"REAL_HOME": REAL_HOME,
|
|
"STARSHIP_CONFIG": demo_env.starship_config.resolve(),
|
|
"TARGET_DEBUG": (repo_root / "target" / "debug").resolve(),
|
|
"ANTHROPIC_API_KEY": os.environ.get("ANTHROPIC_API_KEY", ""),
|
|
"CLAUDE_CODE_OAUTH_TOKEN": os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", ""),
|
|
"GIT_PAGER": "", # Overridden by record_theme
|
|
}
|
|
|
|
|
|
def record_theme(
|
|
demo_env: "DemoEnv",
|
|
tape_template: Path,
|
|
output_gif: Path,
|
|
repo_root: Path,
|
|
vhs_binary: str = "vhs",
|
|
size: DemoSize = None,
|
|
):
|
|
"""Record one demo GIF in a prepared environment, in the environment's theme.
|
|
|
|
Args:
|
|
demo_env: Prepared demo environment for this theme
|
|
tape_template: Path to the .tape template file
|
|
output_gif: Output GIF path
|
|
repo_root: Path to worktrunk repo root (for target/debug)
|
|
vhs_binary: VHS binary to use (default "vhs", can be path to custom build)
|
|
size: Canvas and font size (default SIZE_DOCS)
|
|
"""
|
|
if size is None:
|
|
size = SIZE_DOCS
|
|
|
|
tape_rendered = demo_env.out_dir / ".rendered.tape"
|
|
delta_flags = "delta --paging=never"
|
|
if demo_env.theme == "light":
|
|
delta_flags += " --light"
|
|
replacements = {
|
|
**build_tape_replacements(demo_env, repo_root),
|
|
"OUTPUT_GIF": output_gif,
|
|
"THEME": format_theme_for_vhs(THEMES[demo_env.theme]),
|
|
"WIDTH": size.width,
|
|
"HEIGHT": size.height,
|
|
"FONTSIZE": size.fontsize,
|
|
"GIT_PAGER": delta_flags,
|
|
}
|
|
|
|
rendered = render_tape(tape_template, replacements, repo_root)
|
|
if not rendered:
|
|
return
|
|
|
|
tape_rendered.write_text(rendered)
|
|
print(f"\nRecording {demo_env.theme} GIF...")
|
|
record_vhs(tape_rendered, vhs_binary, expected_output=output_gif)
|
|
tape_rendered.unlink(missing_ok=True)
|
|
print(f"GIF saved to {output_gif}")
|