CLI Demo Recorder
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パスウェイを参照してください。
動作の仕組み [両方]
フェーズ1: 計画 (15~30分)
- まずツールのドキュメントを読む — アクションを書く前に
- 5~7の機能を選ぶ — 初心者にとって目に見えて、即座に理解でき、自明なもの
- 目に見えない機能はスキップ (バックグラウンドデーモン、出力なしの自動保存)
- パスウェイを選択 (上の表を参照) — これによりハーネス設計全体が決まる
フェーズ2: ハーネスの構築 (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でプロンプトを送信し、asciinemaがそのセッションにアタッチするPythonスクリプト。
フェーズ3: 録画と検証 (10~30分)
- CLI:
python tests/test_demo.py --record→ キャストテキストフラグメントをチェック - TUI:
python tests/test_demo.py --record→ ツールコールのJSONLをパース
フェーズ4: 変換 [両方]
# 以下の設定テーブルの値を使用。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 — tagline here", "\033[1;96m"),
row(),
row("This demo shows:", "\033[90m"),
row(" 1. Feature one", "\033[90m"),
row(" 2. Feature two", "\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、設定関連のルートも出力します。3つすべてをリロケートし、デコードされたキャストからオペレーターのホーム、ログイン、ホスト名、メールアドレスをゲートします。詳細は references/common-pitfalls.md の「インストール状態コマンドがオペレーターのマシンを漏洩する」セクションを参照してください。
日付シフトフィクスチャ [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は、ハーネスが開始する前にシェルの初期化(プロンプト、環境変数、RCファイル)を録画します。これにより、クリーンなバナーの代わりにシェルアーティファクトを含む乱雑な最初のフレームが生成されます。これを修正するには、録画後に、バナーマーカーを見つけ、その前の画面クリアエスケープシーケンスまで戻り、以前のすべてのイベントを削除し、タイムスタンプをt=0にリベースするトリムを実行します。
def trim_cast_to_banner(cast_file: Path, banner_marker: str = "mytool") -> None:
"""シェル初期化イベントをトリミングし、バナーが最初の表示フレームになるようにする。
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 act: mytool messages search keyword --context 1
test: mytool messages search keyword --format plain --full-uuid
→ テストはパスするが、デモは異なる出力を表示し、テストはデモについて何も証明しない
ルール: テストが異なるフラグを必要とする場合、そのフラグを(a)デモアクションにも追加するか、(b)デモの実際の出力が満たすプロパティを検証します。
検証 [CLIのみ]
キャストファイル内に期待されるテキストが表示されているかを確認します:
def verify_recording(cast_file: Path) -> bool:
content = cast_file.read_text()
checks = [
("Sessions:", "Act 1: stats label present"),
("authentication", "Act 4: search result shows keyword"),
]
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 ✓ Demo complete — {tool name and tagline}\033[0m\n"
"\033[1;32m ══════════════════════════════════════\033[0m\n"
"\n"
" Install: {exact command from pyproject.toml/README}\n"
"\n"
)
TUIパスウェイ [TUIのみ]
アーキテクチャ: なぜtmux + 実際のTUIか
デモの最初のバージョンは通常、シミュレートされた出力を表示します。ハーネスはフックを直接呼び出し、フックが言うであろうことを出力します。これは2つの理由で間違っています:
- それはユーザーが見るものではありません。 ユーザーが実際に見るのは完全なインタラクティブTUIです:ツールコールブロック、AIの会話応答、その応答に埋め込まれたブロックメッセージ。
- フックがまったく起動していない可能性があります。 これは実際のツールコールを録画しなければ知り得ません。
正しいアーキテクチャ (TUIライブ):
Pythonハーネス
→ tmuxセッションを作成
→ asciinemaがアタッチ: asciinema rec --command "tmux attach-session -t SESSION"
→ バックグラウンドスレッド: tmux send-keys "claude --dangerously-skip-permissions" Enter
→ trust/saf をポーリング
...
[翻訳のため省略]
---
## 付録 [両方]
### 参照ファイル
- `references/common-pitfalls.md` — よくある落とし穴とその回避策
- `references/rust-demos.md` — Rust CLIツール向けのデモハーネスパターン
- `references/typescript-demos.md` — TypeScript CLIツール向けのデモハーネスパターン
### トラブルシューティング
- **録画が空**: capture_output=True を使用していないか確認。正しいパスウェイを選択しているか確認。
- **バナーの代わりにシェルアーティファクト**: `trim_cast_to_banner()` を実行。
- **タイミングがおかしい**: `pause()` と `delay` パラメータを調整。`_TIMED` フラグが録画中にTrueであることを確認。
- **MP4変換に失敗**: 上記のフォールバック戦略を使用。利用可能なコーデックを確認 (`ffmpeg -encoders | grep -E 'libx265|libx264|h264_videotoolbox'`)。
- **TUIライブでフックが起動しない**: ツールの権限モデルを確認。`--dangerously-skip-permissions` (Claude) または同等のフラグが必要な場合あり。
### 環境変数
| 変数 | 目的 | 例 |
|----------|---------|-------|
| `TOOL_ISOLATION_VAR` | ツールのデータ読み取りをリダイレクト | `CLAUDE_CONFIG_DIR`, `XDG_DATA_HOME` |
| `DEMO_TIMING` | `true` に設定すると録画タイミングを有効化 | `true` |
| `DEMO_CAST_FILE` | 出力キャストファイルへのパス | `demo.cast` |