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)創作專業的示範影片。它使用 asciinema、agg 和 ffmpeg 的組合,將終端機工作階段轉換為 GIF 和 MP4 影片。此技能的主要區別在於,它能區分純 CLI 工具(無需 AI 工作階段,直接使用子程序流程)和需要模擬鍵盤輸入的互動式 TUI 工具(透過 tmux)。它提供了 Python、Rust、TypeScript 和 Bash 的範例程式碼,以建立可靠、可驗證的「示範框架」(demo harness),這些框架同時也作為測試使用,確保示範與實際工具行為保持一致。此工作流程包含規劃、建置框架、錄製和轉換等階段,並強調使用 `capture_output=False` 等關鍵實作細節,以及處理初始框架清理和隱私隔離等常見陷阱。

相容平台✓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 工作階段、透過 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

示範的第一個工作版本通常會顯示模擬輸出 — 框架直接呼叫鉤子並列印鉤子會說的內容。這在兩個方面是錯誤的:

  1. 這不是使用者看到的。 使用者看到的是完整的互動式 TUI:工具呼叫區塊、AI 的回應、嵌入在該回應中的區塊訊息。
  2. 鉤子可能根本沒有觸發。 如果沒有錄製實際的工具呼叫,這是無法得知的。

正確的架構(TUI 即時):

Python 框架
  → 建立 tmux 工作階段
  → asciinema 附加:asciinema rec --command "tmux attach-session -t SESSION"
  → 背景執行緒:tmux send-keys "claude --dangerously-skip-permissions" Enter
  → 輪詢 trust/saf
...

[為翻譯而截斷]

Individual skills in this repo

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

相關技能