Communitygithub.com

ahundt/cli-demo-recorder

为CLI和TUI工具创建演示视频。当用户要求“录制演示”、“制作演示GIF”、“创建演示视频”、“录制我的CLI工具”、“asciinema演示”、“演示录制脚本”时使用。涵盖 asciinema+agg+ffmpeg 流程、tmux录制、多Shell以及Python、Rust、TypeScript和Bash中的双用途演示+测试框架。

cli-demo-recorder 是什么?

这是一个为CLI和TUI工具创建专业演示视频的AI技能。它提供两种工作路径:针对无交互终端的纯CLI工具(直接通过子进程录制),以及针对需要交互式AI会话的TUI工具(通过tmux会话驱动)。该技能支持 asciinema、agg 和 ffmpeg 的转换流程,能够生成高质量的GIF和MP4演示视频。它在多个平台上运行,包括Python、Rust、TypeScript和Bash,并为每种语言提供了专门的实现指南。通过合理的架构设计(如隐私隔离、日期偏移夹具、初始帧清理),该技能能够生成清晰、可靠且可验证的演示视频。用户可以直接使用`/cli-demo-recorder`命令或请求“帮助我录制CLI工具演示”来调用它。

兼容平台✓Claude Code~Codex CLI✓Cursor✓Gemini CLI
npx skills add https://github.com/ahundt/autorun/tree/HEAD/plugins/autorun/skills/cli-demo-recorder

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

CLI 演示录制器

为CLI工具录制精美的演示视频——可以是直接的子进程录制(CLI工具,无AI会话)或真实的TUI录制(通过tmux进行交互式AI会话)。

何时使用此技能: 当需要为CLI工具、插件或终端应用程序制作演示GIF/视频时。 调用方式: /ar:cli-demo-recorder(自动运行插件)或 /cli-demo-recorder(技能根目录),或“帮我为我的CLI工具录制演示”


选择您的路径 [两者均适用]

根据工具是否具有交互式TUI会话来选择路径。选择错误 → 录制内容无实际价值。

工具类型交互式TUI?使用AI/LLM?正确路径
纯CLI (aise, git, curl)否否CLI:框架即录制
CLI + AI会话 (claude -p)无需TUI是CLI:验证输出有用
TUI工具的插件/钩子通过TUI是TUI实时:tmux + 窗格
仅含钩子动作的插件通过钩子否TUI脚本化:run_hook() + --play
生成交互式TUI是可能TUI实时:通过tmux驱动TUI
批处理/配置工具否否CLI:capture_output=False

警告:在CLI演示中使用subprocess.run(capture_output=True)会完全静默录制——asciinema将捕获不到任何内容。请参见CLI路径以获得正确的模式。


工作原理 [两者均适用]

第一阶段:计划 (15–30分钟)

  1. 首先阅读工具的文档——在编写任何脚本之前
  2. 选择5–7个功能,这些功能对新手来说应该是可见、即时且不言自明的
  3. 跳过不可见的功能(后台守护进程、自动保存但无可见输出)
  4. 选择您的路径(参见上表)——这决定了整个框架的设计

第二阶段:构建框架 (30–60分钟)

  • CLI:调用 subprocess.run(..., capture_output=False) 的Python脚本。asciinema 录制 python test_demo.py --run-acts。
  • TUI脚本化:为每个动作直接调用 run_hook() 的Python脚本。asciinema 录制 python test_demo.py --play。
  • TUI实时:创建tmux会话的Python脚本,通过 send-keys 发送提示,asciinema 附加到该会话。

第三阶段:录制和验证 (10–30分钟)

  • CLI:python tests/test_demo.py --record → 检查cast文本片段
  • TUI:python tests/test_demo.py --record → 解析JSONL以获取工具调用

第四阶段:转换 [两者均适用]

# 来自下方设置表的值;fontdue 渲染矢量质量的抗锯齿文本
agg demo.cast demo.gif \
    --theme dracula \
    --font-size 16 \
    --renderer fontdue \
    --speed 0.75 \
    --idle-time-limit 10

# MP4:4种策略回退(最佳压缩优先)
# 策略1:libx265 HEVC(tune=animation — 相同质量下比 libx264 小约50%)
ffmpeg -y -i demo.gif -movflags faststart \
    -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" \
    -c:v libx265 -preset slow -crf 28 -tune animation \
    -pix_fmt yuv420p -tag:v hvc1 demo.mp4 2>/dev/null \
  || ffmpeg -y -i demo.gif -movflags faststart \
    -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" \
    -c:v libx264 -preset slow -crf 28 -tune animation \
    -pix_fmt yuv420p demo.mp4 2>/dev/null \
  || ffmpeg -y -i demo.gif -movflags faststart \
    -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" \
    -c:v h264_videotoolbox -q:v 65 -pix_fmt yuv420p -color_range tv demo.mp4 2>/dev/null \
  || ffmpeg -y -i demo.gif -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" \
    -pix_fmt yuv420p demo.mp4

重要:将GIF转换为MP4(而不是cast直接转MP4)。GIF已经过处理;从cast直接转MP4会丢失agg所做的速度/空闲时间调整。使用 tune=animation(而不是 tune=fast)——终端录制具有平坦的颜色和锐利的边缘,与动画压缩相匹配。

总计: 完成一个精致、经过验证的演示大约需要75–120分钟。


CLI 路径 [仅限CLI]

架构:框架即录制

对于CLI工具,Python框架作为命令运行,并被asciinema录制。无需tmux,无需窗格附加。

asciinema rec demo.cast --command "python test_demo.py --run-acts"
                                                        ↑ 框架在此运行
  → 框架输出 $ 提示,使用 capture_output=False 运行子进程
  → asciinema 捕获来自进程的所有stdout
  → agg demo.cast demo.gif
# ✅ 正确 — 输出流向终端,asciinema 能够捕获
subprocess.run("mytool subcommand", shell=True, capture_output=False, env=DEMO_ENV)

# ❌ CLI 演示错误 — 输出被捕获到Python中,asciinema 看不到任何内容
result = subprocess.run(["mytool", "subcommand"], capture_output=True)

核心辅助函数 [仅限CLI]

_TIMED = False  # 仅在 --run-acts(录制模式)内为 True;_DEMO_WITH_TIMING 也常用

def pause(seconds: float) -> None:
    """在pytest中为空操作;在录制期间休眠。错误1+2:时序 ≠ 间隔。"""
    if _TIMED:
        time.sleep(seconds)

def _type(text: str, delay: float = 0.04) -> None:
    if _TIMED:
        for ch in text:
            sys.stdout.write(ch); sys.stdout.flush(); time.sleep(delay)
    else:
        sys.stdout.write(text); sys.stdout.flush()

def _run(cmd: str) -> None:
    """显示键入的 $ 提示,然后运行命令。
    capture_output=False 至关重要 — 输出必须流向终端。
    """
    _type(f"\n\033[1;32m$\033[0m ", delay=0)
    _type(cmd + "\n", delay=0.045)
    pause(0.3)
    subprocess.run(cmd, env=DEMO_ENV, shell=True, capture_output=False, text=True)

def section(title: str) -> None:
    """动作之间的视觉分隔线。

    分隔线前3个换行符 = 与上一个动作的视觉间距(根据间距需要更改此值)。
    pause() 持续时间 = 阅读时间(根据时间需要单独更改)。
    这些是完全独立的旋钮 — 请不要混淆它们。

    bar_len = max(68, len(title) + 6) 防止分隔线比标题短。
    """
    bar_len = max(68, len(title) + 6)
    bar = "─" * bar_len
    sys.stdout.write(f"\n\n\n\033[90m{bar}\033[0m\n")
    sys.stdout.write(f"\033[1;96m  {title}\033[0m\n")
    sys.stdout.write(f"\033[90m{bar}\033[0m\n")
    sys.stdout.flush()

介绍横幅 [仅限CLI]

对于CLI工具,横幅是一个直接打印到stdout的Python字符串:

def banner() -> None:
    W = 68  # 仅在纯文本上计算内边距 — len() 计算中不含ANSI码
    def row(text: str = "", style: str = "") -> str:
        content = (" " + text).ljust(W)  # W 个可见字符;长度中不含ANSI码
        return f"\033[90m  ║\033[0m{style}{content}\033[0m\033[90m║\033[0m"
    # ❌ 错误 — ANSI码会膨胀 len(),导致对齐错误:
    # bad = f"\033[1m{text}\033[0m".ljust(W)
    lines = [
        f"\033[90m  ╔{'═'*W}╗\033[0m",
        row("mytool — 标语在此", "\033[1;96m"),
        row(),
        row("此演示展示:", "\033[90m"),
        row("  1. 功能一", "\033[90m"),
        row("  2. 功能二", "\033[90m"),
        f"\033[90m  ╚{'═'*W}╝\033[0m",
    ]
    print("\n" + "\n".join(lines) + "\n")

隐私隔离 [仅限CLI]

# DEMO_DATA_DIR:已提交的合成测试夹具(无真实用户数据)
# TOOL_ISOLATION_VAR:重定向工具数据读取的环境变量
# 示例:CLAUDE_CONFIG_DIR (aise), XDG_DATA_HOME, APP_DATA_DIR
DEMO_DATA_DIR = Path(__file__).parent / "tool-demo"
DEMO_ENV = {**os.environ, "TOOL_ISOLATION_VAR": str(DEMO_DATA_DIR)}

对于报告安装状态的命令(status、doctor、install、--version -v),仅重定向数据读取是不够的:它们还会打印 HOME、PATH 和配置相关的根目录。需要重定位所有这三个目录,并对解码后的cast中操作员的home、登录名、主机名和电子邮件进行门控检查 — 详见 references/common-pitfalls.md中的“安装状态命令会泄露操作员的机器信息”章节。

日期偏移夹具 [仅限CLI]

当演示动作使用 --since Nd、--after DATE 或任何时间相关的过滤器时需要。

def create_dated_demo_dir() -> Path:
    """将 DEMO_DATA_DIR 复制到临时目录,并将时间戳偏移到接近今天。
    不这样做:几个月前的夹具会导致 --since 3d 结果为空。
    已提交的夹具文件永远不会被修改 — 只有临时副本会被偏移。
    调整 _TS_RE 和偏移逻辑以匹配您工具的时间戳格式。
    """
    _TS_RE = re.compile(r'"timestamp":\s*"(\d{4}-\d{2}-\d{2}T[^"]+)"')
    # 在夹具中找到最大时间戳,计算与(今天 - 1天)的差值
    tmp = Path(tempfile.mkdtemp(prefix="tool-demo-dated-"))
    shutil.copytree(DEMO_DATA_DIR, tmp / "data")
    for f in (tmp / "data").rglob("*.jsonl"):
        f.write_text(_TS_RE.sub(lambda m: shift_ts(m, delta), f.read_text()))
    return tmp

录制 [仅限CLI]

def record(cast_file: Path) -> None:
    dated_dir = create_dated_demo_dir()
    record_env = {**os.environ, "TOOL_ISOLATION_VAR": str(dated_dir)}
    try:
        subprocess.run([
            asciinema, "rec", str(cast_file),
            "--command", f"{sys.executable} {__file__} --run-acts",
            "--window-size", "160x48",   # 窄工具使用 100x35
            "--capture-env", "TERM,COLORTERM,TOOL_ISOLATION_VAR",
        ], env=record_env, check=True)
    finally:
        shutil.rmtree(dated_dir, ignore_errors=True)

初始帧清理 [两者均适用]

Asciinema 在框架启动前会录制Shell初始化(提示、环境变量、RC文件)。这会导致第一帧包含Shell工件而不是干净的横幅。通过录制后的修剪来解决此问题:找到横幅标记,回溯到前面的清屏转义序列,删除之前的所有事件,并将时间戳重置为t=0。

def trim_cast_to_banner(cast_file: Path, banner_marker: str = "mytool") -> None:
    """修剪Shell初始化事件,使横幅成为第一个可见帧。

    原地修改 cast_file。保留JSON头部行。
    """
    lines = cast_file.read_text().splitlines()
    if len(lines) < 2:
        return

    header = lines[0]   # JSON头部 — 总是第1行
    events = lines[1:]

    # 找到第一个包含横幅标记文本的事件。
    banner_idx = None
    for i, line in enumerate(events):
        if banner_marker in line:
            banner_idx = i
            break

    if banner_idx is None:
        print(f"[trim] 标记 {banner_marker!r} 未找到 — 跳过修剪")
        return

    # 回溯到横幅前面的清屏转义序列。
    clear_idx = banner_idx
    for i in range(banner_idx - 1, -1, -1):
        if "\\u001b[H\\u001b[2J" in events[i] or "\\033[H\\033[2J" in events[i]:
            clear_idx = i
            break

    kept = events[clear_idx:]
    if not kept:
        return

    # 重置时间戳:第一个保留的事件变为 t=0。
    first_ts = json.loads(kept[0])[0]
    rebased = []
    for line in kept:
        evt = json.loads(line)
        evt[0] = round(evt[0] - first_ts, 6)
        rebased.append(json.dumps(evt))

    cast_file.write_text(header + "\n" + "\n".join(rebased) + "\n")
    trimmed = len(events) - len(kept)
    print(f"[trim] 已移除 {trimmed} 个横幅前的事件(保留了 {len(kept)} 个)")

在录制后、转换前调用:trim_cast_to_banner(CAST_FILE, banner_marker="mytool_name")。 对于Rust和TypeScript实现,请参阅 references/rust-demos.md 和 references/typescript-demos.md。

先编写动作,再编写匹配的测试 [仅限CLI]

测试/演示漂移的根本原因: 独立于演示动作编写测试。

✅ 正确的工作流程:
1. 确定 run_demo_acts() — 为每个动作确定确切的命令字符串
2. 使用相同的命令字符串逐字编写 TestDemoFree
3. 永远不要在测试中添加不属于演示的 --format、--limit 或其他标志

❌ 错误的工作流程:
  演示动作:  mytool messages search keyword --context 1
  测试:      mytool messages search keyword --format plain --full-uuid
  → 测试通过,演示显示不同的输出,测试无法验证演示的任何内容

规则:如果测试需要不同的标志来验证输出,要么 (a) 将该标志也添加到演示动作中,要么 (b) 验证演示实际输出满足的属性。

验证 [仅限CLI]

检查cast文件中是否包含预期文本:

def verify_recording(cast_file: Path) -> bool:
    content = cast_file.read_text()
    checks = [
        ("Sessions:",      "动作1:存在统计标签"),
        ("authentication", "动作4:搜索结果包含关键词"),
    ]
    return all(fragment in content for fragment, _ in checks)

确定性夹具ID [仅限CLI]

# ID遵循可识别的模式 — 在录制中易于发现
_S1 = str(uuid.UUID("cafe0001-cafe-cafe-cafe-000000000001"))
# 跨运行稳定的工具调用ID:
_id = hashlib.md5(f"{session_id}{timestamp}{path}".encode()).hexdigest()[:8]

结束信息 [仅限CLI]

# 始终从 pyproject.toml 或 README 读取安装命令 — 切勿猜测
sys.stdout.write(
    "\033[1;32m  ══════════════════════════════════════\033[0m\n"
    "\033[1;32m  ✓  演示完成 — {工具名称和标语}\033[0m\n"
    "\033[1;32m  ══════════════════════════════════════\033[0m\n"
    "\n"
    "  安装:  {来自pyproject.toml/README的确切命令}\n"
    "\n"
)

TUI 路径 [仅限TUI]

架构:为什么使用 tmux + 真实TUI

演示的第一个工作版本通常显示模拟的输出 — 框架直接调用钩子并打印钩子会说的话。这样做有两个问题:

  1. 这不是用户看到的。 用户看到的是完整的交互式TUI:工具调用块、AI的对话响应、嵌入在该响应中的块消息。
  2. 钩子可能根本没有触发。 如果不录制实际的工具调用,这是无法知道的。

正确的架构(TUI实时):

Python框架
  → 创建tmux会话
  → asciinema附加:asciinema rec --command "tmux attach-session -t SESSION"
  → 后台线程:tmux send-keys "claude --dangerously-skip-permissions" Enter
  → 轮询信任/安全确认

… [为翻译而截断]

Individual skills in this repo

This repo contains 2 individual skills — each has its own dedicated page.

相关技能