Enregistreur de Démo CLI
Enregistrez des vidéos de démonstration soignées pour les outils CLI — soit comme enregistrements directs de sous-processus (outils CLI, sans session IA) ou enregistrements TUI réels (sessions IA interactives via tmux).
Utilisez cette compétence lorsque : Un GIF/vidéo de démo est nécessaire pour un outil CLI, un plugin ou une application terminal.
Invoquez avec : /ar:cli-demo-recorder (plugin autorun) ou /cli-demo-recorder (racine des compétences), ou "Aide-moi à enregistrer une démo pour mon outil CLI"
Choisissez Votre Voie [Les Deux]
Choisissez la voie en fonction du fait que l'outil a une session TUI interactive. Un mauvais choix → l'enregistrement ne capture rien d'utile.
| Type d'outil | TUI interactif ? | Utilise l'IA/LLM ? | Voie correcte |
|---|---|---|---|
| CLI pure (aise, git, curl) | Non | Non | CLI : le harnais EST l'enregistrement |
| CLI + session IA (claude -p) | Pas de TUI | Oui | CLI : vérifier que la sortie est utile |
| Plugin/hook pour outil TUI | Via TUI | Oui | TUI live : tmux + panneau |
| Plugin avec actions hook uniquement | Via hook | Non | TUI scripté : run_hook() + --play |
| Lance un TUI interactif | Oui | Peut-être | TUI live : piloter le TUI via tmux |
| Outil batch/config | Non | Non | CLI : capture_output=False |
ATTENTION : Utiliser subprocess.run(capture_output=True) pour une démo CLI rend l'enregistrement totalement silencieux — asciinema ne capture rien. Voir la voie CLI pour le modèle correct.
Comment Ça Marche [Les Deux]
Phase 1 : Planification (15–30 min)
- Lisez d'abord la documentation de l'outil — avant d'écrire une seule action
- Choisissez 5–7 fonctionnalités qui sont visibles, immédiates et explicites pour les nouveaux utilisateurs
- Ignorez les fonctionnalités invisibles (démons en arrière-plan, sauvegarde automatique sans sortie visible)
- Choisissez votre voie (voir tableau ci-dessus) — cela détermine toute la conception du harnais
Phase 2 : Construire le Harnais (30–60 min)
- CLI : Script Python qui appelle
subprocess.run(..., capture_output=False). asciinema enregistrepython test_demo.py --run-acts. - TUI scripté : Script Python qui appelle
run_hook()directement pour chaque action. asciinema enregistrepython test_demo.py --play. - TUI live : Script Python qui crée une session tmux, envoie des invites via
send-keys, et asciinema s'attache à cette session.
Phase 3 : Enregistrer et Vérifier (10–30 min)
- CLI :
python tests/test_demo.py --record→ vérifie les fragments de texte du cast - TUI :
python tests/test_demo.py --record→ analyse le JSONL pour les appels d'outil
Phase 4 : Convertir [Les Deux]
# valeurs du tableau des paramètres ci-dessous ; fontdue rend un texte anti-aliasé de qualité vectorielle
agg demo.cast demo.gif \
--theme dracula \
--font-size 16 \
--renderer fontdue \
--speed 0.75 \
--idle-time-limit 10
# MP4 : repli à 4 stratégies (meilleure compression en premier)
# Stratégie 1 : libx265 HEVC (tune=animation — ~50% plus petit que libx264 à qualité égale)
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
Important : Convertissez GIF → MP4 (pas cast → MP4). Le GIF est déjà traité ; passer cast → MP4 directement manque les ajustements de vitesse/temps mort de agg. Utilisez tune=animation (pas tune=fast) — les enregistrements de terminal ont des couleurs plates et des bords nets qui correspondent à la compression d'animation.
Total : ~75–120 minutes pour une démo soignée et vérifiée.
Voie CLI [CLI Uniquement]
Architecture : Le Harnais EST l'Enregistrement
Pour les outils CLI, le harnais Python s'exécute comme la commande qu'asciinema enregistre. Pas de tmux, pas d'attachement de panneau.
asciinema rec demo.cast --command "python test_demo.py --run-acts"
↑ le harnais s'exécute ici
→ le harnais affiche $ invite, exécute subprocess avec capture_output=False
→ asciinema capture tout stdout du processus
→ agg demo.cast demo.gif
# ✅ CORRECT — la sortie va vers le terminal, asciinema la capture
subprocess.run("mytool subcommand", shell=True, capture_output=False, env=DEMO_ENV)
# ❌ ERRONÉ pour les démos CLI — capture la sortie dans Python, asciinema ne voit rien
result = subprocess.run(["mytool", "subcommand"], capture_output=True)
Aides Principales [CLI Uniquement]
_TIMED = False # True uniquement dans --run-acts (mode enregistrement) ; _DEMO_WITH_TIMING aussi courant
def pause(seconds: float) -> None:
"""Ne fait rien dans pytest ; dort pendant l'enregistrement. Erreurs 1+2 : le timing ≠ l'espacement."""
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:
"""Affiche l'invite $ tapée, puis exécute la commande.
capture_output=False est CRITIQUE — la sortie doit aller vers le terminal.
"""
_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:
"""Séparateur de section visuel entre les actions.
3 retours à la ligne AVANT la barre = espace visuel de l'action précédente (changez ceci pour l'espacement).
Les durées de pause() = temps de lecture (changez séparément pour le timing).
Ce sont des boutons INDÉPENDANTS — ne les confondez pas.
bar_len = max(68, len(title) + 6) empêche les barres plus courtes que le titre.
"""
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()
Bannière d'Introduction [CLI Uniquement]
Pour les outils CLI, la bannière est une chaîne Python imprimée directement sur stdout :
def banner() -> None:
W = 68 # calcule le remplissage sur le texte PLAIN uniquement — pas de codes ANSI dans les calculs len()
def row(text: str = "", style: str = "") -> str:
content = (" " + text).ljust(W) # W caractères visibles ; pas d'ANSI dans la longueur
return f"\033[90m ║\033[0m{style}{content}\033[0m\033[90m║\033[0m"
# ❌ ERRONÉ — les codes ANSI augmentent len(), décalent le remplissage :
# bad = f"\033[1m{text}\033[0m".ljust(W)
lines = [
f"\033[90m ╔{'═'*W}╗\033[0m",
row("mytool — accroche ici", "\033[1;96m"),
row(),
row("Cette démo montre :", "\033[90m"),
row(" 1. Fonctionnalité un", "\033[90m"),
row(" 2. Fonctionnalité deux", "\033[90m"),
f"\033[90m ╚{'═'*W}╝\033[0m",
]
print("\n" + "\n".join(lines) + "\n")
Isolation de la Vie Privée [CLI Uniquement]
# DEMO_DATA_DIR : fixtures synthétiques commités (pas de données utilisateur réelles)
# TOOL_ISOLATION_VAR : variable d'environnement qui redirige les lectures de données de l'outil
# Exemples : 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)}
Rediriger les lectures de données ne suffit pas pour les commandes qui rapportent l'état d'installation (status, doctor, install, --version -v) : elles affichent aussi HOME, PATH, et les racines relatives à la configuration. Déplacez les trois et filtrez le cast décodé pour le home, login, hostname, et email de l'opérateur — détails dans references/common-pitfalls.md, section "Les commandes d'état d'installation divulguent la machine de l'opérateur".
Fixtures de Décalage Temporel [CLI Uniquement]
Requis lorsque les actions de démo utilisent --since Nd, --after DATE, ou tout filtre relatif au temps.
def create_dated_demo_dir() -> Path:
"""Copie DEMO_DATA_DIR dans un répertoire temporaire avec des timestamps décalés vers aujourd'hui.
Sans cela : les fixtures de mois passés → 0 résultats pour --since 3d.
Les fichiers fixture commités ne sont JAMAIS modifiés — seule la copie temporaire est décalée.
Adaptez _TS_RE et la logique de décalage au format de timestamp de votre outil.
"""
_TS_RE = re.compile(r'"timestamp":\s*"(\d{4}-\d{2}-\d{2}T[^"]+)"')
# trouver le timestamp max dans les fixtures, calculer le delta pour (aujourd'hui - 1 jour)
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
Enregistrement [CLI Uniquement]
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 pour les outils étroits
"--capture-env", "TERM,COLORTERM,TOOL_ISOLATION_VAR",
], env=record_env, check=True)
finally:
shutil.rmtree(dated_dir, ignore_errors=True)
Nettoyage de la Première Image [Les Deux]
Asciinema enregistre l'initialisation du shell (invite, variables d'environnement, fichiers RC) avant que le harnais ne démarre. Cela produit une première image encombrée avec des artefacts shell au lieu d'une bannière propre. Corrigez cela avec une coupe post-enregistrement qui trouve le marqueur de bannière, remonte jusqu'à l'échappement d'effacement d'écran précédent, supprime tous les événements antérieurs, et rebase les timestamps à t=0.
def trim_cast_to_banner(cast_file: Path, banner_marker: str = "mytool") -> None:
"""Coupe les événements d'init du shell pour que la bannière soit la première image visible.
Modifie cast_file sur place. Préserve la ligne d'en-tête JSON.
"""
lines = cast_file.read_text().splitlines()
if len(lines) < 2:
return
header = lines[0] # en-tête JSON — toujours ligne 1
events = lines[1:]
# Trouver le premier événement contenant le texte du marqueur de bannière.
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"[coupe] marqueur {banner_marker!r} non trouvé — coupe ignorée")
return
# Revenir à l'échappement d'effacement d'écran juste avant la bannière.
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
# Rebaser les timestamps : le premier événement conservé devient 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"[coupe] Supprimé {trimmed} événements avant la bannière (conservé {len(kept)})")
Appelez après l'enregistrement, avant la conversion : trim_cast_to_banner(CAST_FILE, banner_marker="mytool_name").
Pour les implémentations Rust et TypeScript, voir references/rust-demos.md et references/typescript-demos.md.
Écrivez d'Abord les Actions, puis les Tests Correspondants [CLI Uniquement]
Cause racine de la dérive test/démo : écrire les tests indépendamment des actions de démo.
✅ Flux de travail correct :
1. Finalisez run_demo_acts() — corrigez la chaîne de commande EXACTE pour chaque action
2. Écrivez TestDemoFree en utilisant CES MÊMES chaînes de commande, textuellement
3. N'ajoutez jamais --format, --limit, ou des flags supplémentaires aux tests qui ne sont pas dans la démo
❌ Flux de travail erroné :
action de démo : mytool messages search keyword --context 1
test : mytool messages search keyword --format plain --full-uuid
→ le test passe, la démo montre une sortie différente, le test ne prouve rien sur la démo
Règle : si un test a besoin d'un flag différent pour vérifier la sortie, soit (a) ajoutez ce flag à l'action de démo aussi, soit (b) vérifiez une propriété que la sortie réelle de la démo satisfait.
Vérification [CLI Uniquement]
Vérifiez que le texte attendu apparaît dans le fichier cast :
def verify_recording(cast_file: Path) -> bool:
content = cast_file.read_text()
checks = [
("Sessions :", "Action 1 : étiquette de statistiques présente"),
("authentification", "Action 4 : résultat de recherche montre le mot-clé"),
]
return all(fragment in content for fragment, _ in checks)
IDs de Fixtures Déterministes [CLI Uniquement]
# Les IDs suivent un modèle reconnaissable — faciles à repérer dans les enregistrements
_S1 = str(uuid.UUID("cafe0001-cafe-cafe-cafe-000000000001"))
# IDs d'appels d'outil stables entre les exécutions :
_id = hashlib.md5(f"{session_id}{timestamp}{path}".encode()).hexdigest()[:8]
Message de Clôture [CLI Uniquement]
# Lisez toujours la commande d'installation depuis pyproject.toml ou README — ne devinez jamais
sys.stdout.write(
"\033[1;32m ══════════════════════════════════════\033[0m\n"
"\033[1;32m ✓ Démo terminée — {nom de l'outil et accroche}\033[0m\n"
"\033[1;32m ══════════════════════════════════════\033[0m\n"
"\n"
" Installer : {commande exacte depuis pyproject.toml/README}\n"
"\n"
)
Voie TUI [TUI Uniquement]
Architecture : Pourquoi tmux + TUI Réel
La première version fonctionnelle d'une démo montre généralement une sortie simulée — le harnais appelle le hook directement et imprime ce que le hook dirait. C'est erroné pour deux raisons :
- Ce n'est pas ce que les utilisateurs voient. Les utilisateurs voient le TUI interactif complet : blocs d'appels d'outil, la réponse conversationnelle de l'IA, le message de bloc intégré dans cette réponse.
- Le hook peut ne pas avoir été déclenché du tout. C'est impossible à savoir sans enregistrer les appels d'outil réels.
Architecture correcte (TUI live) :
Python harness
→ crée une session tmux
→ asciinema s'attache : asciinema rec --command "tmux attach-session -t SESSION"
→ thread en arrière-plan : tmux send-keys "claude --dangerously-skip-permissions" Entrée
→ interroge la confiance/sécurité...
... [tronqué pour la traduction]