epi-recorder 1.0.0__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
epi_cli/keys.py CHANGED
@@ -234,7 +234,7 @@ def generate_default_keypair_if_missing(console_output: bool = True) -> bool:
234
234
  private_path, public_path = key_manager.generate_keypair("default")
235
235
 
236
236
  if console_output:
237
- console.print("\n[bold green]🔐 Welcome to EPI![/bold green]")
237
+ console.print("\n[bold green]Welcome to EPI![/bold green]")
238
238
  console.print("\n[dim]Generated default Ed25519 key pair for signing:[/dim]")
239
239
  console.print(f" [cyan]Private:[/cyan] {private_path}")
240
240
  console.print(f" [cyan]Public:[/cyan] {public_path}")
@@ -260,8 +260,8 @@ def print_keys_table(keys: list[dict[str, str]]) -> None:
260
260
  table.add_column("Public Key", style="blue")
261
261
 
262
262
  for key in keys:
263
- private_status = "" if key["has_private"] else ""
264
- public_status = "" if key["has_public"] else ""
263
+ private_status = "[Y]" if key["has_private"] else "[N]"
264
+ public_status = "[Y]" if key["has_public"] else "[N]"
265
265
 
266
266
  table.add_row(
267
267
  key["name"],
epi_cli/ls.py ADDED
@@ -0,0 +1,160 @@
1
+ """
2
+ EPI CLI Ls - List recordings in ./epi-recordings/ directory.
3
+
4
+ Usage:
5
+ epi ls
6
+ """
7
+
8
+ import json
9
+ import zipfile
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Dict, Any
13
+
14
+ import typer
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ from epi_core.container import EPIContainer
19
+
20
+ console = Console()
21
+
22
+ app = typer.Typer(name="ls", help="List local recordings (./epi-recordings/)")
23
+
24
+ DEFAULT_DIR = Path("epi-recordings")
25
+
26
+
27
+ def _format_metrics(metrics: Dict[str, Any]) -> str:
28
+ """Format metrics dictionary as a compact string."""
29
+ if not metrics:
30
+ return ""
31
+
32
+ formatted = []
33
+ for key, value in metrics.items():
34
+ if isinstance(value, float):
35
+ # Format floats to 2 decimal places
36
+ formatted.append(f"{key}={value:.2f}")
37
+ else:
38
+ formatted.append(f"{key}={value}")
39
+
40
+ return ", ".join(formatted)
41
+
42
+
43
+ def _get_recording_info(epi_file: Path) -> dict:
44
+ """
45
+ Extract basic info from a recording.
46
+
47
+ Returns:
48
+ Dictionary with recording metadata
49
+ """
50
+ try:
51
+ # Read manifest
52
+ manifest = EPIContainer.read_manifest(epi_file)
53
+
54
+ # Get file stats
55
+ stats = epi_file.stat()
56
+ size_mb = stats.st_size / (1024 * 1024)
57
+ modified = datetime.fromtimestamp(stats.st_mtime)
58
+
59
+ # Extract CLI command if available
60
+ cli_command = getattr(manifest, 'cli_command', None)
61
+
62
+ # Extract originating script from cli_command
63
+ script = "Unknown"
64
+ if cli_command:
65
+ parts = cli_command.split()
66
+ for i, part in enumerate(parts):
67
+ if part.endswith('.py'):
68
+ script = Path(part).name
69
+ break
70
+
71
+ # Check signature
72
+ signed = "Yes" if manifest.signature else "No"
73
+
74
+ # Quick integrity check
75
+ integrity_ok, _ = EPIContainer.verify_integrity(epi_file)
76
+ status = "[OK]" if integrity_ok else "[FAIL]"
77
+
78
+ # Extract new metadata fields
79
+ goal = getattr(manifest, 'goal', None)
80
+ metrics = getattr(manifest, 'metrics', None)
81
+ tags = getattr(manifest, 'tags', None)
82
+
83
+ return {
84
+ "name": epi_file.name,
85
+ "script": script,
86
+ "size_mb": f"{size_mb:.2f}",
87
+ "modified": modified.strftime("%Y-%m-%d %H:%M:%S"),
88
+ "signed": signed,
89
+ "status": status,
90
+ "goal": goal or "",
91
+ "metrics_summary": _format_metrics(metrics) if metrics else "",
92
+ "tags_summary": ", ".join(tags) if tags else ""
93
+ }
94
+ except Exception as e:
95
+ return {
96
+ "name": epi_file.name,
97
+ "script": "Error",
98
+ "size_mb": "?",
99
+ "modified": "?",
100
+ "signed": "?",
101
+ "status": f"[ERR] {str(e)[:20]}",
102
+ "goal": "",
103
+ "metrics_summary": "",
104
+ "tags_summary": ""
105
+ }
106
+
107
+
108
+ @app.callback(invoke_without_command=True)
109
+ def ls(
110
+ all_dirs: bool = typer.Option(False, "--all", "-a", help="Search current directory too"),
111
+ ):
112
+ """
113
+ List local recordings in ./epi-recordings/ directory.
114
+
115
+ Shows created/verified status and originating script if available.
116
+ """
117
+ # Find recordings
118
+ recordings = []
119
+
120
+ # Check default directory
121
+ if DEFAULT_DIR.exists():
122
+ recordings.extend(DEFAULT_DIR.glob("*.epi"))
123
+
124
+ # Optionally check current directory
125
+ if all_dirs:
126
+ recordings.extend(Path(".").glob("*.epi"))
127
+
128
+ # Remove duplicates
129
+ recordings = list(set(recordings))
130
+ recordings.sort(key=lambda p: p.stat().st_mtime, reverse=True)
131
+
132
+ if not recordings:
133
+ console.print("[yellow]No recordings found[/yellow]")
134
+ if not DEFAULT_DIR.exists():
135
+ console.print(f"[dim]Directory {DEFAULT_DIR} does not exist yet[/dim]")
136
+ console.print("[dim]Tip: Run 'epi run script.py' to create your first recording[/dim]")
137
+ return
138
+
139
+ # Build table
140
+ table = Table(title=f"EPI Recordings ({len(recordings)} found)")
141
+ table.add_column("Name", style="cyan", no_wrap=True)
142
+ table.add_column("Modified", style="dim")
143
+ table.add_column("Goal", style="blue", no_wrap=False)
144
+ table.add_column("Metrics", style="purple", no_wrap=False)
145
+ table.add_column("Tags", style="green", no_wrap=False)
146
+
147
+ for recording in recordings:
148
+ info = _get_recording_info(recording)
149
+ table.add_row(
150
+ info["name"],
151
+ info["modified"],
152
+ info["goal"][:50] + "..." if len(info["goal"]) > 50 else info["goal"],
153
+ info["metrics_summary"],
154
+ info["tags_summary"]
155
+ )
156
+
157
+ console.print()
158
+ console.print(table)
159
+ console.print()
160
+ console.print(f"[dim]Tip: View a recording with 'epi view <name>'[/dim]")
epi_cli/main.py CHANGED
@@ -12,7 +12,31 @@ from epi_cli.keys import generate_default_keypair_if_missing
12
12
  # Create Typer app
13
13
  app = typer.Typer(
14
14
  name="epi",
15
- help="EPI - Evidence Packaged Infrastructure for AI workflows",
15
+ help="""EPI - Evidence Packaged Infrastructure for AI workflows
16
+
17
+ Commands:
18
+ run <script.py> Record, auto-verify and open viewer. (Zero-config)
19
+ record --out <file.epi> -- <cmd...>
20
+ Advanced: record any command, exact output file.
21
+ verify <file.epi> Verify a recording's integrity.
22
+ view <file.epi|name> Open recording in browser (name resolves ./epi-recordings/).
23
+ ls List local recordings (./epi-recordings/).
24
+ keys Manage keys (list/generate/export) - advanced.
25
+ help Show this quickstart.
26
+
27
+ Quickstart (first 30s):
28
+ 1) Install: pip install epi-recorder
29
+ 2) Record (simplest): epi run my_script.py
30
+ -> Saved: ./epi-recordings/my_script_20251121_231501.epi
31
+ -> Verified: OK
32
+ -> Viewer: opened in browser
33
+ 3) See recordings: epi ls
34
+ 4) Open a recording: epi view my_script_20251121_231501
35
+
36
+ Tips:
37
+ - Want explicit name? Use the advanced command: epi record --out experiment.epi -- python my_script.py
38
+ - For scripts using the API, use @record decorator or with record(): no filenames needed.
39
+ """,
16
40
  add_completion=False,
17
41
  no_args_is_help=True,
18
42
  rich_markup_mode="rich"
@@ -40,20 +64,61 @@ def version():
40
64
  console.print("[dim]The PDF for AI workflows[/dim]")
41
65
 
42
66
 
67
+ @app.command(name="help")
68
+ def show_help():
69
+ """Show extended quickstart help."""
70
+ help_text = """[bold cyan]EPI Recorder - Quickstart Guide[/bold cyan]
71
+
72
+ [bold]Usage:[/bold] epi <command> [options]
73
+
74
+ [bold]Commands:[/bold]
75
+ [cyan]run[/cyan] <script.py> Record, auto-verify and open viewer. (Zero-config)
76
+ [cyan]record[/cyan] --out <file.epi> -- <cmd...>
77
+ Advanced: record any command, exact output file.
78
+ [cyan]verify[/cyan] <file.epi> Verify a recording's integrity.
79
+ [cyan]view[/cyan] <file.epi|name> Open recording in browser (name resolves ./epi-recordings/).
80
+ [cyan]ls[/cyan] List local recordings (./epi-recordings/).
81
+ [cyan]keys[/cyan] Manage keys (list/generate/export) - advanced.
82
+ [cyan]help[/cyan] Show this quickstart.
83
+
84
+ [bold]Quickstart (first 30s):[/bold]
85
+ 1) Install: pip install epi-recorder
86
+ 2) Record (simplest): [green]epi run my_script.py[/green]
87
+ -> Saved: ./epi-recordings/my_script_20251121_231501.epi
88
+ -> Verified: OK
89
+ -> Viewer: opened in browser
90
+ 3) See recordings: [green]epi ls[/green]
91
+ 4) Open a recording: [green]epi view my_script_20251121_231501[/green]
92
+
93
+ [bold]Tips:[/bold]
94
+ - Want explicit name? Use the advanced command: epi record --out experiment.epi -- python my_script.py
95
+ - For scripts using the API, use @record decorator or with record(): no filenames needed.
96
+ """
97
+ console.print(help_text)
98
+
99
+
43
100
  # Import and register subcommands
44
101
  # These will be added as they're implemented
45
102
 
103
+ # NEW: run command (zero-config) - direct import
104
+ from epi_cli.run import run as run_command
105
+ app.command(name="run", help="Record, auto-verify and open viewer. (Zero-config)")(run_command)
106
+
46
107
  # Phase 1: verify command
47
108
  from epi_cli.verify import verify_app
48
109
  app.add_typer(verify_app, name="verify", help="Verify .epi file integrity and authenticity")
49
110
 
50
- # Phase 2: record command
111
+ # Phase 2: record command (legacy/advanced)
51
112
  from epi_cli.record import app as record_app
52
- app.add_typer(record_app, name="record", help="Record a workflow into a .epi file")
113
+ app.add_typer(record_app, name="record", help="Advanced: record any command, exact output file.")
53
114
 
54
115
  # Phase 3: view command
55
116
  from epi_cli.view import app as view_app
56
- app.add_typer(view_app, name="view", help="View .epi file in browser")
117
+ app.add_typer(view_app, name="view", help="Open recording in browser (name resolves ./epi-recordings/)")
118
+
119
+ # NEW: ls command
120
+ from epi_cli.ls import ls as ls_command
121
+ app.command(name="ls", help="List local recordings (./epi-recordings/)")(ls_command)
57
122
 
58
123
  # Phase 1: keys command (for manual key management)
59
124
  @app.command()
@@ -70,11 +135,11 @@ def keys(
70
135
  if action == "generate":
71
136
  try:
72
137
  private_path, public_path = key_manager.generate_keypair(name, overwrite=overwrite)
73
- console.print(f"\n[bold green] Generated key pair:[/bold green] {name}")
138
+ console.print(f"\n[bold green][OK] Generated key pair:[/bold green] {name}")
74
139
  console.print(f" [cyan]Private:[/cyan] {private_path}")
75
140
  console.print(f" [cyan]Public:[/cyan] {public_path}\n")
76
141
  except FileExistsError as e:
77
- console.print(f"[red] Error:[/red] {e}")
142
+ console.print(f"[red][FAIL] Error:[/red] {e}")
78
143
  raise typer.Exit(1)
79
144
 
80
145
  elif action == "list":
@@ -87,11 +152,11 @@ def keys(
87
152
  console.print(f"\n[bold]Public key for '{name}':[/bold]")
88
153
  console.print(f"[cyan]{public_key_b64}[/cyan]\n")
89
154
  except FileNotFoundError as e:
90
- console.print(f"[red] Error:[/red] {e}")
155
+ console.print(f"[red][FAIL] Error:[/red] {e}")
91
156
  raise typer.Exit(1)
92
157
 
93
158
  else:
94
- console.print(f"[red] Unknown action:[/red] {action}")
159
+ console.print(f"[red][FAIL] Unknown action:[/red] {action}")
95
160
  console.print("[dim]Valid actions: generate, list, export[/dim]")
96
161
  raise typer.Exit(1)
97
162
 
epi_cli/record.py CHANGED
@@ -94,10 +94,17 @@ def record(
94
94
  ):
95
95
  """
96
96
  Record a command and package the run into a .epi file.
97
+
98
+ [NOTICE] For simpler usage, try: epi run script.py
99
+ This command (epi record --out) is for advanced/CI use cases.
97
100
  """
98
101
  if not command:
99
- console.print("[red] No command provided[/red]")
102
+ console.print("[red][FAIL] No command provided[/red]")
100
103
  raise typer.Exit(1)
104
+
105
+ # Show deprecation notice
106
+ console.print("[dim][NOTICE] For simpler usage, try: epi run script.py[/dim]")
107
+ console.print("[dim]This advanced command is for CI/exact-control use cases.[/dim]\n")
101
108
 
102
109
  # Normalize command
103
110
  cmd = _ensure_python_command(command)
@@ -171,11 +178,11 @@ def record(
171
178
  temp_zip.replace(out)
172
179
  signed = True
173
180
  except Exception as e:
174
- console.print(f"[yellow]⚠️ Signing failed:[/yellow] {e}")
181
+ console.print(f"[yellow][WARN] Signing failed:[/yellow] {e}")
175
182
 
176
183
  # Final output panel
177
184
  size_mb = out.stat().st_size / (1024 * 1024)
178
- title = " Recording complete" if rc == 0 else "⚠️ Recording finished with errors"
185
+ title = "[OK] Recording complete" if rc == 0 else "[WARN] Recording finished with errors"
179
186
  panel = Panel(
180
187
  f"[bold]File:[/bold] {out}\n"
181
188
  f"[bold]Size:[/bold] {size_mb:.1f} MB\n"
epi_cli/run.py ADDED
@@ -0,0 +1,318 @@
1
+ """
2
+ EPI CLI Run - Zero-config recording command.
3
+
4
+ Usage:
5
+ epi run script.py
6
+
7
+ This command:
8
+ - Auto-generates output filename in ./epi-recordings/
9
+ - Records the script execution
10
+ - Verifies the recording
11
+ - Opens the viewer automatically
12
+ """
13
+
14
+ import os
15
+ import shlex
16
+ import sys
17
+ import tempfile
18
+ import time
19
+ import zipfile
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+ from typing import List, Optional
23
+
24
+ import typer
25
+ from rich.console import Console
26
+ from rich.panel import Panel
27
+
28
+ from epi_core.container import EPIContainer
29
+ from epi_core.schemas import ManifestModel
30
+ from epi_core.trust import verify_signature, get_signer_name, create_verification_report
31
+ from epi_cli.keys import KeyManager
32
+ from epi_recorder.environment import save_environment_snapshot
33
+
34
+ console = Console()
35
+
36
+ app = typer.Typer(name="run", help="Zero-config recording: epi run my_script.py")
37
+
38
+ DEFAULT_DIR = Path("epi-recordings")
39
+
40
+
41
+ def _gen_auto_name(script_path: Path) -> Path:
42
+ """
43
+ Generate automatic output filename in ./epi-recordings/ directory.
44
+
45
+ Args:
46
+ script_path: Path to the script being recorded
47
+
48
+ Returns:
49
+ Path to the .epi file
50
+ """
51
+ base = script_path.stem if script_path.name != "-" else "recording"
52
+ timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
53
+ DEFAULT_DIR.mkdir(parents=True, exist_ok=True)
54
+ return DEFAULT_DIR / f"{base}_{timestamp}.epi"
55
+
56
+
57
+ def _ensure_python_command(cmd: List[str]) -> List[str]:
58
+ """
59
+ Ensure the command is run with Python if it looks like a Python script.
60
+ """
61
+ if not cmd:
62
+ return cmd
63
+ first = cmd[0]
64
+ if first.lower().endswith('.py'):
65
+ return [sys.executable] + cmd
66
+ return cmd
67
+
68
+
69
+ def _build_env_for_child(steps_dir: Path, enable_redaction: bool) -> dict:
70
+ """
71
+ Build environment variables for child process to enable recording via sitecustomize.
72
+ """
73
+ env = os.environ.copy()
74
+
75
+ # Indicate recording mode and where to write steps
76
+ env["EPI_RECORD"] = "1"
77
+ env["EPI_STEPS_DIR"] = str(steps_dir)
78
+ env["EPI_REDACT"] = "1" if enable_redaction else "0"
79
+
80
+ # Create a temporary bootstrap dir with sitecustomize.py
81
+ bootstrap_dir = Path(tempfile.mkdtemp(prefix="epi_bootstrap_"))
82
+ sitecustomize = bootstrap_dir / "sitecustomize.py"
83
+ sitecustomize.write_text(
84
+ "from epi_recorder.bootstrap import initialize_recording\n",
85
+ encoding="utf-8",
86
+ )
87
+
88
+ # Prepend bootstrap dir and project root to PYTHONPATH
89
+ project_root = Path(__file__).resolve().parent.parent
90
+ existing = env.get("PYTHONPATH", "")
91
+ sep = os.pathsep
92
+ env["PYTHONPATH"] = f"{bootstrap_dir}{sep}{project_root}{(sep + existing) if existing else ''}"
93
+
94
+ return env
95
+
96
+
97
+ def _verify_recording(epi_file: Path) -> tuple[bool, str]:
98
+ """
99
+ Verify the recording and return status.
100
+
101
+ Returns:
102
+ (success, message) tuple
103
+ """
104
+ try:
105
+ manifest = EPIContainer.read_manifest(epi_file)
106
+ integrity_ok, mismatches = EPIContainer.verify_integrity(epi_file)
107
+
108
+ if not integrity_ok:
109
+ return False, f"Integrity check failed ({len(mismatches)} mismatches)"
110
+
111
+ # Check signature
112
+ if manifest.signature:
113
+ signer_name = get_signer_name(manifest.signature)
114
+ key_manager = KeyManager()
115
+
116
+ try:
117
+ public_key = key_manager.load_public_key(signer_name or "default")
118
+ signature_valid, msg = verify_signature(manifest, public_key)
119
+
120
+ if signature_valid:
121
+ return True, "OK (signed & verified)"
122
+ else:
123
+ return False, f"Signature invalid: {msg}"
124
+ except FileNotFoundError:
125
+ return True, "OK (unsigned - no public key)"
126
+ else:
127
+ return True, "OK (unsigned)"
128
+
129
+ except Exception as e:
130
+ return False, f"Verification failed: {e}"
131
+
132
+
133
+ def _open_viewer(epi_file: Path) -> bool:
134
+ """
135
+ Open the viewer for the recording.
136
+
137
+ Returns:
138
+ True if opened successfully
139
+ """
140
+ try:
141
+ import webbrowser
142
+
143
+ # Extract viewer to temp location
144
+ temp_dir = Path(tempfile.mkdtemp(prefix="epi_view_"))
145
+ viewer_path = temp_dir / "viewer.html"
146
+
147
+ with zipfile.ZipFile(epi_file, "r") as zf:
148
+ if "viewer.html" in zf.namelist():
149
+ zf.extract("viewer.html", temp_dir)
150
+ file_url = viewer_path.as_uri()
151
+ return webbrowser.open(file_url)
152
+
153
+ return False
154
+ except Exception:
155
+ return False
156
+
157
+
158
+ @app.command()
159
+ def run(
160
+ script: Path = typer.Argument(..., help="Python script to record"),
161
+ no_verify: bool = typer.Option(False, "--no-verify", help="Skip verification"),
162
+ no_open: bool = typer.Option(False, "--no-open", help="Don't open viewer automatically"),
163
+ # New metadata options
164
+ goal: Optional[str] = typer.Option(None, "--goal", help="Goal or objective of this workflow"),
165
+ notes: Optional[str] = typer.Option(None, "--notes", help="Additional notes about this workflow"),
166
+ metric: Optional[List[str]] = typer.Option(None, "--metric", help="Key=value metrics (can be used multiple times)"),
167
+ approved_by: Optional[str] = typer.Option(None, "--approved-by", help="Person who approved this workflow"),
168
+ tag: Optional[List[str]] = typer.Option(None, "--tag", help="Tags for categorizing this workflow (can be used multiple times)"),
169
+ ):
170
+ """
171
+ Zero-config recording: record + verify + view.
172
+
173
+ Example:
174
+ epi run my_script.py
175
+ epi run script.py --goal "improve accuracy" --notes "test run" --metric accuracy=0.92 --metric latency=210 --approved-by "bob" --tag test --tag v1
176
+ """
177
+ # Validate script exists
178
+ if not script.exists():
179
+ console.print(f"[red][FAIL] Error:[/red] Script not found: {script}")
180
+ raise typer.Exit(1)
181
+
182
+ # Parse metrics if provided
183
+ metrics_dict = None
184
+ if metric:
185
+ metrics_dict = {}
186
+ for m in metric:
187
+ if "=" in m:
188
+ key, value = m.split("=", 1)
189
+ # Try to convert to float if possible, otherwise keep as string
190
+ try:
191
+ metrics_dict[key] = float(value)
192
+ except ValueError:
193
+ metrics_dict[key] = value
194
+ else:
195
+ console.print(f"[yellow]Warning:[/yellow] Invalid metric format: {m} (expected key=value)")
196
+
197
+ # Auto-generate output filename
198
+ out = _gen_auto_name(script)
199
+
200
+ # Normalize command
201
+ cmd = _ensure_python_command([str(script)])
202
+
203
+ # Prepare workspace
204
+ temp_workspace = Path(tempfile.mkdtemp(prefix="epi_record_"))
205
+ steps_dir = temp_workspace
206
+ env_json = temp_workspace / "env.json"
207
+
208
+ # Capture environment snapshot
209
+ save_environment_snapshot(env_json, include_all_env_vars=False, redact_env_vars=True)
210
+
211
+ # Build child environment and run
212
+ child_env = _build_env_for_child(steps_dir, enable_redaction=True)
213
+
214
+ # Create stdout/stderr logs
215
+ stdout_log = temp_workspace / "stdout.log"
216
+ stderr_log = temp_workspace / "stderr.log"
217
+
218
+ console.print(f"[dim]Recording:[/dim] {script.name}")
219
+
220
+ import subprocess
221
+
222
+ start = time.time()
223
+ with open(stdout_log, "wb") as out_f, open(stderr_log, "wb") as err_f:
224
+ proc = subprocess.Popen(cmd, env=child_env, stdout=out_f, stderr=err_f)
225
+ rc = proc.wait()
226
+ duration = round(time.time() - start, 3)
227
+
228
+ # Build manifest with metadata
229
+ manifest = ManifestModel(
230
+ cli_command=" ".join(shlex.quote(c) for c in cmd),
231
+ goal=goal,
232
+ notes=notes,
233
+ metrics=metrics_dict,
234
+ approved_by=approved_by,
235
+ tags=tag
236
+ )
237
+
238
+ # Package into .epi
239
+ EPIContainer.pack(temp_workspace, manifest, out)
240
+
241
+ # Auto-sign
242
+ signed = False
243
+ try:
244
+ km = KeyManager()
245
+ priv = km.load_private_key("default")
246
+
247
+ # Read manifest from ZIP
248
+ import json as _json
249
+ with zipfile.ZipFile(out, "r") as zf:
250
+ raw = zf.read("manifest.json").decode("utf-8")
251
+ data = _json.loads(raw)
252
+
253
+ # Sign manifest
254
+ from epi_core.schemas import ManifestModel as _MM
255
+ from epi_core.trust import sign_manifest as _sign
256
+ m = _MM(**data)
257
+ sm = _sign(m, priv, "default")
258
+ signed_json = sm.model_dump_json(indent=2)
259
+
260
+ # Replace manifest in ZIP
261
+ temp_zip = out.with_suffix(".epi.tmp")
262
+ with zipfile.ZipFile(out, "r") as zf_in:
263
+ with zipfile.ZipFile(temp_zip, "w", zipfile.ZIP_DEFLATED) as zf_out:
264
+ for item in zf_in.namelist():
265
+ if item != "manifest.json":
266
+ zf_out.writestr(item, zf_in.read(item))
267
+ zf_out.writestr("manifest.json", signed_json)
268
+
269
+ temp_zip.replace(out)
270
+ signed = True
271
+ except Exception:
272
+ pass # Non-fatal
273
+
274
+ # Verify
275
+ verified = False
276
+ verify_msg = "Skipped"
277
+ if not no_verify:
278
+ verified, verify_msg = _verify_recording(out)
279
+
280
+ # Open viewer
281
+ viewer_opened = False
282
+ if not no_open and rc == 0 and verified:
283
+ viewer_opened = _open_viewer(out)
284
+
285
+ # Print results
286
+ size_mb = out.stat().st_size / (1024 * 1024)
287
+
288
+ lines = []
289
+ lines.append(f"[bold]Saved:[/bold] {out}")
290
+ lines.append(f"[bold]Size:[/bold] {size_mb:.2f} MB")
291
+ lines.append(f"[bold]Duration:[/bold] {duration}s")
292
+
293
+ if not no_verify:
294
+ if verified:
295
+ lines.append(f"[bold]Verified:[/bold] [green]{verify_msg}[/green]")
296
+ else:
297
+ lines.append(f"[bold]Verified:[/bold] [red]{verify_msg}[/red]")
298
+
299
+ if viewer_opened:
300
+ lines.append(f"[bold]Viewer:[/bold] [green]Opened in browser[/green]")
301
+ elif not no_open:
302
+ lines.append(f"[bold]Viewer:[/bold] [yellow]Could not open automatically[/yellow]")
303
+ lines.append(f"[dim]Open with:[/dim] epi view {out.name}")
304
+
305
+ title = "[OK] Recording complete" if rc == 0 else "[WARN] Recording finished with errors"
306
+ panel = Panel(
307
+ "\n".join(lines),
308
+ title=title,
309
+ border_style="green" if rc == 0 else "yellow",
310
+ )
311
+ console.print(panel)
312
+
313
+ # Exit with appropriate code
314
+ if rc != 0:
315
+ raise typer.Exit(rc)
316
+ if not verified and not no_verify:
317
+ raise typer.Exit(1)
318
+ raise typer.Exit(0)