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 경로를 참조하십시오.
작동 방식 [모두 해당]
1단계: 계획 (15–30분)
- 도구의 문서를 먼저 읽으십시오 — 단일 행동(act)을 작성하기 전에
- 5–7개의 기능을 선택하십시오 — 신규 사용자에게 가시적이고, 즉각적이며, 설명이 필요 없는 것들
- 보이지 않는 기능은 건너뛰십시오 (백그라운드 데몬, 가시적 출력 없이 자동 저장)
- 경로를 선택하십시오 (위 표 참조) — 전체 하네스 디자인이 결정됩니다
2단계: 하네스 구축 (30–60분)
- CLI:
subprocess.run(..., capture_output=False)를 호출하는 Python 스크립트. asciinema가python test_demo.py --run-acts를 녹화합니다. - TUI 스크립팅됨: 각 행동(act)에 대해
run_hook()을 직접 호출하는 Python 스크립트. asciinema가python test_demo.py --play를 녹화합니다. - TUI 라이브: tmux 세션을 생성하고,
send-keys로 프롬프트를 보내며, asciinema가 해당 세션에 연결됩니다.
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로 subprocess 실행
→ 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:
"""행동(act) 사이의 시각적 구분선.
막대 앞에 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. 기능 1", "\033[90m"),
row(" 2. 기능 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 및 구성 관련 루트를 출력합니다. 세 가지를 모두 재배치하고 운영자의 홈, 로그인, 호스트 이름 및 이메일에 대해 디코딩된 캐스트를 필터링하십시오 — 자세한 내용은 references/common-pitfalls.md의 "설치 상태 명령이 운영자의 머신을 누설함" 섹션을 참조하십시오.
날짜 이동 픽스처 [CLI 전용]
데모 행동(act)이 --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}개의 이벤트 제거됨 (kept {len(kept)})")
녹화 후, 변환 전에 호출: trim_cast_to_banner(CAST_FILE, banner_marker="mytool_name").
Rust 및 TypeScript 구현은 references/rust-demos.md 및 references/typescript-demos.md를 참조하십시오.
먼저 행동(Act)을 작성하고, 그 다음 일치하는 테스트를 작성하십시오 [CLI 전용]
테스트/데모 불일치의 근본 원인: 데모 행동(act)과 독립적으로 테스트를 작성하는 것.
✅ 올바른 워크플로:
1. run_demo_acts()를 확정 — 각 행동(act)에 대한 정확한 명령 문자열을 고정
2. 해당하는 동일한 명령 문자열을 그대로 사용하여 TestDemoFree 작성
3. 데모에 없는 --format, --limit 또는 추가 플래그를 테스트에 절대 추가하지 않음
❌ 잘못된 워크플로:
데모 행동: mytool messages search keyword --context 1
테스트: 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:", "행동 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 전용]
아키텍처: Why tmux + Real TUI
데모의 첫 번째 작업 버전은 일반적으로 시뮬레이션된 출력을 보여줍니다 — 하네스가 훅을 직접 호출하고 훅이 말할 내용을 출력합니다. 이는 두 가지 이유로 잘못되었습니다:
- 사용자가 보는 것이 아닙니다. 사용자는 전체 대화형 TUI를 봅니다: 도구 호출 블록, AI의 대화형 응답, 해당 응답에 포함된 블록 메시지.
- 훅이 전혀 실행되지 않았을 수도 있습니다. 실제 도구 호출을 녹화하지 않고는 이것을 알 수 없습니다.
올바른 아키텍처 (TUI 라이브):
Python 하네스
→ tmux 세션 생성
→ asciinema 연결: asciinema rec --command "tmux attach-session -t SESSION"
→ 백그라운드 스레드: tmux send-keys "claude --dangerously-skip-permissions" Enter
→ 신뢰/안전 확인 폴링
...