bugcap 0.2.2__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.
bugcap/cli.py ADDED
@@ -0,0 +1,942 @@
1
+ import argparse
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ import threading
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from . import backends, capture, config, ghcli, recorder, refs, repo, service, sync, validation
11
+ from .capture import CaptureError
12
+ from .errors import ServiceError
13
+ from .ghcli import GhError
14
+ from .store import STATUSES, Store
15
+
16
+
17
+ # --- shared helpers (T014) ----------------------------------------------------
18
+
19
+ def current_repo():
20
+ """The RepoConfig discovered from the working directory, or None."""
21
+ return repo.load_repo_config()
22
+
23
+
24
+ def resolve_report(store: Store, report_id: int):
25
+ """Return the report, or print the standard not-found error and return None."""
26
+ report = store.get(report_id)
27
+ if report is None:
28
+ print(f"error: no report with id {report_id}", file=sys.stderr)
29
+ return report
30
+
31
+
32
+ from .sync import human_size # noqa: E402,F401
33
+
34
+
35
+ def print_service_error(exc: ServiceError) -> None:
36
+ print(f"error: {exc.message}", file=sys.stderr)
37
+ if exc.code == "invalid_reference":
38
+ print("valid references: " + (", ".join(exc.details.get("valid", [])) or "(none)"), file=sys.stderr)
39
+
40
+
41
+ def print_ingest(added, rejected) -> None:
42
+ """One line per input: `added #<idx> ...` on stdout, `skipped ...` on stderr."""
43
+ for media in added:
44
+ name = os.path.basename(media.path or "")
45
+ print(f"added #{media.idx} {name} ({media.kind}, {human_size(media.size_bytes)})")
46
+ for item in rejected:
47
+ print(f"skipped {item['source']}: {item['reason']}", file=sys.stderr)
48
+
49
+
50
+ # --- capture / list / show ----------------------------------------------------
51
+
52
+ def cmd_capture(args: argparse.Namespace) -> int:
53
+ cfg = current_repo()
54
+ captured: Optional[str] = None
55
+ if not args.image:
56
+ try:
57
+ captured = str(capture.capture_screenshot())
58
+ except CaptureError as exc:
59
+ print(f"error: {exc}", file=sys.stderr)
60
+ return 1
61
+
62
+ title = args.title or input("Title: ").strip()
63
+ notes = args.note
64
+ if notes is None:
65
+ notes = input("Notes (optional): ").strip()
66
+
67
+ tags = list(args.tag or [])
68
+ repo_key: Optional[str] = None
69
+ if cfg is not None:
70
+ repo_key = cfg.key
71
+ if cfg.tag not in tags:
72
+ tags.append(cfg.tag)
73
+
74
+ labels = list(args.label or [])
75
+ with Store() as store:
76
+ try:
77
+ report, result = service.create_report(
78
+ store,
79
+ title=title,
80
+ notes=notes or "",
81
+ tags=tags,
82
+ repo=repo_key,
83
+ sources=list(args.image or []),
84
+ labels=labels if args.image else None,
85
+ require_media=bool(args.image),
86
+ captured=captured,
87
+ captured_label=(labels[0] if labels and not args.image else None),
88
+ )
89
+ except ServiceError as exc:
90
+ print_service_error(exc)
91
+ return 1
92
+
93
+ if report is None:
94
+ print_ingest([], result.rejected)
95
+ print("error: no image could be added; no report saved", file=sys.stderr)
96
+ return 1
97
+
98
+ print(f"Saved report #{report.id}: {report.title}")
99
+ for path in report.image_paths:
100
+ print(f" image: {path}")
101
+ if args.image:
102
+ print_ingest(result.added, result.rejected)
103
+ return 1 if result.rejected else 0
104
+
105
+
106
+ def cmd_list(args: argparse.Namespace) -> int:
107
+ cfg = current_repo()
108
+ scope = cfg.key if (cfg is not None and not args.all) else None
109
+
110
+ with Store() as store:
111
+ reports = store.list(repo=scope)
112
+
113
+ if not reports:
114
+ print("No reports yet. Run `bugcap capture` to create one.")
115
+ return 0
116
+
117
+ for report in reports:
118
+ tags = f" [{', '.join(report.tags)}]" if report.tags else ""
119
+ print(f"#{report.id} {report.created_at} {report.status:8s} {report.title}{tags}")
120
+ return 0
121
+
122
+
123
+ def cmd_show(args: argparse.Namespace) -> int:
124
+ with Store() as store:
125
+ report = resolve_report(store, args.id)
126
+ if report is None:
127
+ return 1
128
+
129
+ print(f"#{report.id} {report.title}")
130
+ print(f" created_at: {report.created_at}")
131
+ print(f" status: {report.status}")
132
+ if report.repo:
133
+ print(f" repo: {report.repo}")
134
+ print(f" tags: {', '.join(report.tags) or '(none)'}")
135
+ print(f" notes: {refs.display_notes(report.notes, report.media) or '(none)'}")
136
+ print(f" images:")
137
+ for path in report.image_paths:
138
+ print(f" - {path}")
139
+ detailed = [m for m in report.media if not (m.kind == "image" and m.source == "legacy")]
140
+ if detailed:
141
+ print(" media:")
142
+ for m in detailed:
143
+ where = m.path or f"({len(m.frames)} frames)"
144
+ origin = f" (source: {m.source})" if m.source else ""
145
+ print(f" #{m.idx} {m.kind:8s} {m.label or '-':12s} {where} {human_size(m.size_bytes)}{origin}")
146
+ print(f" synced_refs:")
147
+ if report.synced_refs:
148
+ for tracker, ref in report.synced_refs.items():
149
+ print(f" - {tracker}: {ref}")
150
+ else:
151
+ print(" (not synced to any tracker yet)")
152
+ return 0
153
+
154
+
155
+ # --- setup (T017) -------------------------------------------------------------
156
+
157
+ def _setup_capture(args: argparse.Namespace) -> int:
158
+ backend = backends.detect()
159
+ if backend is not None:
160
+ print(f"Capture tool found: {backend.name} ({backend.description}).")
161
+ return 0
162
+
163
+ rec = backends.recommended()
164
+ cmd = backends.install_command(rec)
165
+ if cmd is None:
166
+ print(
167
+ f"No capture tool found and no supported package manager detected.\n"
168
+ f"Install {rec.name} manually: {backends.guidance(rec)}",
169
+ file=sys.stderr,
170
+ )
171
+ return 1
172
+
173
+ joined = " ".join(cmd)
174
+ if not args.yes:
175
+ if not sys.stdin.isatty():
176
+ print(
177
+ f"No capture tool found. Install {rec.name} with:\n {joined}\n"
178
+ f"(re-run with --yes to install automatically)",
179
+ file=sys.stderr,
180
+ )
181
+ return 1
182
+ answer = input(f"Install {rec.name}? [y/N] ").strip().lower()
183
+ if answer != "y":
184
+ print("Nothing installed.")
185
+ return 1
186
+
187
+ print(f"Installing {rec.name}: {joined}")
188
+ rc = subprocess.run(cmd).returncode
189
+ if rc != 0:
190
+ print(f"error: install command failed (exit {rc}).", file=sys.stderr)
191
+ return 1
192
+ print(f"{rec.name} installed.")
193
+ return 0
194
+
195
+
196
+ def _setup_recorder(args: argparse.Namespace) -> None:
197
+ """Recorder lines are appended after the capture-tool output."""
198
+ found = recorder.detect_recorder()
199
+ if found is not None:
200
+ print(f"Screen recorder found: {found.name} ({found.description}).")
201
+ return
202
+ ffmpeg = recorder.by_name("ffmpeg")
203
+ cmd = backends.install_command(ffmpeg)
204
+ print("No screen recorder found (needed for `bugcap record`).")
205
+ print(f" Install ffmpeg: {' '.join(cmd) if cmd else backends.guidance(ffmpeg)}")
206
+ if backends.platform_key() == "linux" and os.environ.get("WAYLAND_DISPLAY"):
207
+ print(f" On Wayland, also: {backends.guidance(recorder.by_name('wf-recorder'))}")
208
+ if args.yes and cmd:
209
+ print(f"Installing ffmpeg: {' '.join(cmd)}")
210
+ rc = subprocess.run(cmd).returncode
211
+ print("ffmpeg installed." if rc == 0 else f"error: install command failed (exit {rc}).")
212
+
213
+
214
+ def _setup_tkinter() -> None:
215
+ try:
216
+ import tkinter # noqa: F401
217
+ except ImportError:
218
+ hint = "install your distro's python3-tk package (e.g. sudo apt install python3-tk)" \
219
+ if backends.platform_key() == "linux" else "reinstall Python with Tk support"
220
+ print(f"Live mode (`bugcap live`) needs tkinter, which is missing: {hint}.")
221
+ else:
222
+ print("Live mode: tkinter is available.")
223
+
224
+
225
+ def cmd_setup(args: argparse.Namespace) -> int:
226
+ rc = _setup_capture(args)
227
+ _setup_recorder(args)
228
+ _setup_tkinter()
229
+ return rc
230
+
231
+
232
+ # --- repo values: validation and re-homing reports -----------------------------
233
+
234
+ def check_repo_values(github=None, images_repo=None, images_path=None, images_branch=None) -> bool:
235
+ """Validate the format of the given values, then verify them with `gh`. Prints the problem
236
+ and returns False for a definite error; offline/unauthenticated only warns."""
237
+ try:
238
+ validation.validate_repo_values(github, images_repo, images_path, images_branch)
239
+ except ValueError as exc:
240
+ print(f"error: {exc}", file=sys.stderr)
241
+ return False
242
+ checks = []
243
+ if github:
244
+ checks.append((github, None, False))
245
+ if images_repo:
246
+ checks.append((images_repo, images_branch, True))
247
+ for slug, branch, need_write in checks:
248
+ try:
249
+ ghcli.verify_repo(slug, branch=branch, need_write=need_write)
250
+ except ghcli.GhUnavailable as exc:
251
+ print(f"warning: could not verify {slug} with gh ({exc}); continuing", file=sys.stderr)
252
+ except GhError as exc:
253
+ print(f"error: {exc}", file=sys.stderr)
254
+ return False
255
+ return True
256
+
257
+
258
+ def _identity(cfg) -> str:
259
+ return f"{cfg.key} (tag {cfg.tag})"
260
+
261
+
262
+ def plan_repo_move(store: Store, old, new) -> tuple[bool, int]:
263
+ """(identity changed, number of reports that would be affected)."""
264
+ if old.key == new.key and old.tag == new.tag:
265
+ return False, 0
266
+ reports = store.list(repo=old.key)
267
+ if old.key == new.key:
268
+ reports = [r for r in reports if old.tag in r.tags]
269
+ return True, len(reports)
270
+
271
+
272
+ def decide_migration(args, old, new, count: int) -> Optional[bool]:
273
+ """True = move the reports, False = leave them, None = stop (non-interactive without a flag)."""
274
+ if count == 0:
275
+ return False
276
+ explicit = getattr(args, "migrate", None)
277
+ if explicit is not None:
278
+ return explicit
279
+ summary = f"{count} existing report(s) are stored under {_identity(old)}; the new value is {_identity(new)}."
280
+ if sys.stdin is not None and sys.stdin.isatty():
281
+ answer = input(f"{summary}\nMove them (and their images/data) to the new repo? [y/N] ").strip().lower()
282
+ return answer == "y"
283
+ print(
284
+ f"error: {summary}\nRe-run with --migrate to move them, or --no-migrate to leave them "
285
+ "(they would then not show in `bugcap list` for this repo).",
286
+ file=sys.stderr,
287
+ )
288
+ return None
289
+
290
+
291
+ def apply_migration(store: Store, old, new, move: bool, count: int) -> None:
292
+ if move:
293
+ moved = store.move_repo(old.key, new.key, old.tag, new.tag)
294
+ print(f"Moved {moved} report(s) from {_identity(old)} to {_identity(new)}.")
295
+ elif count:
296
+ print(
297
+ f"Left {count} report(s) under {_identity(old)}; they will not show in `bugcap list` "
298
+ "for this repo (use `bugcap list --all`).",
299
+ file=sys.stderr,
300
+ )
301
+
302
+
303
+ # --- init (T018) --------------------------------------------------------------
304
+
305
+ def cmd_init(args: argparse.Namespace) -> int:
306
+ root = repo.git_root() or Path.cwd()
307
+ if not check_repo_values(args.github, args.images_repo, None, None):
308
+ return 1
309
+ target = root / repo.CONFIG_NAME
310
+ old = None
311
+ new = None
312
+ move = False
313
+ count = 0
314
+ if target.exists() and args.force:
315
+ old = repo._config_from_file(target)
316
+ new = repo.build_config(root, tag=args.tag, github=args.github, images_repo=args.images_repo)
317
+ with Store() as store:
318
+ changed, count = plan_repo_move(store, old, new)
319
+ if changed:
320
+ decision = decide_migration(args, old, new, count)
321
+ if decision is None:
322
+ return 1
323
+ move = decision
324
+ try:
325
+ cfg = repo.init_repo(
326
+ root,
327
+ tag=args.tag,
328
+ github=args.github,
329
+ images_repo=args.images_repo,
330
+ force=args.force,
331
+ )
332
+ except repo.RepoConfigError as exc:
333
+ print(f"error: {exc}", file=sys.stderr)
334
+ return 1
335
+
336
+ print(f"Initialized {repo.CONFIG_NAME} in {cfg.root}")
337
+ print(f" tag: {cfg.tag}")
338
+ if cfg.github:
339
+ print(f" github: {cfg.github}")
340
+ if cfg.images_repo:
341
+ print(f" images_repo: {cfg.images_repo}")
342
+ if old is not None and new is not None and (old.key != cfg.key or old.tag != cfg.tag):
343
+ with Store() as store:
344
+ apply_migration(store, old, cfg, move, count)
345
+ return 0
346
+
347
+
348
+ # --- config repo (show / set / unset) -----------------------------------------
349
+
350
+ def _repo_config_or_error():
351
+ path = repo.find_config()
352
+ if path is None:
353
+ print(f"error: no {repo.CONFIG_NAME} here; run `bugcap init` first.", file=sys.stderr)
354
+ return None
355
+ return repo._config_from_file(path)
356
+
357
+
358
+ def cmd_config_repo_show(args: argparse.Namespace) -> int:
359
+ cfg = _repo_config_or_error()
360
+ if cfg is None:
361
+ return 1
362
+ print(f"{repo.CONFIG_NAME}: {cfg.root / repo.CONFIG_NAME}")
363
+ for key in repo.SETTABLE_KEYS:
364
+ print(f" {key + ':':14s} {getattr(cfg, key) or '(not set)'}")
365
+ print(f" {'repo key:':14s} {cfg.key}")
366
+ return 0
367
+
368
+
369
+ def _change_repo_config(args: argparse.Namespace, key: str, value: Optional[str]) -> int:
370
+ import dataclasses
371
+
372
+ old = _repo_config_or_error()
373
+ if old is None:
374
+ return 1
375
+ if key not in repo.SETTABLE_KEYS:
376
+ print(f"error: unknown key {key!r}; choose one of: {', '.join(repo.SETTABLE_KEYS)}", file=sys.stderr)
377
+ return 2
378
+ if value is None and key == "tag":
379
+ print("error: the tag cannot be unset; set it to a new value instead.", file=sys.stderr)
380
+ return 2
381
+ if value is not None:
382
+ if key == "tag":
383
+ if not value.strip():
384
+ print("error: the tag must not be empty.", file=sys.stderr)
385
+ return 2
386
+ else:
387
+ if key == "images_path":
388
+ value = value.strip("/")
389
+ values = {"github": None, "images_repo": None, "images_path": None, "images_branch": None}
390
+ values[key] = value
391
+ # a branch is only meaningful with its repo, and a new repo is checked with its branch
392
+ if key == "images_branch":
393
+ values["images_repo"] = old.images_repo
394
+ if key == "images_repo":
395
+ values["images_branch"] = old.images_branch
396
+ if not check_repo_values(**values):
397
+ return 1
398
+ new = dataclasses.replace(old, **{key: value})
399
+ move, count = False, 0
400
+ with Store() as store:
401
+ changed, count = plan_repo_move(store, old, new)
402
+ if changed:
403
+ decision = decide_migration(args, old, new, count)
404
+ if decision is None:
405
+ return 1
406
+ move = decision
407
+ repo.write_config(new)
408
+ print(f"{key}: {getattr(old, key) or '(not set)'} -> {value or '(not set)'}")
409
+ if changed:
410
+ apply_migration(store, old, new, move, count)
411
+ return 0
412
+
413
+
414
+ def cmd_config_repo_set(args: argparse.Namespace) -> int:
415
+ return _change_repo_config(args, args.key, args.value)
416
+
417
+
418
+ def cmd_config_repo_unset(args: argparse.Namespace) -> int:
419
+ return _change_repo_config(args, args.key, None)
420
+
421
+
422
+ # --- triage: edit / tag (T023) ------------------------------------------------
423
+
424
+ def cmd_edit(args: argparse.Namespace) -> int:
425
+ if args.title is None and args.note is None and args.status is None:
426
+ print("error: provide at least one of --title, --note, --status", file=sys.stderr)
427
+ return 2
428
+ with Store() as store:
429
+ if resolve_report(store, args.id) is None:
430
+ return 1
431
+ try:
432
+ with store.transaction():
433
+ if args.note is not None:
434
+ service.set_notes(store, args.id, args.note)
435
+ report = store.update(args.id, title=args.title, status=args.status)
436
+ except ServiceError as exc:
437
+ print_service_error(exc)
438
+ return 1
439
+ except ValueError as exc:
440
+ print(f"error: {exc}", file=sys.stderr)
441
+ return 2
442
+
443
+ changed = []
444
+ if args.title is not None:
445
+ changed.append("title")
446
+ if args.note is not None:
447
+ changed.append("notes")
448
+ if args.status is not None:
449
+ changed.append("status")
450
+ print(f"Updated report #{report.id} ({', '.join(changed)}).")
451
+ return 0
452
+
453
+
454
+ def cmd_tag(args: argparse.Namespace) -> int:
455
+ with Store() as store:
456
+ report = resolve_report(store, args.id)
457
+ if report is None:
458
+ return 1
459
+ tags = list(report.tags)
460
+ changed = []
461
+ if args.action == "add":
462
+ for tag in args.tags:
463
+ if tag not in tags:
464
+ tags.append(tag)
465
+ changed.append(f"+{tag}")
466
+ else: # remove
467
+ for tag in args.tags:
468
+ if tag in tags:
469
+ tags.remove(tag)
470
+ changed.append(f"-{tag}")
471
+ store.set_tags(args.id, tags)
472
+
473
+ label = ", ".join(tags) or "(none)"
474
+ if changed:
475
+ print(f"#{args.id} {' '.join(changed)} -> [{label}]")
476
+ else:
477
+ print(f"#{args.id} tags unchanged: [{label}]")
478
+ return 0
479
+
480
+
481
+ # --- attach (T029) ------------------------------------------------------------
482
+
483
+ def _note_for_images(args: argparse.Namespace) -> Optional[str]:
484
+ """`--note`, or an optional prompt on an interactive terminal (as `capture` does)."""
485
+ if args.note is not None:
486
+ return args.note
487
+ if sys.stdin is not None and sys.stdin.isatty():
488
+ return input("Note about this image (optional): ").strip()
489
+ return None
490
+
491
+
492
+ def cmd_attach(args: argparse.Namespace) -> int:
493
+ with Store() as store:
494
+ report = resolve_report(store, args.id)
495
+ if report is None:
496
+ return 1
497
+ labels = list(args.label or [])
498
+ added: list = []
499
+ rejected: list = []
500
+ shot_path = None
501
+ if args.image:
502
+ try:
503
+ result = service.add_media(store, args.id, list(args.image), labels)
504
+ except ServiceError as exc:
505
+ print_service_error(exc)
506
+ return 1
507
+ added, rejected = result.added, result.rejected
508
+ print_ingest(added, rejected)
509
+ else:
510
+ try:
511
+ shot_path = capture.capture_screenshot()
512
+ except CaptureError as exc:
513
+ print(f"error: {exc}", file=sys.stderr)
514
+ return 1
515
+ try:
516
+ added = [service.attach_captured(store, args.id, str(shot_path), labels[0] if labels else None)]
517
+ except ServiceError as exc:
518
+ print_service_error(exc)
519
+ return 1
520
+
521
+ note = _note_for_images(args) if added else args.note
522
+ if note and not added:
523
+ print("error: no image was added, so the note was not saved.", file=sys.stderr)
524
+ elif note:
525
+ try:
526
+ service.append_note(store, args.id, note, added)
527
+ except ServiceError as exc:
528
+ print_service_error(exc)
529
+ print("warning: the image was attached but the note was not saved.", file=sys.stderr)
530
+ return 1
531
+ current = store.get(args.id)
532
+ if args.image:
533
+ try:
534
+ refs.validate_references(current.notes, current.media)
535
+ except ServiceError as exc:
536
+ print(f"warning: {exc.message}", file=sys.stderr)
537
+
538
+ if shot_path is not None:
539
+ print(f"Attached {shot_path} to report #{args.id}")
540
+ elif note and added:
541
+ print(f"Added note to report #{args.id}")
542
+ return 1 if rejected or (note and not added) else 0
543
+
544
+
545
+ # --- images (list / relabel / remove) -----------------------------------------
546
+
547
+ def cmd_images(args: argparse.Namespace) -> int:
548
+ with Store() as store:
549
+ report = resolve_report(store, args.id)
550
+ if report is None:
551
+ return 1
552
+ if args.action is None:
553
+ for m in report.media:
554
+ where = m.path or f"({len(m.frames)} frames)"
555
+ print(f"#{m.idx} {m.label or '-':12s} {m.kind:8s} {human_size(m.size_bytes):>9s} {where}")
556
+ return 0
557
+ try:
558
+ if args.action == "relabel":
559
+ if len(args.args) != 2:
560
+ print("error: usage: bugcap images <id> relabel <idx|label> <new-label>", file=sys.stderr)
561
+ return 2
562
+ count = service.relabel_media(store, args.id, args.args[0], args.args[1], force=args.force)
563
+ print(f"relabelled {args.args[0]} -> {args.args[1]}; rewrote {count} reference(s)")
564
+ else:
565
+ if len(args.args) != 1:
566
+ print("error: usage: bugcap images <id> remove <idx|label>", file=sys.stderr)
567
+ return 2
568
+ count = service.remove_media(store, args.id, args.args[0], force=args.force)
569
+ print(f"removed {args.args[0]}; rewrote {count} reference(s)")
570
+ except ServiceError as exc:
571
+ print_service_error(exc)
572
+ if exc.code == "referenced_image":
573
+ print(f"notes: {report.notes}", file=sys.stderr)
574
+ return 1
575
+ return 0
576
+
577
+
578
+ # --- record -------------------------------------------------------------------
579
+
580
+ def cmd_record(args: argparse.Namespace) -> int:
581
+ if args.backend:
582
+ chosen = recorder.by_name(args.backend)
583
+ if not all(shutil.which(b) for b in chosen.binaries):
584
+ print(f"error: {chosen.name} is not installed. {backends.guidance(chosen)}", file=sys.stderr)
585
+ return 3
586
+ else:
587
+ chosen = recorder.detect_recorder()
588
+ if chosen is None:
589
+ ffmpeg = recorder.by_name("ffmpeg")
590
+ print("error: no screen recorder found. " + backends.guidance(ffmpeg), file=sys.stderr)
591
+ print("Run `bugcap setup` to see install options.", file=sys.stderr)
592
+ return 3
593
+
594
+ cfg = current_repo()
595
+ with Store() as store:
596
+ if args.id is not None and resolve_report(store, args.id) is None:
597
+ return 1
598
+
599
+ fps = args.fps or (30 if args.format == "video" else 5)
600
+ stop = threading.Event()
601
+ if sys.stdin is not None and sys.stdin.isatty():
602
+ def _wait_enter():
603
+ try:
604
+ sys.stdin.readline()
605
+ finally:
606
+ stop.set()
607
+
608
+ threading.Thread(target=_wait_enter, daemon=True).start()
609
+ print(f"Recording with {chosen.name} (max {args.max_seconds} s). Press Enter or Ctrl+C to stop.", file=sys.stderr)
610
+ try:
611
+ result = recorder.run_recording(
612
+ chosen.name, args.format, fps, args.max_seconds, args.max_mb, wait_for_stop=stop.is_set,
613
+ )
614
+ except recorder.RecorderError as exc:
615
+ print(f"error: {exc}", file=sys.stderr)
616
+ if exc.guidance:
617
+ print(exc.guidance, file=sys.stderr)
618
+ return 3 if exc.guidance else 1
619
+
620
+ tags = list(args.tag or [])
621
+ repo_key = None
622
+ if cfg is not None and args.id is None:
623
+ repo_key = cfg.key
624
+ if cfg.tag not in tags:
625
+ tags.append(cfg.tag)
626
+ with Store() as store:
627
+ try:
628
+ report, media = service.add_recording(
629
+ store, args.id, str(result.path) if result.path else None, result.kind, result.mime,
630
+ result.size_bytes, frame_paths=result.frames or None, title=args.title,
631
+ notes=args.note, tags=tags, repo=repo_key,
632
+ )
633
+ except ServiceError as exc:
634
+ recorder.discard(result)
635
+ print_service_error(exc)
636
+ return 1
637
+ print(f"recorded {result.duration:.1f} s, {human_size(result.size_bytes)} ({result.kind}), stopped: {result.reason}")
638
+ print(f"added #{media.idx} to report #{report.id}")
639
+ return 0
640
+
641
+
642
+ # --- live ---------------------------------------------------------------------
643
+
644
+ def cmd_live(args: argparse.Namespace) -> int:
645
+ from . import live
646
+
647
+ return live.run(args.repo_dir)
648
+
649
+
650
+ # --- dashboard ----------------------------------------------------------------
651
+
652
+ def cmd_dashboard(args: argparse.Namespace) -> int:
653
+ from .dashboard import server
654
+
655
+ host = args.host or "127.0.0.1"
656
+ explicit = args.host is not None
657
+ if explicit and host not in server.LOOPBACK:
658
+ print(f"warning: dashboard exposed on {host}; anyone on that network can read and edit your bugs",
659
+ file=sys.stderr)
660
+ try:
661
+ server.serve(host=host, port=args.port, open_browser=args.open, explicit_host=explicit)
662
+ except (ValueError, OSError) as exc:
663
+ print(f"error: {exc}", file=sys.stderr)
664
+ return 1
665
+ return 0
666
+
667
+
668
+ # --- github pull (T029) -------------------------------------------------------
669
+
670
+ def _ask_screenshot_cb():
671
+ def cb(report, issue) -> Optional[str]:
672
+ prompt = f"Add a screenshot for issue #{issue['number']} {issue['title']!r}? [y/N/path] "
673
+ answer = input(prompt).strip()
674
+ if not answer or answer.lower() == "n":
675
+ return None
676
+ try:
677
+ if answer.lower() == "y":
678
+ path = str(capture.capture_screenshot())
679
+ else:
680
+ path = str(capture.import_image(Path(answer)))
681
+ except CaptureError as exc:
682
+ print(f" skipped: {exc}", file=sys.stderr)
683
+ return None
684
+ note = input("Note about this image (optional): ").strip()
685
+ return (path, note) if note else path
686
+ return cb
687
+
688
+
689
+ def cmd_github_pull(args: argparse.Namespace) -> int:
690
+ cfg = current_repo()
691
+ slug = args.repo or (cfg.github if cfg else None)
692
+ if not slug:
693
+ print(
694
+ "error: no GitHub repo; pass --repo owner/repo or run `bugcap init` "
695
+ "in a repo with a GitHub origin.",
696
+ file=sys.stderr,
697
+ )
698
+ return 1
699
+ ask_cb = _ask_screenshot_cb() if args.ask else None
700
+ try:
701
+ with Store() as store:
702
+ summary = sync.pull_issues(store, slug, labels=args.label, limit=args.limit, ask_cb=ask_cb)
703
+ except GhError as exc:
704
+ print(f"error: {exc}", file=sys.stderr)
705
+ return 1
706
+
707
+ print(
708
+ f"pulled from {slug}: created {summary['created']}, "
709
+ f"updated {summary['updated']}, unchanged {summary['unchanged']}"
710
+ )
711
+ return 0
712
+
713
+
714
+ # --- sync (T035) --------------------------------------------------------------
715
+
716
+ _DESTINATIONS = {"github": sync.GitHubDestination}
717
+
718
+
719
+ def _consent_prompt(slug: str, visibility: str) -> bool:
720
+ answer = input(f"Commit image copies to {visibility} repo {slug}? [y/N] ").strip().lower()
721
+ return answer == "y"
722
+
723
+
724
+ def cmd_sync(args: argparse.Namespace) -> int:
725
+ with Store() as store:
726
+ report = resolve_report(store, args.id)
727
+ if report is None:
728
+ return 1
729
+
730
+ cfg = current_repo()
731
+ linked = report.synced_refs.get("github.issue", "")
732
+ issue_slug = (
733
+ args.repo
734
+ or (report.repo if report.repo and "/" in report.repo else None)
735
+ or (cfg.github if cfg else None)
736
+ or (linked.split("#")[0] if "#" in linked else None)
737
+ )
738
+ if not issue_slug:
739
+ print(
740
+ "error: no target repo; pass --repo owner/repo or sync from an initialized repo.",
741
+ file=sys.stderr,
742
+ )
743
+ return 1
744
+
745
+ destination = _DESTINATIONS[args.to]()
746
+ try:
747
+ destination.ensure_ready()
748
+ except GhError as exc:
749
+ print(f"error: {exc}", file=sys.stderr)
750
+ return 1
751
+
752
+ images = sync.resolve_images_target(
753
+ args.images_repo, args.images_path, args.images_branch,
754
+ cfg, config.get_sync_defaults(), issue_slug,
755
+ )
756
+ if not check_repo_values(
757
+ github=args.repo, images_repo=images.repo, images_path=images.path, images_branch=images.branch,
758
+ ):
759
+ return 1
760
+ opts = sync.SyncOptions(
761
+ issue_slug=issue_slug,
762
+ images=images,
763
+ assume_yes=args.yes,
764
+ interactive=sys.stdin.isatty(),
765
+ confirm=_consent_prompt,
766
+ max_upload_mb=config.get_max_upload_mb(),
767
+ )
768
+ try:
769
+ result = sync.sync_report(store, report, destination, opts)
770
+ except GhError as exc:
771
+ print(f"error: {exc}", file=sys.stderr)
772
+ return 1
773
+
774
+ if result.issue_url:
775
+ print(f"issue: {result.issue_url}")
776
+ for link in result.image_permalinks:
777
+ print(f"image: {link}")
778
+ for message in result.messages:
779
+ print(message)
780
+ return 0
781
+
782
+
783
+ # --- mcp-serve (T040) ---------------------------------------------------------
784
+
785
+ def cmd_mcp_serve(args: argparse.Namespace) -> int:
786
+ from . import mcp_server
787
+ try:
788
+ mcp_server.run(log_level=args.log_level, log_file=args.log_file)
789
+ except mcp_server.MCPUnavailable as exc:
790
+ print(f"error: {exc}", file=sys.stderr)
791
+ return 1
792
+ return 0
793
+
794
+
795
+ # --- parser -------------------------------------------------------------------
796
+
797
+ def _migrate_flags(p: argparse.ArgumentParser) -> None:
798
+ group = p.add_mutually_exclusive_group()
799
+ group.add_argument("--migrate", dest="migrate", action="store_true", default=None,
800
+ help="When the repo identity changes, move existing reports to the new one.")
801
+ group.add_argument("--no-migrate", dest="migrate", action="store_false",
802
+ help="When the repo identity changes, leave existing reports where they are.")
803
+
804
+
805
+ def build_parser() -> argparse.ArgumentParser:
806
+ parser = argparse.ArgumentParser(
807
+ prog="bugcap",
808
+ description="Local-first, agent-readable bug capture: annotated screenshots + notes.",
809
+ )
810
+ sub = parser.add_subparsers(dest="command", required=True)
811
+
812
+ p = sub.add_parser("setup", help="Detect a capture tool, or recommend/install one.")
813
+ p.add_argument("--yes", action="store_true", help="Install the recommended tool without prompting.")
814
+ p.set_defaults(func=cmd_setup)
815
+
816
+ p = sub.add_parser("init", help="Write .bugcap.toml so captures here are tagged/scoped.")
817
+ p.add_argument("--tag", help="Repo tag (defaults to the directory name).")
818
+ p.add_argument("--github", help="GitHub owner/repo (defaults to the origin remote).")
819
+ p.add_argument("--images-repo", dest="images_repo", help="Repo for committed image copies ([sync]).")
820
+ p.add_argument("--force", action="store_true", help="Overwrite an existing .bugcap.toml.")
821
+ _migrate_flags(p)
822
+ p.set_defaults(func=cmd_init)
823
+
824
+ p = sub.add_parser("capture", help="Capture (or import) a screenshot and save a report.")
825
+ p.add_argument("--title", help="Short title for the report.")
826
+ p.add_argument("--note", help="Free-form notes/observations.")
827
+ p.add_argument("--tag", action="append", help="Tag to attach (repeatable).")
828
+ p.add_argument("--image", action="append", metavar="SRC",
829
+ help="Import an image (path, glob or http(s) URL) instead of launching a capture tool; repeatable.")
830
+ p.add_argument("--label", action="append", help="Label for the --image at the same position (repeatable).")
831
+ p.set_defaults(func=cmd_capture)
832
+
833
+ p = sub.add_parser("list", help="List reports (current repo only, unless --all).")
834
+ p.add_argument("--all", action="store_true", help="List reports from every repo.")
835
+ p.set_defaults(func=cmd_list)
836
+
837
+ p = sub.add_parser("show", help="Show a single report in detail.")
838
+ p.add_argument("id", type=int, help="Report id (see `bugcap list`).")
839
+ p.set_defaults(func=cmd_show)
840
+
841
+ p = sub.add_parser("edit", help="Edit a report's title, notes and/or status.")
842
+ p.add_argument("id", type=int)
843
+ p.add_argument("--title")
844
+ p.add_argument("--note")
845
+ p.add_argument("--status", help=f"One of: {', '.join(STATUSES)}")
846
+ p.set_defaults(func=cmd_edit)
847
+
848
+ p = sub.add_parser("tag", help="Add or remove tags on a report.")
849
+ p.add_argument("id", type=int)
850
+ p.add_argument("action", choices=["add", "remove"])
851
+ p.add_argument("tags", nargs="+")
852
+ p.set_defaults(func=cmd_tag)
853
+
854
+ p = sub.add_parser("attach", help="Attach a screenshot (captured or imported) to a report.")
855
+ p.add_argument("id", type=int)
856
+ p.add_argument("--image", action="append", metavar="SRC",
857
+ help="Import an image (path, glob or http(s) URL) instead of launching a capture tool; repeatable.")
858
+ p.add_argument("--label", action="append", help="Label for the --image at the same position (repeatable).")
859
+ p.add_argument("--note", help="Text appended to the report's notes, tied to the image(s) added here.")
860
+ p.set_defaults(func=cmd_attach)
861
+
862
+ p = sub.add_parser("images", help="List a report's images, or relabel/remove one.")
863
+ p.add_argument("id", type=int)
864
+ p.add_argument("action", nargs="?", choices=["relabel", "remove"])
865
+ p.add_argument("args", nargs="*", help="relabel: <idx|label> <new-label>; remove: <idx|label>")
866
+ p.add_argument("--force", action="store_true", help="Rewrite @ references in the notes instead of refusing.")
867
+ p.set_defaults(func=cmd_images)
868
+
869
+ cfg_p = sub.add_parser("config", help="Inspect or change settings.")
870
+ cfg_sub = cfg_p.add_subparsers(dest="config_target", required=True)
871
+ repo_p = cfg_sub.add_parser("repo", help="This repo's .bugcap.toml values (tag, github, [sync]).")
872
+ repo_sub = repo_p.add_subparsers(dest="repo_action", required=True)
873
+ p = repo_sub.add_parser("show", help="Print the current values.")
874
+ p.set_defaults(func=cmd_config_repo_show)
875
+ p = repo_sub.add_parser("set", help="Change one value (validated; offers to move reports on identity changes).")
876
+ p.add_argument("key", help=f"One of: {', '.join(repo.SETTABLE_KEYS)}")
877
+ p.add_argument("value")
878
+ _migrate_flags(p)
879
+ p.set_defaults(func=cmd_config_repo_set)
880
+ p = repo_sub.add_parser("unset", help="Remove one value (not the tag).")
881
+ p.add_argument("key")
882
+ _migrate_flags(p)
883
+ p.set_defaults(func=cmd_config_repo_unset)
884
+
885
+ p = sub.add_parser("record", help="Record the screen (video, keyframes or animated GIF) into a report.")
886
+ p.add_argument("--id", type=int, help="Attach to this report (default: create a new one).")
887
+ p.add_argument("--format", choices=list(recorder.FORMATS), default="animated")
888
+ p.add_argument("--max-seconds", dest="max_seconds", type=int, default=30)
889
+ p.add_argument("--fps", type=int, help="Frames per second (default 5, or 30 for video).")
890
+ p.add_argument("--max-mb", dest="max_mb", type=float, default=25)
891
+ p.add_argument("--backend", choices=[b.name for b in recorder.RECORD_BACKENDS])
892
+ p.add_argument("--title")
893
+ p.add_argument("--note")
894
+ p.add_argument("--tag", action="append")
895
+ p.set_defaults(func=cmd_record)
896
+
897
+ p = sub.add_parser("live", help="Floating control window to capture and report many bugs in a row.")
898
+ p.add_argument("--repo-dir", dest="repo_dir", help="Directory whose .bugcap.toml scopes the reports.")
899
+ p.set_defaults(func=cmd_live)
900
+
901
+ p = sub.add_parser("dashboard", help="Browse and triage reports in a local web page (127.0.0.1 only).")
902
+ p.add_argument("--port", type=int, default=8765, help="Port (0 picks a free one).")
903
+ p.add_argument("--host", help="Bind address (default 127.0.0.1; anything else exposes your bugs).")
904
+ p.add_argument("--open", action="store_true", help="Open the page in your browser.")
905
+ p.set_defaults(func=cmd_dashboard)
906
+
907
+ gh = sub.add_parser("github", help="GitHub integration (via the `gh` CLI).")
908
+ gh_sub = gh.add_subparsers(dest="github_command", required=True)
909
+ pull = gh_sub.add_parser("pull", help="Import issues as reports.")
910
+ pull.add_argument("--repo", help="owner/repo (defaults to .bugcap.toml).")
911
+ pull.add_argument("--label", action="append", help="Filter by label (repeatable).")
912
+ pull.add_argument("--limit", type=int, default=30, help="Max issues (default 30).")
913
+ pull.add_argument("--ask", action="store_true", help="Ask to add a screenshot per issue.")
914
+ pull.set_defaults(func=cmd_github_pull)
915
+
916
+ p = sub.add_parser("sync", help="Push a report to a destination (GitHub).")
917
+ p.add_argument("id", type=int)
918
+ p.add_argument("--to", choices=list(_DESTINATIONS), default="github")
919
+ p.add_argument("--repo", help="Issue repo owner/repo.")
920
+ p.add_argument("--images-repo", dest="images_repo", help="Repo to commit image copies to.")
921
+ p.add_argument("--images-path", dest="images_path", help="Directory within the images repo.")
922
+ p.add_argument("--images-branch", dest="images_branch", help="Branch within the images repo.")
923
+ p.add_argument("--yes", action="store_true", help="Accept image-commit consent prompts.")
924
+ p.set_defaults(func=cmd_sync)
925
+
926
+ p = sub.add_parser("mcp-serve", help="Run the stdio MCP server (needs the 'mcp' extra).")
927
+ p.add_argument("--log-level", dest="log_level", choices=["debug", "info", "warning", "error"],
928
+ help="Log level (default info; or BUGCAP_LOG_LEVEL, or [log] level in config.toml).")
929
+ p.add_argument("--log-file", dest="log_file", help="Log file (default: logs/mcp-server.log in the data dir).")
930
+ p.set_defaults(func=cmd_mcp_serve)
931
+
932
+ return parser
933
+
934
+
935
+ def main(argv: list[str] | None = None) -> int:
936
+ parser = build_parser()
937
+ args = parser.parse_args(argv)
938
+ return args.func(args)
939
+
940
+
941
+ if __name__ == "__main__":
942
+ raise SystemExit(main())