debugbrief 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.
debugbrief/cli.py ADDED
@@ -0,0 +1,843 @@
1
+ """Command-line interface for DebugBrief (argparse only).
2
+
3
+ Commands:
4
+ debugbrief start "<title>"
5
+ debugbrief note <text ...>
6
+ debugbrief run [--shell] [--timeout N] [--no-redact] -- <command ...>
7
+ debugbrief run "<command>"
8
+ debugbrief redo [--timeout N] [--no-redact]
9
+ debugbrief end [--mode pr|handoff|incident] [--format md|json|both] [--stdout]
10
+ debugbrief cancel [--yes]
11
+ debugbrief status
12
+ debugbrief doctor [--fix]
13
+ debugbrief last
14
+ debugbrief open [--last | --path PATH]
15
+ debugbrief list [--json]
16
+ debugbrief show <session_id> [--json]
17
+
18
+ run and note auto-start a session if none is active.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import shlex
27
+ import subprocess
28
+ import sys
29
+ from datetime import datetime
30
+ from pathlib import Path
31
+ from typing import List, Optional
32
+
33
+ from . import __version__
34
+ from .command_runner import DEFAULT_TIMEOUT_SECONDS, RunResult, run_command
35
+ from .doctor import run_doctor
36
+ from .models import COMMAND_STATUS_PASSED, CommandData
37
+ from .paths import ensure_local_ignore, resolve_project_paths
38
+ from .redaction import PLACEHOLDER
39
+ from .reporters import VALID_MODES, build_context, render_report
40
+ from .reports_index import first_title, infer_mode, latest_report
41
+ from .session_manager import SessionError, SessionManager
42
+ from .sessions_index import (
43
+ is_verified,
44
+ load_all_sessions,
45
+ report_modes_for,
46
+ resolve_session_id,
47
+ )
48
+ from .utils import eprint, is_supported_platform
49
+
50
+ PROG = "debugbrief"
51
+
52
+
53
+ def build_parser() -> argparse.ArgumentParser:
54
+ parser = argparse.ArgumentParser(
55
+ prog=PROG,
56
+ description=(
57
+ "Record the meaningful context of a debugging session and turn it "
58
+ "into a useful markdown brief (PR, handoff, or incident)."
59
+ ),
60
+ )
61
+ parser.add_argument(
62
+ "--version", action="version", version=f"%(prog)s {__version__}"
63
+ )
64
+ subparsers = parser.add_subparsers(dest="command", metavar="<command>")
65
+
66
+ # start --------------------------------------------------------------
67
+ p_start = subparsers.add_parser("start", help="Start a new debugging session.")
68
+ p_start.add_argument("title", help="A short, descriptive session title.")
69
+ p_start.add_argument(
70
+ "--shell",
71
+ action="store_true",
72
+ help=(
73
+ "EXPERIMENTAL: spawn an interactive subshell to capture shell "
74
+ "history. Not available in v1 (see README)."
75
+ ),
76
+ )
77
+ p_start.set_defaults(func=cmd_start)
78
+
79
+ # note ---------------------------------------------------------------
80
+ p_note = subparsers.add_parser("note", help="Append a note to the active session.")
81
+ p_note.add_argument(
82
+ "text",
83
+ nargs="+",
84
+ help=(
85
+ "The note text. Quoting is optional: "
86
+ "debugbrief note remember to check the lock ordering"
87
+ ),
88
+ )
89
+ p_note.set_defaults(func=cmd_note)
90
+
91
+ # run ----------------------------------------------------------------
92
+ p_run = subparsers.add_parser(
93
+ "run",
94
+ help="Execute a command, capturing its outcome into the active session.",
95
+ )
96
+ p_run.add_argument(
97
+ "--shell",
98
+ action="store_true",
99
+ help=(
100
+ "Run the command through the system shell (enables pipes, "
101
+ "redirection, &&). Default parses with shlex and runs without a shell."
102
+ ),
103
+ )
104
+ p_run.add_argument(
105
+ "--timeout",
106
+ type=int,
107
+ default=DEFAULT_TIMEOUT_SECONDS,
108
+ metavar="SECONDS",
109
+ help=f"Kill the command after this many seconds (default {DEFAULT_TIMEOUT_SECONDS}).",
110
+ )
111
+ p_run.add_argument(
112
+ "--no-redact",
113
+ dest="no_redact",
114
+ action="store_true",
115
+ help=(
116
+ "Store captured output and the command verbatim, without secret "
117
+ "redaction. Use only when you know the output is safe."
118
+ ),
119
+ )
120
+ p_run.add_argument(
121
+ "command",
122
+ nargs=argparse.REMAINDER,
123
+ help=(
124
+ "The command to run. Put DebugBrief flags first, then -- and the "
125
+ "command as you would normally type it: "
126
+ "debugbrief run -- python -m pytest -q tests/. "
127
+ 'A single quoted argument also works: debugbrief run "pytest -q".'
128
+ ),
129
+ )
130
+ p_run.set_defaults(func=cmd_run)
131
+
132
+ # redo ---------------------------------------------------------------
133
+ p_redo = subparsers.add_parser(
134
+ "redo",
135
+ help="Re-run the most recently captured command in the active session.",
136
+ )
137
+ p_redo.add_argument(
138
+ "--timeout",
139
+ type=int,
140
+ default=DEFAULT_TIMEOUT_SECONDS,
141
+ metavar="SECONDS",
142
+ help=f"Kill the command after this many seconds (default {DEFAULT_TIMEOUT_SECONDS}).",
143
+ )
144
+ p_redo.add_argument(
145
+ "--no-redact",
146
+ dest="no_redact",
147
+ action="store_true",
148
+ help="Store captured output verbatim, without secret redaction.",
149
+ )
150
+ p_redo.set_defaults(func=cmd_redo)
151
+
152
+ # end ----------------------------------------------------------------
153
+ p_end = subparsers.add_parser(
154
+ "end", help="Finalize the session and write a markdown report."
155
+ )
156
+ p_end.add_argument(
157
+ "--mode",
158
+ default="pr",
159
+ choices=VALID_MODES,
160
+ help="Report style to generate (default pr).",
161
+ )
162
+ p_end.add_argument(
163
+ "--format",
164
+ dest="report_format",
165
+ choices=["md", "json", "both"],
166
+ default="md",
167
+ help="Report output format (default md). 'both' writes markdown and JSON.",
168
+ )
169
+ p_end.add_argument(
170
+ "--stdout",
171
+ dest="to_stdout",
172
+ action="store_true",
173
+ help=(
174
+ "Print the rendered markdown report to stdout (the file is still "
175
+ "written). Informational lines move to stderr, so the output pipes "
176
+ "cleanly: debugbrief end --stdout | gh pr comment --body-file -"
177
+ ),
178
+ )
179
+ p_end.set_defaults(func=cmd_end)
180
+
181
+ # cancel -------------------------------------------------------------
182
+ p_cancel = subparsers.add_parser(
183
+ "cancel",
184
+ help="Discard the active session without writing a report.",
185
+ )
186
+ p_cancel.add_argument(
187
+ "--yes",
188
+ action="store_true",
189
+ help="Skip the confirmation prompt.",
190
+ )
191
+ p_cancel.set_defaults(func=cmd_cancel)
192
+
193
+ # status -------------------------------------------------------------
194
+ p_status = subparsers.add_parser("status", help="Show the active session status.")
195
+ p_status.set_defaults(func=cmd_status)
196
+
197
+ # doctor -------------------------------------------------------------
198
+ p_doctor = subparsers.add_parser(
199
+ "doctor", help="Run a health check on the project and DebugBrief state."
200
+ )
201
+ p_doctor.add_argument(
202
+ "--fix",
203
+ action="store_true",
204
+ help=(
205
+ "Apply safe fixes: create .debugbrief/ directories and add "
206
+ ".debugbrief/ to .git/info/exclude. Never touches .gitignore."
207
+ ),
208
+ )
209
+ p_doctor.set_defaults(func=cmd_doctor)
210
+
211
+ # last ---------------------------------------------------------------
212
+ p_last = subparsers.add_parser(
213
+ "last", help="Show the most recently generated report."
214
+ )
215
+ p_last.set_defaults(func=cmd_last)
216
+
217
+ # open ---------------------------------------------------------------
218
+ p_open = subparsers.add_parser(
219
+ "open", help="Open a report in $EDITOR (defaults to the latest report)."
220
+ )
221
+ p_open.add_argument(
222
+ "--last", action="store_true", help="Open the latest report (default)."
223
+ )
224
+ p_open.add_argument(
225
+ "--path", metavar="REPORT", help="Open a specific report file."
226
+ )
227
+ p_open.set_defaults(func=cmd_open)
228
+
229
+ # list ---------------------------------------------------------------
230
+ p_list = subparsers.add_parser(
231
+ "list", help="List recorded sessions, most recent first."
232
+ )
233
+ p_list.add_argument(
234
+ "--json", action="store_true", help="Emit a structured JSON summary."
235
+ )
236
+ p_list.set_defaults(func=cmd_list)
237
+
238
+ # show ---------------------------------------------------------------
239
+ p_show = subparsers.add_parser(
240
+ "show", help="Show a compact summary of one session."
241
+ )
242
+ p_show.add_argument(
243
+ "session_id", help="Full or short (unambiguous prefix) session id."
244
+ )
245
+ p_show.add_argument(
246
+ "--json", action="store_true", help="Emit the full session as JSON."
247
+ )
248
+ p_show.set_defaults(func=cmd_show)
249
+
250
+ return parser
251
+
252
+
253
+ def _manager() -> SessionManager:
254
+ paths = resolve_project_paths()
255
+ return SessionManager(paths)
256
+
257
+
258
+ def _apply_local_ignore(manager, paths, session):
259
+ """Ensure .debugbrief/ is locally ignored; record any warning on the session."""
260
+ changed, warnings = ensure_local_ignore(paths)
261
+ if warnings:
262
+ from .utils import now_iso8601
263
+
264
+ for warning in warnings:
265
+ session.add_warning(warning, now_iso8601())
266
+ manager.save_session(session)
267
+ return changed, warnings
268
+
269
+
270
+ def _ensure_session(manager, paths, seed_text):
271
+ """Return the active session, auto-starting one if none is active.
272
+
273
+ Auto-start prints a clear one-line notice and continues, so a note or a
274
+ command run is never silently dropped.
275
+ """
276
+ if manager.has_active():
277
+ return manager.load_active()
278
+ session = manager.auto_start(seed_text)
279
+ changed, warnings = _apply_local_ignore(manager, paths, session)
280
+ # Status lines go to stderr so a wrapped command's stdout stays clean.
281
+ eprint(f"Auto-started a DebugBrief session (none was active): {session.title}")
282
+ eprint(f" id: {session.session_id}")
283
+ if changed:
284
+ eprint(" ignore: added .debugbrief/ to .git/info/exclude")
285
+ for warning in warnings:
286
+ eprint(f" warning: {warning}")
287
+ return session
288
+
289
+
290
+ # Handlers -----------------------------------------------------------------
291
+ def cmd_start(args: argparse.Namespace) -> int:
292
+ if args.shell:
293
+ eprint(
294
+ "Experimental shell-history capture (start --shell) is not available "
295
+ "in v1.\n"
296
+ "The reliable capture model is explicit execution:\n"
297
+ ' debugbrief run "<command>"\n'
298
+ "Start a normal session with: debugbrief start \"<title>\""
299
+ )
300
+ return 2
301
+
302
+ paths = resolve_project_paths()
303
+ manager = SessionManager(paths)
304
+ session = manager.start(args.title)
305
+
306
+ # Locally ignore .debugbrief/ via .git/info/exclude (never touches .gitignore).
307
+ changed, warnings = _apply_local_ignore(manager, paths, session)
308
+
309
+ print("Started DebugBrief session.")
310
+ print(f" id: {session.session_id}")
311
+ print(f" title: {session.title}")
312
+ print(f" root: {session.project_root}")
313
+ if session.git.is_repo:
314
+ branch = session.git.branch or (
315
+ "(detached HEAD)" if session.git.detached_head else "(no branch)"
316
+ )
317
+ print(f" git: repo on {branch}")
318
+ else:
319
+ print(" git: not a Git repository (continuing locally)")
320
+ if changed:
321
+ print(" ignore: added .debugbrief/ to .git/info/exclude")
322
+ for warning in warnings:
323
+ eprint(f" warning: {warning}")
324
+ print("")
325
+ print("Next:")
326
+ print(' debugbrief note "<observation>"')
327
+ print(" debugbrief run -- <command>")
328
+ print(" debugbrief redo")
329
+ print(" debugbrief end [--mode pr|handoff|incident]")
330
+ return 0
331
+
332
+
333
+ def cmd_note(args: argparse.Namespace) -> int:
334
+ # Unquoted notes arrive as multiple tokens; a lossy single-space join is
335
+ # fine for prose. The quoted single-argument form passes through verbatim.
336
+ text = args.text if isinstance(args.text, str) else " ".join(args.text)
337
+ paths = resolve_project_paths()
338
+ manager = SessionManager(paths)
339
+ _ensure_session(manager, paths, text)
340
+ session = manager.add_note(text)
341
+ print(f"Noted ({session.summary.notes_count} total).")
342
+ return 0
343
+
344
+
345
+ def cmd_run(args: argparse.Namespace) -> int:
346
+ command_str = _reconstruct_command(args.command)
347
+ if not command_str.strip():
348
+ eprint(
349
+ "No command given. Usage: debugbrief run [flags] -- <command ...> "
350
+ 'or debugbrief run "<command>"'
351
+ )
352
+ return 2
353
+
354
+ paths = resolve_project_paths()
355
+ manager = SessionManager(paths)
356
+
357
+ if args.timeout <= 0:
358
+ eprint("--timeout must be a positive number of seconds.")
359
+ return 2
360
+
361
+ # Auto-start a session if none is active so the run is never dropped. The
362
+ # title seed uses the plain tokens as typed, not the shlex-quoted
363
+ # reconstruction, so auto titles read naturally in list and reports.
364
+ _ensure_session(manager, paths, _plain_command_text(args.command))
365
+
366
+ # The command's own stdout/stderr stream through live while it runs.
367
+ # DebugBrief's status lines all go to stderr so the wrapped command's
368
+ # stdout stays clean for piping.
369
+ eprint(f"$ {command_str}")
370
+ result = run_command(
371
+ command=command_str,
372
+ cwd=manager.paths.project_root,
373
+ use_shell=args.shell,
374
+ timeout_seconds=args.timeout,
375
+ redact=not args.no_redact,
376
+ )
377
+ manager.record_command(result)
378
+ _print_command_outcome(result, args.timeout)
379
+ return result.propagated_exit_code
380
+
381
+
382
+ def _print_command_outcome(result: RunResult, timeout_seconds: int) -> None:
383
+ """Report a captured command's outcome on stderr (shared by run and redo)."""
384
+ data = result.command_data
385
+ if result.errored:
386
+ eprint(f" error: {result.error_message}")
387
+ elif result.timed_out:
388
+ eprint(f" status: timed out after {timeout_seconds}s (recorded)")
389
+ else:
390
+ verdict = "passed" if data.classification.status == COMMAND_STATUS_PASSED else "failed"
391
+ eprint(f" status: {verdict} (exit {data.exit_code})")
392
+ eprint(f" duration: {data.duration_seconds}s")
393
+ if data.classification.is_test:
394
+ eprint(f" test: {data.classification.tool or 'unknown'}")
395
+ if data.stdout_truncated:
396
+ eprint(" note: stdout preview was truncated")
397
+ if data.stderr_truncated:
398
+ eprint(" note: stderr preview was truncated")
399
+ if data.redacted:
400
+ eprint(" note: secret-like values were redacted")
401
+
402
+
403
+ def cmd_redo(args: argparse.Namespace) -> int:
404
+ paths = resolve_project_paths()
405
+ manager = SessionManager(paths)
406
+
407
+ if args.timeout <= 0:
408
+ eprint("--timeout must be a positive number of seconds.")
409
+ return 2
410
+
411
+ session = manager.load_active()
412
+ if session is None:
413
+ eprint(
414
+ "No active DebugBrief session, so there is nothing to redo. "
415
+ "Run a command first: debugbrief run -- <command>"
416
+ )
417
+ return 1
418
+
419
+ command_events = session.command_events()
420
+ if not command_events:
421
+ eprint(
422
+ "No commands have been captured in this session yet. "
423
+ "Run one first: debugbrief run -- <command>"
424
+ )
425
+ return 1
426
+
427
+ last = CommandData.from_dict(command_events[-1].data)
428
+ if PLACEHOLDER in last.command:
429
+ eprint(
430
+ f"The last stored command contains {PLACEHOLDER}, a redaction "
431
+ "placeholder, not the real text, so it cannot be re-run. "
432
+ "Run the command again yourself: debugbrief run -- <command>"
433
+ )
434
+ return 1
435
+
436
+ eprint(f"$ {last.command} (redo)")
437
+ result = run_command(
438
+ command=last.command,
439
+ cwd=manager.paths.project_root,
440
+ use_shell=last.used_shell,
441
+ timeout_seconds=args.timeout,
442
+ redact=not args.no_redact,
443
+ )
444
+ manager.record_command(result)
445
+ _print_command_outcome(result, args.timeout)
446
+ return result.propagated_exit_code
447
+
448
+
449
+ def cmd_end(args: argparse.Namespace) -> int:
450
+ manager = _manager()
451
+ session = manager.end(args.mode, args.report_format)
452
+ # With --stdout the report itself owns stdout; everything informational
453
+ # moves to stderr so the output pipes cleanly.
454
+ info = eprint if args.to_stdout else print
455
+ info(f"Session completed: {session.title}")
456
+ info(f" mode: {args.mode}")
457
+ if args.report_format in ("md", "both"):
458
+ info(
459
+ f" report: {manager.paths.report_file(session.session_id, args.mode)}"
460
+ )
461
+ if args.report_format in ("json", "both"):
462
+ info(
463
+ f" json: "
464
+ f"{manager.paths.report_json_file(session.session_id, args.mode)}"
465
+ )
466
+ info(f" session: {manager.paths.session_file(session.session_id)}")
467
+ if args.to_stdout:
468
+ sys.stdout.write(render_report(session, args.mode))
469
+ return 0
470
+
471
+
472
+ def cmd_cancel(args: argparse.Namespace) -> int:
473
+ manager = _manager()
474
+ session = manager.load_active()
475
+ if session is None:
476
+ eprint("No active DebugBrief session to cancel.")
477
+ return 1
478
+
479
+ if not args.yes:
480
+ try:
481
+ answer = input(f"Discard active session '{session.title}'? [y/N] ")
482
+ except EOFError:
483
+ # No stdin to answer with (e.g. a pipe): treat as a decline.
484
+ answer = ""
485
+ if answer.strip().lower() != "y":
486
+ eprint("Aborted; the session is still active.")
487
+ return 1
488
+
489
+ manager.cancel()
490
+ eprint(f"Discarded session '{session.title}' (no report written).")
491
+ return 0
492
+
493
+
494
+ def cmd_status(args: argparse.Namespace) -> int:
495
+ manager = _manager()
496
+ status = manager.build_status()
497
+
498
+ if not status.get("active"):
499
+ print("No active DebugBrief session.")
500
+ print('Start one with: debugbrief start "<title>"')
501
+ return 0
502
+
503
+ if status.get("interrupted"):
504
+ print("A session appears INTERRUPTED or inconsistent.")
505
+ print(f" id: {status.get('session_id')}")
506
+ if status.get("title"):
507
+ print(f" title: {status.get('title')}")
508
+ reason = status.get("reason")
509
+ if reason:
510
+ print(f" reason: {reason}")
511
+ print("")
512
+ print("Recovery:")
513
+ print(" - Inspect .debugbrief/active_session.json and the sessions/ folder.")
514
+ print(" - Remove .debugbrief/active_session.json to clear the active pointer,")
515
+ print(" then start a fresh session.")
516
+ return 0
517
+
518
+ print(f"Active session: {status.get('title')}")
519
+ print(f" id: {status.get('session_id')}")
520
+ print(f" status: {status.get('status')}")
521
+ print(f" root: {status.get('project_root')}")
522
+ print(f" started: {status.get('start')}")
523
+ print(f" notes: {status.get('notes_count')}")
524
+ print(
525
+ f" commands: {status.get('commands_count')} "
526
+ f"({status.get('failed_commands_count')} failed)"
527
+ )
528
+ if status.get("is_repo"):
529
+ branch = status.get("branch") or (
530
+ "(detached HEAD)" if status.get("detached_head") else "(no branch)"
531
+ )
532
+ print(f" git: {branch}")
533
+ else:
534
+ print(" git: not a Git repository")
535
+ for warning in status.get("warnings", []):
536
+ print(f" warning: {warning}")
537
+ print("")
538
+ print("End with: debugbrief end --mode pr|handoff|incident")
539
+ return 0
540
+
541
+
542
+ def cmd_doctor(args: argparse.Namespace) -> int:
543
+ paths = resolve_project_paths()
544
+ report = run_doctor(paths, fix=args.fix)
545
+ print("DebugBrief doctor")
546
+ print("=================")
547
+ for check in report.checks:
548
+ detail = f" - {check.detail}" if check.detail else ""
549
+ print(f"[{check.level}] {check.name}{detail}")
550
+ print("")
551
+ print(report.summary)
552
+ return report.exit_code
553
+
554
+
555
+ def cmd_last(args: argparse.Namespace) -> int:
556
+ paths = resolve_project_paths()
557
+ report_path = latest_report(paths.reports_dir)
558
+ if report_path is None:
559
+ eprint(
560
+ "No DebugBrief reports found. Generate one with: "
561
+ "debugbrief end --mode pr|handoff|incident"
562
+ )
563
+ return 1
564
+
565
+ mode = infer_mode(report_path) or "unknown"
566
+ title = first_title(report_path)
567
+ mtime = datetime.fromtimestamp(report_path.stat().st_mtime)
568
+ print(f"Latest report: {report_path}")
569
+ print(f" mode: {mode}")
570
+ print(f" modified: {mtime.strftime('%Y-%m-%d %H:%M:%S')}")
571
+ print(f" title: {title or '(no title line found)'}")
572
+ return 0
573
+
574
+
575
+ def cmd_open(args: argparse.Namespace) -> int:
576
+ paths = resolve_project_paths()
577
+
578
+ target: Optional[Path]
579
+ if args.path:
580
+ target = Path(args.path).expanduser()
581
+ if not target.exists():
582
+ eprint(f"Report not found: {target}")
583
+ return 1
584
+ else:
585
+ target = latest_report(paths.reports_dir)
586
+ if target is None:
587
+ eprint(
588
+ "No DebugBrief reports found to open. Generate one with: "
589
+ "debugbrief end --mode pr|handoff|incident"
590
+ )
591
+ return 1
592
+
593
+ editor = os.environ.get("EDITOR", "").strip()
594
+ if not editor:
595
+ print(f"Report path: {target}")
596
+ print(
597
+ "Set the $EDITOR environment variable to open reports automatically, "
598
+ 'e.g. export EDITOR="vim" (or "code -w").'
599
+ )
600
+ return 0
601
+
602
+ try:
603
+ editor_args = shlex.split(editor)
604
+ except ValueError:
605
+ editor_args = [editor]
606
+
607
+ try:
608
+ completed = subprocess.run([*editor_args, str(target)])
609
+ except (OSError, ValueError) as exc:
610
+ eprint(f"Could not open editor ({exc}).")
611
+ eprint(f"Report path: {target}")
612
+ return 1
613
+
614
+ if completed.returncode != 0:
615
+ eprint(f"Editor exited with code {completed.returncode}.")
616
+ eprint(f"Report path: {target}")
617
+ return 1
618
+ return 0
619
+
620
+
621
+ def _session_summary_dict(paths, session) -> dict:
622
+ modes = report_modes_for(paths, session.session_id)
623
+ return {
624
+ "session_id": session.session_id,
625
+ "short_id": session.session_id[:8],
626
+ "status": session.status,
627
+ "title": session.title,
628
+ "start": session.timestamps.start,
629
+ "end": session.timestamps.end,
630
+ "commands_count": session.summary.commands_count,
631
+ "notes_count": session.summary.notes_count,
632
+ "failed_commands_count": session.summary.failed_commands_count,
633
+ "verified": is_verified(session),
634
+ "report_modes": modes,
635
+ }
636
+
637
+
638
+ def cmd_list(args: argparse.Namespace) -> int:
639
+ paths = resolve_project_paths()
640
+ sessions = load_all_sessions(paths)
641
+
642
+ if not sessions:
643
+ if args.json:
644
+ print("[]")
645
+ return 1
646
+ eprint(
647
+ "No DebugBrief sessions found. Start one with: "
648
+ 'debugbrief start "<title>"'
649
+ )
650
+ return 1
651
+
652
+ if args.json:
653
+ payload = [_session_summary_dict(paths, s) for s in sessions]
654
+ print(json.dumps(payload, indent=2))
655
+ return 0
656
+
657
+ print(f"{len(sessions)} session(s), most recent first:")
658
+ print("")
659
+ for session in sessions:
660
+ modes = report_modes_for(paths, session.session_id)
661
+ modes_label = ", ".join(modes) if modes else "none"
662
+ verified = "verified" if is_verified(session) else "unverified"
663
+ print(f"- {session.session_id[:8]} [{session.status}] {session.title}")
664
+ print(f" started: {session.timestamps.start or 'unknown'}")
665
+ print(
666
+ f" commands: {session.summary.commands_count} "
667
+ f"({session.summary.failed_commands_count} failed)"
668
+ )
669
+ print(f" notes: {session.summary.notes_count}")
670
+ print(f" verification: {verified}")
671
+ print(f" reports: {modes_label}")
672
+ return 0
673
+
674
+
675
+ def cmd_show(args: argparse.Namespace) -> int:
676
+ paths = resolve_project_paths()
677
+ resolved, matches = resolve_session_id(paths, args.session_id)
678
+
679
+ if resolved is None:
680
+ if not matches:
681
+ eprint(
682
+ f"No session found matching id {args.session_id!r}. "
683
+ "Use 'debugbrief list' to see available sessions."
684
+ )
685
+ else:
686
+ eprint(
687
+ f"Ambiguous session id {args.session_id!r} matches "
688
+ f"{len(matches)} sessions:"
689
+ )
690
+ for sid in matches:
691
+ eprint(f" {sid}")
692
+ eprint("Provide more characters to disambiguate.")
693
+ return 1
694
+
695
+ manager = SessionManager(paths)
696
+ try:
697
+ session = manager.load_session_file(resolved)
698
+ except SessionError as exc:
699
+ eprint(f"error: {exc}")
700
+ return 1
701
+
702
+ if args.json:
703
+ print(json.dumps(session.to_dict(), indent=2))
704
+ return 0
705
+
706
+ ctx = build_context(session)
707
+ modes = report_modes_for(paths, session.session_id)
708
+
709
+ print(f"Session: {session.title}")
710
+ print(f" id: {session.session_id}")
711
+ print(f" status: {session.status}")
712
+ print(f" root: {session.project_root}")
713
+ print(f" started: {session.timestamps.start or 'unknown'}")
714
+ print(f" ended: {session.timestamps.end or '(not ended)'}")
715
+ if session.git.is_repo:
716
+ branch = session.git.branch or (
717
+ "(detached HEAD)" if session.git.detached_head else "(no branch)"
718
+ )
719
+ print(f" git: {branch}")
720
+ else:
721
+ print(" git: not a Git repository")
722
+
723
+ print("")
724
+ print(f"Notes ({len(ctx.notes)}):")
725
+ if ctx.notes:
726
+ for _ts, text in ctx.notes:
727
+ print(f" - {text}")
728
+ else:
729
+ print(" (none)")
730
+
731
+ print("")
732
+ print(f"Relevant commands ({len(ctx.report_commands)}):")
733
+ if ctx.report_commands:
734
+ for rc in ctx.report_commands:
735
+ repeat = f" x{rc.count}" if rc.count > 1 else ""
736
+ exit_repr = "n/a" if rc.exit_code is None else str(rc.exit_code)
737
+ print(f" - {rc.command}{repeat} -> {rc.status} (exit {exit_repr})")
738
+ else:
739
+ print(" (none)")
740
+
741
+ if ctx.failed_commands:
742
+ print("")
743
+ print(f"Failed commands ({len(ctx.failed_commands)}):")
744
+ for rc in ctx.failed_commands:
745
+ print(f" - {rc.command}")
746
+
747
+ print("")
748
+ print(f"Verification commands ({len(ctx.verification_commands)}):")
749
+ if ctx.verification_commands:
750
+ for rc in ctx.verification_commands:
751
+ print(f" - {rc.command}")
752
+ else:
753
+ print(" (none passed)")
754
+
755
+ print("")
756
+ if session.git.is_repo and session.summary.file_changes:
757
+ print(f"Changed files ({len(session.summary.file_changes)}):")
758
+ for fc in session.summary.file_changes:
759
+ print(f" - {fc.status} {fc.path}")
760
+ elif session.git.is_repo and session.summary.modified_files:
761
+ print(f"Changed files ({len(session.summary.modified_files)}):")
762
+ for path in session.summary.modified_files:
763
+ print(f" - {path}")
764
+ else:
765
+ print("Changed files: (none recorded)")
766
+
767
+ print("")
768
+ if modes:
769
+ print("Reports:")
770
+ for mode in modes:
771
+ print(f" - {mode}: {paths.report_file(session.session_id, mode)}")
772
+ else:
773
+ print("Reports: (none generated)")
774
+ return 0
775
+
776
+
777
+ def _reconstruct_command(parts: List[str]) -> str:
778
+ """Reconstruct the command string from the raw ``run`` tokens.
779
+
780
+ A leading ``--`` separator is dropped. A single remaining token (the quoted
781
+ form) is preserved verbatim. Multiple tokens (the ``--`` passthrough form)
782
+ are joined with ``shlex.join`` so arguments containing spaces or quotes
783
+ survive intact into storage, reports, and re-runs.
784
+ """
785
+ tokens = list(parts)
786
+ if tokens and tokens[0] == "--":
787
+ tokens = tokens[1:]
788
+ if not tokens:
789
+ return ""
790
+ if len(tokens) == 1:
791
+ return tokens[0]
792
+ return shlex.join(tokens)
793
+
794
+
795
+ def _plain_command_text(parts: List[str]) -> str:
796
+ """Space-join the raw ``run`` tokens (minus a leading ``--``) for display.
797
+
798
+ Used only as the auto-start title seed, where the shlex-quoted
799
+ reconstruction would read as nested quote noise. The executed and stored
800
+ command always comes from :func:`_reconstruct_command`.
801
+ """
802
+ tokens = list(parts)
803
+ if tokens and tokens[0] == "--":
804
+ tokens = tokens[1:]
805
+ return " ".join(tokens)
806
+
807
+
808
+ def main(argv: Optional[List[str]] = None) -> int:
809
+ if not is_supported_platform():
810
+ eprint(
811
+ "DebugBrief v1 supports Unix-like systems only (Linux, macOS, BSD).\n"
812
+ "Windows and PowerShell are not supported in this version."
813
+ )
814
+ return 2
815
+
816
+ parser = build_parser()
817
+ args = parser.parse_args(argv)
818
+
819
+ # The run subparser reuses the "command" attribute for its token list, so
820
+ # the presence of a handler is what marks a subcommand as selected.
821
+ if not hasattr(args, "func"):
822
+ parser.print_help()
823
+ return 2
824
+
825
+ try:
826
+ return args.func(args)
827
+ except SessionError as exc:
828
+ eprint(f"error: {exc}")
829
+ return 1
830
+ except BrokenPipeError:
831
+ # The consumer of our stdout closed the pipe early, e.g.
832
+ # `debugbrief list | head -1`. Point stdout at devnull so the
833
+ # interpreter does not raise a second error while flushing on exit,
834
+ # and return the Unix convention for SIGPIPE (128 + 13).
835
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
836
+ return 141
837
+ except KeyboardInterrupt: # pragma: no cover
838
+ eprint("Interrupted.")
839
+ return 130
840
+
841
+
842
+ if __name__ == "__main__": # pragma: no cover
843
+ sys.exit(main())