hexastack-cli 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hexastack_cli/__init__.py +6 -0
- hexastack_cli/adapters/__init__.py +13 -0
- hexastack_cli/adapters/app.py +77 -0
- hexastack_cli/adapters/presenter.py +137 -0
- hexastack_cli/adapters/routing.py +396 -0
- hexastack_cli/infra/__init__.py +29 -0
- hexastack_cli/infra/autodiscovery.py +215 -0
- hexastack_cli/infra/bootstrap.py +80 -0
- hexastack_cli/infra/config.py +43 -0
- hexastack_cli/infra/decorators.py +219 -0
- hexastack_cli/py.typed +0 -0
- hexastack_cli/testing/__init__.py +10 -0
- hexastack_cli/testing/narrator.py +192 -0
- hexastack_cli/testing/terminal.py +298 -0
- hexastack_cli-0.0.0.dist-info/METADATA +140 -0
- hexastack_cli-0.0.0.dist-info/RECORD +18 -0
- hexastack_cli-0.0.0.dist-info/WHEEL +4 -0
- hexastack_cli-0.0.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Terminal session testing and feature demo narration engine for CLI applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from typer.testing import CliRunner
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class TerminalEvent:
|
|
16
|
+
"""Represents a discrete timestamped event in a recorded terminal session."""
|
|
17
|
+
|
|
18
|
+
time_offset: float
|
|
19
|
+
event_type: str # "input", "output", "step"
|
|
20
|
+
payload: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CliNarrator:
|
|
24
|
+
"""Orchestrates CLI command execution, human-like typing emulation, and session recording.
|
|
25
|
+
|
|
26
|
+
Notes/Architectural Intent:
|
|
27
|
+
Provides a unified interface for testing CLI tools that:
|
|
28
|
+
1. In standard test / CI mode: Runs instantly via in-memory CliRunner.
|
|
29
|
+
2. In demo mode (RECORD_DEMO=1): Generates WebVTT narration subtitles
|
|
30
|
+
and directly renders a fixed-window, auto-scrolling .webm video via Playwright.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
app: Any,
|
|
36
|
+
output_name: str | None = None,
|
|
37
|
+
output_dir: Path | None = None,
|
|
38
|
+
width: int = 100,
|
|
39
|
+
height: int = 24,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Initialize CLI narrator attached to a Typer or Click application.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
app: Target Typer application instance.
|
|
45
|
+
output_name: Base filename (without extension) for saving recordings.
|
|
46
|
+
output_dir: Target output directory for generated demo artifacts.
|
|
47
|
+
width: Terminal column width.
|
|
48
|
+
height: Terminal row height.
|
|
49
|
+
"""
|
|
50
|
+
self.app = app
|
|
51
|
+
self.output_name = output_name
|
|
52
|
+
self.output_dir = output_dir or Path("docs/assets/demos")
|
|
53
|
+
self.width = width
|
|
54
|
+
self.height = height
|
|
55
|
+
self.record_mode = os.environ.get("RECORD_DEMO") in ("1", "true", "True")
|
|
56
|
+
self.runner = CliRunner()
|
|
57
|
+
self.start_time: float = time.time()
|
|
58
|
+
self.events: list[TerminalEvent] = []
|
|
59
|
+
self.captions: list[tuple[float, float, str]] = []
|
|
60
|
+
self._current_step_start: float | None = None
|
|
61
|
+
self._current_step_text: str | None = None
|
|
62
|
+
|
|
63
|
+
def step(self, caption: str) -> None:
|
|
64
|
+
"""Add a descriptive chapter step / subtitle narration for viewers.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
caption: Subtitle narration text explaining the current terminal action.
|
|
68
|
+
"""
|
|
69
|
+
now = time.time() - self.start_time
|
|
70
|
+
|
|
71
|
+
if self._current_step_start is not None and self._current_step_text is not None:
|
|
72
|
+
self.captions.append(
|
|
73
|
+
(self._current_step_start, now, self._current_step_text)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
self._current_step_start = now
|
|
77
|
+
self._current_step_text = caption
|
|
78
|
+
self.events.append(
|
|
79
|
+
TerminalEvent(time_offset=now, event_type="step", payload=caption)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def run_command(
|
|
83
|
+
self,
|
|
84
|
+
args: list[str],
|
|
85
|
+
caption: str | None = None,
|
|
86
|
+
input_text: str | None = None,
|
|
87
|
+
type_delay: float = 0.05,
|
|
88
|
+
) -> Any:
|
|
89
|
+
"""Execute a CLI command with simulated typing cadence and output recording.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
args: Command line arguments list (e.g. ["new", "web-api", "my-service"]).
|
|
93
|
+
caption: Optional narration step explaining this command.
|
|
94
|
+
input_text: Optional interactive stdin text to feed to the command.
|
|
95
|
+
type_delay: Seconds per keystroke when recording in demo mode.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
CliRunner invocation result.
|
|
99
|
+
"""
|
|
100
|
+
if caption:
|
|
101
|
+
self.step(caption)
|
|
102
|
+
|
|
103
|
+
cmd_str = "hexastack " + " ".join(args)
|
|
104
|
+
cmd_start = time.time() - self.start_time
|
|
105
|
+
|
|
106
|
+
if self.record_mode:
|
|
107
|
+
# Simulate human keystroke cadence in event timeline
|
|
108
|
+
sim_time = cmd_start
|
|
109
|
+
for char in f"$ {cmd_str}\n":
|
|
110
|
+
sim_time += type_delay
|
|
111
|
+
self.events.append(
|
|
112
|
+
TerminalEvent(
|
|
113
|
+
time_offset=sim_time, event_type="input", payload=char
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Execute command synchronously
|
|
118
|
+
result = self.runner.invoke(self.app, args, input=input_text)
|
|
119
|
+
out_time = time.time() - self.start_time
|
|
120
|
+
|
|
121
|
+
self.events.append(
|
|
122
|
+
TerminalEvent(
|
|
123
|
+
time_offset=out_time, event_type="output", payload=result.output
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
def finish(self) -> dict[str, Path]:
|
|
130
|
+
"""Finalize recording and export .webm and .vtt subtitle artifacts.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Dictionary containing written artifact paths.
|
|
134
|
+
"""
|
|
135
|
+
if not self.record_mode:
|
|
136
|
+
return {}
|
|
137
|
+
|
|
138
|
+
now = time.time() - self.start_time
|
|
139
|
+
if self._current_step_start is not None and self._current_step_text is not None:
|
|
140
|
+
self.captions.append(
|
|
141
|
+
(self._current_step_start, now, self._current_step_text)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
out_dir = self.output_dir
|
|
145
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
base_name = self.output_name or "cli-demo"
|
|
147
|
+
artifacts: dict[str, Path] = {}
|
|
148
|
+
|
|
149
|
+
# 1. Export WebVTT (.vtt) synchronized narration subtitles
|
|
150
|
+
vtt_lines = ["WEBVTT", ""]
|
|
151
|
+
for idx, (start_s, end_s, text) in enumerate(self.captions, start=1):
|
|
152
|
+
vtt_lines.append(str(idx))
|
|
153
|
+
vtt_lines.append(
|
|
154
|
+
f"{self._format_vtt_time(start_s)} --> {self._format_vtt_time(end_s)}"
|
|
155
|
+
)
|
|
156
|
+
vtt_lines.append(text)
|
|
157
|
+
vtt_lines.append("")
|
|
158
|
+
|
|
159
|
+
vtt_path = out_dir / f"{base_name}.vtt"
|
|
160
|
+
vtt_path.write_text("\n".join(vtt_lines), encoding="utf-8")
|
|
161
|
+
artifacts["vtt"] = vtt_path
|
|
162
|
+
|
|
163
|
+
# 2. Render directly to .webm video via Playwright Rich Terminal
|
|
164
|
+
try:
|
|
165
|
+
from hexastack_cli.testing.terminal import render_cli_demo_video
|
|
166
|
+
|
|
167
|
+
video_path = out_dir / f"{base_name}.webm"
|
|
168
|
+
render_cli_demo_video(
|
|
169
|
+
events=self.events,
|
|
170
|
+
output_path=video_path,
|
|
171
|
+
title=f"Hexastack CLI — {base_name}",
|
|
172
|
+
)
|
|
173
|
+
artifacts["webm"] = video_path
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
return artifacts
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _format_vtt_time(seconds: float) -> str:
|
|
181
|
+
"""Format seconds into WebVTT timestamp (HH:MM:SS.mmm)."""
|
|
182
|
+
hrs = int(seconds // 3600)
|
|
183
|
+
mins = int((seconds % 3600) // 60)
|
|
184
|
+
secs = int(seconds % 60)
|
|
185
|
+
millis = int(round((seconds - int(seconds)) * 1000))
|
|
186
|
+
return f"{hrs:02d}:{mins:02d}:{secs:02d}.{millis:03d}"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
__all__ = [
|
|
190
|
+
"CliNarrator",
|
|
191
|
+
"TerminalEvent",
|
|
192
|
+
]
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Rich HTML Terminal view and Playwright video renderer for CLI demo recordings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from hexastack_cli.testing.narrator import TerminalEvent
|
|
11
|
+
from hexastack_core.domain.exceptions import MissingDependencyError
|
|
12
|
+
|
|
13
|
+
TERMINAL_HTML_TEMPLATE = """<!DOCTYPE html>
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="UTF-8">
|
|
17
|
+
<title>{title}</title>
|
|
18
|
+
<style>
|
|
19
|
+
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
20
|
+
body {{
|
|
21
|
+
background: #0d1117;
|
|
22
|
+
color: #e6edf3;
|
|
23
|
+
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace;
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
justify-content: center;
|
|
27
|
+
height: 100vh;
|
|
28
|
+
overflow: hidden;
|
|
29
|
+
padding: 40px;
|
|
30
|
+
}}
|
|
31
|
+
.window {{
|
|
32
|
+
background: #161b22;
|
|
33
|
+
border-radius: 12px;
|
|
34
|
+
box-shadow: 0 20px 50px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.1);
|
|
35
|
+
width: 1080px;
|
|
36
|
+
height: 560px;
|
|
37
|
+
max-height: 560px;
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: column;
|
|
40
|
+
overflow: hidden;
|
|
41
|
+
}}
|
|
42
|
+
.titlebar {{
|
|
43
|
+
background: #21262d;
|
|
44
|
+
padding: 12px 16px;
|
|
45
|
+
display: flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
border-bottom: 1px solid rgba(255,255,255,0.08);
|
|
48
|
+
flex-shrink: 0;
|
|
49
|
+
}}
|
|
50
|
+
.dots {{
|
|
51
|
+
display: flex;
|
|
52
|
+
gap: 8px;
|
|
53
|
+
}}
|
|
54
|
+
.dot {{
|
|
55
|
+
width: 12px;
|
|
56
|
+
height: 12px;
|
|
57
|
+
border-radius: 50%;
|
|
58
|
+
}}
|
|
59
|
+
.dot-red {{ background: #ff5f56; }}
|
|
60
|
+
.dot-yellow {{ background: #ffbd2e; }}
|
|
61
|
+
.dot-green {{ background: #27c93f; }}
|
|
62
|
+
.title {{
|
|
63
|
+
flex: 1;
|
|
64
|
+
text-align: center;
|
|
65
|
+
color: #8b949e;
|
|
66
|
+
font-size: 13px;
|
|
67
|
+
font-weight: 500;
|
|
68
|
+
margin-right: 48px;
|
|
69
|
+
}}
|
|
70
|
+
.content {{
|
|
71
|
+
padding: 24px;
|
|
72
|
+
font-size: 15px;
|
|
73
|
+
line-height: 1.6;
|
|
74
|
+
flex: 1;
|
|
75
|
+
overflow-y: auto;
|
|
76
|
+
white-space: pre-wrap;
|
|
77
|
+
word-break: break-word;
|
|
78
|
+
scroll-behavior: smooth;
|
|
79
|
+
}}
|
|
80
|
+
.content::-webkit-scrollbar {{
|
|
81
|
+
display: none;
|
|
82
|
+
}}
|
|
83
|
+
.content {{
|
|
84
|
+
-ms-overflow-style: none;
|
|
85
|
+
scrollbar-width: none;
|
|
86
|
+
}}
|
|
87
|
+
.prompt {{ color: #7ee787; font-weight: bold; }}
|
|
88
|
+
.command {{ color: #79c0ff; font-weight: bold; }}
|
|
89
|
+
.step-banner {{
|
|
90
|
+
position: fixed;
|
|
91
|
+
bottom: 32px;
|
|
92
|
+
left: 50%;
|
|
93
|
+
transform: translateX(-50%);
|
|
94
|
+
background: rgba(15, 23, 42, 0.92);
|
|
95
|
+
color: #ffffff;
|
|
96
|
+
padding: 12px 24px;
|
|
97
|
+
border-radius: 8px;
|
|
98
|
+
font-size: 17px;
|
|
99
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
100
|
+
font-weight: 500;
|
|
101
|
+
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
|
|
102
|
+
border: 1px solid rgba(255,255,255,0.15);
|
|
103
|
+
backdrop-filter: blur(8px);
|
|
104
|
+
z-index: 9999;
|
|
105
|
+
transition: opacity 0.2s ease-in-out;
|
|
106
|
+
text-align: center;
|
|
107
|
+
max-width: 80%;
|
|
108
|
+
}}
|
|
109
|
+
.cursor {{
|
|
110
|
+
display: inline-block;
|
|
111
|
+
width: 8px;
|
|
112
|
+
height: 16px;
|
|
113
|
+
background: #58a6ff;
|
|
114
|
+
vertical-align: text-bottom;
|
|
115
|
+
animation: blink 1s step-end infinite;
|
|
116
|
+
}}
|
|
117
|
+
@keyframes blink {{ 50% {{ opacity: 0; }} }}
|
|
118
|
+
</style>
|
|
119
|
+
</head>
|
|
120
|
+
<body>
|
|
121
|
+
<div class="window">
|
|
122
|
+
<div class="titlebar">
|
|
123
|
+
<div class="dots">
|
|
124
|
+
<div class="dot dot-red"></div>
|
|
125
|
+
<div class="dot dot-yellow"></div>
|
|
126
|
+
<div class="dot dot-green"></div>
|
|
127
|
+
</div>
|
|
128
|
+
<div class="title">{title} — bash</div>
|
|
129
|
+
</div>
|
|
130
|
+
<div class="content" id="terminal-content"><span class="cursor"></span></div>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="step-banner" id="banner" style="display: none;"></div>
|
|
133
|
+
</body>
|
|
134
|
+
</html>
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _strip_ansi(text: str) -> str:
|
|
139
|
+
"""Remove ANSI color and escape sequences from text."""
|
|
140
|
+
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
141
|
+
return ansi_escape.sub("", text)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def render_cli_demo_video(
|
|
145
|
+
events: list[TerminalEvent],
|
|
146
|
+
output_path: Path,
|
|
147
|
+
title: str = "Hexastack CLI",
|
|
148
|
+
width: int = 1280,
|
|
149
|
+
height: int = 720,
|
|
150
|
+
) -> tuple[Path, Path]:
|
|
151
|
+
"""Render a sequence of TerminalEvents into synchronized .webm video and .vtt subtitle track.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
events: Chronological sequence of input, output, and step narrative events.
|
|
155
|
+
output_path: Destination Path for .webm video file.
|
|
156
|
+
title: Window title text for terminal banner.
|
|
157
|
+
width: Video frame width in pixels.
|
|
158
|
+
height: Video frame height in pixels.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Tuple of (video_path, vtt_path).
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
MissingDependencyError: If `playwright` is not installed.
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
from playwright.sync_api import sync_playwright
|
|
168
|
+
except ImportError as e:
|
|
169
|
+
raise MissingDependencyError(
|
|
170
|
+
"Playwright is required to record CLI demos into .webm video. "
|
|
171
|
+
"Install with 'pip install hexastack-cli[testing]' or 'pip install playwright'."
|
|
172
|
+
) from e
|
|
173
|
+
|
|
174
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
temp_dir = output_path.parent / ".temp_video"
|
|
176
|
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
|
|
178
|
+
html_content = TERMINAL_HTML_TEMPLATE.format(title=html.escape(title))
|
|
179
|
+
vtt_captions: list[tuple[float, float, str]] = []
|
|
180
|
+
current_step_start: float | None = None
|
|
181
|
+
current_step_text: str | None = None
|
|
182
|
+
|
|
183
|
+
with sync_playwright() as p:
|
|
184
|
+
browser = p.chromium.launch(headless=True)
|
|
185
|
+
context = browser.new_context(
|
|
186
|
+
viewport={"width": width, "height": height},
|
|
187
|
+
record_video_dir=str(temp_dir),
|
|
188
|
+
record_video_size={"width": width, "height": height},
|
|
189
|
+
)
|
|
190
|
+
page = context.new_page()
|
|
191
|
+
page.set_content(html_content)
|
|
192
|
+
page.wait_for_timeout(500)
|
|
193
|
+
start_playback_time = 0.5
|
|
194
|
+
|
|
195
|
+
current_time = start_playback_time
|
|
196
|
+
|
|
197
|
+
for ev in events:
|
|
198
|
+
if ev.event_type == "step":
|
|
199
|
+
if current_step_start is not None and current_step_text is not None:
|
|
200
|
+
vtt_captions.append(
|
|
201
|
+
(current_step_start, current_time, current_step_text)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
current_step_start = current_time
|
|
205
|
+
current_step_text = ev.payload
|
|
206
|
+
|
|
207
|
+
safe_text = html.escape(ev.payload).replace("'", "\\'")
|
|
208
|
+
page.evaluate(
|
|
209
|
+
f"""(() => {{
|
|
210
|
+
const banner = document.getElementById('banner');
|
|
211
|
+
if (banner) {{
|
|
212
|
+
banner.innerText = '{safe_text}';
|
|
213
|
+
banner.style.display = 'block';
|
|
214
|
+
}}
|
|
215
|
+
}})();"""
|
|
216
|
+
)
|
|
217
|
+
page.wait_for_timeout(600)
|
|
218
|
+
current_time += 0.6
|
|
219
|
+
|
|
220
|
+
elif ev.event_type == "input":
|
|
221
|
+
char_str = html.escape(ev.payload)
|
|
222
|
+
if char_str == "\n":
|
|
223
|
+
char_str = "<br/>"
|
|
224
|
+
page.evaluate(
|
|
225
|
+
f"""(() => {{
|
|
226
|
+
const terminal = document.getElementById('terminal-content');
|
|
227
|
+
const cursor = terminal.querySelector('.cursor');
|
|
228
|
+
const span = document.createElement('span');
|
|
229
|
+
span.className = 'command';
|
|
230
|
+
span.innerHTML = '{char_str}';
|
|
231
|
+
terminal.insertBefore(span, cursor);
|
|
232
|
+
terminal.scrollTop = terminal.scrollHeight;
|
|
233
|
+
}})();"""
|
|
234
|
+
)
|
|
235
|
+
page.wait_for_timeout(35)
|
|
236
|
+
current_time += 0.035
|
|
237
|
+
|
|
238
|
+
elif ev.event_type == "output":
|
|
239
|
+
clean_text = _strip_ansi(ev.payload)
|
|
240
|
+
escaped_output = html.escape(clean_text).replace("\n", "<br/>")
|
|
241
|
+
page.evaluate(
|
|
242
|
+
f"""(() => {{
|
|
243
|
+
const terminal = document.getElementById('terminal-content');
|
|
244
|
+
const cursor = terminal.querySelector('.cursor');
|
|
245
|
+
const div = document.createElement('div');
|
|
246
|
+
div.style.color = '#c9d1d9';
|
|
247
|
+
div.style.margin = '4px 0 12px 0';
|
|
248
|
+
div.innerHTML = '{escaped_output}';
|
|
249
|
+
terminal.insertBefore(div, cursor);
|
|
250
|
+
terminal.scrollTop = terminal.scrollHeight;
|
|
251
|
+
}})();"""
|
|
252
|
+
)
|
|
253
|
+
page.wait_for_timeout(1000)
|
|
254
|
+
current_time += 1.0
|
|
255
|
+
|
|
256
|
+
if current_step_start is not None and current_step_text is not None:
|
|
257
|
+
vtt_captions.append(
|
|
258
|
+
(current_step_start, current_time + 1.5, current_step_text)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
page.wait_for_timeout(1500)
|
|
262
|
+
video = page.video
|
|
263
|
+
page.close()
|
|
264
|
+
context.close()
|
|
265
|
+
browser.close()
|
|
266
|
+
|
|
267
|
+
if video:
|
|
268
|
+
video_src = Path(video.path())
|
|
269
|
+
shutil.copy2(video_src, output_path)
|
|
270
|
+
|
|
271
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
272
|
+
|
|
273
|
+
# Export synchronized WebVTT subtitles with true video timestamps
|
|
274
|
+
vtt_lines = ["WEBVTT", ""]
|
|
275
|
+
for idx, (start_s, end_s, text) in enumerate(vtt_captions, start=1):
|
|
276
|
+
vtt_lines.append(str(idx))
|
|
277
|
+
vtt_lines.append(f"{_format_vtt_time(start_s)} --> {_format_vtt_time(end_s)}")
|
|
278
|
+
vtt_lines.append(text)
|
|
279
|
+
vtt_lines.append("")
|
|
280
|
+
|
|
281
|
+
vtt_path = output_path.with_suffix(".vtt")
|
|
282
|
+
vtt_path.write_text("\n".join(vtt_lines), encoding="utf-8")
|
|
283
|
+
|
|
284
|
+
return output_path, vtt_path
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _format_vtt_time(seconds: float) -> str:
|
|
288
|
+
"""Format seconds into WebVTT timestamp (HH:MM:SS.mmm)."""
|
|
289
|
+
hrs = int(seconds // 3600)
|
|
290
|
+
mins = int((seconds % 3600) // 60)
|
|
291
|
+
secs = int(seconds % 60)
|
|
292
|
+
millis = int(round((seconds - int(seconds)) * 1000))
|
|
293
|
+
return f"{hrs:02d}:{mins:02d}:{secs:02d}.{millis:03d}"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
__all__ = [
|
|
297
|
+
"render_cli_demo_video",
|
|
298
|
+
]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: hexastack-cli
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Hexastack CLI presentation adapter with Typer and Rich
|
|
5
|
+
Author: Richard West
|
|
6
|
+
Author-email: Richard West <dopplereffect.us@gmail.com>
|
|
7
|
+
Requires-Dist: hexastack-core
|
|
8
|
+
Requires-Dist: hexastack-cqrs
|
|
9
|
+
Requires-Dist: rich>=13.8.0
|
|
10
|
+
Requires-Dist: typer>=0.27.1
|
|
11
|
+
Requires-Dist: playwright>=1.49.0 ; extra == 'testing'
|
|
12
|
+
Requires-Python: >=3.13
|
|
13
|
+
Provides-Extra: testing
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# hexastack-cli
|
|
17
|
+
|
|
18
|
+
> Typer and Rich presentation adapter for Hexastack: nested commands, aliases, piped outputs, and CQRS dispatching.
|
|
19
|
+
|
|
20
|
+
[](https://www.python.org/downloads/)
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 1. Overview & Capabilities
|
|
25
|
+
|
|
26
|
+
`hexastack-cli` turns Hexastack CQRS commands and queries into intuitive, modern CLI applications:
|
|
27
|
+
|
|
28
|
+
- **Nested Command Hierarchies**: Nest subcommands naturally (`app user create`, `app db migrate`) using `@cli_group`.
|
|
29
|
+
- **Command Aliases**: Register multiple aliases for the same action (`app user new == app user create`).
|
|
30
|
+
- **Feature Flag Gating**: Gate CLI commands dynamically with `@feature_flag_command(...)` and `@cli_command(..., feature_flag=...)`.
|
|
31
|
+
- **Rich Formatted & CI-Friendly Output**: Beautiful tables, panels, and spinners for interactive terminals; clean text/JSON streaming for CI/CD pipelines.
|
|
32
|
+
- **Direct CQRS Dispatching**: Declaratively expose domain commands (`@cli_command`) and queries (`@cli_query`) with automatic parameter parsing and validation.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 2. Package Anatomy & Key Components
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
hexastack_cli/
|
|
40
|
+
├── domain/ # CliContext, OutputFormat enum
|
|
41
|
+
├── adapters/ # create_cli_app, Rich presenters, Typer command runners
|
|
42
|
+
└── infra/ # CliBootstrapper (order=30), @cli_command, @cli_query, @cli_group, @feature_flag_command
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Key Exports
|
|
46
|
+
|
|
47
|
+
| Category | Exports |
|
|
48
|
+
|---|---|
|
|
49
|
+
| **Application Factory** | `create_cli_app`, `CliBootstrapper` (order=30) |
|
|
50
|
+
| **Decorators** | `@cli_command`, `@cli_query`, `@cli_group`, `@feature_flag_command` |
|
|
51
|
+
| **Presenters** | `RichTerminalPresenter`, `ConsolePresenter`, `TablePresenter`, `JsonPresenter` |
|
|
52
|
+
| **Testing & Demo Narration** | `CliNarrator`, `TerminalEvent` |
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 3. Monorepo & Sibling Relationships
|
|
57
|
+
|
|
58
|
+
```mermaid
|
|
59
|
+
graph TD
|
|
60
|
+
subgraph UserInvocation ["CLI Invocations"]
|
|
61
|
+
INV["Terminal Commands & Scripts"]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
subgraph CliLayer ["hexastack-cli"]
|
|
65
|
+
TYPER["Typer Application (Nested Groups & Aliases)"]
|
|
66
|
+
RICH["Rich Presenters (Tables, Panels, JSON)"]
|
|
67
|
+
SCAN["CLI Decorator Scanner (@cli_command, @cli_query)"]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
subgraph CQRSExecution ["hexastack-cqrs"]
|
|
71
|
+
CBUS["CommandBusPort"]
|
|
72
|
+
QBUS["QueryBusPort"]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
subgraph Kernel ["hexastack-core"]
|
|
76
|
+
DI["rodi.Container"]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
INV --> TYPER
|
|
80
|
+
TYPER --> SCAN
|
|
81
|
+
SCAN -->|dispatches to| CBUS
|
|
82
|
+
SCAN -->|dispatches to| QBUS
|
|
83
|
+
SCAN --> RICH
|
|
84
|
+
|
|
85
|
+
TYPER -. resolves buses from DI .-> DI
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Explicit Dependencies (Direct)
|
|
89
|
+
- `hexastack-core`: Core kernel, DI container, and ports.
|
|
90
|
+
- `hexastack-cqrs`: `CommandBusPort` and `QueryBusPort` for message dispatching.
|
|
91
|
+
- `typer>=0.27.1`: CLI command parser and shell completion.
|
|
92
|
+
- `rich>=15.0.0`: Terminal formatting, tables, and colors.
|
|
93
|
+
|
|
94
|
+
### Implied / Behavioral Relationships (DI-Mediated)
|
|
95
|
+
- **CQRS Integration**: Dispatches CLI argument payloads directly into the application's command and query buses.
|
|
96
|
+
- **Umbrella CLI**: Consumed by the `hexastack` umbrella package to power diagnostic commands (`hexastack info`, `hexastack inspect registry`, `hexastack demo ping`).
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 4. Installation
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# Standalone install
|
|
104
|
+
pip install hexastack-cli
|
|
105
|
+
|
|
106
|
+
# Via umbrella package
|
|
107
|
+
pip install "hexastack[cli]"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 5. Quickstart Example
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from dataclasses import dataclass
|
|
116
|
+
from hexastack_core.infra.bootstrap import bootstrap
|
|
117
|
+
from hexastack_cqrs.domain.query import Query
|
|
118
|
+
from hexastack_cqrs.infra.decorators import query_handler
|
|
119
|
+
from hexastack_cli.infra.decorators import cli_query, cli_group
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class CheckStatusQuery(Query):
|
|
124
|
+
service_name: str
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@query_handler(CheckStatusQuery)
|
|
128
|
+
class CheckStatusHandler:
|
|
129
|
+
def __call__(self, qry: CheckStatusQuery) -> dict:
|
|
130
|
+
return {"service": qry.service_name, "status": "ONLINE"}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# Expose query as a CLI command
|
|
134
|
+
cli_query("status", aliases=["st", "health"], help="Check status of a service")(
|
|
135
|
+
CheckStatusQuery
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
runtime = bootstrap(packages_to_scan=[__name__])
|
|
139
|
+
cli_app = runtime.get("cli_app")
|
|
140
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
hexastack_cli/__init__.py,sha256=BxoY0Y5o6VtjzlPkspHmgZYaPuFAKVmA5hwnpg93CMk,86
|
|
2
|
+
hexastack_cli/adapters/__init__.py,sha256=JzoxVVKhUjuRmAJO5MK0mrwLwTKXl9xO7zLfohk22Cw,342
|
|
3
|
+
hexastack_cli/adapters/app.py,sha256=BuS5pjqlg3Fv_6K90TFvADXtwfDfVSak8R-2oIzKJPg,2576
|
|
4
|
+
hexastack_cli/adapters/presenter.py,sha256=3TRwSSxLgEhFVvcDRu5VogHoC-kVAgdHDEAD3p4u9vo,4419
|
|
5
|
+
hexastack_cli/adapters/routing.py,sha256=v02J5KpyqYuAEdjG38Gr-pxLxloWQle3oa-DZnObbLk,13394
|
|
6
|
+
hexastack_cli/infra/__init__.py,sha256=m4BGkzq6_9zbmhBn3Qwvxtp56J7-i1haflleg5vauKA,628
|
|
7
|
+
hexastack_cli/infra/autodiscovery.py,sha256=Mr-YLrEaf38_zQcOgjVJlDtLbX7bt-bjQODbxW5X0ng,6848
|
|
8
|
+
hexastack_cli/infra/bootstrap.py,sha256=99tD1hdnNWRDyLNwQJ9ogBdJgpGIV9OPzDFVde_d_Q4,2475
|
|
9
|
+
hexastack_cli/infra/config.py,sha256=e79HN42O_fnBvsHEKhsET5deWHAZrPJPABmMQrO9940,1235
|
|
10
|
+
hexastack_cli/infra/decorators.py,sha256=fmU2Svga9GdaoOyVZLXOrUCCtbdmVbLq0gISrwcph30,7167
|
|
11
|
+
hexastack_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
hexastack_cli/testing/__init__.py,sha256=zB7VmUX7T0Hpuyv9O1Qfj4PkgfuE06DLklnahkWl1LQ,295
|
|
13
|
+
hexastack_cli/testing/narrator.py,sha256=dXInuCxPzUFSyo0o0ME9x9xRYsRvBHdNbMv3OCOanS8,6542
|
|
14
|
+
hexastack_cli/testing/terminal.py,sha256=QOHhLtRw6bk27gKji5DDNQKCb0sUxwVMpSxIEXpU0EE,10248
|
|
15
|
+
hexastack_cli-0.0.0.dist-info/WHEEL,sha256=EmLkUISDECbcUx3FMCYOqokNOJqNp2r0d4mJzjErvvs,80
|
|
16
|
+
hexastack_cli-0.0.0.dist-info/entry_points.txt,sha256=LPpUygBzbXJ0_5IXxaesAbJjJ5Vmp8wQQOtgfbY5Yj8,79
|
|
17
|
+
hexastack_cli-0.0.0.dist-info/METADATA,sha256=Id59pQZbPtZ_Ya4igL7J1vSjOqR26y_JnJR3Jz1kEqg,4529
|
|
18
|
+
hexastack_cli-0.0.0.dist-info/RECORD,,
|