Communitygithub.com

CLI 데모 레코더

CLI 및 TUI 도구용 데모 비디오를 생성합니다. 사용자가 "데모 녹화", "데모 GIF 만들기", "데모 비디오 생성", "내 CLI 도구 녹화", "asciinema 데모", "데모 녹화 스크립트"를 요청할 때 사용하세요. asciinema+agg+ffmpeg 파이프라인, tmux 녹화, 멀티쉘, Python, Rust, TypeScript, Bash로 작성된 이중 목적의 데모+테스트 하네스를 다룹니다.

CLI 데모 레코더란 무엇인가요?

CLI 데모 레코더는 CLI 및 TUI(텍스트 사용자 인터페이스) 도구의 고품질 데모 비디오를 제작하기 위한 포괄적인 스킬입니다. 이 스킬은 asciinema, agg, ffmpeg 도구를 활용하여 녹화에서 변환까지 완전한 파이프라인을 제공합니다. CLI 도구의 경우, subprocess 호출을 캡처하여 직접 데모를 생성하는 Python 기반 테스트 하네스를 사용합니다. TUI 도구의 경우, tmux 세션을 생성하고 제어하여 실제 대화형 AI 세션을 캡처합니다. 이 스킬은 Python, Rust, TypeScript, Bash 등 여러 프로그래밍 언어를 지원하며, 특히 AI 에이전트와 작업하는 개발자에게 유용합니다. 데모 하네스는 테스트로도 이중 역할을 하여, 시연된 명령이 기록될 뿐만 아니라 예상대로 작동하는지 검증되도록 보장합니다. 이 접근 방식은 데모와 테스트 코드의 차이(드리프트)를 방지하며, 최종 출력물은 GIF 또는 MP4 형식으로 제공되어 문서, README, 제품 프레젠테이션에 포함될 수 있습니다.

지원 대상✓Claude Code~Codex CLI✓Cursor✓Gemini CLI
npx skills add https://github.com/ahundt/autorun/tree/HEAD/plugins/autorun/skills/cli-demo-recorder

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

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분)

  1. 도구의 문서를 먼저 읽으십시오 — 단일 행동(act)을 작성하기 전에
  2. 5–7개의 기능을 선택하십시오 — 신규 사용자에게 가시적이고, 즉각적이며, 설명이 필요 없는 것들
  3. 보이지 않는 기능은 건너뛰십시오 (백그라운드 데몬, 가시적 출력 없이 자동 저장)
  4. 경로를 선택하십시오 (위 표 참조) — 전체 하네스 디자인이 결정됩니다

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

데모의 첫 번째 작업 버전은 일반적으로 시뮬레이션된 출력을 보여줍니다 — 하네스가 훅을 직접 호출하고 훅이 말할 내용을 출력합니다. 이는 두 가지 이유로 잘못되었습니다:

  1. 사용자가 보는 것이 아닙니다. 사용자는 전체 대화형 TUI를 봅니다: 도구 호출 블록, AI의 대화형 응답, 해당 응답에 포함된 블록 메시지.
  2. 훅이 전혀 실행되지 않았을 수도 있습니다. 실제 도구 호출을 녹화하지 않고는 이것을 알 수 없습니다.

올바른 아키텍처 (TUI 라이브):

Python 하네스
  → tmux 세션 생성
  → asciinema 연결: asciinema rec --command "tmux attach-session -t SESSION"
  → 백그라운드 스레드: tmux send-keys "claude --dangerously-skip-permissions" Enter
  → 신뢰/안전 확인 폴링

...

Individual skills in this repo

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

관련 스킬