bingo-light 2.0.0

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.
package/bingo-light ADDED
@@ -0,0 +1,1094 @@
1
+ #!/usr/bin/env python3
2
+ """bingo-light — AI-native fork maintenance tool.
3
+
4
+ CLI entry point. Delegates all business logic to bingo_core.Repo.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import re
13
+ import sys
14
+ from typing import Any, Dict, Optional
15
+
16
+ # ─── Ensure the directory containing this script is on sys.path ──────────────
17
+ _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
18
+ if _SCRIPT_DIR not in sys.path:
19
+ sys.path.insert(0, _SCRIPT_DIR)
20
+
21
+ from bingo_core import VERSION, BingoError, Repo # noqa: E402
22
+ from bingo_core.setup import run_setup # noqa: E402
23
+
24
+ # ─── Colors ──────────────────────────────────────────────────────────────────
25
+
26
+ BOLD = "\033[1m"
27
+ RED = "\033[31m"
28
+ GREEN = "\033[32m"
29
+ YELLOW = "\033[33m"
30
+ CYAN = "\033[36m"
31
+ DIM = "\033[2m"
32
+ RESET = "\033[0m"
33
+
34
+
35
+ def _colors_enabled() -> bool:
36
+ """Return True if stdout is a TTY and NO_COLOR is not set."""
37
+ return sys.stdout.isatty() and not os.environ.get("NO_COLOR", "")
38
+
39
+
40
+ def _c(code: str, text: str) -> str:
41
+ """Wrap text in an ANSI code if colors are enabled."""
42
+ if _colors_enabled():
43
+ return f"{code}{text}{RESET}"
44
+ return text
45
+
46
+
47
+ # ─── Output helpers ──────────────────────────────────────────────────────────
48
+
49
+
50
+ def _json_print(data: dict) -> None:
51
+ """Print a dict as compact JSON to stdout."""
52
+ print(json.dumps(data, default=str))
53
+
54
+
55
+ def _error_exit(msg: str, json_mode: bool) -> None:
56
+ """Print error and exit 1."""
57
+ if json_mode:
58
+ _json_print({"ok": False, "error": str(msg)})
59
+ else:
60
+ print(f"{_c(RED, 'x')} {msg}", file=sys.stderr)
61
+ sys.exit(1)
62
+
63
+
64
+ # ─── Human-readable formatters ──────────────────────────────────────────────
65
+
66
+
67
+ def _format_status(result: dict) -> str:
68
+ """Format status result for human reading."""
69
+ lines = []
70
+ lines.append("")
71
+ lines.append(f" {_c(BOLD, 'Upstream:')} {result.get('upstream_url', '?')}")
72
+ lines.append(f" {_c(BOLD, 'Branch:')} {result.get('upstream_branch', '?')}")
73
+ lines.append(f" {_c(BOLD, 'Current:')} {result.get('current_branch', '?')}")
74
+ lines.append("")
75
+
76
+ behind = result.get("behind", 0)
77
+ if behind == 0:
78
+ lines.append(f" {_c(GREEN, 'Up to date with upstream')}")
79
+ else:
80
+ lines.append(f" {_c(YELLOW, f'{behind} commit(s) behind upstream')}")
81
+ lines.append("")
82
+
83
+ patches = result.get("patches", [])
84
+ lines.append(f" {_c(BOLD, 'Patches:')} {len(patches)}")
85
+ lines.append("")
86
+ for i, p in enumerate(patches, 1):
87
+ name = p.get("name", "?")
88
+ subject = p.get("subject", "")
89
+ h = p.get("hash", "")[:7]
90
+ files = p.get("files", 0)
91
+ desc = re.sub(r"^\[bl\] [^:]+:\s*", "", subject) if subject else ""
92
+ lines.append(
93
+ f" {i} {_c(CYAN, name)} {desc} "
94
+ f"{_c(DIM, h)} {files} file(s)"
95
+ )
96
+ lines.append("")
97
+ return "\n".join(lines)
98
+
99
+
100
+ def _format_patch_list(result: dict) -> str:
101
+ """Format patch list for human reading."""
102
+ patches = result.get("patches", [])
103
+ lines = []
104
+ lines.append("")
105
+ lines.append(f"{_c(BOLD, 'Patch Stack')} (bottom = applied first)")
106
+ lines.append("")
107
+ for i, p in enumerate(patches, 1):
108
+ name = p.get("name", "?")
109
+ h = p.get("hash", "")[:7]
110
+ lines.append(f" {i} {_c(CYAN, name)} ({_c(DIM, h)})")
111
+ # Show file details if available (verbose mode)
112
+ file_details = p.get("file_details", [])
113
+ if file_details:
114
+ for fd in file_details:
115
+ lines.append(f" {fd}")
116
+ else:
117
+ stat = p.get("stat", "")
118
+ if stat:
119
+ lines.append(f" {stat}")
120
+ else:
121
+ ins = p.get("insertions", 0)
122
+ dele = p.get("deletions", 0)
123
+ files = p.get("files", 0)
124
+ fc = "file" if files == 1 else "files"
125
+ lines.append(
126
+ f" {files} {fc} changed, {ins} insertions(+), {dele} deletions(-)"
127
+ )
128
+ lines.append("")
129
+ lines.append(f" Total: {len(patches)} patch(es)")
130
+ lines.append("")
131
+ return "\n".join(lines)
132
+
133
+
134
+ def _format_sync(result: dict) -> str:
135
+ """Format sync result for human reading."""
136
+ if result.get("dry_run"):
137
+ behind = result.get("behind", 0)
138
+ patches = result.get("patches", 0)
139
+ return (
140
+ f"{_c(YELLOW, '!')} Dry run: {behind} new upstream commit(s), "
141
+ f"{patches} patch(es) to rebase."
142
+ )
143
+ if result.get("up_to_date"):
144
+ return f"{_c(GREEN, 'OK')} Already up to date."
145
+ if result.get("conflict"):
146
+ files = result.get("conflicted_files", [])
147
+ lines = [f"{_c(RED, 'x')} Sync conflict! Rebase paused."]
148
+ for f in files:
149
+ lines.append(f" {_c(YELLOW, '~')} {f}")
150
+ lines.append("\n Resolve conflicts, then: git add <file> && git rebase --continue")
151
+ lines.append(" Or abort: git rebase --abort")
152
+ return "\n".join(lines)
153
+ patches = result.get("patches_rebased", result.get("patches", 0))
154
+ return f"{_c(GREEN, 'OK')} Sync complete! {patches} patch(es) rebased cleanly."
155
+
156
+
157
+ def _format_doctor(result: dict) -> str:
158
+ """Format doctor result for human reading."""
159
+ lines = []
160
+ checks = result.get("checks", [])
161
+ for check in checks:
162
+ name = check.get("name", "?")
163
+ status = check.get("status", "")
164
+ ok = status == "pass" or check.get("ok", False)
165
+ is_warn = status == "warn"
166
+ if ok:
167
+ symbol = _c(GREEN, "OK")
168
+ elif is_warn:
169
+ symbol = _c(YELLOW, "WARN")
170
+ else:
171
+ symbol = _c(RED, "FAIL")
172
+ detail = check.get("detail", check.get("message", ""))
173
+ lines.append(f" {symbol} {name}")
174
+ if detail and (not ok or is_warn):
175
+ lines.append(f" {_c(DIM, detail)}")
176
+ return "\n".join(lines)
177
+
178
+
179
+ def _format_diff(result: dict) -> str:
180
+ """Format diff result."""
181
+ diff_text = result.get("diff", "")
182
+ if not diff_text:
183
+ return "No changes vs upstream."
184
+ return diff_text
185
+
186
+
187
+ def _format_log(result: dict) -> str:
188
+ """Format log result — compact one-line-per-sync."""
189
+ syncs = result.get("syncs", result.get("history", result.get("entries", [])))
190
+ if not syncs:
191
+ return "No sync history."
192
+ lines = []
193
+ for e in syncs:
194
+ ts = e.get("timestamp", "")
195
+ n = e.get("upstream_commits_integrated", 0)
196
+ patches = e.get("patches", [])
197
+ before = e.get("upstream_before", "")[:7]
198
+ after = e.get("upstream_after", "")[:7]
199
+ summary = f"{n} commit(s) integrated"
200
+ if before and after:
201
+ summary += f" {before} \u2192 {after}"
202
+ if patches:
203
+ summary += f" ({len(patches)} patch(es) rebased)"
204
+ lines.append(f" {_c(DIM, ts)} {summary}")
205
+ return "\n".join(lines)
206
+
207
+
208
+ def _format_history(result: dict) -> str:
209
+ """Format history result — verbose with per-patch hash mappings."""
210
+ syncs = result.get("syncs", result.get("history", result.get("entries", [])))
211
+ if not syncs:
212
+ return "No sync history."
213
+ lines = []
214
+ for e in syncs:
215
+ ts = e.get("timestamp", "")
216
+ n = e.get("upstream_commits_integrated", 0)
217
+ before = e.get("upstream_before", "")[:7]
218
+ after = e.get("upstream_after", "")[:7]
219
+ patches = e.get("patches", [])
220
+ lines.append(f" {_c(BOLD, 'Sync')} @ {ts}")
221
+ lines.append(f" Upstream: {before} \u2192 {after} ({n} commit(s))")
222
+ if patches:
223
+ lines.append(" Patches rebased:")
224
+ for p in patches:
225
+ name = p.get("name", "?")
226
+ h = p.get("hash", "?")[:7]
227
+ lines.append(f" {name} {_c(DIM, h)}")
228
+ lines.append("")
229
+ return "\n".join(lines).rstrip()
230
+
231
+
232
+ def _format_conflict_analyze(result: dict) -> str:
233
+ """Format conflict analysis."""
234
+ conflicts = result.get("conflicts", [])
235
+ if not conflicts:
236
+ return f"{_c(GREEN, 'OK')} No conflicts detected."
237
+ lines = [f"{_c(YELLOW, '!')} {len(conflicts)} conflicting file(s):"]
238
+ for c in conflicts:
239
+ f = c.get("file", "?")
240
+ hint = c.get("merge_hint", "")
241
+ lines.append(f" {_c(RED, f)}")
242
+ if hint:
243
+ lines.append(f" hint: {hint}")
244
+ return "\n".join(lines)
245
+
246
+
247
+ def _format_conflict_resolve(result: dict) -> str:
248
+ """Format conflict-resolve result for human reading."""
249
+ if result.get("ok") is False:
250
+ return f"{_c(RED, 'x')} {result.get('error', 'Resolution failed.')}"
251
+
252
+ resolved = result.get("resolved", "?")
253
+ remaining = result.get("remaining", [])
254
+
255
+ if remaining:
256
+ n = len(remaining)
257
+ files = ", ".join(remaining)
258
+ return (
259
+ f"{_c(GREEN, 'OK')} Resolved {resolved}. "
260
+ f"{n} file(s) still in conflict: {files}"
261
+ )
262
+
263
+ if result.get("sync_complete"):
264
+ return (
265
+ f"{_c(GREEN, 'OK')} Resolved {resolved} "
266
+ f"\u2014 sync complete!"
267
+ )
268
+
269
+ if result.get("conflict"):
270
+ patch = result.get("current_patch", "")
271
+ conflicts = result.get("conflicts", [])
272
+ lines = [
273
+ f"{_c(GREEN, 'OK')} Resolved {resolved} "
274
+ f"\u2014 next patch has conflicts:"
275
+ ]
276
+ if patch:
277
+ lines.append(f" Patch: {patch}")
278
+ for c in conflicts:
279
+ f = c.get("file", "?")
280
+ hint = c.get("merge_hint", "")
281
+ lines.append(f" {_c(YELLOW, '~')} {f}")
282
+ if hint:
283
+ lines.append(f" hint: {hint}")
284
+ return "\n".join(lines)
285
+
286
+ if result.get("rebase_continued"):
287
+ return (
288
+ f"{_c(GREEN, 'OK')} Resolved {resolved} "
289
+ f"\u2014 rebase continued."
290
+ )
291
+
292
+ return f"{_c(GREEN, 'OK')} Resolved {resolved}."
293
+
294
+
295
+ def _format_session(result: dict) -> str:
296
+ """Format session output."""
297
+ content = result.get("content", result.get("session", ""))
298
+ if content:
299
+ return content
300
+ return result.get("message", "Session updated.")
301
+
302
+
303
+ def _format_config(result: dict) -> str:
304
+ """Format config output."""
305
+ # config list
306
+ items = result.get("items", result.get("config", None))
307
+ if items is not None:
308
+ if isinstance(items, dict):
309
+ lines = [f" {k} = {v}" for k, v in items.items()]
310
+ return "\n".join(lines) if lines else "No configuration set."
311
+ if isinstance(items, list):
312
+ lines = [f" {item}" for item in items]
313
+ return "\n".join(lines) if lines else "No configuration set."
314
+ # config get
315
+ value = result.get("value", None)
316
+ if value is not None:
317
+ return str(value)
318
+ return result.get("message", "OK")
319
+
320
+
321
+ def _format_test(result: dict) -> str:
322
+ """Format test result."""
323
+ passed = result.get("test") == "pass" or result.get("passed", False)
324
+ output = result.get("output", "")
325
+ status = _c(GREEN, "PASS") if passed else _c(RED, "FAIL")
326
+ lines = [f"Test: {status}"]
327
+ if output:
328
+ lines.append(output)
329
+ return "\n".join(lines)
330
+
331
+
332
+ def _format_auto_sync(result: dict) -> str:
333
+ """Format auto-sync result."""
334
+ schedule = result.get("schedule", "")
335
+ return f"{_c(GREEN, 'OK')} GitHub Actions workflow generated ({schedule})."
336
+
337
+
338
+ def _format_workspace(result: dict) -> str:
339
+ """Format workspace result."""
340
+ # workspace list / status
341
+ repos = result.get("repos", None)
342
+ if repos is not None:
343
+ if not repos:
344
+ return "No repos in workspace."
345
+ lines = [f"{_c(BOLD, 'Workspace repos:')}"]
346
+ for r in repos:
347
+ alias = r.get("alias", "?")
348
+ path = r.get("path", "?")
349
+ status = r.get("status", "")
350
+ behind = r.get("behind", None)
351
+ patches = r.get("patches", None)
352
+ line = f" {_c(CYAN, alias)} {_c(DIM, path)}"
353
+ if behind is not None:
354
+ if behind > 0:
355
+ line += f" {_c(YELLOW, f'{behind} behind')}"
356
+ else:
357
+ line += f" {_c(GREEN, 'up to date')}"
358
+ line += f" {patches} patch(es)"
359
+ elif status == "missing":
360
+ line += f" {_c(RED, 'missing')}"
361
+ elif status == "error":
362
+ line += f" {_c(RED, r.get('error', 'error'))}"
363
+ lines.append(line)
364
+ return "\n".join(lines)
365
+ # workspace sync
366
+ synced = result.get("synced", None)
367
+ if synced is not None:
368
+ lines = []
369
+ for s in synced:
370
+ alias = s.get("alias", "?")
371
+ st = s.get("status", "?")
372
+ color = GREEN if st == "ok" else RED
373
+ lines.append(f" {_c(color, st):>12} {alias}")
374
+ return "\n".join(lines)
375
+ return result.get("message", "OK")
376
+
377
+
378
+ def _format_patch_show(result: dict) -> str:
379
+ """Format patch show."""
380
+ patch = result.get("patch", {})
381
+ diff_text = patch.get("diff", "") if isinstance(patch, dict) else ""
382
+ if not diff_text:
383
+ # fallback: top-level diff key
384
+ diff_text = result.get("diff", "")
385
+ return diff_text if diff_text else "Empty patch."
386
+
387
+
388
+ def _format_init(result: dict) -> str:
389
+ """Format init result."""
390
+ upstream = result.get("upstream", "")
391
+ branch = result.get("branch", "")
392
+ reinit = result.get("reinit", False)
393
+ label = "Re-initialized (config updated)." if reinit else "Fork tracking initialized."
394
+ return (
395
+ f"{_c(GREEN, 'OK')} {label}\n"
396
+ f" Upstream: {upstream}\n"
397
+ f" Branch: {branch}\n"
398
+ f" Tracking: {result.get('tracking', '')}\n"
399
+ f" Patches: {result.get('patches', '')}"
400
+ )
401
+
402
+
403
+ def _format_patch_new(result: dict) -> str:
404
+ """Format patch new result."""
405
+ name = result.get("patch", "")
406
+ h = result.get("hash", "")
407
+ desc = result.get("description", "")
408
+ return (
409
+ f"{_c(GREEN, 'OK')} Patch created: {_c(CYAN, name)} ({_c(DIM, h)})\n"
410
+ f" {desc}"
411
+ )
412
+
413
+
414
+ def _format_patch_drop(result: dict) -> str:
415
+ """Format patch drop result."""
416
+ name = result.get("dropped", "")
417
+ h = result.get("hash", "")
418
+ return f"{_c(GREEN, 'OK')} Dropped patch: {_c(CYAN, name)} ({_c(DIM, h)})"
419
+
420
+
421
+ def _format_patch_export(result: dict) -> str:
422
+ """Format patch export result."""
423
+ count = result.get("count", 0)
424
+ directory = result.get("directory", "")
425
+ return f"{_c(GREEN, 'OK')} Exported {count} patch(es) to {directory}"
426
+
427
+
428
+ def _format_patch_import(result: dict) -> str:
429
+ """Format patch import result."""
430
+ count = result.get("patch_count", 0)
431
+ return f"{_c(GREEN, 'OK')} Import complete. {count} patch(es) in stack."
432
+
433
+
434
+ def _format_smart_sync(result: dict) -> str:
435
+ """Format smart-sync result for human reading."""
436
+ if result.get("ok") is False:
437
+ action = result.get("action", "")
438
+ if action == "needs_human":
439
+ conflicts = result.get("remaining_conflicts", [])
440
+ auto = result.get("conflicts_auto_resolved", 0)
441
+ patch = result.get("current_patch", "")
442
+ lines = [f"{_c(RED, 'x')} Smart sync: unresolved conflict(s)."]
443
+ if patch:
444
+ lines.append(f" Patch: {patch}")
445
+ if auto:
446
+ lines.append(f" Auto-resolved: {auto} step(s) via rerere")
447
+ for c_ in conflicts:
448
+ f = c_.get("file", c_) if isinstance(c_, dict) else c_
449
+ lines.append(f" {_c(YELLOW, '~')} {f}")
450
+ lines.append(
451
+ "\n Resolve conflicts, then: git add <file> && "
452
+ "git rebase --continue"
453
+ )
454
+ lines.append(" Or abort: git rebase --abort")
455
+ return "\n".join(lines)
456
+ err = result.get("error", "Operation failed.")
457
+ return f"{_c(RED, 'x')} {err}"
458
+ action = result.get("action", "")
459
+ if action == "none":
460
+ return f"{_c(GREEN, 'OK')} Already up to date."
461
+ patches = result.get("patches_rebased", 0)
462
+ auto = result.get("conflicts_resolved", 0)
463
+ msg = f"{_c(GREEN, 'OK')} Sync complete! {patches} patch(es) rebased."
464
+ if auto:
465
+ msg += f" ({auto} conflict(s) auto-resolved via rerere)"
466
+ return msg
467
+
468
+
469
+ def _format_patch_meta(result: dict) -> str:
470
+ """Format patch meta result for human reading."""
471
+ if result.get("ok") is False:
472
+ return f"{_c(RED, 'x')} {result.get('error', 'Failed.')}"
473
+ # Set operation — echo what was set
474
+ if "set" in result:
475
+ k = result["set"]
476
+ v = result["value"]
477
+ patch = result.get("patch", "")
478
+ return f"{_c(GREEN, 'OK')} {patch}: {k} = {v}"
479
+ # Get all metadata
480
+ meta = result.get("meta")
481
+ if meta is not None:
482
+ patch = result.get("patch", "")
483
+ lines = [f"{_c(BOLD, patch)}"]
484
+ for k, v in meta.items():
485
+ if k == "tags":
486
+ v = ", ".join(v) if v else "(none)"
487
+ elif v is None:
488
+ v = "(not set)"
489
+ elif v == "":
490
+ v = "(not set)"
491
+ lines.append(f" {k}: {v}")
492
+ return "\n".join(lines)
493
+ # Get single key
494
+ k = result.get("key", "")
495
+ v = result.get("value", "")
496
+ if isinstance(v, list):
497
+ v = ", ".join(v) if v else "(none)"
498
+ elif v is None or v == "":
499
+ v = "(not set)"
500
+ return f" {k}: {v}" if k else f"{_c(GREEN, 'OK')} Done."
501
+
502
+
503
+ def _format_undo(result: dict) -> str:
504
+ """Format undo result."""
505
+ if result.get("ok") is False:
506
+ err = result.get("error", "Undo failed.")
507
+ return f"{_c(RED, 'x')} {err}"
508
+ msg = result.get("message", "")
509
+ if msg:
510
+ return f"{_c(GREEN, 'OK')} {msg}"
511
+ restored = result.get("restored_to", "")[:7]
512
+ return f"{_c(GREEN, 'OK')} Restored to {restored}"
513
+
514
+
515
+ def _format_setup(result: dict) -> str:
516
+ """Setup writes its own interactive output to stderr; nothing extra needed."""
517
+ return ""
518
+
519
+
520
+ def _format_generic(result: dict) -> str:
521
+ """Fallback formatter: show message or OK."""
522
+ if result.get("ok") is False:
523
+ err = result.get("error", "Operation failed.")
524
+ return f"{_c(RED, 'x')} {err}"
525
+ msg = result.get("message", "")
526
+ if msg:
527
+ return f"{_c(GREEN, 'OK')} {msg}"
528
+ return f"{_c(GREEN, 'OK')} Done."
529
+
530
+
531
+ # ─── Help text ───────────────────────────────────────────────────────────────
532
+
533
+
534
+ def show_help() -> str:
535
+ """Return the help text, mimicking the original Bash version."""
536
+ c = _colors_enabled()
537
+ b = BOLD if c else ""
538
+ cy = CYAN if c else ""
539
+ dm = DIM if c else ""
540
+ r = RESET if c else ""
541
+
542
+ return f"""
543
+ bingo-light — AI-native fork maintenance tool.
544
+
545
+ Manages your customizations as a clean patch stack on top of upstream,
546
+ so syncing with upstream is always a one-command operation.
547
+
548
+ {b}Usage:{r} bingo-light <command> [options]
549
+
550
+ {b}Setup:{r}
551
+ {cy}init{r} <upstream-url> [branch] Initialize fork tracking
552
+ {cy}setup{r} Configure MCP for AI tools (interactive)
553
+
554
+ {b}Patch Management:{r}
555
+ {cy}patch new{r} <name> Create a new patch
556
+ {cy}patch list{r} [-v] List all patches
557
+ {cy}patch show{r} <name|index> Show patch diff
558
+ {cy}patch edit{r} <name|index> Amend an existing patch
559
+ {cy}patch drop{r} <name|index> Remove a patch
560
+ {cy}patch export{r} [dir] Export patches as .patch files
561
+ {cy}patch import{r} <file|dir> Import .patch files
562
+ {cy}patch reorder{r} Reorder patch stack
563
+ {cy}patch squash{r} <idx1> <idx2> Merge two patches
564
+ {cy}patch meta{r} <name> [key] [val] Get/set patch metadata
565
+
566
+ {b}Sync with Upstream:{r}
567
+ {cy}sync{r} [--dry-run] [--force] [--test] Rebase patches onto latest upstream
568
+ {cy}smart-sync{r} One-shot sync: auto-resolves conflicts via rerere
569
+ {cy}undo{r} Undo last sync
570
+
571
+ {b}Monitoring:{r}
572
+ {cy}status{r} Health check & conflict prediction
573
+ {cy}doctor{r} Diagnose setup issues
574
+ {cy}diff{r} Show all changes vs upstream
575
+ {cy}log{r} Show sync history
576
+ {cy}conflict-analyze{r} Analyze rebase conflicts (structured output)
577
+ {cy}conflict-resolve{r} <file> Resolve a conflict file and continue
578
+ {cy}history{r} Detailed sync history with hash mappings
579
+ {cy}session{r} [update] AI session notes (.bingo/session.md)
580
+
581
+ {b}Configuration:{r}
582
+ {cy}config{r} get|set|list Manage configuration
583
+ {cy}test{r} Run configured test suite
584
+
585
+ {b}Automation:{r}
586
+ {cy}auto-sync{r} Generate GitHub Actions workflow
587
+
588
+ {b}Multi-repo:{r}
589
+ {cy}workspace{r} init|add|remove|status|sync|list Multi-repo management
590
+
591
+ {b}Quick Start:{r}
592
+
593
+ {dm}# 1. Fork a project on GitHub, clone your fork{r}
594
+ git clone https://github.com/YOU/project.git
595
+ cd project
596
+
597
+ {dm}# 2. Initialize bingo-light{r}
598
+ bingo-light init https://github.com/ORIGINAL/project.git
599
+
600
+ {dm}# 3. Make changes and create patches{r}
601
+ vim src/feature.py
602
+ bingo-light patch new my-custom-feature
603
+
604
+ {dm}# 4. Later, sync with upstream{r}
605
+ bingo-light sync
606
+
607
+ {dm}Version {VERSION}{r}
608
+ """
609
+
610
+
611
+ # ─── Argument parsing ────────────────────────────────────────────────────────
612
+
613
+
614
+ class _BingoParser(argparse.ArgumentParser):
615
+ """Custom parser that emits bingo-light style errors instead of argparse defaults."""
616
+
617
+ def error(self, message: str) -> None:
618
+ # Detect if --json was in sys.argv
619
+ json_mode = "--json" in sys.argv
620
+ # Rewrite argparse "invalid choice" to include "unknown" keyword for test compat
621
+ if "invalid choice:" in message:
622
+ # Extract the bad command name from argparse message
623
+ import re as _re
624
+ m = _re.search(r"invalid choice: '([^']+)'", message)
625
+ if m:
626
+ message = f"Unknown command: {m.group(1)}"
627
+ _error_exit(
628
+ f"{message}. Run 'bingo-light --help' for usage.",
629
+ json_mode,
630
+ )
631
+
632
+
633
+ def build_parser() -> argparse.ArgumentParser:
634
+ """Build the CLI argument parser with all subcommands."""
635
+ parser = _BingoParser(
636
+ prog="bingo-light",
637
+ description="AI-native fork maintenance tool.",
638
+ add_help=False,
639
+ )
640
+ # Note: --json, --yes, --version, --help are pre-extracted in main()
641
+ # before argparse runs. They are NOT defined here.
642
+
643
+ sub = parser.add_subparsers(dest="command")
644
+
645
+ # init
646
+ p_init = sub.add_parser("init", add_help=False)
647
+ p_init.add_argument("upstream_url")
648
+ p_init.add_argument("branch", nargs="?", default="")
649
+
650
+ # status
651
+ sub.add_parser("status", aliases=["st"], add_help=False)
652
+
653
+ # sync
654
+ p_sync = sub.add_parser("sync", aliases=["s"], add_help=False)
655
+ p_sync.add_argument("--dry-run", dest="dry_run", action="store_true")
656
+ p_sync.add_argument("--force", action="store_true")
657
+ p_sync.add_argument("--test", action="store_true")
658
+
659
+ # smart-sync
660
+ sub.add_parser("smart-sync", add_help=False)
661
+
662
+ # undo
663
+ sub.add_parser("undo", add_help=False)
664
+
665
+ # diff
666
+ sub.add_parser("diff", aliases=["d"], add_help=False)
667
+
668
+ # doctor
669
+ sub.add_parser("doctor", add_help=False)
670
+
671
+ # log
672
+ sub.add_parser("log", add_help=False)
673
+
674
+ # history
675
+ sub.add_parser("history", add_help=False)
676
+
677
+ # conflict-analyze
678
+ sub.add_parser("conflict-analyze", add_help=False)
679
+
680
+ # conflict-resolve
681
+ cr = sub.add_parser("conflict-resolve", add_help=False)
682
+ cr.add_argument("resolve_file", nargs="?", default="")
683
+ cr.add_argument("--content-stdin", action="store_true")
684
+
685
+ # session
686
+ p_session = sub.add_parser("session", add_help=False)
687
+ p_session.add_argument("session_action", nargs="?", default="")
688
+
689
+ # config
690
+ p_config = sub.add_parser("config", add_help=False)
691
+ p_config.add_argument("config_action", choices=["get", "set", "list"])
692
+ p_config.add_argument("config_key", nargs="?", default="")
693
+ p_config.add_argument("config_value", nargs="?", default="")
694
+
695
+ # test
696
+ sub.add_parser("test", add_help=False)
697
+
698
+ # auto-sync
699
+ p_auto = sub.add_parser("auto-sync", add_help=False)
700
+ p_auto.add_argument("--schedule", default="daily")
701
+
702
+ # patch
703
+ p_patch = sub.add_parser("patch", aliases=["p"], add_help=False)
704
+ patch_sub = p_patch.add_subparsers(dest="patch_command")
705
+
706
+ pp_new = patch_sub.add_parser("new", add_help=False)
707
+ pp_new.add_argument("patch_name")
708
+
709
+ pp_list = patch_sub.add_parser("list", add_help=False)
710
+ pp_list.add_argument("-v", "--verbose", action="store_true")
711
+
712
+ pp_show = patch_sub.add_parser("show", add_help=False)
713
+ pp_show.add_argument("target")
714
+
715
+ pp_drop = patch_sub.add_parser("drop", add_help=False)
716
+ pp_drop.add_argument("target")
717
+
718
+ pp_edit = patch_sub.add_parser("edit", add_help=False)
719
+ pp_edit.add_argument("target")
720
+
721
+ pp_export = patch_sub.add_parser("export", add_help=False)
722
+ pp_export.add_argument("output_dir", nargs="?", default=".bl-patches")
723
+
724
+ pp_import = patch_sub.add_parser("import", add_help=False)
725
+ pp_import.add_argument("import_path")
726
+
727
+ pp_reorder = patch_sub.add_parser("reorder", add_help=False)
728
+ pp_reorder.add_argument("--order", default="")
729
+ pp_reorder.add_argument("order_positional", nargs="?", default="")
730
+
731
+ pp_squash = patch_sub.add_parser("squash", add_help=False)
732
+ pp_squash.add_argument("idx1", type=int)
733
+ pp_squash.add_argument("idx2", type=int)
734
+
735
+ pp_meta = patch_sub.add_parser("meta", add_help=False)
736
+ pp_meta.add_argument("meta_target")
737
+ pp_meta.add_argument("meta_key", nargs="?", default="")
738
+ pp_meta.add_argument("meta_value", nargs="?", default="")
739
+
740
+ # workspace
741
+ p_ws = sub.add_parser("workspace", aliases=["ws"], add_help=False)
742
+ ws_sub = p_ws.add_subparsers(dest="ws_command")
743
+
744
+ ws_sub.add_parser("init", add_help=False)
745
+
746
+ ws_add = ws_sub.add_parser("add", add_help=False)
747
+ ws_add.add_argument("ws_path", nargs="?", default="")
748
+ ws_add.add_argument("--alias", default="")
749
+
750
+ ws_sub.add_parser("list", add_help=False)
751
+ ws_sub.add_parser("sync", add_help=False)
752
+ ws_sub.add_parser("status", add_help=False)
753
+
754
+ ws_remove = ws_sub.add_parser("remove", add_help=False)
755
+ ws_remove.add_argument("ws_target")
756
+
757
+ # setup
758
+ p_setup = sub.add_parser("setup", add_help=False)
759
+ p_setup.add_argument("--no-completions", dest="no_completions", action="store_true")
760
+
761
+ # help
762
+ sub.add_parser("help", add_help=False)
763
+
764
+ return parser
765
+
766
+
767
+ # ─── Dispatch ────────────────────────────────────────────────────────────────
768
+
769
+
770
+ def dispatch(args: argparse.Namespace, json_mode: bool) -> Optional[dict]:
771
+ """Dispatch a parsed command to the Repo method and return the result dict."""
772
+ cmd = args.command
773
+
774
+ # setup doesn't need a Repo (works outside git repos)
775
+ if cmd == "setup":
776
+ return run_setup(
777
+ yes=getattr(args, "yes_mode", False),
778
+ json_mode=json_mode,
779
+ no_completions=getattr(args, "no_completions", False),
780
+ )
781
+
782
+ repo = Repo()
783
+
784
+ if cmd == "init":
785
+ return repo.init(args.upstream_url, args.branch)
786
+
787
+ if cmd in ("status", "st"):
788
+ return repo.status()
789
+
790
+ if cmd in ("sync", "s"):
791
+ return repo.sync(
792
+ dry_run=args.dry_run,
793
+ force=args.force or getattr(args, "yes_mode", False),
794
+ test=args.test,
795
+ )
796
+
797
+ if cmd == "smart-sync":
798
+ return repo.smart_sync()
799
+
800
+ if cmd == "undo":
801
+ return repo.undo()
802
+
803
+ if cmd in ("diff", "d"):
804
+ return repo.diff()
805
+
806
+ if cmd == "doctor":
807
+ return repo.doctor()
808
+
809
+ if cmd == "log":
810
+ return repo.history()
811
+
812
+ if cmd == "history":
813
+ return repo.history()
814
+
815
+ if cmd == "conflict-analyze":
816
+ return repo.conflict_analyze()
817
+
818
+ if cmd == "conflict-resolve":
819
+ content = ""
820
+ if args.content_stdin:
821
+ import sys as _sys
822
+ content = _sys.stdin.read()
823
+ return repo.conflict_resolve(args.resolve_file, content)
824
+
825
+ if cmd == "session":
826
+ update = (args.session_action == "update")
827
+ return repo.session(update=update)
828
+
829
+ if cmd == "config":
830
+ if args.config_action == "get":
831
+ if not args.config_key:
832
+ raise BingoError("config get requires a key.")
833
+ return repo.config_get(args.config_key)
834
+ if args.config_action == "set":
835
+ if not args.config_key or not args.config_value:
836
+ raise BingoError("config set requires <key> <value>.")
837
+ return repo.config_set(args.config_key, args.config_value)
838
+ if args.config_action == "list":
839
+ return repo.config_list()
840
+
841
+ if cmd == "test":
842
+ return repo.test()
843
+
844
+ if cmd == "auto-sync":
845
+ return repo.auto_sync(schedule=getattr(args, "schedule", "daily"))
846
+
847
+ if cmd in ("patch", "p"):
848
+ return _dispatch_patch(args, repo)
849
+
850
+ if cmd in ("workspace", "ws"):
851
+ return _dispatch_workspace(args, repo)
852
+
853
+ return None
854
+
855
+
856
+ def _dispatch_patch(args: argparse.Namespace, repo: Repo) -> dict:
857
+ """Dispatch patch subcommands."""
858
+ pcmd = args.patch_command
859
+ if not pcmd:
860
+ raise BingoError(
861
+ "patch requires a subcommand: new, list, show, edit, drop, "
862
+ "export, import, reorder, squash, meta"
863
+ )
864
+
865
+ if pcmd == "new":
866
+ desc = os.environ.get("BINGO_DESCRIPTION", "")
867
+ return repo.patch_new(args.patch_name, description=desc)
868
+
869
+ if pcmd == "list":
870
+ return repo.patch_list(verbose=args.verbose)
871
+
872
+ if pcmd == "show":
873
+ return repo.patch_show(args.target)
874
+
875
+ if pcmd == "drop":
876
+ return repo.patch_drop(args.target)
877
+
878
+ if pcmd == "edit":
879
+ return repo.patch_edit(args.target)
880
+
881
+ if pcmd == "export":
882
+ return repo.patch_export(args.output_dir)
883
+
884
+ if pcmd == "import":
885
+ return repo.patch_import(args.import_path)
886
+
887
+ if pcmd == "reorder":
888
+ order = args.order or getattr(args, "order_positional", "")
889
+ return repo.patch_reorder(order=order)
890
+
891
+ if pcmd == "squash":
892
+ return repo.patch_squash(args.idx1, args.idx2)
893
+
894
+ if pcmd == "meta":
895
+ meta_key = args.meta_key or ""
896
+ meta_value = args.meta_value or ""
897
+ # Support "set-reason VALUE" as shorthand for "reason VALUE"
898
+ if meta_key.startswith("set-") and meta_value:
899
+ meta_key = meta_key[4:] # "set-reason" -> "reason"
900
+ return repo.patch_meta(
901
+ args.meta_target,
902
+ key=meta_key,
903
+ value=meta_value,
904
+ )
905
+
906
+ raise BingoError(f"Unknown patch subcommand: {pcmd}")
907
+
908
+
909
+ def _dispatch_workspace(args: argparse.Namespace, repo: Repo) -> dict:
910
+ """Dispatch workspace subcommands."""
911
+ wcmd = args.ws_command
912
+ if not wcmd:
913
+ raise BingoError(
914
+ "workspace requires a subcommand: init, add, remove, list, sync, status"
915
+ )
916
+
917
+ if wcmd == "init":
918
+ return repo.workspace_init()
919
+
920
+ if wcmd == "add":
921
+ return repo.workspace_add(
922
+ repo_path=args.ws_path,
923
+ alias=args.alias,
924
+ )
925
+
926
+ if wcmd == "list":
927
+ return repo.workspace_list()
928
+
929
+ if wcmd == "sync":
930
+ return repo.workspace_sync()
931
+
932
+ if wcmd == "status":
933
+ return repo.workspace_status()
934
+
935
+ if wcmd == "remove":
936
+ return repo.workspace_remove(args.ws_target)
937
+
938
+ raise BingoError(f"Unknown workspace subcommand: {wcmd}")
939
+
940
+
941
+ # ─── Formatter dispatch ─────────────────────────────────────────────────────
942
+
943
+ _FORMATTERS: Dict[str, Any] = {
944
+ "init": _format_init,
945
+ "status": _format_status,
946
+ "st": _format_status,
947
+ "sync": _format_sync,
948
+ "s": _format_sync,
949
+ "doctor": _format_doctor,
950
+ "diff": _format_diff,
951
+ "d": _format_diff,
952
+ "log": _format_log,
953
+ "history": _format_history,
954
+ "conflict-analyze": _format_conflict_analyze,
955
+ "conflict-resolve": _format_conflict_resolve,
956
+ "session": _format_session,
957
+ "test": _format_test,
958
+ "auto-sync": _format_auto_sync,
959
+ "undo": _format_undo,
960
+ "smart-sync": _format_smart_sync,
961
+ "setup": _format_setup,
962
+ }
963
+
964
+
965
+ def _get_formatter(args: argparse.Namespace):
966
+ """Return the appropriate human-output formatter for a command."""
967
+ cmd = args.command
968
+
969
+ # patch subcommands
970
+ if cmd in ("patch", "p"):
971
+ pcmd = getattr(args, "patch_command", "")
972
+ if pcmd == "list":
973
+ return _format_patch_list
974
+ if pcmd == "show":
975
+ return _format_patch_show
976
+ if pcmd == "new":
977
+ return _format_patch_new
978
+ if pcmd == "drop":
979
+ return _format_patch_drop
980
+ if pcmd == "export":
981
+ return _format_patch_export
982
+ if pcmd == "import":
983
+ return _format_patch_import
984
+ if pcmd == "meta":
985
+ return _format_patch_meta
986
+ return _format_generic
987
+
988
+ # workspace subcommands
989
+ if cmd in ("workspace", "ws"):
990
+ return _format_workspace
991
+
992
+ # config
993
+ if cmd == "config":
994
+ return _format_config
995
+
996
+ return _FORMATTERS.get(cmd, _format_generic)
997
+
998
+
999
+ # ─── Main ────────────────────────────────────────────────────────────────────
1000
+
1001
+
1002
+ def main() -> None:
1003
+ """Entry point."""
1004
+ # ── Pre-extract global flags before argparse (subparsers don't inherit them) ──
1005
+ raw_args = sys.argv[1:]
1006
+ json_mode = False
1007
+ yes_mode = False
1008
+ show_version = False
1009
+ show_help_flag = False
1010
+ cleaned = []
1011
+ for arg in raw_args:
1012
+ if arg == "--json":
1013
+ json_mode = True
1014
+ elif arg in ("--yes", "-y"):
1015
+ yes_mode = True
1016
+ elif arg in ("--version",):
1017
+ show_version = True
1018
+ elif arg in ("--help", "-h"):
1019
+ show_help_flag = True
1020
+ else:
1021
+ cleaned.append(arg)
1022
+
1023
+ # Auto-enable yes when stdin is not a TTY (AI agent / pipe)
1024
+ if not sys.stdin.isatty():
1025
+ yes_mode = True
1026
+
1027
+ # --version (before argparse)
1028
+ if show_version:
1029
+ if json_mode:
1030
+ _json_print({"ok": True, "version": VERSION})
1031
+ else:
1032
+ print(f"bingo-light {VERSION}")
1033
+ return
1034
+
1035
+ # --help or no args or 'help' command
1036
+ if show_help_flag or not cleaned or (cleaned and cleaned[0] == "help"):
1037
+ if json_mode:
1038
+ _json_print({"ok": True, "help": True, "version": VERSION})
1039
+ else:
1040
+ print(show_help())
1041
+ return
1042
+
1043
+ # ── Parse remaining args with argparse ──
1044
+ parser = build_parser()
1045
+ args, unknown = parser.parse_known_args(cleaned)
1046
+ args.json_mode = json_mode
1047
+ args.yes_mode = yes_mode
1048
+
1049
+ # No command after parsing
1050
+ if args.command is None:
1051
+ print(show_help())
1052
+ return
1053
+
1054
+ # Unknown args check
1055
+ if unknown:
1056
+ _error_exit(
1057
+ f"Unknown argument(s): {' '.join(unknown)}. "
1058
+ "Run 'bingo-light --help' for usage.",
1059
+ json_mode,
1060
+ )
1061
+
1062
+ # Dispatch
1063
+ try:
1064
+ result = dispatch(args, json_mode)
1065
+ except BingoError as e:
1066
+ _error_exit(str(e), json_mode)
1067
+ return # unreachable, _error_exit calls sys.exit
1068
+ except KeyboardInterrupt:
1069
+ _error_exit("Interrupted.", json_mode)
1070
+ return
1071
+
1072
+ if result is None:
1073
+ _error_exit(
1074
+ f"Unknown command: {args.command}. Run 'bingo-light --help' for usage.",
1075
+ json_mode,
1076
+ )
1077
+ return
1078
+
1079
+ # Output
1080
+ if json_mode:
1081
+ _json_print(result)
1082
+ else:
1083
+ formatter = _get_formatter(args)
1084
+ output = formatter(result)
1085
+ if output:
1086
+ print(output)
1087
+
1088
+ # Exit non-zero if result indicates failure
1089
+ if isinstance(result, dict) and result.get("ok") is False:
1090
+ sys.exit(1)
1091
+
1092
+
1093
+ if __name__ == "__main__":
1094
+ main()