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分钟)
- 首先阅读工具的文档——在编写任何脚本之前
- 选择5–7个功能,这些功能对新手来说应该是可见、即时且不言自明的
- 跳过不可见的功能(后台守护进程、自动保存但无可见输出)
- 选择您的路径(参见上表)——这决定了整个框架的设计
第二阶段:构建框架 (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
演示的第一个工作版本通常显示模拟的输出 — 框架直接调用钩子并打印钩子会说的话。这样做有两个问题:
- 这不是用户看到的。 用户看到的是完整的交互式TUI:工具调用块、AI的对话响应、嵌入在该响应中的块消息。
- 钩子可能根本没有触发。 如果不录制实际的工具调用,这是无法知道的。
正确的架构(TUI实时):
Python框架
→ 创建tmux会话
→ asciinema附加:asciinema rec --command "tmux attach-session -t SESSION"
→ 后台线程:tmux send-keys "claude --dangerously-skip-permissions" Enter
→ 轮询信任/安全确认
… [为翻译而截断]