Skip to content

Commit 9f39ab8

Browse files
committed
feat: add asciinema demo generator with PS1 timing, comment step, and validation
- demo/generate.py: YAML-to-asciinema cast generator with cmd, prompt, pause, and comment step types; hierarchical PS1 resolution; validation pre-pass - demo/demo.yaml: scripted demo for devcode CLI (open, template, ps commands) - demo/demo.gif: generated demo GIF via agg - demo/Makefile: downloads agg + JetBrains Mono font, generates cast and GIF - tests/test_generate.py: 76 tests covering all step types, PS1 timing, validation
1 parent 46e34f9 commit 9f39ab8

File tree

10 files changed

+1088
-0
lines changed

10 files changed

+1088
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,4 @@ poetry.toml
174174
pyrightconfig.json
175175

176176
# End of https://www.toptal.com/developers/gitignore/api/python
177+

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Reusable Dev Containers for any project — without modifying the repository.
1919

2020
---
2121

22+
![Alt text](demo/demo.gif)
23+
2224

2325
`devcode` is a CLI that opens any project in VS Code Dev Containers using reusable, local templates.
2426

demo/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.fonts
2+
.agg
3+
demo.cast

demo/Makefile

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
CAST := demo.cast
2+
GIF := demo.gif
3+
AGG := .agg
4+
FONTS_DIR := .fonts
5+
6+
# Detect platform for agg binary
7+
UNAME_S := $(shell uname -s)
8+
UNAME_M := $(shell uname -m)
9+
10+
ifeq ($(UNAME_S),Linux)
11+
ifeq ($(UNAME_M),x86_64)
12+
AGG_ASSET := agg-x86_64-unknown-linux-gnu
13+
else ifeq ($(UNAME_M),aarch64)
14+
AGG_ASSET := agg-aarch64-unknown-linux-gnu
15+
else
16+
$(error Unsupported Linux architecture: $(UNAME_M))
17+
endif
18+
else ifeq ($(UNAME_S),Darwin)
19+
ifeq ($(UNAME_M),arm64)
20+
AGG_ASSET := agg-aarch64-apple-darwin
21+
else
22+
AGG_ASSET := agg-x86_64-apple-darwin
23+
endif
24+
else
25+
$(error Unsupported OS: $(UNAME_S))
26+
endif
27+
28+
AGG_URL := https://github.com/asciinema/agg/releases/latest/download/$(AGG_ASSET)
29+
FONT_URL := https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip
30+
31+
.PHONY: all gif cast clean
32+
33+
all: gif
34+
35+
gif: $(GIF)
36+
37+
$(GIF): $(CAST) $(AGG) $(FONTS_DIR)
38+
./$(AGG) --font-dir $(FONTS_DIR) --renderer fontdue --font-family "JetBrains Mono" --rows 20 --cols 80 $(CAST) $(GIF)
39+
40+
$(CAST): demo.yaml generate.py
41+
uv run python generate.py
42+
43+
$(AGG):
44+
curl -fsSL -o $(AGG) $(AGG_URL)
45+
chmod +x $(AGG)
46+
47+
$(FONTS_DIR):
48+
mkdir -p $(FONTS_DIR)
49+
curl -fsSL -o $(FONTS_DIR)/JetBrainsMono.zip $(FONT_URL)
50+
unzip -q -o $(FONTS_DIR)/JetBrainsMono.zip "fonts/ttf/*.ttf" -d $(FONTS_DIR)
51+
mv $(FONTS_DIR)/fonts/ttf/*.ttf $(FONTS_DIR)/
52+
rm -rf $(FONTS_DIR)/fonts $(FONTS_DIR)/JetBrainsMono.zip
53+
54+
clean:
55+
rm -f $(GIF) $(CAST) $(AGG)
56+
rm -rf $(FONTS_DIR)

demo/demo.gif

384 KB
Loading

demo/demo.yaml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
config:
2+
width: 120
3+
height: 30
4+
title: "devcode demo"
5+
ps1: "\x1b[92m❯ \x1b[0m"
6+
typing_delay: 0.06
7+
post_cmd_delay: 1.2
8+
comment_color: "\x1b[93m"
9+
between_demos:
10+
delay: 2.0
11+
clear: true
12+
13+
demos:
14+
- name: "Open a project"
15+
steps:
16+
- comment: "Create a new template"
17+
- cmd: "devcode new dev"
18+
output: |
19+
Created template 'dev-test' at ~/.local/share/dev-code/templates/dev
20+
21+
- comment: "Open a project in VS Code using the template"
22+
- cmd: "devcode open dev ~/projects/my-app"
23+
output: "\x1b[32m Opening ~/projects/my-app in VS Code...\x1b[0m\n"
24+
25+
- name: "Manage templates"
26+
steps:
27+
- comment: "List existing templates"
28+
- cmd: "devcode list"
29+
output: |
30+
dev-code
31+
32+
- comment: "Create another template"
33+
- cmd: "devcode new demo"
34+
output: |
35+
Created template 'demo' at ~/.local/share/dev-code/templates/demo
36+
37+
- comment: "List templates again to confirm"
38+
- cmd: "devcode list"
39+
output: |
40+
dev-code
41+
demo
42+
43+
- comment: "Edit the template in VS Code"
44+
- cmd: "devcode edit demo"
45+
output: "\x1b[32m Opening 'demo' template in VS Code...\x1b[0m\n"
46+
47+
- name: "Container status"
48+
steps:
49+
- comment: "Show running containers"
50+
- cmd: "devcode ps"
51+
output: |
52+
# CONTAINER ID TEMPLATE PROJECT PATH STATUS
53+
1 a1b9afa16218 dev ~/projects/my-app Up 3 min
54+
55+
- comment: "Show all containers including stopped ones"
56+
- cmd: "devcode ps -a"
57+
output: |
58+
# CONTAINER ID TEMPLATE PROJECT PATH STATUS
59+
1 a1b2c3d4e5f6 claude ~/projects/mk3serve Exited (0) 2 weeks ago
60+
2 9f8e7d6c5b4a py-dev ~/projects/py-app Exited (0) 1 hours ago
61+
3 a1b9afa16218 dev ~/projects/my-app Up 3 min
62+
63+
- comment: "Interactively pick a container to reopen"
64+
- cmd: "devcode ps -a -i"
65+
output: |
66+
# CONTAINER ID TEMPLATE PROJECT PATH STATUS
67+
1 a1b2c3d4e5f6 claude ~/projects/mk3serve Exited (0) 2 weeks ago
68+
2 9f8e7d6c5b4a py-dev ~/projects/py-app Exited (0) 1 hours ago
69+
3 a1b9afa16218 dev ~/projects/my-app Up 3 min
70+
71+
Open [1-3]:
72+
73+
- prompt: "2"
74+
output: "\x1b[32m Opening '~/projects/py-app' container in VS Code...\x1b[0m\n"

demo/generate.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
#!/usr/bin/env python3
2+
"""Generate an asciinema v2 .cast file from a YAML demo script."""
3+
4+
import json
5+
import sys
6+
from pathlib import Path
7+
8+
import yaml
9+
10+
_HERE = Path(__file__).parent
11+
12+
13+
def _validate(demos: list) -> None:
14+
"""Validate step sequences across all demos before generation begins.
15+
16+
Raises ValueError if any prompt step does not immediately follow a cmd step,
17+
or if any comment step has a null value.
18+
"""
19+
for demo in demos:
20+
steps = demo.get("steps", [])
21+
name = demo.get("name", "?")
22+
for i, step in enumerate(steps):
23+
if "comment" in step and step["comment"] is None:
24+
raise ValueError(
25+
f"Demo '{name}' step {i}: 'comment' value must not be null"
26+
)
27+
if "prompt" in step:
28+
prev = steps[i - 1] if i > 0 else {}
29+
if i == 0 or "cmd" not in prev:
30+
prev_key = list(prev.keys())[0] if prev else "none"
31+
raise ValueError(
32+
f"Demo '{name}' step {i}: 'prompt' must immediately follow 'cmd' "
33+
f"(preceding step has key '{prev_key}')"
34+
)
35+
36+
37+
def _next_ps1(steps: list, from_idx: int, demo_ps1: str | None, global_ps1: str, fallback_step: dict) -> str:
38+
"""Return the resolved PS1 for the next cmd/comment step at or after from_idx.
39+
40+
Scans forward, skipping pause and prompt steps.
41+
Falls back to fallback_step's resolved PS1 if no cmd/comment found.
42+
"""
43+
for step in steps[from_idx:]:
44+
if "cmd" in step or "comment" in step:
45+
return next(v for v in (step.get("ps1"), demo_ps1, global_ps1) if v is not None)
46+
return next(v for v in (fallback_step.get("ps1"), demo_ps1, global_ps1) if v is not None)
47+
48+
49+
def generate(config: dict, demos: list, output_path: str | Path) -> None:
50+
"""Generate a .cast file from config and demos.
51+
52+
Args:
53+
config: dict with keys: width, height, title, ps1,
54+
typing_delay, post_cmd_delay, between_demos{delay, clear},
55+
comment_color
56+
demos: list of {name, ps1?, steps} where each step is
57+
{cmd, output?, ps1?}, {prompt, output?}, {pause}, or {comment, ps1?}
58+
output_path: path to write the .cast file
59+
"""
60+
width = config["width"]
61+
height = config["height"]
62+
title = config["title"]
63+
global_ps1 = config["ps1"]
64+
typing_delay = config["typing_delay"]
65+
post_cmd_delay = config["post_cmd_delay"]
66+
between_delay = config["between_demos"]["delay"]
67+
between_clear = config["between_demos"]["clear"]
68+
comment_color = config["comment_color"]
69+
70+
_validate(demos)
71+
72+
events = []
73+
t = 0.0 # cumulative timestamp
74+
75+
def emit(text: str) -> None:
76+
events.append([round(t, 6), "o", text])
77+
78+
for demo_idx, demo in enumerate(demos):
79+
steps = demo["steps"]
80+
demo_ps1 = demo.get("ps1")
81+
prev_cmd_step: dict | None = None
82+
83+
# Demo-start PS1: find first cmd/comment step and emit its PS1
84+
for step in steps:
85+
if "cmd" in step or "comment" in step:
86+
emit(next(v for v in (step.get("ps1"), demo_ps1, global_ps1) if v is not None))
87+
break
88+
89+
for step_idx, step in enumerate(steps):
90+
if "cmd" in step:
91+
prev_cmd_step = step
92+
for ch in step["cmd"]:
93+
t += typing_delay
94+
emit(ch)
95+
emit("\r\n")
96+
raw_output = (step.get("output", "") or "").replace("\\x1b", "\x1b")
97+
lines = raw_output.rstrip("\n").splitlines()
98+
next_step = steps[step_idx + 1] if step_idx + 1 < len(steps) else None
99+
next_is_prompt = next_step is not None and "prompt" in next_step
100+
for line_idx, line in enumerate(lines):
101+
is_last = line_idx == len(lines) - 1
102+
if is_last and next_is_prompt:
103+
emit(line)
104+
else:
105+
emit(line + "\r\n")
106+
if not next_is_prompt:
107+
emit(_next_ps1(steps, step_idx + 1, demo_ps1, global_ps1, step))
108+
t += post_cmd_delay
109+
110+
elif "prompt" in step:
111+
for ch in step["prompt"]:
112+
t += typing_delay
113+
emit(ch)
114+
emit("\r\n")
115+
raw_output = (step.get("output", "") or "").replace("\\x1b", "\x1b")
116+
lines = raw_output.rstrip("\n").splitlines()
117+
for line in lines:
118+
emit(line + "\r\n")
119+
fallback = prev_cmd_step if prev_cmd_step is not None else step
120+
emit(_next_ps1(steps, step_idx + 1, demo_ps1, global_ps1, fallback))
121+
t += post_cmd_delay
122+
123+
elif "pause" in step:
124+
t += step["pause"]
125+
126+
elif "comment" in step:
127+
text = "# " + step["comment"]
128+
emit(comment_color)
129+
for ch in text:
130+
t += typing_delay
131+
emit(ch)
132+
emit("\x1b[0m")
133+
emit("\r\n")
134+
emit(_next_ps1(steps, step_idx + 1, demo_ps1, global_ps1, step))
135+
t += post_cmd_delay
136+
137+
else:
138+
keys = list(step.keys())
139+
raise ValueError(
140+
f"Unrecognized step (keys: {keys}) in demo '{demo['name']}'. "
141+
f"Steps must have 'cmd', 'prompt', 'pause', or 'comment'."
142+
)
143+
144+
if demo_idx < len(demos) - 1:
145+
t += between_delay
146+
if between_clear:
147+
emit("\x1b[2J\x1b[H")
148+
149+
header = {
150+
"version": 2,
151+
"width": width,
152+
"height": height,
153+
"title": title,
154+
}
155+
156+
with open(output_path, "w", encoding="utf-8") as f:
157+
f.write(json.dumps(header) + "\n")
158+
for event in events:
159+
f.write(json.dumps(event) + "\n")
160+
161+
162+
def main() -> None:
163+
yaml_path = _HERE / "demo.yaml"
164+
cast_path = _HERE / "demo.cast"
165+
166+
try:
167+
with open(yaml_path, encoding="utf-8") as f:
168+
script = yaml.safe_load(f)
169+
except FileNotFoundError:
170+
print(f"Error: demo script not found: {yaml_path}", file=sys.stderr)
171+
sys.exit(1)
172+
except yaml.YAMLError as e:
173+
print(f"Error: failed to parse {yaml_path}: {e}", file=sys.stderr)
174+
sys.exit(1)
175+
176+
generate(script["config"], script["demos"], str(cast_path))
177+
print(f"Written: {cast_path}")
178+
179+
180+
if __name__ == "__main__":
181+
main()

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@ dev = [
3636
"pyfiglet>=1.0.4",
3737
"pytest-cov>=4.0",
3838
"tox>=4.0",
39+
"pyyaml>=6.0",
3940
]

0 commit comments

Comments
 (0)