hexastack-cli 0.8.0__tar.gz → 0.9.0__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 (26) hide show
  1. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/PKG-INFO +60 -3
  2. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/README.md +59 -2
  3. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/pyproject.toml +1 -1
  4. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/pyproject.toml.orig +1 -1
  5. hexastack_cli-0.9.0/src/hexastack_cli/adapters/__init__.py +25 -0
  6. hexastack_cli-0.9.0/src/hexastack_cli/adapters/suggest.py +417 -0
  7. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/infra/app.py +2 -1
  8. hexastack_cli-0.9.0/src/hexastack_cli/testing/narrator.py +323 -0
  9. hexastack_cli-0.8.0/src/hexastack_cli/adapters/__init__.py +0 -11
  10. hexastack_cli-0.8.0/src/hexastack_cli/testing/narrator.py +0 -188
  11. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/__init__.py +0 -0
  12. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/adapters/presenter.py +0 -0
  13. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/adapters/routing.py +0 -0
  14. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/domain/__init__.py +0 -0
  15. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/domain/config.py +0 -0
  16. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/domain/options.py +0 -0
  17. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/infra/__init__.py +0 -0
  18. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/infra/autodiscovery.py +0 -0
  19. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/infra/bootstrap.py +0 -0
  20. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/infra/config.py +0 -0
  21. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/infra/decorators.py +0 -0
  22. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/infra/options.py +0 -0
  23. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/py.typed +0 -0
  24. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/testing/__init__.py +0 -0
  25. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/testing/events.py +0 -0
  26. {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/testing/terminal.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexastack-cli
3
- Version: 0.8.0
3
+ Version: 0.9.0
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>
@@ -37,9 +37,11 @@ Description-Content-Type: text/markdown
37
37
 
38
38
  - **Nested Command Hierarchies**: Nest subcommands naturally (`app user create`, `app db migrate`) using `@cli_group`.
39
39
  - **Command Aliases**: Register multiple aliases for the same action (`app user new == app user create`).
40
+ - **Typo Tolerance & Suggestions (`TyperSuggest`)**: Drop-in replacement for `typer.Typer` that intercepts unrecognized subcommands or flags, computes closest matches via Levenshtein distance, prompts interactively for confirmation, and exits cleanly in CI/non-interactive pipelines.
40
41
  - **Feature Flag Gating**: Gate CLI commands dynamically with `@feature_flag_command(...)` and `@cli_command(..., feature_flag=...)`.
41
42
  - **Rich Formatted & CI-Friendly Output**: Beautiful tables, panels, and spinners for interactive terminals; clean text/JSON streaming for CI/CD pipelines.
42
43
  - **Direct CQRS Dispatching**: Declaratively expose domain commands (`@cli_command`) and queries (`@cli_query`) with automatic parameter parsing and validation.
44
+ - **Automated Demo Narration & Slicing (`CliNarrator`)**: Headless terminal recording generating asciinema streams, high-resolution `.webm` videos, chapterized `.checkpoint_segment()` flushes, and synchronized `.vtt` subtitles.
43
45
 
44
46
  ---
45
47
 
@@ -48,7 +50,8 @@ Description-Content-Type: text/markdown
48
50
  ```
49
51
  hexastack_cli/
50
52
  ├── domain/ # CliContext, OutputFormat enum
51
- ├── adapters/ # Rich presenters, Typer command runners
53
+ ├── adapters/ # Rich presenters, Typer command runners, TyperSuggest
54
+ ├── testing/ # CliNarrator, TerminalEvent (demo video & VTT recorder)
52
55
  └── infra/ # create_cli_app, CliBootstrapper (order=40), @cli_command, @cli_query, @cli_group, @feature_flag_command
53
56
  ```
54
57
 
@@ -56,7 +59,7 @@ hexastack_cli/
56
59
 
57
60
  | Category | Exports |
58
61
  |---|---|
59
- | **Application Factory** | `create_cli_app`, `CliBootstrapper` (order=30) |
62
+ | **Application Factory & Typo Tolerance** | `create_cli_app`, `TyperSuggest`, `SuggestGroup`, `SuggestCommand`, `CliBootstrapper` (order=40) |
60
63
  | **Decorators** | `@cli_command`, `@cli_query`, `@cli_group`, `@feature_flag_command` |
61
64
  | **Presenters** | `RichTerminalPresenter`, `ConsolePresenter`, `TablePresenter`, `JsonPresenter` |
62
65
  | **Testing & Demo Narration** | `CliNarrator`, `TerminalEvent` |
@@ -148,3 +151,57 @@ cli_query("status", aliases=["st", "health"], help="Check status of a service")(
148
151
  runtime = bootstrap(packages_to_scan=[__name__])
149
152
  cli_app = runtime.get("cli_app")
150
153
  ```
154
+
155
+ ---
156
+
157
+ ## 6. Standalone Typo-Tolerant CLI (`TyperSuggest`)
158
+
159
+ `TyperSuggest` is a drop-in replacement for `typer.Typer`. It intercepts mistyped commands and options, presenting suggestions and interactive confirmations without throwing cryptic syntax errors:
160
+
161
+ ```python
162
+ from hexastack_cli.adapters import TyperSuggest
163
+
164
+ app = TyperSuggest(help="My Resilient Service CLI")
165
+
166
+
167
+ @app.command()
168
+ def deploy(environment: str = "staging", force: bool = False):
169
+ print(f"Deploying to {environment} (force={force})")
170
+
171
+
172
+ if __name__ == "__main__":
173
+ app()
174
+ ```
175
+
176
+ ```bash
177
+ $ python cli.py deply
178
+ Unrecognized command 'deply'. Did you mean 'deploy'? [y/N]: y
179
+ Deploying to staging (force=False)
180
+
181
+ $ python cli.py deploy --frc
182
+ Unrecognized option '--frc'. Did you mean '--force'? [y/N]: y
183
+ Deploying to staging (force=True)
184
+ ```
185
+
186
+ In automated CI environments or redirected streams (`not sys.stdin.isatty()`), `TyperSuggest` prints the suggested correction to `stderr` and exits cleanly with error code `2` without hanging.
187
+
188
+ ---
189
+
190
+ ## 7. Automated Demo Recording & Video Slicing (`CliNarrator`)
191
+
192
+ ```python
193
+ from hexastack_cli.testing import CliNarrator
194
+
195
+ narrator = CliNarrator(record_video=True)
196
+ narrator.say("Scaffolding microservice...")
197
+ narrator.run("hexastack new web-api billing-service")
198
+
199
+ # Flush Chapter 1 video & VTT segment
200
+ narrator.checkpoint_segment("ch01-scaffolding")
201
+
202
+ narrator.say("Applying SQLite database migrations...")
203
+ narrator.run("hexastack db migrate")
204
+
205
+ # Flush Chapter 2 video & VTT segment
206
+ narrator.checkpoint_segment("ch02-migrations")
207
+ ```
@@ -20,9 +20,11 @@
20
20
 
21
21
  - **Nested Command Hierarchies**: Nest subcommands naturally (`app user create`, `app db migrate`) using `@cli_group`.
22
22
  - **Command Aliases**: Register multiple aliases for the same action (`app user new == app user create`).
23
+ - **Typo Tolerance & Suggestions (`TyperSuggest`)**: Drop-in replacement for `typer.Typer` that intercepts unrecognized subcommands or flags, computes closest matches via Levenshtein distance, prompts interactively for confirmation, and exits cleanly in CI/non-interactive pipelines.
23
24
  - **Feature Flag Gating**: Gate CLI commands dynamically with `@feature_flag_command(...)` and `@cli_command(..., feature_flag=...)`.
24
25
  - **Rich Formatted & CI-Friendly Output**: Beautiful tables, panels, and spinners for interactive terminals; clean text/JSON streaming for CI/CD pipelines.
25
26
  - **Direct CQRS Dispatching**: Declaratively expose domain commands (`@cli_command`) and queries (`@cli_query`) with automatic parameter parsing and validation.
27
+ - **Automated Demo Narration & Slicing (`CliNarrator`)**: Headless terminal recording generating asciinema streams, high-resolution `.webm` videos, chapterized `.checkpoint_segment()` flushes, and synchronized `.vtt` subtitles.
26
28
 
27
29
  ---
28
30
 
@@ -31,7 +33,8 @@
31
33
  ```
32
34
  hexastack_cli/
33
35
  ├── domain/ # CliContext, OutputFormat enum
34
- ├── adapters/ # Rich presenters, Typer command runners
36
+ ├── adapters/ # Rich presenters, Typer command runners, TyperSuggest
37
+ ├── testing/ # CliNarrator, TerminalEvent (demo video & VTT recorder)
35
38
  └── infra/ # create_cli_app, CliBootstrapper (order=40), @cli_command, @cli_query, @cli_group, @feature_flag_command
36
39
  ```
37
40
 
@@ -39,7 +42,7 @@ hexastack_cli/
39
42
 
40
43
  | Category | Exports |
41
44
  |---|---|
42
- | **Application Factory** | `create_cli_app`, `CliBootstrapper` (order=30) |
45
+ | **Application Factory & Typo Tolerance** | `create_cli_app`, `TyperSuggest`, `SuggestGroup`, `SuggestCommand`, `CliBootstrapper` (order=40) |
43
46
  | **Decorators** | `@cli_command`, `@cli_query`, `@cli_group`, `@feature_flag_command` |
44
47
  | **Presenters** | `RichTerminalPresenter`, `ConsolePresenter`, `TablePresenter`, `JsonPresenter` |
45
48
  | **Testing & Demo Narration** | `CliNarrator`, `TerminalEvent` |
@@ -131,3 +134,57 @@ cli_query("status", aliases=["st", "health"], help="Check status of a service")(
131
134
  runtime = bootstrap(packages_to_scan=[__name__])
132
135
  cli_app = runtime.get("cli_app")
133
136
  ```
137
+
138
+ ---
139
+
140
+ ## 6. Standalone Typo-Tolerant CLI (`TyperSuggest`)
141
+
142
+ `TyperSuggest` is a drop-in replacement for `typer.Typer`. It intercepts mistyped commands and options, presenting suggestions and interactive confirmations without throwing cryptic syntax errors:
143
+
144
+ ```python
145
+ from hexastack_cli.adapters import TyperSuggest
146
+
147
+ app = TyperSuggest(help="My Resilient Service CLI")
148
+
149
+
150
+ @app.command()
151
+ def deploy(environment: str = "staging", force: bool = False):
152
+ print(f"Deploying to {environment} (force={force})")
153
+
154
+
155
+ if __name__ == "__main__":
156
+ app()
157
+ ```
158
+
159
+ ```bash
160
+ $ python cli.py deply
161
+ Unrecognized command 'deply'. Did you mean 'deploy'? [y/N]: y
162
+ Deploying to staging (force=False)
163
+
164
+ $ python cli.py deploy --frc
165
+ Unrecognized option '--frc'. Did you mean '--force'? [y/N]: y
166
+ Deploying to staging (force=True)
167
+ ```
168
+
169
+ In automated CI environments or redirected streams (`not sys.stdin.isatty()`), `TyperSuggest` prints the suggested correction to `stderr` and exits cleanly with error code `2` without hanging.
170
+
171
+ ---
172
+
173
+ ## 7. Automated Demo Recording & Video Slicing (`CliNarrator`)
174
+
175
+ ```python
176
+ from hexastack_cli.testing import CliNarrator
177
+
178
+ narrator = CliNarrator(record_video=True)
179
+ narrator.say("Scaffolding microservice...")
180
+ narrator.run("hexastack new web-api billing-service")
181
+
182
+ # Flush Chapter 1 video & VTT segment
183
+ narrator.checkpoint_segment("ch01-scaffolding")
184
+
185
+ narrator.say("Applying SQLite database migrations...")
186
+ narrator.run("hexastack db migrate")
187
+
188
+ # Flush Chapter 2 video & VTT segment
189
+ narrator.checkpoint_segment("ch02-migrations")
190
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hexastack-cli"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "Hexastack CLI presentation adapter with Typer and Rich"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hexastack-cli"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "Hexastack CLI presentation adapter with Typer and Rich"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -0,0 +1,25 @@
1
+ from hexastack_cli.adapters.presenter import RichTerminalPresenter
2
+ from hexastack_cli.adapters.routing import (
3
+ register_cqrs_command,
4
+ register_cqrs_query,
5
+ )
6
+ from hexastack_cli.adapters.suggest import (
7
+ SuggestCommand,
8
+ SuggestGroup,
9
+ SuggestOptionParser,
10
+ TyperSuggest,
11
+ find_closest_match,
12
+ is_interactive,
13
+ )
14
+
15
+ __all__ = [
16
+ "find_closest_match",
17
+ "is_interactive",
18
+ "register_cqrs_command",
19
+ "register_cqrs_query",
20
+ "RichTerminalPresenter",
21
+ "SuggestCommand",
22
+ "SuggestGroup",
23
+ "SuggestOptionParser",
24
+ "TyperSuggest",
25
+ ]
@@ -0,0 +1,417 @@
1
+ import difflib
2
+ import os
3
+ import re
4
+ import sys
5
+ from collections.abc import Callable, Sequence
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import typer
9
+ import typer.core
10
+
11
+ if TYPE_CHECKING:
12
+ from typer._click import exceptions as click_exceptions
13
+ from typer._click.parser import _OptionParser, _ParsingState
14
+ else:
15
+ try:
16
+ import typer._click.exceptions as click_exceptions
17
+ from typer._click.parser import _OptionParser, _ParsingState
18
+ except ImportError:
19
+ import click.exceptions as click_exceptions
20
+ from click.parser import _OptionParser, _ParsingState
21
+
22
+ __all__ = [
23
+ "find_closest_match",
24
+ "is_interactive",
25
+ "SuggestCommand",
26
+ "SuggestGroup",
27
+ "SuggestOptionParser",
28
+ "TyperSuggest",
29
+ ]
30
+
31
+ _NUMERIC_ARG_RE = re.compile(r"^-[0-9]+(\.[0-9]+)?$")
32
+
33
+
34
+ def is_interactive(ctx: Any | None = None) -> bool:
35
+ """Determine whether the current execution context is interactive.
36
+
37
+ Notes/Architectural Intent:
38
+ Evaluates environment flags (e.g. HEXASTACK_CLI_INTERACTIVE), context metadata,
39
+ and TTY status to strictly prevent hanging prompts in non-interactive CI pipelines.
40
+
41
+ Args:
42
+ ctx: Optional Click or Typer execution context.
43
+
44
+ Returns:
45
+ True if running in an interactive TTY session or forced interactively, False otherwise.
46
+
47
+ Raises:
48
+ None.
49
+ """
50
+ if os.environ.get("HEXASTACK_CLI_INTERACTIVE", "").lower() in ("1", "true", "yes"):
51
+ return True
52
+ if ctx is not None and getattr(ctx, "meta", {}).get("force_interactive"):
53
+ return True
54
+ try:
55
+ return sys.stdin.isatty() and sys.stdout.isatty()
56
+ except Exception:
57
+ return False
58
+
59
+
60
+ def find_closest_match(
61
+ token: str,
62
+ candidates: Sequence[str],
63
+ cutoff: float = 0.6,
64
+ ) -> str | None:
65
+ """Find the closest matching string candidate using difflib sequence matching.
66
+
67
+ Notes/Architectural Intent:
68
+ Performs direct string distance matching, followed by leading-dash stripped
69
+ normalization to forgivingly reconcile CLI flag and subcommand typos.
70
+
71
+ Args:
72
+ token: Target string token to match.
73
+ candidates: Sequence of valid candidate strings.
74
+ cutoff: Minimum similarity ratio required for a match (defaults to 0.6).
75
+
76
+ Returns:
77
+ Closest matching candidate string, or None if no match meets the cutoff.
78
+
79
+ Raises:
80
+ None.
81
+ """
82
+ matches = difflib.get_close_matches(token, candidates, n=1, cutoff=cutoff)
83
+ if matches:
84
+ return matches[0]
85
+
86
+ clean_token = token.lstrip("-")
87
+ norm_candidates = {c.lstrip("-"): c for c in candidates}
88
+ norm_matches = difflib.get_close_matches(
89
+ clean_token, list(norm_candidates.keys()), n=1, cutoff=cutoff
90
+ )
91
+ if norm_matches:
92
+ return norm_candidates[norm_matches[0]]
93
+
94
+ return None
95
+
96
+
97
+ def _substitute_flag_token(
98
+ args: Sequence[str],
99
+ target_opt: str,
100
+ replacement_opt: str,
101
+ ) -> list[str]:
102
+ """Substitute an unrecognized option flag token with its corrected candidate.
103
+
104
+ Notes/Architectural Intent:
105
+ Preserves parameter assignments using '=' syntax (e.g. '--prt=8080' ->
106
+ '--port=8080') while substituting exact option tokens.
107
+
108
+ Args:
109
+ args: Sequence of CLI argument tokens.
110
+ target_opt: Unrecognized option token to replace.
111
+ replacement_opt: Corrected replacement option token.
112
+
113
+ Returns:
114
+ New list of argument tokens with the target option replaced.
115
+
116
+ Raises:
117
+ None.
118
+ """
119
+ updated: list[str] = []
120
+ prefix = f"{target_opt}="
121
+ for token in args:
122
+ if token == target_opt:
123
+ updated.append(replacement_opt)
124
+ elif token.startswith(prefix):
125
+ val = token[len(prefix) :]
126
+ updated.append(f"{replacement_opt}={val}")
127
+ else:
128
+ updated.append(token)
129
+ return updated
130
+
131
+
132
+ def _extract_option_candidates(params: Sequence[Any]) -> list[str]:
133
+ """Extract all available option flags from a collection of command parameters.
134
+
135
+ Notes/Architectural Intent:
136
+ Aggregates both primary option names and secondary negation flags
137
+ (e.g. '--verbose' and '--no-verbose') across all command parameters.
138
+
139
+ Args:
140
+ params: Sequence of Click/Typer Parameter instances.
141
+
142
+ Returns:
143
+ List of all registered option flag strings.
144
+
145
+ Raises:
146
+ None.
147
+ """
148
+ candidates: list[str] = []
149
+ for param in params:
150
+ opts = getattr(param, "opts", [])
151
+ sec_opts = getattr(param, "secondary_opts", [])
152
+ candidates.extend(opts)
153
+ candidates.extend(sec_opts)
154
+ return candidates
155
+
156
+
157
+ class SuggestOptionParser(_OptionParser):
158
+ """Custom Click option parser providing immunity for negative numbers and '--' delimiter.
159
+
160
+ Notes/Architectural Intent:
161
+ Intercepts option token evaluation to prevent negative numbers (e.g. -42, -0.5)
162
+ from being erroneously parsed as unrecognized short options, preserving
163
+ their positional argument semantics. Also ensures bare '--' strictly halts
164
+ option parsing.
165
+ """
166
+
167
+ def _process_args_for_options(self, state: _ParsingState) -> None:
168
+ while state.rargs:
169
+ arg = state.rargs.pop(0)
170
+ arglen = len(arg)
171
+ if arg == "--":
172
+ return
173
+ if _NUMERIC_ARG_RE.match(arg):
174
+ if self.allow_interspersed_args:
175
+ state.largs.append(arg)
176
+ else:
177
+ state.rargs.insert(0, arg)
178
+ return
179
+ elif arg[:1] in self._opt_prefixes and arglen > 1:
180
+ self._process_opts(arg, state)
181
+ elif self.allow_interspersed_args:
182
+ state.largs.append(arg)
183
+ else:
184
+ state.rargs.insert(0, arg)
185
+ return
186
+
187
+
188
+ def _parse_args_with_suggestions(
189
+ command: Any,
190
+ ctx: Any,
191
+ args: list[str],
192
+ super_parse: Callable[[Any, list[str]], list[str]],
193
+ ) -> list[str]:
194
+ """Parse CLI arguments intercepting unrecognized option errors to offer suggestions.
195
+
196
+ Notes/Architectural Intent:
197
+ Wraps command option parsing to catch NoSuchOption, query closest flag matches,
198
+ and either prompt interactively for confirmation or emit standard Click code 2
199
+ errors in non-interactive CI contexts. Preserves args copy against Click in-place mutation.
200
+
201
+ Args:
202
+ command: Command or Group instance providing parameters.
203
+ ctx: Click/Typer context.
204
+ args: List of argument strings.
205
+ super_parse: Bound parse_args method from superclass.
206
+
207
+ Returns:
208
+ Remaining arguments after option parsing.
209
+
210
+ Raises:
211
+ UsageError: When an option typo is encountered in non-interactive mode.
212
+ Abort: When interactive prompt is declined.
213
+ """
214
+ current_args = list(args)
215
+ while True:
216
+ args_copy = list(current_args)
217
+ try:
218
+ return super_parse(ctx, args_copy)
219
+ except click_exceptions.NoSuchOption as exc:
220
+ opt = getattr(exc, "option_name", None)
221
+ if not opt:
222
+ raise
223
+ candidates = _extract_option_candidates(command.get_params(ctx))
224
+ match = find_closest_match(opt, candidates)
225
+ if not match:
226
+ raise
227
+
228
+ if is_interactive(ctx):
229
+ confirmed = typer.confirm(
230
+ f"Unrecognized option '{opt}'. Did you mean '{match}'?",
231
+ default=False,
232
+ )
233
+ if not confirmed:
234
+ raise typer.Abort from exc
235
+ current_args = _substitute_flag_token(current_args, opt, match)
236
+ continue
237
+ raise click_exceptions.UsageError(
238
+ f"No such option: {opt}. Did you mean '{match}'?",
239
+ ctx=ctx,
240
+ ) from exc
241
+
242
+
243
+ class SuggestCommand(typer.core.TyperCommand):
244
+ """TyperCommand with automatic typo tolerance for options and negative number immunity.
245
+
246
+ Notes/Architectural Intent:
247
+ Inherits from TyperCommand to provide typo correction for leaf commands
248
+ and subcommands in TyperSuggest applications.
249
+ """
250
+
251
+ def make_parser(self, ctx: Any) -> SuggestOptionParser:
252
+ """Create a SuggestOptionParser instance populated with command parameters.
253
+
254
+ Args:
255
+ ctx: Click/Typer context.
256
+
257
+ Returns:
258
+ Configured SuggestOptionParser instance.
259
+
260
+ Raises:
261
+ None.
262
+ """
263
+ parser = SuggestOptionParser(ctx)
264
+ for param in self.get_params(ctx):
265
+ param.add_to_parser(parser, ctx)
266
+ return parser
267
+
268
+ def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
269
+ """Parse arguments with typo detection and suggestions for options.
270
+
271
+ Args:
272
+ ctx: Click/Typer context.
273
+ args: Command-line arguments.
274
+
275
+ Returns:
276
+ Remaining positional arguments after option processing.
277
+
278
+ Raises:
279
+ UsageError: When an unrecognized option is encountered in non-interactive mode.
280
+ Abort: When the user declines the correction prompt in interactive mode.
281
+ """
282
+ return _parse_args_with_suggestions(self, ctx, args, super().parse_args)
283
+
284
+
285
+ class SuggestGroup(typer.core.TyperGroup):
286
+ """TyperGroup with automatic typo tolerance for subcommands, options, and negative numbers.
287
+
288
+ Notes/Architectural Intent:
289
+ Overrides resolve_command to intercept unknown subcommands and suggest
290
+ closest matches, supporting both interactive confirmation prompts and
291
+ non-interactive CI error reporting. Defaults command_class to SuggestCommand.
292
+ """
293
+
294
+ command_class: type[typer.core.TyperCommand] = SuggestCommand
295
+
296
+ def make_parser(self, ctx: Any) -> SuggestOptionParser:
297
+ """Create a SuggestOptionParser instance populated with group parameters.
298
+
299
+ Args:
300
+ ctx: Click/Typer context.
301
+
302
+ Returns:
303
+ Configured SuggestOptionParser instance.
304
+
305
+ Raises:
306
+ None.
307
+ """
308
+ parser = SuggestOptionParser(ctx)
309
+ for param in self.get_params(ctx):
310
+ param.add_to_parser(parser, ctx)
311
+ return parser
312
+
313
+ def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
314
+ """Parse arguments with typo detection and suggestions for options.
315
+
316
+ Args:
317
+ ctx: Click/Typer context.
318
+ args: Command-line arguments.
319
+
320
+ Returns:
321
+ Remaining positional arguments after option processing.
322
+
323
+ Raises:
324
+ UsageError: When an unrecognized option is encountered in non-interactive mode.
325
+ Abort: When the user declines the correction prompt in interactive mode.
326
+ """
327
+ return _parse_args_with_suggestions(self, ctx, args, super().parse_args)
328
+
329
+ def resolve_command(
330
+ self,
331
+ ctx: Any,
332
+ args: list[str],
333
+ ) -> tuple[str | None, Any | None, list[str]]:
334
+ """Resolve subcommand with typo tolerance and interactive prompting.
335
+
336
+ Args:
337
+ ctx: Click/Typer execution context.
338
+ args: CLI arguments remaining to be processed.
339
+
340
+ Returns:
341
+ Tuple of (command_name, command_instance, remaining_args).
342
+
343
+ Raises:
344
+ UsageError: When an unrecognized subcommand is encountered in non-interactive mode.
345
+ Abort: When user declines correction prompt in interactive mode.
346
+ """
347
+ if not args:
348
+ return super().resolve_command(ctx, args)
349
+ cmd_name = str(args[0])
350
+ if cmd_name.startswith("-"):
351
+ return super().resolve_command(ctx, args)
352
+
353
+ cmd = self.get_command(ctx, cmd_name)
354
+ if cmd is not None:
355
+ return cmd_name, cmd, args[1:]
356
+
357
+ candidates = list(self.commands.keys())
358
+ match = find_closest_match(cmd_name, candidates)
359
+ if match:
360
+ if is_interactive(ctx):
361
+ confirmed = typer.confirm(
362
+ f"Unrecognized command '{cmd_name}'. Did you mean '{match}'?",
363
+ default=False,
364
+ )
365
+ if confirmed:
366
+ return match, self.get_command(ctx, match), args[1:]
367
+ raise typer.Abort
368
+ raise click_exceptions.UsageError(
369
+ f"No such command '{cmd_name}'. Did you mean '{match}'?",
370
+ ctx=ctx,
371
+ )
372
+
373
+ return super().resolve_command(ctx, args)
374
+
375
+
376
+ class TyperSuggest(typer.Typer):
377
+ """Drop-in replacement for typer.Typer with typo tolerance and suggestion capabilities.
378
+
379
+ Notes/Architectural Intent:
380
+ Defaults group class to SuggestGroup and command class to SuggestCommand,
381
+ ensuring all subcommands and flags are protected by forgiving string-distance
382
+ matching, numeric negative argument immunity, and non-interactive CI safety.
383
+ """
384
+
385
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
386
+ """Initialize TyperSuggest configuring SuggestGroup as default group class.
387
+
388
+ Args:
389
+ *args: Positional arguments forwarded to typer.Typer.
390
+ **kwargs: Keyword arguments forwarded to typer.Typer.
391
+
392
+ Returns:
393
+ None.
394
+
395
+ Raises:
396
+ None.
397
+ """
398
+ kwargs.setdefault("cls", SuggestGroup)
399
+ super().__init__(*args, **kwargs)
400
+
401
+ def command(self, *args: Any, cls: Any = None, **kwargs: Any) -> Any:
402
+ """Register a command defaulting cls to SuggestCommand.
403
+
404
+ Args:
405
+ *args: Positional arguments forwarded to typer.Typer.command.
406
+ cls: Optional command class (defaults to SuggestCommand).
407
+ **kwargs: Keyword arguments forwarded to typer.Typer.command.
408
+
409
+ Returns:
410
+ Decorator registering the command callback.
411
+
412
+ Raises:
413
+ None.
414
+ """
415
+ if cls is None:
416
+ cls = SuggestCommand
417
+ return super().command(*args, cls=cls, **kwargs)
@@ -3,6 +3,7 @@ from rich.console import Console
3
3
  from rodi import Container
4
4
 
5
5
  from hexastack_cli.adapters.presenter import RichTerminalPresenter
6
+ from hexastack_cli.adapters.suggest import TyperSuggest
6
7
  from hexastack_cli.infra.config import HexastackCliConfig
7
8
  from hexastack_core.domain import Generic
8
9
  from hexastack_cqrs.infra.pipeline import ExecutionPipeline
@@ -41,7 +42,7 @@ def create_cli_app(
41
42
  cfg = config or HexastackCliConfig()
42
43
  active_console = console or Console()
43
44
 
44
- app = typer.Typer(
45
+ app = TyperSuggest(
45
46
  name=cfg.app_name,
46
47
  help=cfg.help_text,
47
48
  rich_markup_mode="rich" if cfg.rich_markup else None,
@@ -0,0 +1,323 @@
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,11 +0,0 @@
1
- from hexastack_cli.adapters.presenter import RichTerminalPresenter
2
- from hexastack_cli.adapters.routing import (
3
- register_cqrs_command,
4
- register_cqrs_query,
5
- )
6
-
7
- __all__ = [
8
- "register_cqrs_command",
9
- "register_cqrs_query",
10
- "RichTerminalPresenter",
11
- ]
@@ -1,188 +0,0 @@
1
- """Feature demo narrator and Playwright terminal video recorder for CLI commands."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- import time
7
- from pathlib import Path
8
- from typing import Any
9
-
10
- from typer.testing import CliRunner, Result
11
-
12
- from hexastack_cli.testing.events import TerminalEvent
13
- from hexastack_core.utils.fs import atomic_write_text
14
-
15
-
16
- class CliNarrator:
17
- """Orchestrates structured narrative CLI execution, step recording, and video rendering.
18
-
19
- Notes/Architectural Intent:
20
- 1. In standard CI/unit test mode, runs instantly with zero delay via in-memory CliRunner.
21
- 2. In demo recording mode (`RECORD_DEMO=1`), captures keystrokes, output tokens, and narrative steps,
22
- and directly renders a fixed-window, auto-scrolling .webm video via Playwright.
23
- """
24
-
25
- def __init__(
26
- self,
27
- app: Any,
28
- output_name: str | None = None,
29
- output_dir: Path | None = None,
30
- width: int = 100,
31
- height: int = 24,
32
- ) -> None:
33
- """Initialize CLI narrator attached to a Typer or Click application.
34
-
35
- Args:
36
- app: Target Typer application instance.
37
- output_name: Base filename (without extension) for saving recordings.
38
- output_dir: Target output directory for generated demo artifacts.
39
- width: Terminal column width.
40
- height: Terminal row height.
41
- """
42
- self.app = app
43
- self.output_name = output_name
44
- self.output_dir = output_dir or Path("docs/assets/demos")
45
- self.width = width
46
- self.height = height
47
- self.record_mode = os.environ.get("RECORD_DEMO") in ("1", "true", "True")
48
- self.runner = CliRunner()
49
- self.start_time: float = time.time()
50
- self.events: list[TerminalEvent] = []
51
- self.captions: list[tuple[float, float, str]] = []
52
- self._current_step_start: float | None = None
53
- self._current_step_text: str | None = None
54
-
55
- def step(self, title: str, *, record: bool = True) -> None:
56
- """Mark a new narrative step in the CLI demo recording.
57
-
58
- Args:
59
- title: Human-readable narrative explanation shown in subtitle banner.
60
- record: Whether to emit the step subtitle and recording marker (defaults to True).
61
- """
62
- if not record:
63
- return
64
-
65
- now = time.time() - self.start_time
66
- if self._current_step_start is not None and self._current_step_text is not None:
67
- self.captions.append(
68
- (self._current_step_start, now, self._current_step_text)
69
- )
70
-
71
- self._current_step_start = now
72
- self._current_step_text = title
73
-
74
- if self.record_mode:
75
- self.events.append(
76
- TerminalEvent(event_type="step", payload=title, timestamp=now)
77
- )
78
-
79
- def run_command(
80
- self,
81
- args: list[str],
82
- env: dict[str, str] | None = None,
83
- input: str | None = None,
84
- record: bool = True,
85
- ) -> Result:
86
- """Execute a CLI command within the active Typer application.
87
-
88
- Args:
89
- args: Command-line argument strings.
90
- env: Optional environment variables.
91
- input: Optional stdin input string.
92
- record: Whether to record this command invocation and output (defaults to True).
93
-
94
- Returns:
95
- CliRunner Result containing exit code, stdout, and stderr.
96
- """
97
- cmd_str = f"hexastack {' '.join(args)}"
98
-
99
- if self.record_mode and record:
100
- # 1. Record input command string
101
- now = time.time() - self.start_time
102
- self.events.append(
103
- TerminalEvent(
104
- event_type="input",
105
- payload=f"$ {cmd_str}\n",
106
- timestamp=now,
107
- )
108
- )
109
-
110
- # Execute command in-memory
111
- res = self.runner.invoke(self.app, args, input=input, env=env)
112
-
113
- if self.record_mode and record:
114
- # 2. Record command output text
115
- now = time.time() - self.start_time
116
- output_text = res.stdout if res.stdout else ""
117
- if output_text:
118
- self.events.append(
119
- TerminalEvent(
120
- event_type="output",
121
- payload=output_text,
122
- timestamp=now,
123
- )
124
- )
125
-
126
- return res
127
-
128
- def finish(self) -> dict[str, Path]:
129
- """Finalize the recording and render video and caption artifacts.
130
-
131
- Returns:
132
- Dictionary containing paths to generated artifact files (e.g. 'webm', 'vtt').
133
- """
134
- if not self.record_mode:
135
- return {}
136
-
137
- now = time.time() - self.start_time
138
- if self._current_step_start is not None and self._current_step_text is not None:
139
- self.captions.append(
140
- (self._current_step_start, now, self._current_step_text)
141
- )
142
-
143
- out_dir = self.output_dir
144
- out_dir.mkdir(parents=True, exist_ok=True)
145
- base_name = self.output_name or "cli-demo"
146
- artifacts: dict[str, Path] = {}
147
-
148
- # 1. Export WebVTT (.vtt) synchronized narration subtitles
149
- vtt_lines = ["WEBVTT", ""]
150
- for idx, (start_s, end_s, text) in enumerate(self.captions, start=1):
151
- vtt_lines.append(str(idx))
152
- vtt_lines.append(
153
- f"{self._format_vtt_time(start_s)} --> {self._format_vtt_time(end_s)}"
154
- )
155
- vtt_lines.append(text)
156
- vtt_lines.append("")
157
-
158
- vtt_path = out_dir / f"{base_name}.vtt"
159
- atomic_write_text(vtt_path, "\n".join(vtt_lines), encoding="utf-8")
160
- artifacts["vtt"] = vtt_path
161
-
162
- # 2. Render directly to .webm video via Playwright Rich Terminal
163
- from hexastack_cli.testing.terminal import render_cli_demo_video
164
-
165
- video_path = out_dir / f"{base_name}.webm"
166
- render_cli_demo_video(
167
- events=self.events,
168
- output_path=video_path,
169
- title=f"Hexastack CLI — {base_name}",
170
- )
171
- artifacts["webm"] = video_path
172
-
173
- return artifacts
174
-
175
- @staticmethod
176
- def _format_vtt_time(seconds: float) -> str:
177
- """Format seconds float into standard WebVTT timestamp (HH:MM:SS.mmm)."""
178
- hrs = int(seconds // 3600)
179
- mins = int((seconds % 3600) // 60)
180
- secs = int(seconds % 60)
181
- millis = int(round((seconds - int(seconds)) * 1000))
182
- return f"{hrs:02d}:{mins:02d}:{secs:02d}.{millis:03d}"
183
-
184
-
185
- __all__ = [
186
- "CliNarrator",
187
- "TerminalEvent",
188
- ]