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.
Files changed (24) hide show
  1. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/PKG-INFO +5 -4
  2. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/README.md +3 -2
  3. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/pyproject.toml +3 -3
  4. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/pyproject.toml.orig +3 -3
  5. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/adapters/routing.py +6 -2
  6. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/autodiscovery.py +35 -11
  7. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/decorators.py +15 -2
  8. hexastack_cli-0.9.0/src/hexastack_cli/testing/__init__.py +0 -9
  9. hexastack_cli-0.9.0/src/hexastack_cli/testing/events.py +0 -16
  10. hexastack_cli-0.9.0/src/hexastack_cli/testing/narrator.py +0 -323
  11. hexastack_cli-0.9.0/src/hexastack_cli/testing/terminal.py +0 -329
  12. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/__init__.py +0 -0
  13. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/adapters/__init__.py +0 -0
  14. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/adapters/presenter.py +0 -0
  15. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/adapters/suggest.py +0 -0
  16. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/domain/__init__.py +0 -0
  17. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/domain/config.py +0 -0
  18. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/domain/options.py +0 -0
  19. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/__init__.py +0 -0
  20. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/app.py +0 -0
  21. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/bootstrap.py +0 -0
  22. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/config.py +0 -0
  23. {hexastack_cli-0.9.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/options.py +0 -0
  24. {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.9.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.13
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 hexastack_cli.testing import CliNarrator
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 hexastack_cli.testing import CliNarrator
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.9.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.13"
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.13.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.9.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.13"
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.13.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,9 +0,0 @@
1
- from hexastack_cli.testing.events import TerminalEvent
2
- from hexastack_cli.testing.narrator import CliNarrator
3
- from hexastack_cli.testing.terminal import render_cli_demo_video
4
-
5
- __all__ = [
6
- "CliNarrator",
7
- "render_cli_demo_video",
8
- "TerminalEvent",
9
- ]
@@ -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
- ]