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 工作階段、透過
send-keys發送提示的 Python 腳本,而 asciinema 會附加到該工作階段。
階段三:錄製與驗證(10–30 分鐘)
- CLI:
python tests/test_demo.py --record→ 檢查轉換文字片段 - 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 捕捉程序的所有標準輸出
→ 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 工具,橫幅是直接列印到標準輸出的 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 進行閘控 — 詳細資訊請參閱 references/common-pitfalls.md,章節「Installation-state commands leak the operator's machine」。
日期轉換固定數據 [僅限 CLI]
當示範動作使用 --since Nd、--after DATE 或任何時間相關的篩選器時為必要。
def create_dated_demo_dir() -> Path:
"""將 DEMO_DATA_DIR 複製到暫存目錄,並將時間戳記轉換為接近今天。
沒有這個:幾個月前的固定數據 → 對 --since 3d 的結果為 0。
已提交的固定數據檔案永遠不會被修改 — 只有暫存複本會被轉換。
調整 _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 或其他額外旗標
❌ 錯誤的工作流程:
demo 動作: 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
→ 輪詢 trust/saf
...
[為翻譯而截斷]