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.
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/PKG-INFO +60 -3
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/README.md +59 -2
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/pyproject.toml +1 -1
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/pyproject.toml.orig +1 -1
- hexastack_cli-0.9.0/src/hexastack_cli/adapters/__init__.py +25 -0
- hexastack_cli-0.9.0/src/hexastack_cli/adapters/suggest.py +417 -0
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/infra/app.py +2 -1
- hexastack_cli-0.9.0/src/hexastack_cli/testing/narrator.py +323 -0
- hexastack_cli-0.8.0/src/hexastack_cli/adapters/__init__.py +0 -11
- hexastack_cli-0.8.0/src/hexastack_cli/testing/narrator.py +0 -188
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/__init__.py +0 -0
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/adapters/presenter.py +0 -0
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/adapters/routing.py +0 -0
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/domain/__init__.py +0 -0
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/domain/config.py +0 -0
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/domain/options.py +0 -0
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/infra/__init__.py +0 -0
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/infra/autodiscovery.py +0 -0
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/infra/bootstrap.py +0 -0
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/infra/config.py +0 -0
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/infra/decorators.py +0 -0
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/infra/options.py +0 -0
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/py.typed +0 -0
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/testing/__init__.py +0 -0
- {hexastack_cli-0.8.0 → hexastack_cli-0.9.0}/src/hexastack_cli/testing/events.py +0 -0
- {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.
|
|
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=
|
|
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=
|
|
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
|
+
```
|
|
@@ -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 =
|
|
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
|
-
]
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|