bug2context 0.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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
bug2context/android.py ADDED
@@ -0,0 +1,179 @@
1
+ """Android capture: screen recording and logcat, started together.
2
+
3
+ Recording both from one command is what makes the timelines line up — the
4
+ alignment anchor is simply the moment we start, recorded on the host clock.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import subprocess
11
+ import time
12
+ from dataclasses import dataclass
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+
16
+ DEVICE_VIDEO = "/sdcard/bug2context.mp4"
17
+ SCREENRECORD_LIMIT = 180
18
+ """screenrecord stops itself at 3 minutes; asking for more silently truncates."""
19
+
20
+ SETTLE_SECONDS = 3.0
21
+ """Grace for screenrecord to close the container before the file is pulled.
22
+
23
+ Measured: pulling immediately yields a file ffprobe cannot read at all.
24
+ """
25
+
26
+
27
+ class AdbError(RuntimeError):
28
+ pass
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Capture:
33
+ """What a recording session produced."""
34
+
35
+ video: Path
36
+ logcat: Path
37
+ started_at: datetime
38
+ pid: int | None = None
39
+ """PID of the app under test, when a package was named."""
40
+ stopped_early: bool = False
41
+ """Whether the operator cut the recording short."""
42
+
43
+
44
+ def _adb(*args: str, serial: str | None = None) -> list[str]:
45
+ return ["adb", *(["-s", serial] if serial else []), *args]
46
+
47
+
48
+ def require_device(serial: str | None = None) -> str:
49
+ """Fail early with an actionable message rather than mid-recording."""
50
+ if shutil.which("adb") is None:
51
+ raise AdbError("adb not found. Install Android platform-tools.")
52
+
53
+ proc = subprocess.run(_adb("devices"), capture_output=True, text=True)
54
+ devices = [
55
+ line.split("\t")[0]
56
+ for line in proc.stdout.splitlines()[1:]
57
+ if line.strip().endswith("device")
58
+ ]
59
+ if not devices:
60
+ raise AdbError("No Android device connected. Check USB debugging is on.")
61
+ if serial and serial not in devices:
62
+ raise AdbError(f"Device {serial} not connected. Found: {', '.join(devices)}")
63
+ if not serial and len(devices) > 1:
64
+ raise AdbError(f"Several devices connected, pass --serial: {', '.join(devices)}")
65
+ return serial or devices[0]
66
+
67
+
68
+ def resolve_pid(package: str, serial: str | None = None) -> int | None:
69
+ """PID of a running package, or None if it is not running.
70
+
71
+ Not running is not an error: the user may be about to launch it. The logs
72
+ simply stay unscoped in that case.
73
+ """
74
+ proc = subprocess.run(
75
+ _adb("shell", "pidof", "-s", package, serial=serial),
76
+ capture_output=True, text=True,
77
+ )
78
+ pid = proc.stdout.strip()
79
+ return int(pid) if pid.isdigit() else None
80
+
81
+
82
+ def _stop_recording(serial: str | None) -> None:
83
+ """Ask screenrecord on the device to finish and close its file.
84
+
85
+ It has to be signalled on the device: terminating the local adb was
86
+ measured to leave an unreadable file, while SIGINT there finalises it.
87
+ pkill also matches processes it may not signal and says so on stderr —
88
+ that complaint is not about screenrecord, so it is ignored.
89
+ """
90
+ subprocess.run(
91
+ _adb("shell", "pkill", "-INT", "screenrecord", serial=serial),
92
+ capture_output=True,
93
+ )
94
+
95
+
96
+ def _wait_or_kill(proc: subprocess.Popen, timeout: float = 20.0) -> None:
97
+ try:
98
+ proc.wait(timeout=timeout)
99
+ except subprocess.TimeoutExpired:
100
+ proc.kill()
101
+ proc.wait(timeout=5)
102
+
103
+
104
+ def record(
105
+ out_dir: Path,
106
+ seconds: int = 60,
107
+ serial: str | None = None,
108
+ bit_rate: str = "4M",
109
+ size: str | None = None,
110
+ package: str | None = None,
111
+ ) -> Capture:
112
+ """Record screen and logcat together, then pull both off the device.
113
+
114
+ Blocks until the time runs out or Ctrl-C cuts it short; either way the file
115
+ is finalised on the device before it is pulled. The returned started_at is
116
+ what puts the log entries on the video clock later.
117
+ """
118
+ serial = require_device(serial)
119
+ if seconds > SCREENRECORD_LIMIT:
120
+ raise AdbError(
121
+ f"screenrecord caps at {SCREENRECORD_LIMIT}s; asked for {seconds}s."
122
+ )
123
+
124
+ out_dir.mkdir(parents=True, exist_ok=True)
125
+ video = out_dir / "screen.mp4"
126
+ logcat = out_dir / "logcat.txt"
127
+
128
+ # Capture unfiltered and scope afterwards: if the app crashes and restarts,
129
+ # a --pid capture would stop at the death of the old process.
130
+ pid = resolve_pid(package, serial) if package else None
131
+
132
+ subprocess.run(_adb("logcat", "-c", serial=serial), capture_output=True)
133
+
134
+ record_cmd = _adb(
135
+ "shell", "screenrecord", "--bit-rate", bit_rate,
136
+ "--time-limit", str(seconds),
137
+ *(["--size", size] if size else []),
138
+ DEVICE_VIDEO,
139
+ serial=serial,
140
+ )
141
+
142
+ # Both start now; this timestamp is the alignment anchor for the logs.
143
+ started_at = datetime.now()
144
+ stopped_early = False
145
+ with logcat.open("w") as log_file:
146
+ log_proc = subprocess.Popen(
147
+ _adb("logcat", "-v", "threadtime", serial=serial),
148
+ stdout=log_file, stderr=subprocess.DEVNULL,
149
+ )
150
+ # Its own session, so Ctrl-C reaches this process and not adb: killing
151
+ # adb mid-recording leaves an unfinalised file that ffprobe rejects.
152
+ rec_proc = subprocess.Popen(
153
+ record_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
154
+ start_new_session=True,
155
+ )
156
+ try:
157
+ rec_proc.wait(timeout=seconds + 30)
158
+ except KeyboardInterrupt:
159
+ stopped_early = True
160
+ _stop_recording(serial)
161
+ _wait_or_kill(rec_proc)
162
+ except subprocess.TimeoutExpired:
163
+ _stop_recording(serial)
164
+ _wait_or_kill(rec_proc)
165
+ finally:
166
+ log_proc.terminate()
167
+ log_proc.wait(timeout=10)
168
+
169
+ # screenrecord finishes writing the container after it stops.
170
+ time.sleep(SETTLE_SECONDS)
171
+ pull = subprocess.run(
172
+ _adb("pull", DEVICE_VIDEO, str(video), serial=serial),
173
+ capture_output=True, text=True,
174
+ )
175
+ if pull.returncode != 0 or not video.is_file():
176
+ raise AdbError(f"Could not pull the recording off the device:\n{pull.stderr}")
177
+ subprocess.run(_adb("shell", "rm", DEVICE_VIDEO, serial=serial), capture_output=True)
178
+
179
+ return Capture(video, logcat, started_at, pid, stopped_early)
bug2context/cli.py ADDED
@@ -0,0 +1,90 @@
1
+ from pathlib import Path
2
+
3
+ import typer
4
+
5
+ from .config import Config
6
+
7
+ app = typer.Typer(help="Turn bug screen recordings into context for AI agents.")
8
+
9
+
10
+ @app.callback(invoke_without_command=True)
11
+ def main(ctx: typer.Context) -> None:
12
+ """Run with no arguments for a guided menu; the subcommands take flags."""
13
+ if ctx.invoked_subcommand is None:
14
+ from .menu import run
15
+
16
+ run()
17
+
18
+
19
+ @app.command()
20
+ def process(
21
+ video: Path = typer.Argument(..., exists=True, dir_okay=False),
22
+ out: Path = typer.Option(Path("bundle"), "--out", "-o"),
23
+ scene_threshold: float = Config.scene_threshold,
24
+ max_duration: float = Config.max_duration,
25
+ interval_seconds: float = Config.interval_seconds,
26
+ max_frames: int | None = Config.max_frames,
27
+ phash_distance: int = Config.phash_distance,
28
+ ocr: bool = Config.ocr,
29
+ ocr_upscale: int = Config.ocr_upscale,
30
+ ocr_max_lines: int = Config.ocr_max_lines,
31
+ transcribe: bool = Config.transcribe,
32
+ whisper_model: str = Config.whisper_model,
33
+ language: str | None = Config.language,
34
+ logcat: Path | None = typer.Option(None, exists=True, dir_okay=False),
35
+ log_levels: str = Config.log_levels,
36
+ log_tag: str | None = Config.log_tag,
37
+ log_offset: float = Config.log_offset,
38
+ video_started_at: str | None = Config.video_started_at,
39
+ log_pid: int | None = Config.log_pid,
40
+ log_max_lines: int = Config.log_max_lines,
41
+ ) -> None:
42
+ """Process VIDEO into a context bundle."""
43
+ from .pipeline.assemble import build_bundle
44
+
45
+ cfg = Config(
46
+ scene_threshold, max_duration, interval_seconds, max_frames, phash_distance,
47
+ ocr, ocr_upscale, ocr_max_lines,
48
+ transcribe, whisper_model, language,
49
+ str(logcat) if logcat else None,
50
+ log_levels, log_tag, log_offset, video_started_at,
51
+ log_pid, log_max_lines,
52
+ )
53
+ typer.echo(build_bundle(video, out, cfg))
54
+
55
+
56
+ @app.command()
57
+ def record(
58
+ out: Path = typer.Option(Path("bundle"), "--out", "-o"),
59
+ seconds: int = typer.Option(60, "--seconds", "-t", help="Recording length."),
60
+ serial: str | None = typer.Option(None, help="Device serial, if several are attached."),
61
+ size: str | None = typer.Option(None, help="e.g. 720x1280 — smaller records faster."),
62
+ package: str | None = typer.Option(None, help="App package, to scope logs to it."),
63
+ log_levels: str = Config.log_levels,
64
+ log_tag: str | None = Config.log_tag,
65
+ ocr: bool = Config.ocr,
66
+ max_frames: int | None = Config.max_frames,
67
+ ) -> None:
68
+ """Record an Android device's screen and logcat, then build the bundle.
69
+
70
+ Reproduce the bug while it records; both streams share one start time, so
71
+ the stack trace lands next to the frame that shows the crash.
72
+ """
73
+ from .android import AdbError, record as capture
74
+ from .pipeline.assemble import build_bundle
75
+
76
+ try:
77
+ session = capture(out, seconds=seconds, serial=serial, size=size, package=package)
78
+ except AdbError as error:
79
+ raise typer.BadParameter(str(error)) from error
80
+
81
+ cfg = Config(
82
+ max_frames=max_frames,
83
+ ocr=ocr,
84
+ logcat_path=str(session.logcat),
85
+ log_levels=log_levels,
86
+ log_tag=log_tag,
87
+ video_started_at=session.started_at.isoformat(),
88
+ log_pid=session.pid,
89
+ )
90
+ typer.echo(build_bundle(session.video, out, cfg))
bug2context/config.py ADDED
@@ -0,0 +1,93 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class Config:
6
+ """Pipeline options. Defaults come from PLAN section 3."""
7
+
8
+ scene_threshold: float = 0.08
9
+
10
+ max_duration: float = 600.0
11
+ """Refuse recordings longer than this, in seconds. 0 disables the guard.
12
+
13
+ Nothing here scales badly with length except time, and it scales linearly:
14
+ decoding alone ran at roughly a third of real time on a 60 fps capture, so
15
+ a 44 minute recording means a quarter of an hour before the first frame is
16
+ read. Trimming to the interesting minute is almost always what was meant.
17
+ """
18
+ interval_seconds: float = 1.0
19
+ max_frames: int | None = None
20
+ """Hard cap on published frames; None derives one from the video's length.
21
+
22
+ A fixed 20 was wrong in both directions. Deduplication already collapses
23
+ ordinary recordings well below it — measured 7 states in a 44 s capture and
24
+ 12 in an 11 s one — so the cap never bound there. On an animated app it bit
25
+ hard: one 52 s recording held over 70 distinct states, and 20 threw away
26
+ two thirds of them. Across ten real analyses the cap was raised by hand
27
+ seven times.
28
+ """
29
+
30
+ phash_distance: int = 4
31
+ """Frames closer than this are treated as duplicates.
32
+
33
+ Calibration knob. Measured on encoded video: identical screens sit at 0, a
34
+ small text-only change lands around 6 before compression and can drop to 4.
35
+ Text is the payload of a bug report, so the default errs toward keeping —
36
+ the max_frames cap absorbs the extra. Raise it for videos of constantly
37
+ animating UI that produce noisy near-duplicates.
38
+ """
39
+
40
+ ocr: bool = True
41
+ """Read on-screen text. Silently skipped when no OCR backend is installed."""
42
+
43
+ ocr_upscale: int = 2
44
+ """Enlarge frames before OCR.
45
+
46
+ Calibration knob. Recompressed video (WhatsApp is the worst offender)
47
+ smears small text. Measured on a 1080x1920 frame, a stack trace read as
48
+ "Null PointerSxception Ride Requestkt14" at 1x and correctly at 3x. 2x is
49
+ the default because it recovers most of that at a fraction of the cost;
50
+ raise it for badly recompressed sources.
51
+ """
52
+
53
+ ocr_max_lines: int = 12
54
+ """Cap on text lines reported per frame, so chrome cannot bury the signal."""
55
+
56
+ transcribe: bool = False
57
+ """Transcribe spoken narration.
58
+
59
+ Off by default: it downloads a model on first use and is the slowest stage
60
+ by far, while most bug recordings have no voice-over at all.
61
+ """
62
+
63
+ whisper_model: str = "small"
64
+ """faster-whisper size. 'tiny'/'base' are faster, 'medium' more accurate."""
65
+
66
+ language: str | None = None
67
+ """Force a language code (e.g. "es") instead of letting whisper detect it."""
68
+
69
+ logcat_path: str | None = None
70
+ """Device log file to merge (`adb logcat -v threadtime > log.txt`)."""
71
+
72
+ log_levels: str = "WEF"
73
+ """Minimum logcat levels to keep. A crash drowns in a busy app's debug chatter."""
74
+
75
+ log_tag: str | None = None
76
+ """Only keep entries whose tag contains this substring."""
77
+
78
+ log_offset: float = 0.0
79
+ """Seconds to shift logs by. Manual escape when device and host clocks disagree."""
80
+
81
+ video_started_at: str | None = None
82
+ """ISO time the recording began, used to align logs. `record` fills this in."""
83
+
84
+ log_pid: int | None = None
85
+ """Only keep logs from this process. `record --package` resolves it for you.
86
+
87
+ Level filtering alone is not enough on a real device: a 2 min capture from
88
+ a phone held 7,634 W/E/F lines and none came from the app under test.
89
+ Crash tags (AndroidRuntime, DEBUG) are kept regardless of process.
90
+ """
91
+
92
+ log_max_lines: int = 80
93
+ """Cap on log entries in the report, most severe first. meta.json keeps all."""
@@ -0,0 +1,119 @@
1
+ """MCP server: the primary interface, per PLAN principle 2.
2
+
3
+ Exposes the pipeline as tools so an agent can go from "here is a video of the
4
+ bug" to a chronology without the user running anything by hand.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+
13
+ from mcp.server.mcpserver import Image, MCPServer
14
+
15
+ from .config import Config
16
+
17
+ DEFAULT_WORKDIR = Path("~/.bug2context").expanduser()
18
+
19
+ mcp = MCPServer(
20
+ name="bug2context",
21
+ instructions=(
22
+ "Turns screen recordings of bugs into a chronological, readable report. "
23
+ "Call analyze_bug_video first; it returns the report text plus the bundle "
24
+ "path. Use get_frame to actually look at a moment the report describes."
25
+ ),
26
+ )
27
+
28
+
29
+ def _bundle_dir(video: Path, workdir: Path) -> Path:
30
+ """A directory that does not exist yet.
31
+
32
+ The timestamp only resolves to seconds, so two analyses in the same second
33
+ would land on the same path and the second would overwrite the first.
34
+ """
35
+ base = workdir / f"{video.stem}-{datetime.now():%Y%m%d-%H%M%S}"
36
+ candidate, n = base, 2
37
+ while candidate.exists():
38
+ candidate = base.with_name(f"{base.name}-{n}")
39
+ n += 1
40
+ return candidate
41
+
42
+
43
+ @mcp.tool()
44
+ def analyze_bug_video(
45
+ video_path: str,
46
+ max_frames: int | None = None,
47
+ ocr: bool = Config.ocr,
48
+ transcribe_audio: bool = Config.transcribe,
49
+ workdir: str = str(DEFAULT_WORKDIR),
50
+ ) -> str:
51
+ """Process a bug screen recording into a chronological markdown report.
52
+
53
+ Returns the report itself, so you can reason about it directly. Frames are
54
+ written next to it and referenced by relative path; use get_frame to view one.
55
+
56
+ Leave max_frames unset unless the report looks too coarse or too long: it
57
+ is derived from the recording's length.
58
+
59
+ Set transcribe_audio when the recording has someone narrating the bug; it
60
+ is slow and downloads a model on first use, so it stays off otherwise.
61
+ """
62
+ from .pipeline.assemble import build_bundle
63
+
64
+ video = Path(video_path).expanduser()
65
+ if not video.is_file():
66
+ return f"No video at {video}. Give me the full path to the recording."
67
+
68
+ out_dir = _bundle_dir(video, Path(workdir).expanduser())
69
+ cfg = Config(max_frames=max_frames, ocr=ocr, transcribe=transcribe_audio)
70
+ report_path = build_bundle(video, out_dir, cfg)
71
+
72
+ return f"Bundle: {out_dir}\n\n{report_path.read_text()}"
73
+
74
+
75
+ @mcp.tool()
76
+ def get_frame(bundle_path: str, frame_index: int) -> Image:
77
+ """Return one frame from a bundle so you can look at it.
78
+
79
+ frame_index is the 1-based number shown in the report (frame_003 -> 3).
80
+ """
81
+ frames_dir = Path(bundle_path).expanduser() / "frames"
82
+ matches = sorted(frames_dir.glob(f"frame_{frame_index:03d}_*.png"))
83
+ if not matches:
84
+ available = len(list(frames_dir.glob("frame_*.png")))
85
+ raise ValueError(
86
+ f"No frame {frame_index} in {bundle_path} (bundle has {available})."
87
+ )
88
+ return Image(path=matches[0])
89
+
90
+
91
+ @mcp.tool()
92
+ def list_bundles(workdir: str = str(DEFAULT_WORKDIR)) -> list[dict]:
93
+ """List previously processed bundles, newest first."""
94
+ root = Path(workdir).expanduser()
95
+ if not root.is_dir():
96
+ return []
97
+
98
+ bundles = []
99
+ for meta_path in root.glob("*/meta.json"):
100
+ try:
101
+ meta = json.loads(meta_path.read_text())
102
+ except (OSError, json.JSONDecodeError):
103
+ continue # a half-written bundle should not break the listing
104
+ bundles.append({
105
+ "bundle_path": str(meta_path.parent),
106
+ "video": meta.get("video"),
107
+ "generated_at": meta.get("generated_at"),
108
+ "frames": len(meta.get("frames", [])),
109
+ })
110
+ return sorted(bundles, key=lambda b: b["generated_at"] or "", reverse=True)
111
+
112
+
113
+ def main() -> None:
114
+ """Entry point for `bug2context-mcp` and `python -m bug2context.mcp_server`."""
115
+ mcp.run(transport="stdio")
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
bug2context/menu.py ADDED
@@ -0,0 +1,160 @@
1
+ """Guided flow for people who do not want to remember flags.
2
+
3
+ Everything here is a thin shell over the same commands: the menu asks for what
4
+ the flags would have carried, then calls the identical code path. Nothing is
5
+ reachable only through the menu.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import date
11
+ from pathlib import Path
12
+
13
+ from rich.console import Console
14
+ from rich.prompt import Confirm, IntPrompt, Prompt
15
+ from rich.table import Table
16
+
17
+ from .config import Config
18
+
19
+ DEFAULT_WORKDIR = Path("~/.bug2context").expanduser()
20
+ console = Console()
21
+
22
+
23
+ def _slugify(text: str) -> str:
24
+ kept = [c.lower() if c.isalnum() else "-" for c in text.strip()]
25
+ return "-".join(filter(None, "".join(kept).split("-"))) or "bug"
26
+
27
+
28
+ def _bundle_path(label: str) -> Path:
29
+ """Bundles land where the MCP server looks, so Claude can find them."""
30
+ base = DEFAULT_WORKDIR / f"{_slugify(label)}-{date.today():%Y-%m-%d}"
31
+ candidate, n = base, 2
32
+ while candidate.exists():
33
+ candidate = base.with_name(f"{base.name}-{n}")
34
+ n += 1
35
+ return candidate
36
+
37
+
38
+ def _report(bundle: Path) -> None:
39
+ console.print(f"\n[green]Listo[/green] → [bold]{bundle}[/bold]")
40
+ console.print("Pídeselo a Claude Code así:")
41
+ console.print(f' [dim]analiza el bundle {bundle.name}[/dim]\n')
42
+
43
+
44
+ def _record_android() -> None:
45
+ from .android import AdbError, record, resolve_pid
46
+
47
+ try:
48
+ from .android import require_device
49
+
50
+ serial = require_device()
51
+ except AdbError as error:
52
+ console.print(f"[red]{error}[/red]")
53
+ return
54
+
55
+ console.print(f"Dispositivo: [bold]{serial}[/bold]")
56
+ label = Prompt.ask("¿Qué bug vas a reproducir?", default="bug")
57
+ seconds = IntPrompt.ask(
58
+ "Segundos de grabación (máx. 180)", default=120, choices=None
59
+ )
60
+ if seconds > 180:
61
+ console.print("[yellow]screenrecord corta a 180 s; uso 180.[/yellow]")
62
+ seconds = 180
63
+
64
+ package = None
65
+ if Confirm.ask("¿Acotar los logs a una app?", default=False):
66
+ package = Prompt.ask("Paquete (ej. com.rumborides.driver)")
67
+ if resolve_pid(package, serial) is None:
68
+ console.print(
69
+ f"[yellow]{package} no está corriendo; los logs quedarán sin acotar.[/yellow]"
70
+ )
71
+
72
+ bundle = _bundle_path(label)
73
+ console.print(
74
+ f"\n[bold]Grabando {seconds} s.[/bold] Reproduce el bug ahora — "
75
+ "si la pantalla no cambia, no habrá nada que contar."
76
+ )
77
+ console.print("[dim]Ctrl-C corta antes de tiempo sin dañar el video.[/dim]\n")
78
+ try:
79
+ session = record(bundle, seconds=seconds, serial=serial, package=package)
80
+ except AdbError as error:
81
+ console.print(f"[red]{error}[/red]")
82
+ return
83
+
84
+ if session.stopped_early:
85
+ console.print("[dim]Grabación cortada por ti.[/dim]")
86
+
87
+ from .pipeline.assemble import build_bundle
88
+
89
+ with console.status("Procesando…"):
90
+ build_bundle(session.video, bundle, Config(
91
+ logcat_path=str(session.logcat),
92
+ log_pid=session.pid,
93
+ video_started_at=session.started_at.isoformat(),
94
+ ))
95
+ _report(bundle)
96
+
97
+
98
+ def _process_video() -> None:
99
+ from .pipeline.assemble import build_bundle
100
+ from .pipeline.frames import FFmpegError
101
+
102
+ raw = Prompt.ask("Ruta del video")
103
+ video = Path(raw.strip().strip("'\"")).expanduser()
104
+ if not video.is_file():
105
+ console.print(f"[red]No hay ningún archivo en {video}[/red]")
106
+ return
107
+
108
+ cfg = Config()
109
+ if Confirm.ask("¿El video viene recomprimido (WhatsApp)?", default=False):
110
+ cfg = Config(ocr_upscale=3)
111
+
112
+ bundle = _bundle_path(Prompt.ask("Nombre para el bundle", default=video.stem))
113
+ with console.status("Procesando…"):
114
+ try:
115
+ build_bundle(video, bundle, cfg)
116
+ except FFmpegError as error:
117
+ console.print(f"[yellow]{error}[/yellow]")
118
+ return
119
+ _report(bundle)
120
+
121
+
122
+ def _list_bundles() -> None:
123
+ from .mcp_server import list_bundles
124
+
125
+ bundles = list_bundles(str(DEFAULT_WORKDIR))
126
+ if not bundles:
127
+ console.print(f"[dim]Todavía no hay bundles en {DEFAULT_WORKDIR}[/dim]")
128
+ return
129
+
130
+ table = Table(box=None, header_style="bold")
131
+ table.add_column("Bundle")
132
+ table.add_column("Frames", justify="right")
133
+ table.add_column("Creado")
134
+ for entry in bundles:
135
+ table.add_row(
136
+ Path(entry["bundle_path"]).name,
137
+ str(entry["frames"]),
138
+ (entry["generated_at"] or "")[:16].replace("T", " "),
139
+ )
140
+ console.print(table)
141
+
142
+
143
+ ACTIONS = {
144
+ "1": ("Grabar un Android (pantalla + logs)", _record_android),
145
+ "2": ("Procesar un video que ya tienes", _process_video),
146
+ "3": ("Ver bundles anteriores", _list_bundles),
147
+ }
148
+
149
+
150
+ def run() -> None:
151
+ """Show the menu once and run the chosen action."""
152
+ console.print("\n[bold]bug2context[/bold] — contexto de bugs para agentes\n")
153
+ for key, (label, _) in ACTIONS.items():
154
+ console.print(f" [bold]{key}[/bold] {label}")
155
+ console.print(" [bold]q[/bold] Salir\n")
156
+
157
+ choice = Prompt.ask("Elige", choices=[*ACTIONS, "q"], default="1")
158
+ if choice == "q":
159
+ return
160
+ ACTIONS[choice][1]()
File without changes