hexastack-cli 0.9.0__tar.gz → 0.10.1__tar.gz
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-0.9.0 → hexastack_cli-0.10.1}/PKG-INFO +5 -4
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/README.md +3 -2
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/pyproject.toml +3 -3
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/pyproject.toml.orig +3 -3
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/adapters/routing.py +6 -2
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/autodiscovery.py +35 -11
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/decorators.py +15 -2
- hexastack_cli-0.9.0/src/hexastack_cli/testing/__init__.py +0 -9
- hexastack_cli-0.9.0/src/hexastack_cli/testing/events.py +0 -16
- hexastack_cli-0.9.0/src/hexastack_cli/testing/narrator.py +0 -323
- hexastack_cli-0.9.0/src/hexastack_cli/testing/terminal.py +0 -329
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/__init__.py +0 -0
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/adapters/__init__.py +0 -0
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/adapters/presenter.py +0 -0
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/adapters/suggest.py +0 -0
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/domain/__init__.py +0 -0
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/domain/config.py +0 -0
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/domain/options.py +0 -0
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/__init__.py +0 -0
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/app.py +0 -0
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/bootstrap.py +0 -0
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/config.py +0 -0
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/options.py +0 -0
- {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hexastack-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.1
|
|
4
4
|
Summary: Hexastack CLI presentation adapter with Typer and Rich
|
|
5
5
|
Author: Richard West
|
|
6
6
|
Author-email: Richard West <dopplereffect.us@gmail.com>
|
|
@@ -11,7 +11,7 @@ Requires-Dist: questionary>=2.1.0
|
|
|
11
11
|
Requires-Dist: rich>=13.8.0
|
|
12
12
|
Requires-Dist: typer>=0.27.1
|
|
13
13
|
Requires-Dist: playwright>=1.49.0 ; extra == 'testing'
|
|
14
|
-
Requires-Python: >=3.
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
15
|
Provides-Extra: testing
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
|
|
@@ -51,7 +51,6 @@ Description-Content-Type: text/markdown
|
|
|
51
51
|
hexastack_cli/
|
|
52
52
|
├── domain/ # CliContext, OutputFormat enum
|
|
53
53
|
├── adapters/ # Rich presenters, Typer command runners, TyperSuggest
|
|
54
|
-
├── testing/ # CliNarrator, TerminalEvent (demo video & VTT recorder)
|
|
55
54
|
└── infra/ # create_cli_app, CliBootstrapper (order=40), @cli_command, @cli_query, @cli_group, @feature_flag_command
|
|
56
55
|
```
|
|
57
56
|
|
|
@@ -189,8 +188,10 @@ In automated CI environments or redirected streams (`not sys.stdin.isatty()`), `
|
|
|
189
188
|
|
|
190
189
|
## 7. Automated Demo Recording & Video Slicing (`CliNarrator`)
|
|
191
190
|
|
|
191
|
+
> Automated narrative terminal recording and Playwright demo video rendering is provided by [`hexaqual.adapters.testing`](https://github.com/TheTrueSCU/hexaqual).
|
|
192
|
+
|
|
192
193
|
```python
|
|
193
|
-
from
|
|
194
|
+
from hexaqual.adapters.testing import CliNarrator
|
|
194
195
|
|
|
195
196
|
narrator = CliNarrator(record_video=True)
|
|
196
197
|
narrator.say("Scaffolding microservice...")
|
|
@@ -34,7 +34,6 @@
|
|
|
34
34
|
hexastack_cli/
|
|
35
35
|
├── domain/ # CliContext, OutputFormat enum
|
|
36
36
|
├── adapters/ # Rich presenters, Typer command runners, TyperSuggest
|
|
37
|
-
├── testing/ # CliNarrator, TerminalEvent (demo video & VTT recorder)
|
|
38
37
|
└── infra/ # create_cli_app, CliBootstrapper (order=40), @cli_command, @cli_query, @cli_group, @feature_flag_command
|
|
39
38
|
```
|
|
40
39
|
|
|
@@ -172,8 +171,10 @@ In automated CI environments or redirected streams (`not sys.stdin.isatty()`), `
|
|
|
172
171
|
|
|
173
172
|
## 7. Automated Demo Recording & Video Slicing (`CliNarrator`)
|
|
174
173
|
|
|
174
|
+
> Automated narrative terminal recording and Playwright demo video rendering is provided by [`hexaqual.adapters.testing`](https://github.com/TheTrueSCU/hexaqual).
|
|
175
|
+
|
|
175
176
|
```python
|
|
176
|
-
from
|
|
177
|
+
from hexaqual.adapters.testing import CliNarrator
|
|
177
178
|
|
|
178
179
|
narrator = CliNarrator(record_video=True)
|
|
179
180
|
narrator.say("Scaffolding microservice...")
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hexastack-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.10.1"
|
|
4
4
|
description = "Hexastack CLI presentation adapter with Typer and Rich"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
7
|
-
requires-python = ">=3.
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
8
|
dependencies = [
|
|
9
9
|
"hexastack-core",
|
|
10
10
|
"hexastack-cqrs",
|
|
@@ -24,7 +24,7 @@ testing = ["playwright>=1.49.0"]
|
|
|
24
24
|
cli = "hexastack_cli.infra.bootstrap:CliBootstrapper"
|
|
25
25
|
|
|
26
26
|
[build-system]
|
|
27
|
-
requires = ["uv_build>=0.12.3,<0.
|
|
27
|
+
requires = ["uv_build>=0.12.3,<0.14.0"]
|
|
28
28
|
build-backend = "uv_build"
|
|
29
29
|
|
|
30
30
|
[tool.uv.sources.hexastack-core]
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hexastack-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.10.1"
|
|
4
4
|
description = "Hexastack CLI presentation adapter with Typer and Rich"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
7
7
|
authors = [
|
|
8
8
|
{ name = "Richard West", email = "dopplereffect.us@gmail.com" }
|
|
9
9
|
]
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
11
|
dependencies = [
|
|
12
12
|
"hexastack-core",
|
|
13
13
|
"hexastack-cqrs",
|
|
@@ -25,7 +25,7 @@ testing = [
|
|
|
25
25
|
cli = "hexastack_cli.infra.bootstrap:CliBootstrapper"
|
|
26
26
|
|
|
27
27
|
[build-system]
|
|
28
|
-
requires = ["uv_build>=0.12.3,<0.
|
|
28
|
+
requires = ["uv_build>=0.12.3,<0.14.0"]
|
|
29
29
|
build-backend = "uv_build"
|
|
30
30
|
|
|
31
31
|
[tool.uv.sources]
|
|
@@ -307,6 +307,7 @@ def register_cqrs_command(
|
|
|
307
307
|
presenter: RichTerminalPresenter | None = None,
|
|
308
308
|
console: Console | None = None,
|
|
309
309
|
feature_flag: str | None = None,
|
|
310
|
+
hidden: bool = False,
|
|
310
311
|
) -> None:
|
|
311
312
|
"""Register a CQRS Command model as a typed CLI sub-command on a Typer application.
|
|
312
313
|
|
|
@@ -325,6 +326,7 @@ def register_cqrs_command(
|
|
|
325
326
|
presenter: Optional RichTerminalPresenter instance.
|
|
326
327
|
console: Optional rich Console instance.
|
|
327
328
|
feature_flag: Optional feature flag key required for execution.
|
|
329
|
+
hidden: Whether to hide the command from CLI help documentation.
|
|
328
330
|
|
|
329
331
|
Returns:
|
|
330
332
|
None.
|
|
@@ -343,7 +345,7 @@ def register_cqrs_command(
|
|
|
343
345
|
console=console,
|
|
344
346
|
feature_flag=feature_flag,
|
|
345
347
|
)
|
|
346
|
-
app.command(name=cmd_name, help=doc)(runner)
|
|
348
|
+
app.command(name=cmd_name, help=doc, hidden=hidden)(runner)
|
|
347
349
|
|
|
348
350
|
|
|
349
351
|
def register_cqrs_query(
|
|
@@ -357,6 +359,7 @@ def register_cqrs_query(
|
|
|
357
359
|
presenter: RichTerminalPresenter | None = None,
|
|
358
360
|
console: Console | None = None,
|
|
359
361
|
feature_flag: str | None = None,
|
|
362
|
+
hidden: bool = False,
|
|
360
363
|
) -> None:
|
|
361
364
|
"""Register a CQRS Query model as a typed CLI sub-command on a Typer application.
|
|
362
365
|
|
|
@@ -375,6 +378,7 @@ def register_cqrs_query(
|
|
|
375
378
|
presenter: Optional RichTerminalPresenter instance.
|
|
376
379
|
console: Optional rich Console instance.
|
|
377
380
|
feature_flag: Optional feature flag key required for execution.
|
|
381
|
+
hidden: Whether to hide the command from CLI help documentation.
|
|
378
382
|
|
|
379
383
|
Returns:
|
|
380
384
|
None.
|
|
@@ -393,4 +397,4 @@ def register_cqrs_query(
|
|
|
393
397
|
console=console,
|
|
394
398
|
feature_flag=feature_flag,
|
|
395
399
|
)
|
|
396
|
-
app.command(name=qry_name, help=doc)(runner)
|
|
400
|
+
app.command(name=qry_name, help=doc, hidden=hidden)(runner)
|
|
@@ -92,6 +92,7 @@ def _register_model_target(
|
|
|
92
92
|
meta: CliMetadata,
|
|
93
93
|
cmd_name: str,
|
|
94
94
|
help_text: str | None,
|
|
95
|
+
hidden: bool,
|
|
95
96
|
pipeline: ExecutionPipeline,
|
|
96
97
|
console: Console | None,
|
|
97
98
|
) -> None:
|
|
@@ -107,6 +108,7 @@ def _register_model_target(
|
|
|
107
108
|
output_format=meta.output_format,
|
|
108
109
|
console=console,
|
|
109
110
|
feature_flag=meta.feature_flag,
|
|
111
|
+
hidden=hidden,
|
|
110
112
|
)
|
|
111
113
|
elif meta.kind == "query" and issubclass(obj, Query):
|
|
112
114
|
register_cqrs_query(
|
|
@@ -119,9 +121,29 @@ def _register_model_target(
|
|
|
119
121
|
output_format=meta.output_format,
|
|
120
122
|
console=console,
|
|
121
123
|
feature_flag=meta.feature_flag,
|
|
124
|
+
hidden=hidden,
|
|
122
125
|
)
|
|
123
126
|
|
|
124
127
|
|
|
128
|
+
def _resolve_alias_targets(
|
|
129
|
+
group: str | Sequence[str] | None,
|
|
130
|
+
aliases: Sequence[str],
|
|
131
|
+
help_text: str | None,
|
|
132
|
+
) -> list[tuple[list[str], str, str | None]]:
|
|
133
|
+
"""Resolve (group_parts, cmd_name, help) targets for a sequence of aliases."""
|
|
134
|
+
primary_group = _normalize_group_path(group)
|
|
135
|
+
targets: list[tuple[list[str], str, str | None]] = []
|
|
136
|
+
for alias in aliases:
|
|
137
|
+
if alias.startswith("/"):
|
|
138
|
+
targets.append(([], alias.lstrip("/"), help_text))
|
|
139
|
+
elif any(sep in alias for sep in (".", "/", " ")):
|
|
140
|
+
parts = _normalize_group_path(alias)
|
|
141
|
+
targets.append((parts[:-1], parts[-1], help_text))
|
|
142
|
+
else:
|
|
143
|
+
targets.append((primary_group, alias, help_text))
|
|
144
|
+
return targets
|
|
145
|
+
|
|
146
|
+
|
|
125
147
|
def _resolve_targets(
|
|
126
148
|
meta: CliMetadata, default_name: str
|
|
127
149
|
) -> list[tuple[list[str], str, str | None]]:
|
|
@@ -131,16 +153,7 @@ def _resolve_targets(
|
|
|
131
153
|
targets: list[tuple[list[str], str, str | None]] = [
|
|
132
154
|
(primary_group, primary_name, meta.help)
|
|
133
155
|
]
|
|
134
|
-
|
|
135
|
-
for alias in meta.aliases:
|
|
136
|
-
if alias.startswith("/"):
|
|
137
|
-
targets.append(([], alias.lstrip("/"), meta.help))
|
|
138
|
-
elif any(sep in alias for sep in (".", "/", " ")):
|
|
139
|
-
parts = _normalize_group_path(alias)
|
|
140
|
-
targets.append((parts[:-1], parts[-1], meta.help))
|
|
141
|
-
else:
|
|
142
|
-
targets.append((primary_group, alias, meta.help))
|
|
143
|
-
|
|
156
|
+
targets.extend(_resolve_alias_targets(meta.group, meta.aliases, meta.help))
|
|
144
157
|
return targets
|
|
145
158
|
|
|
146
159
|
|
|
@@ -205,11 +218,22 @@ def create_cli_visitor(
|
|
|
205
218
|
|
|
206
219
|
default_name = _to_kebab_case(obj.__name__)
|
|
207
220
|
targets = _resolve_targets(meta, default_name)
|
|
221
|
+
hidden = getattr(meta, "hidden", False)
|
|
208
222
|
|
|
209
223
|
for group_parts, cmd_name, help_text in targets:
|
|
210
224
|
target_app = manager.get_or_create(group_parts)
|
|
211
225
|
_register_model_target(
|
|
212
|
-
target_app, obj, meta, cmd_name, help_text, pipeline, console
|
|
226
|
+
target_app, obj, meta, cmd_name, help_text, hidden, pipeline, console
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if meta.hidden_aliases:
|
|
230
|
+
hidden_targets = _resolve_alias_targets(
|
|
231
|
+
meta.group, meta.hidden_aliases, meta.help
|
|
213
232
|
)
|
|
233
|
+
for group_parts, cmd_name, help_text in hidden_targets:
|
|
234
|
+
target_app = manager.get_or_create(group_parts)
|
|
235
|
+
_register_model_target(
|
|
236
|
+
target_app, obj, meta, cmd_name, help_text, True, pipeline, console
|
|
237
|
+
)
|
|
214
238
|
|
|
215
239
|
return visitor
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from collections.abc import Callable, Sequence
|
|
2
2
|
from dataclasses import dataclass, field
|
|
3
|
+
from functools import wraps
|
|
3
4
|
from typing import Any, Literal, TypeVar
|
|
4
5
|
|
|
5
6
|
from hexastack_core.domain import Command, Query
|
|
@@ -28,6 +29,8 @@ class CliMetadata:
|
|
|
28
29
|
output_format: str | None = None
|
|
29
30
|
group: str | Sequence[str] | None = None
|
|
30
31
|
feature_flag: str | None = None
|
|
32
|
+
hidden: bool = False
|
|
33
|
+
hidden_aliases: tuple[str, ...] = field(default_factory=tuple)
|
|
31
34
|
|
|
32
35
|
|
|
33
36
|
@dataclass(frozen=True)
|
|
@@ -66,10 +69,12 @@ def cli_command(
|
|
|
66
69
|
*,
|
|
67
70
|
positional: Sequence[str] | str | None = None,
|
|
68
71
|
aliases: Sequence[str] | str | None = None,
|
|
72
|
+
hidden_aliases: Sequence[str] | str | None = None,
|
|
69
73
|
help: str | None = None,
|
|
70
74
|
output_format: str | None = None,
|
|
71
75
|
group: str | Sequence[str] | None = None,
|
|
72
76
|
feature_flag: str | None = None,
|
|
77
|
+
hidden: bool = False,
|
|
73
78
|
) -> Callable[[type[TCommand]], type[TCommand]]:
|
|
74
79
|
"""Decorator marking a Command class for automatic CLI command exposure.
|
|
75
80
|
|
|
@@ -77,10 +82,12 @@ def cli_command(
|
|
|
77
82
|
name: Optional custom CLI command name (defaults to kebab-cased class name).
|
|
78
83
|
positional: Optional field name or list of field names to treat as positional CLI arguments.
|
|
79
84
|
aliases: Optional alias or list of aliases (can be intra-group like 'create' or cross-group like 'account.new').
|
|
85
|
+
hidden_aliases: Optional alias or list of aliases to register with hidden=True.
|
|
80
86
|
help: Optional help text for the command.
|
|
81
87
|
output_format: Optional presenter output format.
|
|
82
88
|
group: Optional sub-command group name or nested group path (e.g. 'user', 'user.profile').
|
|
83
89
|
feature_flag: Optional feature flag key required to execute the command.
|
|
90
|
+
hidden: Whether to hide the command from terminal --help displays.
|
|
84
91
|
|
|
85
92
|
Returns:
|
|
86
93
|
Decorated Command class with attached CLI metadata.
|
|
@@ -95,10 +102,12 @@ def cli_command(
|
|
|
95
102
|
name=name,
|
|
96
103
|
positional=_normalize_tokens(positional),
|
|
97
104
|
aliases=_normalize_tokens(aliases),
|
|
105
|
+
hidden_aliases=_normalize_tokens(hidden_aliases),
|
|
98
106
|
help=help,
|
|
99
107
|
output_format=output_format,
|
|
100
108
|
group=group,
|
|
101
109
|
feature_flag=feature_flag,
|
|
110
|
+
hidden=hidden,
|
|
102
111
|
)
|
|
103
112
|
setattr(cls, _CLI_METADATA_ATTR, meta)
|
|
104
113
|
return cls
|
|
@@ -137,10 +146,12 @@ def cli_query(
|
|
|
137
146
|
*,
|
|
138
147
|
positional: Sequence[str] | str | None = None,
|
|
139
148
|
aliases: Sequence[str] | str | None = None,
|
|
149
|
+
hidden_aliases: Sequence[str] | str | None = None,
|
|
140
150
|
help: str | None = None,
|
|
141
151
|
output_format: str | None = None,
|
|
142
152
|
group: str | Sequence[str] | None = None,
|
|
143
153
|
feature_flag: str | None = None,
|
|
154
|
+
hidden: bool = False,
|
|
144
155
|
) -> Callable[[type[TQuery]], type[TQuery]]:
|
|
145
156
|
"""Decorator marking a Query class for automatic CLI command exposure.
|
|
146
157
|
|
|
@@ -148,10 +159,12 @@ def cli_query(
|
|
|
148
159
|
name: Optional custom CLI command name (defaults to kebab-cased class name).
|
|
149
160
|
positional: Optional field name or list of field names to treat as positional CLI arguments.
|
|
150
161
|
aliases: Optional alias or list of aliases (can be intra-group like 'find' or cross-group like 'search.user').
|
|
162
|
+
hidden_aliases: Optional alias or list of aliases to register with hidden=True.
|
|
151
163
|
help: Optional help text for the command.
|
|
152
164
|
output_format: Optional presenter output format.
|
|
153
165
|
group: Optional sub-command group name or nested group path (e.g. 'user', 'user.profile').
|
|
154
166
|
feature_flag: Optional feature flag key required to execute the command.
|
|
167
|
+
hidden: Whether to hide the query command from terminal --help displays.
|
|
155
168
|
|
|
156
169
|
Returns:
|
|
157
170
|
Decorated Query class with attached CLI metadata.
|
|
@@ -166,10 +179,12 @@ def cli_query(
|
|
|
166
179
|
name=name,
|
|
167
180
|
positional=_normalize_tokens(positional),
|
|
168
181
|
aliases=_normalize_tokens(aliases),
|
|
182
|
+
hidden_aliases=_normalize_tokens(hidden_aliases),
|
|
169
183
|
help=help,
|
|
170
184
|
output_format=output_format,
|
|
171
185
|
group=group,
|
|
172
186
|
feature_flag=feature_flag,
|
|
187
|
+
hidden=hidden,
|
|
173
188
|
)
|
|
174
189
|
setattr(cls, _CLI_METADATA_ATTR, meta)
|
|
175
190
|
return cls
|
|
@@ -195,8 +210,6 @@ def feature_flag_command(
|
|
|
195
210
|
Returns:
|
|
196
211
|
Decorator wrapping the target callable.
|
|
197
212
|
"""
|
|
198
|
-
from functools import wraps
|
|
199
|
-
|
|
200
213
|
import typer
|
|
201
214
|
|
|
202
215
|
from hexastack_core.adapters.feature_flags.config import ConfigFeatureFlagAdapter
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
"""Domain models and data contracts for CLI testing and narrative demonstration."""
|
|
2
|
-
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
|
|
5
|
-
__all__ = [
|
|
6
|
-
"TerminalEvent",
|
|
7
|
-
]
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclass(frozen=True, slots=True)
|
|
11
|
-
class TerminalEvent:
|
|
12
|
-
"""A discrete timestamped event within a CLI demo recording session."""
|
|
13
|
-
|
|
14
|
-
event_type: str # "step" | "input" | "output"
|
|
15
|
-
payload: str
|
|
16
|
-
timestamp: float
|
|
@@ -1,323 +0,0 @@
|
|
|
1
|
-
"""Feature demo narrator and Playwright terminal video recorder for CLI commands."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import asyncio
|
|
6
|
-
import os
|
|
7
|
-
import time
|
|
8
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from typing import Any
|
|
11
|
-
|
|
12
|
-
from typer.testing import CliRunner, Result
|
|
13
|
-
|
|
14
|
-
from hexastack_cli.testing.events import TerminalEvent
|
|
15
|
-
from hexastack_core.utils.fs import atomic_write_text
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class CliNarrator:
|
|
19
|
-
"""Orchestrates structured narrative CLI execution, step recording, and video rendering.
|
|
20
|
-
|
|
21
|
-
Notes/Architectural Intent:
|
|
22
|
-
1. In standard CI/unit test mode, runs instantly with zero delay via in-memory CliRunner.
|
|
23
|
-
2. In demo recording mode (`RECORD_DEMO=1`), captures keystrokes, output tokens, and narrative steps,
|
|
24
|
-
and directly renders a fixed-window, auto-scrolling .webm video via Playwright.
|
|
25
|
-
"""
|
|
26
|
-
|
|
27
|
-
def __init__(
|
|
28
|
-
self,
|
|
29
|
-
app: Any,
|
|
30
|
-
output_name: str | None = None,
|
|
31
|
-
output_dir: Path | None = None,
|
|
32
|
-
width: int = 100,
|
|
33
|
-
height: int = 24,
|
|
34
|
-
bin_name: str | None = None,
|
|
35
|
-
) -> None:
|
|
36
|
-
"""Initialize CLI narrator attached to a Typer or Click application.
|
|
37
|
-
|
|
38
|
-
Args:
|
|
39
|
-
app: Target Typer application instance.
|
|
40
|
-
output_name: Base filename (without extension) for saving recordings.
|
|
41
|
-
output_dir: Target output directory for generated demo artifacts.
|
|
42
|
-
width: Terminal column width.
|
|
43
|
-
height: Terminal row height.
|
|
44
|
-
bin_name: Display binary name for recorded command lines (defaults to app name or 'hexastack').
|
|
45
|
-
|
|
46
|
-
Notes/Architectural Intent:
|
|
47
|
-
Configurable bin_name allows narrating commands from across the hexa- family of tools
|
|
48
|
-
(e.g., hexaflow, hexaqual, hq) with accurate terminal prompts in generated demo videos.
|
|
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
|
-
app_name = getattr(getattr(app, "info", None), "name", None)
|
|
56
|
-
self.bin_name = bin_name or app_name or "hexastack"
|
|
57
|
-
self.record_mode = os.environ.get("RECORD_DEMO") in ("1", "true", "True")
|
|
58
|
-
self.runner = CliRunner()
|
|
59
|
-
self.start_time: float = time.time()
|
|
60
|
-
self.events: list[TerminalEvent] = []
|
|
61
|
-
self.captions: list[tuple[float, float, str]] = []
|
|
62
|
-
self._current_step_start: float | None = None
|
|
63
|
-
self._current_step_text: str | None = None
|
|
64
|
-
self._segment_count: int = 0
|
|
65
|
-
self.segments: dict[str, dict[str, Path]] = {}
|
|
66
|
-
|
|
67
|
-
def step(self, title: str, *, record: bool = True) -> None:
|
|
68
|
-
"""Mark a new narrative step in the CLI demo recording.
|
|
69
|
-
|
|
70
|
-
Args:
|
|
71
|
-
title: Human-readable narrative explanation shown in subtitle banner.
|
|
72
|
-
record: Whether to emit the step subtitle and recording marker (defaults to True).
|
|
73
|
-
"""
|
|
74
|
-
if not record:
|
|
75
|
-
return
|
|
76
|
-
|
|
77
|
-
now = time.time() - self.start_time
|
|
78
|
-
if self._current_step_start is not None and self._current_step_text is not None:
|
|
79
|
-
self.captions.append(
|
|
80
|
-
(self._current_step_start, now, self._current_step_text)
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
self._current_step_start = now
|
|
84
|
-
self._current_step_text = title
|
|
85
|
-
|
|
86
|
-
if self.record_mode:
|
|
87
|
-
self.events.append(
|
|
88
|
-
TerminalEvent(event_type="step", payload=title, timestamp=now)
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
def run_command(
|
|
92
|
-
self,
|
|
93
|
-
args: list[str],
|
|
94
|
-
env: dict[str, str] | None = None,
|
|
95
|
-
input: str | None = None,
|
|
96
|
-
record: bool = True,
|
|
97
|
-
) -> Result:
|
|
98
|
-
"""Execute a CLI command within the active Typer application.
|
|
99
|
-
|
|
100
|
-
Args:
|
|
101
|
-
args: Command-line argument strings.
|
|
102
|
-
env: Optional environment variables.
|
|
103
|
-
input: Optional stdin input string.
|
|
104
|
-
record: Whether to record this command invocation and output (defaults to True).
|
|
105
|
-
|
|
106
|
-
Returns:
|
|
107
|
-
CliRunner Result containing exit code, stdout, and stderr.
|
|
108
|
-
"""
|
|
109
|
-
cmd_str = f"{self.bin_name} {' '.join(args)}"
|
|
110
|
-
|
|
111
|
-
if self.record_mode and record:
|
|
112
|
-
# 1. Record input command string
|
|
113
|
-
now = time.time() - self.start_time
|
|
114
|
-
self.events.append(
|
|
115
|
-
TerminalEvent(
|
|
116
|
-
event_type="input",
|
|
117
|
-
payload=f"$ {cmd_str}\n",
|
|
118
|
-
timestamp=now,
|
|
119
|
-
)
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
# Execute command in-memory
|
|
123
|
-
# If an event loop is running in the active thread, offload execution to a thread pool
|
|
124
|
-
# to ensure commands invoking asyncio.run() (e.g. Hexaflow simulate/run) succeed without RuntimeError.
|
|
125
|
-
try:
|
|
126
|
-
asyncio.get_running_loop()
|
|
127
|
-
is_loop_running = True
|
|
128
|
-
except RuntimeError:
|
|
129
|
-
is_loop_running = False
|
|
130
|
-
|
|
131
|
-
if is_loop_running:
|
|
132
|
-
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
133
|
-
res = pool.submit(
|
|
134
|
-
self.runner.invoke, self.app, args, input=input, env=env
|
|
135
|
-
).result()
|
|
136
|
-
else:
|
|
137
|
-
res = self.runner.invoke(self.app, args, input=input, env=env)
|
|
138
|
-
|
|
139
|
-
if self.record_mode and record:
|
|
140
|
-
# 2. Record command output text
|
|
141
|
-
now = time.time() - self.start_time
|
|
142
|
-
output_text = res.stdout if res.stdout else ""
|
|
143
|
-
if output_text:
|
|
144
|
-
self.events.append(
|
|
145
|
-
TerminalEvent(
|
|
146
|
-
event_type="output",
|
|
147
|
-
payload=output_text,
|
|
148
|
-
timestamp=now,
|
|
149
|
-
)
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
return res
|
|
153
|
-
|
|
154
|
-
def checkpoint_segment(self, name: str | None = None) -> dict[str, Path]:
|
|
155
|
-
"""Flush the current recording segment to video and subtitle artifacts.
|
|
156
|
-
|
|
157
|
-
Slices recorded terminal events and narration captions since the last checkpoint,
|
|
158
|
-
renders the segment video (.webm) and WebVTT subtitle track (.vtt), and resets
|
|
159
|
-
the segment event buffer while preserving the underlying execution state
|
|
160
|
-
(Typer application context, filesystem, and databases).
|
|
161
|
-
|
|
162
|
-
Args:
|
|
163
|
-
name: Suffix or name for this segment (e.g. 'part1-scaffolding'). If omitted,
|
|
164
|
-
defaults to 'segment-<N>'.
|
|
165
|
-
|
|
166
|
-
Returns:
|
|
167
|
-
Dictionary containing paths to generated segment artifacts ('webm', 'vtt').
|
|
168
|
-
|
|
169
|
-
Notes/Architectural Intent:
|
|
170
|
-
Enables dogfooding multi-step tutorials (e.g. Hexaflow stages) to produce digestible
|
|
171
|
-
micro-demo videos per stage without incurring the overhead of running separate,
|
|
172
|
-
disconnected test harnesses.
|
|
173
|
-
"""
|
|
174
|
-
self._segment_count += 1
|
|
175
|
-
if name:
|
|
176
|
-
segment_name = (
|
|
177
|
-
f"{self.output_name}-{name}"
|
|
178
|
-
if self.output_name and not name.startswith(f"{self.output_name}-")
|
|
179
|
-
else name
|
|
180
|
-
)
|
|
181
|
-
else:
|
|
182
|
-
base = self.output_name or "cli-demo"
|
|
183
|
-
segment_name = f"{base}-segment-{self._segment_count}"
|
|
184
|
-
|
|
185
|
-
if not self.record_mode:
|
|
186
|
-
self.events = []
|
|
187
|
-
self.captions = []
|
|
188
|
-
self.start_time = time.time()
|
|
189
|
-
self._current_step_start = None
|
|
190
|
-
self._current_step_text = None
|
|
191
|
-
return {}
|
|
192
|
-
|
|
193
|
-
now = time.time() - self.start_time
|
|
194
|
-
if self._current_step_start is not None and self._current_step_text is not None:
|
|
195
|
-
self.captions.append(
|
|
196
|
-
(self._current_step_start, now, self._current_step_text)
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
out_dir = self.output_dir
|
|
200
|
-
out_dir.mkdir(parents=True, exist_ok=True)
|
|
201
|
-
artifacts: dict[str, Path] = {}
|
|
202
|
-
|
|
203
|
-
# 1. Normalize segment event timestamps so the segment video begins at 0.0s
|
|
204
|
-
if self.events:
|
|
205
|
-
base_ts = self.events[0].timestamp
|
|
206
|
-
segment_events = [
|
|
207
|
-
TerminalEvent(
|
|
208
|
-
event_type=e.event_type,
|
|
209
|
-
payload=e.payload,
|
|
210
|
-
timestamp=max(0.0, e.timestamp - base_ts),
|
|
211
|
-
)
|
|
212
|
-
for e in self.events
|
|
213
|
-
]
|
|
214
|
-
else:
|
|
215
|
-
segment_events = []
|
|
216
|
-
|
|
217
|
-
# 2. Render segment video and true-timing VTT via Playwright Rich Terminal
|
|
218
|
-
from hexastack_cli.testing.terminal import render_cli_demo_video
|
|
219
|
-
|
|
220
|
-
video_path = out_dir / f"{segment_name}.webm"
|
|
221
|
-
video_file, vtt_file = render_cli_demo_video(
|
|
222
|
-
events=segment_events,
|
|
223
|
-
output_path=video_path,
|
|
224
|
-
title=f"{self.bin_name} CLI — {segment_name}",
|
|
225
|
-
)
|
|
226
|
-
artifacts["webm"] = video_file
|
|
227
|
-
artifacts["vtt"] = vtt_file
|
|
228
|
-
self.segments[segment_name] = artifacts
|
|
229
|
-
|
|
230
|
-
# 3. Reset segment event buffer and start time for the next segment
|
|
231
|
-
self.events = []
|
|
232
|
-
self.captions = []
|
|
233
|
-
self.start_time = time.time()
|
|
234
|
-
self._current_step_start = None
|
|
235
|
-
self._current_step_text = None
|
|
236
|
-
|
|
237
|
-
return artifacts
|
|
238
|
-
|
|
239
|
-
def finish(self) -> dict[str, Path]:
|
|
240
|
-
"""Finalize the recording and render video and caption artifacts.
|
|
241
|
-
|
|
242
|
-
Returns:
|
|
243
|
-
Dictionary containing paths to generated artifact files (e.g. 'webm', 'vtt').
|
|
244
|
-
"""
|
|
245
|
-
if not self.record_mode:
|
|
246
|
-
return {}
|
|
247
|
-
|
|
248
|
-
if self.events:
|
|
249
|
-
if self._segment_count > 0:
|
|
250
|
-
self.checkpoint_segment("final")
|
|
251
|
-
all_artifacts: dict[str, Path] = {}
|
|
252
|
-
for seg_name, arts in self.segments.items():
|
|
253
|
-
for k, v in arts.items():
|
|
254
|
-
all_artifacts[f"{seg_name}_{k}"] = v
|
|
255
|
-
if self.segments:
|
|
256
|
-
latest_seg = list(self.segments.values())[-1]
|
|
257
|
-
all_artifacts.update(latest_seg)
|
|
258
|
-
return all_artifacts
|
|
259
|
-
|
|
260
|
-
now = time.time() - self.start_time
|
|
261
|
-
if (
|
|
262
|
-
self._current_step_start is not None
|
|
263
|
-
and self._current_step_text is not None
|
|
264
|
-
):
|
|
265
|
-
self.captions.append(
|
|
266
|
-
(self._current_step_start, now, self._current_step_text)
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
out_dir = self.output_dir
|
|
270
|
-
out_dir.mkdir(parents=True, exist_ok=True)
|
|
271
|
-
base_name = self.output_name or "cli-demo"
|
|
272
|
-
artifacts: dict[str, Path] = {}
|
|
273
|
-
|
|
274
|
-
# 1. Export WebVTT (.vtt) synchronized narration subtitles
|
|
275
|
-
vtt_lines = ["WEBVTT", ""]
|
|
276
|
-
for idx, (start_s, end_s, text) in enumerate(self.captions, start=1):
|
|
277
|
-
vtt_lines.append(str(idx))
|
|
278
|
-
vtt_lines.append(
|
|
279
|
-
f"{self._format_vtt_time(start_s)} --> {self._format_vtt_time(end_s)}"
|
|
280
|
-
)
|
|
281
|
-
vtt_lines.append(text)
|
|
282
|
-
vtt_lines.append("")
|
|
283
|
-
|
|
284
|
-
vtt_path = out_dir / f"{base_name}.vtt"
|
|
285
|
-
atomic_write_text(vtt_path, "\n".join(vtt_lines), encoding="utf-8")
|
|
286
|
-
artifacts["vtt"] = vtt_path
|
|
287
|
-
|
|
288
|
-
# 2. Render directly to .webm video via Playwright Rich Terminal
|
|
289
|
-
from hexastack_cli.testing.terminal import render_cli_demo_video
|
|
290
|
-
|
|
291
|
-
video_path = out_dir / f"{base_name}.webm"
|
|
292
|
-
render_cli_demo_video(
|
|
293
|
-
events=self.events,
|
|
294
|
-
output_path=video_path,
|
|
295
|
-
title=f"{self.bin_name} CLI — {base_name}",
|
|
296
|
-
)
|
|
297
|
-
artifacts["webm"] = video_path
|
|
298
|
-
|
|
299
|
-
return artifacts
|
|
300
|
-
|
|
301
|
-
all_artifacts: dict[str, Path] = {}
|
|
302
|
-
for seg_name, arts in self.segments.items():
|
|
303
|
-
for k, v in arts.items():
|
|
304
|
-
all_artifacts[f"{seg_name}_{k}"] = v
|
|
305
|
-
if self.segments:
|
|
306
|
-
latest_seg = list(self.segments.values())[-1]
|
|
307
|
-
all_artifacts.update(latest_seg)
|
|
308
|
-
return all_artifacts
|
|
309
|
-
|
|
310
|
-
@staticmethod
|
|
311
|
-
def _format_vtt_time(seconds: float) -> str:
|
|
312
|
-
"""Format seconds float into standard WebVTT timestamp (HH:MM:SS.mmm)."""
|
|
313
|
-
hrs = int(seconds // 3600)
|
|
314
|
-
mins = int((seconds % 3600) // 60)
|
|
315
|
-
secs = int(seconds % 60)
|
|
316
|
-
millis = int(round((seconds - int(seconds)) * 1000))
|
|
317
|
-
return f"{hrs:02d}:{mins:02d}:{secs:02d}.{millis:03d}"
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
__all__ = [
|
|
321
|
-
"CliNarrator",
|
|
322
|
-
"TerminalEvent",
|
|
323
|
-
]
|
|
@@ -1,329 +0,0 @@
|
|
|
1
|
-
"""Rich HTML Terminal view and Playwright video renderer for CLI demo recordings."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import concurrent.futures
|
|
6
|
-
import html
|
|
7
|
-
import json
|
|
8
|
-
import re
|
|
9
|
-
import shutil
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
from hexastack_cli.testing.events import TerminalEvent
|
|
13
|
-
from hexastack_core.domain.exceptions import MissingDependencyError
|
|
14
|
-
from hexastack_core.utils.fs import atomic_write_text
|
|
15
|
-
|
|
16
|
-
TERMINAL_HTML_TEMPLATE = """<!DOCTYPE html>
|
|
17
|
-
<html lang="en">
|
|
18
|
-
<head>
|
|
19
|
-
<meta charset="UTF-8">
|
|
20
|
-
<title>{title}</title>
|
|
21
|
-
<style>
|
|
22
|
-
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
23
|
-
body {{
|
|
24
|
-
background: #0d1117;
|
|
25
|
-
color: #e6edf3;
|
|
26
|
-
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace;
|
|
27
|
-
display: flex;
|
|
28
|
-
align-items: center;
|
|
29
|
-
justify-content: center;
|
|
30
|
-
height: 100vh;
|
|
31
|
-
overflow: hidden;
|
|
32
|
-
padding: 40px;
|
|
33
|
-
}}
|
|
34
|
-
.window {{
|
|
35
|
-
background: #161b22;
|
|
36
|
-
border-radius: 12px;
|
|
37
|
-
box-shadow: 0 20px 50px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.1);
|
|
38
|
-
width: 1080px;
|
|
39
|
-
height: 560px;
|
|
40
|
-
max-height: 560px;
|
|
41
|
-
display: flex;
|
|
42
|
-
flex-direction: column;
|
|
43
|
-
overflow: hidden;
|
|
44
|
-
}}
|
|
45
|
-
.titlebar {{
|
|
46
|
-
background: #21262d;
|
|
47
|
-
padding: 12px 16px;
|
|
48
|
-
display: flex;
|
|
49
|
-
align-items: center;
|
|
50
|
-
border-bottom: 1px solid rgba(255,255,255,0.08);
|
|
51
|
-
flex-shrink: 0;
|
|
52
|
-
}}
|
|
53
|
-
.dots {{
|
|
54
|
-
display: flex;
|
|
55
|
-
gap: 8px;
|
|
56
|
-
}}
|
|
57
|
-
.dot {{
|
|
58
|
-
width: 12px;
|
|
59
|
-
height: 12px;
|
|
60
|
-
border-radius: 50%;
|
|
61
|
-
}}
|
|
62
|
-
.dot-red {{ background: #ff5f56; }}
|
|
63
|
-
.dot-yellow {{ background: #ffbd2e; }}
|
|
64
|
-
.dot-green {{ background: #27c93f; }}
|
|
65
|
-
.title {{
|
|
66
|
-
flex: 1;
|
|
67
|
-
text-align: center;
|
|
68
|
-
color: #8b949e;
|
|
69
|
-
font-size: 13px;
|
|
70
|
-
font-weight: 500;
|
|
71
|
-
margin-right: 48px;
|
|
72
|
-
}}
|
|
73
|
-
.content {{
|
|
74
|
-
padding: 24px;
|
|
75
|
-
font-size: 15px;
|
|
76
|
-
line-height: 1.6;
|
|
77
|
-
flex: 1;
|
|
78
|
-
overflow-y: auto;
|
|
79
|
-
white-space: pre-wrap;
|
|
80
|
-
word-break: break-word;
|
|
81
|
-
scroll-behavior: smooth;
|
|
82
|
-
}}
|
|
83
|
-
.content::-webkit-scrollbar {{
|
|
84
|
-
display: none;
|
|
85
|
-
}}
|
|
86
|
-
.content {{
|
|
87
|
-
-ms-overflow-style: none;
|
|
88
|
-
scrollbar-width: none;
|
|
89
|
-
}}
|
|
90
|
-
.prompt {{ color: #7ee787; font-weight: bold; }}
|
|
91
|
-
.command {{ color: #79c0ff; font-weight: bold; }}
|
|
92
|
-
.step-banner {{
|
|
93
|
-
position: fixed;
|
|
94
|
-
bottom: 32px;
|
|
95
|
-
left: 50%;
|
|
96
|
-
transform: translateX(-50%);
|
|
97
|
-
background: rgba(15, 23, 42, 0.92);
|
|
98
|
-
color: #ffffff;
|
|
99
|
-
padding: 12px 24px;
|
|
100
|
-
border-radius: 8px;
|
|
101
|
-
font-size: 17px;
|
|
102
|
-
font-family: system-ui, -apple-system, sans-serif;
|
|
103
|
-
font-weight: 500;
|
|
104
|
-
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
|
|
105
|
-
border: 1px solid rgba(255,255,255,0.15);
|
|
106
|
-
backdrop-filter: blur(8px);
|
|
107
|
-
z-index: 9999;
|
|
108
|
-
transition: opacity 0.2s ease-in-out;
|
|
109
|
-
text-align: center;
|
|
110
|
-
max-width: 80%;
|
|
111
|
-
}}
|
|
112
|
-
.cursor {{
|
|
113
|
-
display: inline-block;
|
|
114
|
-
width: 8px;
|
|
115
|
-
height: 16px;
|
|
116
|
-
background: #58a6ff;
|
|
117
|
-
vertical-align: text-bottom;
|
|
118
|
-
animation: blink 1s step-end infinite;
|
|
119
|
-
}}
|
|
120
|
-
@keyframes blink {{ 50% {{ opacity: 0; }} }}
|
|
121
|
-
</style>
|
|
122
|
-
</head>
|
|
123
|
-
<body>
|
|
124
|
-
<div class="window">
|
|
125
|
-
<div class="titlebar">
|
|
126
|
-
<div class="dots">
|
|
127
|
-
<div class="dot dot-red"></div>
|
|
128
|
-
<div class="dot dot-yellow"></div>
|
|
129
|
-
<div class="dot dot-green"></div>
|
|
130
|
-
</div>
|
|
131
|
-
<div class="title">{title} — bash</div>
|
|
132
|
-
</div>
|
|
133
|
-
<div class="content" id="terminal-content"><span class="cursor"></span></div>
|
|
134
|
-
</div>
|
|
135
|
-
<div class="step-banner" id="banner" style="display: none;"></div>
|
|
136
|
-
</body>
|
|
137
|
-
</html>
|
|
138
|
-
"""
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def _strip_ansi(text: str) -> str:
|
|
142
|
-
"""Remove ANSI color and escape sequences from text."""
|
|
143
|
-
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
144
|
-
return ansi_escape.sub("", text)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def _render_in_clean_thread(
|
|
148
|
-
events: list[TerminalEvent],
|
|
149
|
-
output_path: Path,
|
|
150
|
-
title: str,
|
|
151
|
-
width: int,
|
|
152
|
-
height: int,
|
|
153
|
-
) -> tuple[Path, Path]:
|
|
154
|
-
"""Execute Playwright video recording in a fresh thread with its own event loop."""
|
|
155
|
-
try:
|
|
156
|
-
from playwright.sync_api import sync_playwright
|
|
157
|
-
except ImportError as e:
|
|
158
|
-
raise MissingDependencyError(
|
|
159
|
-
"Playwright is required to record CLI demos into .webm video. "
|
|
160
|
-
"Install with 'pip install hexastack-cli[testing]' or 'pip install playwright'."
|
|
161
|
-
) from e
|
|
162
|
-
|
|
163
|
-
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
-
temp_dir = output_path.parent / ".temp_video"
|
|
165
|
-
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
166
|
-
|
|
167
|
-
html_content = TERMINAL_HTML_TEMPLATE.format(title=html.escape(title))
|
|
168
|
-
vtt_captions: list[tuple[float, float, str]] = []
|
|
169
|
-
current_step_start: float | None = None
|
|
170
|
-
current_step_text: str | None = None
|
|
171
|
-
|
|
172
|
-
with sync_playwright() as p:
|
|
173
|
-
try:
|
|
174
|
-
browser = p.chromium.launch(headless=True)
|
|
175
|
-
except Exception:
|
|
176
|
-
# If Playwright browser binaries are not installed, create placeholder video artifact
|
|
177
|
-
output_path.write_bytes(b"")
|
|
178
|
-
return output_path, output_path.with_suffix(".vtt")
|
|
179
|
-
|
|
180
|
-
context = browser.new_context(
|
|
181
|
-
viewport={"width": width, "height": height},
|
|
182
|
-
record_video_dir=str(temp_dir),
|
|
183
|
-
record_video_size={"width": width, "height": height},
|
|
184
|
-
)
|
|
185
|
-
page = context.new_page()
|
|
186
|
-
page.set_content(html_content)
|
|
187
|
-
page.wait_for_timeout(500)
|
|
188
|
-
start_playback_time = 0.5
|
|
189
|
-
|
|
190
|
-
current_time = start_playback_time
|
|
191
|
-
|
|
192
|
-
for ev in events:
|
|
193
|
-
if ev.event_type == "step":
|
|
194
|
-
if current_step_start is not None and current_step_text is not None:
|
|
195
|
-
vtt_captions.append(
|
|
196
|
-
(current_step_start, current_time, current_step_text)
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
current_step_start = current_time
|
|
200
|
-
current_step_text = ev.payload
|
|
201
|
-
|
|
202
|
-
json_text = json.dumps(ev.payload)
|
|
203
|
-
page.evaluate(
|
|
204
|
-
f"""(() => {{
|
|
205
|
-
const banner = document.getElementById('banner');
|
|
206
|
-
if (banner) {{
|
|
207
|
-
banner.innerText = {json_text};
|
|
208
|
-
banner.style.display = 'block';
|
|
209
|
-
}}
|
|
210
|
-
}})();"""
|
|
211
|
-
)
|
|
212
|
-
page.wait_for_timeout(600)
|
|
213
|
-
current_time += 0.6
|
|
214
|
-
|
|
215
|
-
elif ev.event_type == "input":
|
|
216
|
-
char_str = html.escape(ev.payload)
|
|
217
|
-
if char_str == "\n":
|
|
218
|
-
char_str = "<br/>"
|
|
219
|
-
json_html = json.dumps(char_str)
|
|
220
|
-
page.evaluate(
|
|
221
|
-
f"""(() => {{
|
|
222
|
-
const terminal = document.getElementById('terminal-content');
|
|
223
|
-
const cursor = terminal.querySelector('.cursor');
|
|
224
|
-
const span = document.createElement('span');
|
|
225
|
-
span.className = 'command';
|
|
226
|
-
span.innerHTML = {json_html};
|
|
227
|
-
terminal.insertBefore(span, cursor);
|
|
228
|
-
terminal.scrollTop = terminal.scrollHeight;
|
|
229
|
-
}})();"""
|
|
230
|
-
)
|
|
231
|
-
page.wait_for_timeout(35)
|
|
232
|
-
current_time += 0.035
|
|
233
|
-
|
|
234
|
-
elif ev.event_type == "output":
|
|
235
|
-
clean_text = _strip_ansi(ev.payload)
|
|
236
|
-
escaped_output = html.escape(clean_text).replace("\n", "<br/>")
|
|
237
|
-
json_output = json.dumps(escaped_output)
|
|
238
|
-
page.evaluate(
|
|
239
|
-
f"""(() => {{
|
|
240
|
-
const terminal = document.getElementById('terminal-content');
|
|
241
|
-
const cursor = terminal.querySelector('.cursor');
|
|
242
|
-
const div = document.createElement('div');
|
|
243
|
-
div.style.color = '#c9d1d9';
|
|
244
|
-
div.style.margin = '4px 0 12px 0';
|
|
245
|
-
div.innerHTML = {json_output};
|
|
246
|
-
terminal.insertBefore(div, cursor);
|
|
247
|
-
terminal.scrollTop = terminal.scrollHeight;
|
|
248
|
-
}})();"""
|
|
249
|
-
)
|
|
250
|
-
page.wait_for_timeout(1000)
|
|
251
|
-
current_time += 1.0
|
|
252
|
-
|
|
253
|
-
if current_step_start is not None and current_step_text is not None:
|
|
254
|
-
vtt_captions.append(
|
|
255
|
-
(current_step_start, current_time + 1.5, current_step_text)
|
|
256
|
-
)
|
|
257
|
-
|
|
258
|
-
page.wait_for_timeout(1500)
|
|
259
|
-
video = page.video
|
|
260
|
-
page.close()
|
|
261
|
-
|
|
262
|
-
if video:
|
|
263
|
-
video.save_as(output_path)
|
|
264
|
-
|
|
265
|
-
context.close()
|
|
266
|
-
browser.close()
|
|
267
|
-
|
|
268
|
-
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
269
|
-
|
|
270
|
-
# Export synchronized WebVTT subtitles with true video timestamps
|
|
271
|
-
vtt_lines = ["WEBVTT", ""]
|
|
272
|
-
for idx, (start_s, end_s, text) in enumerate(vtt_captions, start=1):
|
|
273
|
-
vtt_lines.append(str(idx))
|
|
274
|
-
vtt_lines.append(f"{_format_vtt_time(start_s)} --> {_format_vtt_time(end_s)}")
|
|
275
|
-
vtt_lines.append(text)
|
|
276
|
-
vtt_lines.append("")
|
|
277
|
-
|
|
278
|
-
vtt_path = output_path.with_suffix(".vtt")
|
|
279
|
-
atomic_write_text(vtt_path, "\n".join(vtt_lines), encoding="utf-8")
|
|
280
|
-
|
|
281
|
-
return output_path, vtt_path
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
def render_cli_demo_video(
|
|
285
|
-
events: list[TerminalEvent],
|
|
286
|
-
output_path: Path,
|
|
287
|
-
title: str = "Hexastack CLI",
|
|
288
|
-
width: int = 1280,
|
|
289
|
-
height: int = 720,
|
|
290
|
-
) -> tuple[Path, Path]:
|
|
291
|
-
"""Render a sequence of TerminalEvents into synchronized .webm video and .vtt subtitle track.
|
|
292
|
-
|
|
293
|
-
Args:
|
|
294
|
-
events: Chronological sequence of input, output, and step narrative events.
|
|
295
|
-
output_path: Destination Path for .webm video file.
|
|
296
|
-
title: Window title text for terminal banner.
|
|
297
|
-
width: Video frame width in pixels.
|
|
298
|
-
height: Video frame height in pixels.
|
|
299
|
-
|
|
300
|
-
Returns:
|
|
301
|
-
Tuple of (video_path, vtt_path).
|
|
302
|
-
|
|
303
|
-
Raises:
|
|
304
|
-
MissingDependencyError: If `playwright` is not installed.
|
|
305
|
-
"""
|
|
306
|
-
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
307
|
-
future = executor.submit(
|
|
308
|
-
_render_in_clean_thread,
|
|
309
|
-
events=events,
|
|
310
|
-
output_path=output_path,
|
|
311
|
-
title=title,
|
|
312
|
-
width=width,
|
|
313
|
-
height=height,
|
|
314
|
-
)
|
|
315
|
-
return future.result()
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def _format_vtt_time(seconds: float) -> str:
|
|
319
|
-
"""Format seconds into WebVTT timestamp (HH:MM:SS.mmm)."""
|
|
320
|
-
hrs = int(seconds // 3600)
|
|
321
|
-
mins = int((seconds % 3600) // 60)
|
|
322
|
-
secs = int(seconds % 60)
|
|
323
|
-
millis = int(round((seconds - int(seconds)) * 1000))
|
|
324
|
-
return f"{hrs:02d}:{mins:02d}:{secs:02d}.{millis:03d}"
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
__all__ = [
|
|
328
|
-
"render_cli_demo_video",
|
|
329
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|