debtpilot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
debtpilot_core/cli.py ADDED
@@ -0,0 +1,682 @@
1
+ """
2
+ DebtPilot CLI
3
+ Usage: debtpilot <command> [options]
4
+ """
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+ from typing import List
12
+
13
+ try:
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+ from rich.panel import Panel
17
+ from rich import box
18
+ HAS_RICH = True
19
+ except ImportError:
20
+ HAS_RICH = False
21
+
22
+ from debtpilot_core import database as db
23
+ from debtpilot_core import linter, scanner, scorer
24
+
25
+ console = Console() if HAS_RICH else None
26
+
27
+ GRADE_COLORS = {"A": "green", "B": "yellow", "C": "orange3", "D": "red", "F": "bold red"}
28
+ GRADE_EMOJI = {"A": "āœ…", "B": "āš ļø ", "C": "🟠", "D": "šŸ”“", "F": "šŸ’€"}
29
+
30
+
31
+ def _cprint(msg, style=""):
32
+ if console:
33
+ console.print(msg, style=style)
34
+ else:
35
+ print(msg)
36
+
37
+
38
+ def _bar(score: float, width: int = 20) -> str:
39
+ filled = int(score / 100 * width)
40
+ return "ā–ˆ" * filled + "ā–‘" * (width - filled)
41
+
42
+
43
+ def _grade_style(grade: str) -> str:
44
+ return GRADE_COLORS.get(grade, "white")
45
+
46
+
47
+ def cmd_scan(args) -> int:
48
+ db.ensure_db()
49
+ target = Path(args.path).resolve()
50
+
51
+ if target.is_file():
52
+ files = [str(target)]
53
+ elif target.is_dir():
54
+ exts = {".py", ".js", ".jsx", ".ts", ".tsx", ".mjs"}
55
+ ignored = {
56
+ "node_modules", ".git", "__pycache__", ".venv", "venv",
57
+ "dist", "build", ".mypy_cache", ".pytest_cache", ".tox",
58
+ "site-packages", "migrations",
59
+ }
60
+ files = []
61
+ import re
62
+ backup_pattern = re.compile(r'_\d{14}\.py$')
63
+ for f in target.rglob("*"):
64
+ if f.suffix in exts and not any(p in ignored for p in f.parts):
65
+ if not backup_pattern.search(f.name):
66
+ files.append(str(f))
67
+ else:
68
+ _cprint(f"Path not found: {args.path}")
69
+ return 1
70
+
71
+ if not files:
72
+ _cprint("No supported files found.")
73
+ return 0
74
+
75
+ _cprint(f"\n[bold]DebtPilot scan[/bold] — {len(files)} file(s)\n" if HAS_RICH
76
+ else f"\nDebtPilot scan — {len(files)} file(s)\n")
77
+
78
+ results = []
79
+ for fp in files:
80
+ lint_data = linter.lint_file(fp)
81
+ all_issues = []
82
+ for issues in lint_data.values():
83
+ if isinstance(issues, list) and issues and isinstance(issues[0], dict) and "line" in issues[0]:
84
+ all_issues.extend(issues)
85
+ todos = lint_data.get("todos", [])
86
+ scan_data = scanner.scan_file(fp, todos)
87
+ score_data = scorer.compute_score(
88
+ churn=scan_data["churn"], complexity=scan_data["complexity"],
89
+ lint_issues=all_issues, todos=scan_data["todos"],
90
+ staleness_days=scan_data["staleness_days"],
91
+ )
92
+ db.upsert_score(fp, score_data)
93
+ if scan_data["churn"]["commits"] > 0:
94
+ db.upsert_churn(fp, scan_data["churn"]["commits"],
95
+ scan_data["churn"]["authors"], scan_data["churn"]["last_commit"])
96
+ for tool, issues in lint_data.items():
97
+ if tool != "todos" and issues:
98
+ db.upsert_lint(fp, tool, issues)
99
+ if scan_data["todos"]:
100
+ db.upsert_todos(fp, scan_data["todos"])
101
+
102
+ results.append((fp, score_data))
103
+ grade = score_data["grade"]
104
+ total = score_data["total"]
105
+
106
+ if HAS_RICH:
107
+ console.print(
108
+ f" {GRADE_EMOJI[grade]} [bold]{Path(fp).name:<40}[/bold] "
109
+ f"[{_grade_style(grade)}]{total:>5.1f}[/{_grade_style(grade)}] "
110
+ f"[dim]{score_data['label']}[/dim]"
111
+ )
112
+ else:
113
+ print(f" [{grade}] {Path(fp).name:<40} {total:>5.1f} {score_data['label']}")
114
+
115
+ if len(results) > 1:
116
+ avg = sum(s["total"] for _, s in results) / len(results)
117
+ worst = max(results, key=lambda x: x[1]["total"])
118
+ _cprint(f"\n Average debt score : {avg:.1f}")
119
+ _cprint(f" Highest risk file : {Path(worst[0]).name} ({worst[1]['total']:.1f})\n")
120
+
121
+ return 0
122
+
123
+
124
+ def cmd_report(args) -> int:
125
+ db.ensure_db()
126
+ scores = db.get_all_scores(limit=args.limit or 20, min_score=args.min_score or 0)
127
+
128
+ if not scores:
129
+ _cprint("No data yet. Run: debtpilot scan .")
130
+ return 0
131
+
132
+ if HAS_RICH:
133
+ table = Table(title="⚔ Debt Report", box=box.ROUNDED, highlight=True)
134
+ table.add_column("Grade", justify="center", width=6)
135
+ table.add_column("File", style="cyan", min_width=35)
136
+ table.add_column("Score", justify="right", width=7)
137
+ table.add_column("Churn", justify="right", width=7)
138
+ table.add_column("Complexity", justify="right", width=11)
139
+ table.add_column("Lint", justify="right", width=6)
140
+ table.add_column("TODOs", justify="right", width=7)
141
+ for row in scores:
142
+ g = scorer.score_to_grade(row["score"])[0]
143
+ table.add_row(
144
+ f"[{_grade_style(g)}]{g}[/{_grade_style(g)}]",
145
+ Path(row["filepath"]).name,
146
+ f"[{_grade_style(g)}]{row['score']:.1f}[/{_grade_style(g)}]",
147
+ f"{row.get('churn_score', 0):.0f}",
148
+ f"{row.get('complexity_score', 0):.0f}",
149
+ f"{row.get('lint_score', 0):.0f}",
150
+ f"{row.get('todo_score', 0):.0f}",
151
+ )
152
+ console.print(table)
153
+ else:
154
+ print(f"\n{'Grade':<6} {'File':<40} {'Score':>6}")
155
+ print("-" * 55)
156
+ for row in scores:
157
+ g = scorer.score_to_grade(row["score"])[0]
158
+ print(f" [{g}] {Path(row['filepath']).name:<38} {row['score']:>6.1f}")
159
+
160
+ stats = db.get_stats()
161
+ _cprint(f"\n šŸ“Š {stats['files_tracked']} files tracked | avg score {stats['avg_debt_score']:.1f}"
162
+ f" | {stats['active_alerts']} active alert(s) | {stats['stale_todos']} stale TODOs")
163
+ return 0
164
+
165
+
166
+ def cmd_file(args) -> int:
167
+ db.ensure_db()
168
+ fp = str(Path(args.filepath).resolve())
169
+ score = db.get_latest_score(fp)
170
+
171
+ if not score:
172
+ _cprint(f"No data for {fp}. Run: debtpilot scan {args.filepath}")
173
+ return 1
174
+
175
+ g, label = scorer.score_to_grade(score["score"])
176
+ todos = db.get_todos(fp)
177
+
178
+ if HAS_RICH:
179
+ lines = ["[bold]Sub-scores[/bold]"]
180
+ for key, lbl in [("churn_score", "Churn"), ("complexity_score", "Complexity"),
181
+ ("lint_score", "Lint"), ("todo_score", "TODOs"), ("staleness_score", "Staleness")]:
182
+ v = score.get(key, 0)
183
+ lines.append(f" {lbl:<12} {_bar(v, 18)} {v:>5.1f}")
184
+ if todos:
185
+ lines.append(f"\n[bold]TODOs ({len(todos)})[/bold]")
186
+ for t in sorted(todos, key=lambda x: -x.get("age_days", 0))[:5]:
187
+ age = t.get("age_days", 0)
188
+ color = "red" if age > 90 else "yellow" if age > 30 else "white"
189
+ lines.append(f" [{color}]L{t['line']:<5} {age:>3}d {t.get('tag','TODO')}: {t.get('text','')[:60]}[/{color}]")
190
+ fake = {f"{k}_score": score.get(f"{k}_score", 0)
191
+ for k in ["churn","complexity","lint","todo","staleness"]}
192
+ recs = scorer.top_recommendations(fake)
193
+ if recs:
194
+ lines.append("\n[bold]Recommendations[/bold]")
195
+ for r in recs:
196
+ lines.append(f" • {r}")
197
+ title = f"{GRADE_EMOJI[g]} [bold]{Path(fp).name}[/bold] [{_grade_style(g)}]{label} ({score['score']:.1f})[/{_grade_style(g)}]"
198
+ console.print(Panel("\n".join(lines), title=title, border_style=_grade_style(g)))
199
+ else:
200
+ print(f"\n[{g}] {Path(fp).name} score={score['score']:.1f} {label}")
201
+ for key, lbl in [("churn_score","Churn"),("complexity_score","Complexity"),
202
+ ("lint_score","Lint"),("todo_score","TODOs")]:
203
+ print(f" {lbl}: {score.get(key,0):.0f}")
204
+ return 0
205
+
206
+
207
+ def cmd_alerts(args) -> int:
208
+ db.ensure_db()
209
+ alerts = db.get_alerts(dismissed=False)
210
+ if not alerts:
211
+ _cprint("āœ… No active alerts.")
212
+ return 0
213
+ _cprint(f"\nšŸ”” {len(alerts)} active alert(s)\n")
214
+ for a in alerts:
215
+ ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(a["created_at"]))
216
+ print(f" #{a['id']} {a['alert_type']:<16} {Path(a['filepath']).name:<30} score={a['score']:.1f} {ts}")
217
+ print(f" {a['message']}")
218
+ return 0
219
+
220
+
221
+ def cmd_dismiss(args) -> int:
222
+ db.ensure_db()
223
+ db.dismiss_alert(args.alert_id)
224
+ _cprint(f"Alert #{args.alert_id} dismissed.")
225
+ return 0
226
+
227
+
228
+ def cmd_start(args) -> int:
229
+ try:
230
+ from debtpilot_core.daemon import main_daemon
231
+ except ImportError as e:
232
+ _cprint(f"Daemon dependencies missing: {e}")
233
+ _cprint("Install with: pip install \"debtpilot[full]\"")
234
+ return 1
235
+ project = str(Path(args.dir or ".").resolve())
236
+ port = args.port or 57017
237
+ _cprint(f"šŸš€ Starting DebtPilot daemon — watching {project} (ws://127.0.0.1:{port})")
238
+ _cprint(" Press Ctrl+C to stop.\n")
239
+ main_daemon(project, port)
240
+ return 0
241
+
242
+
243
+ def cmd_todos(args) -> int:
244
+ db.ensure_db()
245
+ todos = db.get_todos(min_age_days=args.min_age or 0)
246
+ if not todos:
247
+ _cprint("No TODOs found.")
248
+ return 0
249
+ _cprint(f"\nšŸ“Œ {len(todos)} TODO(s)\n")
250
+ for t in todos:
251
+ age = t.get("age_days", 0)
252
+ print(f" {age:>4}d {t.get('tag','TODO'):<6} {Path(t['filepath']).name}:{t['line']} {t.get('text','')[:70]}")
253
+ return 0
254
+
255
+
256
+ def cmd_clean(args) -> int:
257
+ days = args.days or 90
258
+ db.clear_old_data(days)
259
+ _cprint(f"Cleaned data older than {days} days.")
260
+ return 0
261
+
262
+ def cmd_dashboard(args) -> int:
263
+ from debtpilot_core.dashboard import generate_dashboard
264
+ db.ensure_db()
265
+ output = generate_dashboard(args.output)
266
+ _cprint(f"āœ… Dashboard generated: [bold]{output}[/bold]" if HAS_RICH else f"Dashboard generated: {output}")
267
+ _cprint(" Open in your browser to view.")
268
+ return 0
269
+
270
+
271
+ def cmd_ai(args) -> int:
272
+ from debtpilot_core.ai_advisor import analyze_file
273
+ db.ensure_db()
274
+ fp = str(Path(args.filepath).resolve())
275
+ score = db.get_latest_score(fp)
276
+ if not score:
277
+ _cprint(f"No scan data for {fp}. Run: debtpilot scan {args.filepath}")
278
+ return 1
279
+
280
+ _cprint(f"\nšŸ¤– AI analysing [bold]{Path(fp).name}[/bold]...\n" if HAS_RICH
281
+ else f"\nAI analysing {Path(fp).name}...")
282
+
283
+ lint = db.get_lint(fp)
284
+ all_issues = []
285
+ for l in lint[:1]:
286
+ issues = l.get("issues", [])
287
+ if isinstance(issues, str):
288
+ import json as _json
289
+ issues = _json.loads(issues)
290
+ all_issues.extend(issues)
291
+
292
+ todos = db.get_todos(fp)
293
+ from debtpilot_core.scanner import analyze_complexity
294
+ complexity = analyze_complexity(fp)
295
+
296
+ result = analyze_file(fp, score, all_issues, todos, complexity)
297
+
298
+ if "error" in result and not result.get("fixes"):
299
+ _cprint(f"[red]{result['error']}[/red]" if HAS_RICH else result["error"])
300
+ return 1
301
+
302
+ if HAS_RICH:
303
+ from rich.panel import Panel
304
+ risk_colors = {"LOW":"green","MEDIUM":"yellow","HIGH":"orange3","CRITICAL":"bold red"}
305
+ risk = result.get("risk_level","UNKNOWN")
306
+ console.print(Panel(
307
+ f"[bold]Risk Level:[/bold] [{risk_colors.get(risk,'white')}]{risk}[/{risk_colors.get(risk,'white')}]\n\n"
308
+ f"{result.get('summary','')}",
309
+ title="šŸ¤– AI Summary", border_style="blue"
310
+ ))
311
+ fixes = result.get("fixes", [])
312
+ if fixes:
313
+ console.print("\n[bold]Suggested Fixes:[/bold]\n")
314
+ for i, fix in enumerate(fixes, 1):
315
+ console.print(f" [bold cyan]{i}. {fix['title']}[/bold cyan] [dim]({fix.get('effort','?')})[/dim]")
316
+ console.print(f" [yellow]Problem:[/yellow] {fix['problem']}")
317
+ console.print(f" [green]Solution:[/green] {fix['solution']}")
318
+ if fix.get("code_example"):
319
+ console.print(f" [dim]{fix['code_example'][:200]}[/dim]")
320
+ console.print()
321
+ actions = result.get("priority_actions", [])
322
+ if actions:
323
+ console.print("[bold]Priority Actions:[/bold]")
324
+ for a in actions:
325
+ console.print(f" • {a}")
326
+ eta = result.get("estimated_fix_time")
327
+ if eta:
328
+ console.print(f"\n [dim]Estimated fix time: {eta}[/dim]")
329
+ else:
330
+ print(f"\nRisk: {result.get('risk_level')}")
331
+ print(f"Summary: {result.get('summary')}\n")
332
+ for i, fix in enumerate(result.get("fixes", []), 1):
333
+ print(f"{i}. {fix['title']} ({fix.get('effort')})")
334
+ print(f" Problem: {fix['problem']}")
335
+ print(f" Solution: {fix['solution']}\n")
336
+ return 0
337
+
338
+
339
+ def cmd_predict(args) -> int:
340
+ from debtpilot_core.predictor import predict_all, team_risk_summary
341
+ db.ensure_db()
342
+ predictions = predict_all(limit=args.limit)
343
+ summary = team_risk_summary()
344
+
345
+ if not predictions:
346
+ _cprint("No data yet. Run: debtpilot scan .")
347
+ return 0
348
+
349
+ _cprint(f"\nšŸ”® [bold]Bug Prediction Report[/bold] — {summary['total_files']} files analysed\n"
350
+ if HAS_RICH else f"\nBug Prediction — {summary['total_files']} files\n")
351
+ _cprint(f" Critical: {summary['critical']} High: {summary['high']} "
352
+ f"Medium: {summary['medium']} Low: {summary['low']}\n")
353
+
354
+ for p in predictions:
355
+ colors = {"CRITICAL":"bold red","HIGH":"red","MEDIUM":"orange3","LOW":"green"}
356
+ risk = p["risk_level"]
357
+ prob = p["probability"]
358
+ name = Path(p["filepath"]).name
359
+ drivers = " Ā· ".join(p["drivers"][:2])
360
+ if HAS_RICH:
361
+ console.print(f" [{colors.get(risk,'white')}]{risk:<10}[/{colors.get(risk,'white')}]"
362
+ f" {prob:>4}% [cyan]{name:<40}[/cyan] [dim]{drivers}[/dim]")
363
+ else:
364
+ print(f" {risk:<10} {prob:>4}% {name:<40} {drivers}")
365
+ return 0
366
+
367
+
368
+ def cmd_digest(args) -> int:
369
+ from debtpilot_core.digest import send_digest, preview_digest
370
+ db.ensure_db()
371
+ project = args.project
372
+
373
+ if args.preview:
374
+ output = preview_digest(project_name=project)
375
+ _cprint(f"āœ… Digest preview saved: [bold]{output}[/bold]" if HAS_RICH else f"Preview saved: {output}")
376
+ _cprint(" Open in your browser to view.")
377
+ return 0
378
+
379
+ if args.send:
380
+ _cprint("šŸ“§ Sending weekly digest...")
381
+ result = send_digest(project_name=project)
382
+ if result["success"]:
383
+ _cprint(f"āœ… Sent to: {', '.join(result['sent_to'])}")
384
+ else:
385
+ _cprint(f"āŒ Failed: {result['error']}")
386
+ return 0 if result.get("success") else 1
387
+
388
+ _cprint("Use --preview to generate a preview or --send to send the email.")
389
+ return 0
390
+
391
+
392
+ def cmd_report_pdf(args) -> int:
393
+ from debtpilot_core.reporter import generate_pdf_report
394
+ db.ensure_db()
395
+ output = generate_pdf_report(output_path=args.output, project_name=args.project)
396
+ _cprint(f"āœ… Report generated: [bold]{output}[/bold]" if HAS_RICH else f"Report saved: {output}")
397
+ _cprint(" Open in browser → Ctrl+P → Save as PDF")
398
+ return 0
399
+
400
+
401
+ def cmd_hook(args) -> int:
402
+ from debtpilot_core.hooks import install_hook, uninstall_hook, hook_status
403
+ project = str(Path(".").resolve())
404
+
405
+ if args.install:
406
+ result = install_hook(project, threshold=args.threshold)
407
+ if result["success"]:
408
+ _cprint(f"āœ… {result['message']}")
409
+ else:
410
+ _cprint(f"āŒ {result['error']}")
411
+ return 0 if result["success"] else 1
412
+
413
+ if args.uninstall:
414
+ result = uninstall_hook(project)
415
+ _cprint(f"āœ… {result.get('message','Done')}" if result["success"] else f"āŒ {result['error']}")
416
+ return 0 if result["success"] else 1
417
+
418
+ if args.status:
419
+ status = hook_status(project)
420
+ if status["installed"]:
421
+ _cprint(f"āœ… Hook installed — blocks commits with score > {status['threshold']}")
422
+ else:
423
+ _cprint("āŒ Hook not installed. Run: debtpilot hook --install")
424
+ return 0
425
+
426
+ _cprint("Use --install, --uninstall, or --status")
427
+ return 0
428
+
429
+
430
+ def cmd_config(args) -> int:
431
+ from debtpilot_core.ai_advisor import save_api_key
432
+ from debtpilot_core.digest import save_email_config, get_email_config
433
+
434
+ if args.gemini_key:
435
+ save_api_key(args.gemini_key)
436
+ _cprint("āœ… Gemini API key saved.")
437
+
438
+ if args.gmail and args.gmail_password:
439
+ recipients = [r.strip() for r in args.recipients.split(",")] if args.recipients else []
440
+ save_email_config(args.gmail, args.gmail_password, recipients)
441
+ _cprint("āœ… Gmail config saved.")
442
+ elif args.gmail or args.gmail_password:
443
+ _cprint("Provide both --gmail and --gmail-password together.")
444
+ return 1
445
+
446
+ if not any([args.gemini_key, args.gmail, args.gmail_password]):
447
+ config = get_email_config()
448
+ _cprint("\n[bold]Current Config:[/bold]" if HAS_RICH else "\nCurrent Config:")
449
+ _cprint(f" Gemini key : {'āœ… set' if config.get('gemini_api_key') else 'āŒ not set'}")
450
+ _cprint(f" Gmail : {config.get('gmail_address', 'āŒ not set')}")
451
+ _cprint(f" Recipients : {', '.join(config.get('digest_recipients', [])) or 'āŒ not set'}")
452
+ _cprint("\nTo set:")
453
+ _cprint(" debtpilot config --gemini-key YOUR_KEY")
454
+ _cprint(" debtpilot config --gmail you@gmail.com --gmail-password YOUR_APP_PASSWORD --recipients team@company.com")
455
+
456
+ return 0
457
+
458
+ def cmd_autostart(args) -> int:
459
+ from debtpilot_core.autostart import install_autostart, uninstall_autostart, autostart_status
460
+
461
+ if args.install:
462
+ project = str(Path(args.dir or ".").resolve())
463
+ result = install_autostart(project_dir=project, port=args.port)
464
+ _cprint(result["message"] if result["success"] else f"āŒ {result['error']}")
465
+ return 0 if result["success"] else 1
466
+
467
+ if args.uninstall:
468
+ result = uninstall_autostart()
469
+ _cprint(result.get("message", "") if result["success"] else f"āŒ {result['error']}")
470
+ return 0 if result["success"] else 1
471
+
472
+ if args.status:
473
+ status = autostart_status()
474
+ if status["installed"]:
475
+ _cprint("āœ… Autostart installed")
476
+ _cprint(f" Watching : {status.get('project_dir')}")
477
+ _cprint(f" Port : {status.get('port')}")
478
+ _cprint(f" Next run : {status.get('next_run')}")
479
+ else:
480
+ _cprint("āŒ Autostart not installed")
481
+ _cprint(" Run: debtpilot autostart --install")
482
+ return 0
483
+
484
+ _cprint("Use --install, --uninstall, or --status")
485
+ return 0
486
+
487
+
488
+ def cmd_status(args) -> int:
489
+ from debtpilot_core.autostart import autostart_status
490
+ from debtpilot_core.hooks import hook_status
491
+ from debtpilot_core.ai_advisor import get_api_key
492
+ from debtpilot_core.digest import get_email_config
493
+ import socket
494
+
495
+ db.ensure_db()
496
+ stats = db.get_stats()
497
+ project = str(Path(".").resolve())
498
+
499
+ # Check if daemon is running
500
+ daemon_running = False
501
+ try:
502
+ s = socket.create_connection(("127.0.0.1", 57017), timeout=1)
503
+ s.close()
504
+ daemon_running = True
505
+ except Exception:
506
+ pass
507
+
508
+ auto = autostart_status()
509
+ hook = hook_status(project)
510
+ api_key = get_api_key()
511
+ email_config = get_email_config()
512
+
513
+ if HAS_RICH:
514
+ from rich.panel import Panel
515
+ from rich.table import Table
516
+ from rich import box
517
+
518
+ table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
519
+ table.add_column("Item", style="bold")
520
+ table.add_column("Status")
521
+ table.add_column("Info", style="dim")
522
+
523
+ table.add_row(
524
+ "Daemon",
525
+ "[green]ā— Running[/green]" if daemon_running else "[red]ā— Stopped[/red]",
526
+ "ws://127.0.0.1:57017" if daemon_running else "Run: debtpilot start"
527
+ )
528
+ table.add_row(
529
+ "Autostart",
530
+ "[green]āœ… Enabled[/green]" if auto.get("installed") else "[yellow]āŒ Disabled[/yellow]",
531
+ auto.get("project_dir", "Run: debtpilot autostart --install")
532
+ )
533
+ table.add_row(
534
+ "Git Hook",
535
+ "[green]āœ… Installed[/green]" if hook.get("installed") else "[yellow]āŒ Not installed[/yellow]",
536
+ f"Threshold: {hook.get('threshold', 'N/A')}" if hook.get("installed") else "Run: debtpilot hook --install"
537
+ )
538
+ table.add_row(
539
+ "AI Advisor",
540
+ "[green]āœ… Configured[/green]" if api_key else "[yellow]āŒ Not configured[/yellow]",
541
+ "Gemini API key set" if api_key else "Run: debtpilot config --gemini-key KEY"
542
+ )
543
+ table.add_row(
544
+ "Email Digest",
545
+ "[green]āœ… Configured[/green]" if email_config.get("gmail_address") else "[yellow]āŒ Not configured[/yellow]",
546
+ email_config.get("gmail_address", "Run: debtpilot config --gmail")
547
+ )
548
+ table.add_row(
549
+ "Files Tracked",
550
+ f"[cyan]{stats['files_tracked']}[/cyan]",
551
+ f"avg score {stats['avg_debt_score']:.1f}"
552
+ )
553
+ table.add_row(
554
+ "Active Alerts",
555
+ f"[{'red' if stats['active_alerts'] > 0 else 'green'}]{stats['active_alerts']}[/{'red' if stats['active_alerts'] > 0 else 'green'}]",
556
+ "Run: debtpilot alerts" if stats["active_alerts"] > 0 else "All clear"
557
+ )
558
+ table.add_row(
559
+ "Stale TODOs",
560
+ f"[{'orange3' if stats['stale_todos'] > 0 else 'green'}]{stats['stale_todos']}[/{'orange3' if stats['stale_todos'] > 0 else 'green'}]",
561
+ "Run: debtpilot todos --min-age 30" if stats["stale_todos"] > 0 else "All clear"
562
+ )
563
+
564
+ console.print(Panel(table, title="⚔ DebtPilot Status", border_style="blue"))
565
+ else:
566
+ print("\n⚔ DebtPilot Status")
567
+ print("─" * 45)
568
+ print(f" Daemon : {'ā— Running' if daemon_running else 'ā— Stopped'}")
569
+ print(f" Autostart : {'āœ… Enabled' if auto.get('installed') else 'āŒ Disabled'}")
570
+ print(f" Git Hook : {'āœ… Installed' if hook.get('installed') else 'āŒ Not installed'}")
571
+ print(f" AI Advisor : {'āœ… Configured' if api_key else 'āŒ Not configured'}")
572
+ print(f" Email : {'āœ… Configured' if email_config.get('gmail_address') else 'āŒ Not configured'}")
573
+ print(f" Files : {stats['files_tracked']} (avg score {stats['avg_debt_score']:.1f})")
574
+ print(f" Alerts : {stats['active_alerts']}")
575
+ print(f" Stale TODOs : {stats['stale_todos']}\n")
576
+ return 0
577
+
578
+ def main() -> None:
579
+ parser = argparse.ArgumentParser(
580
+ prog="debtpilot",
581
+ description="DebtPilot — track technical debt before it blows up in production",
582
+ )
583
+ sub = parser.add_subparsers(dest="command")
584
+
585
+ p_scan = sub.add_parser("scan")
586
+ p_scan.add_argument("path", nargs="?", default=".")
587
+
588
+ p_report = sub.add_parser("report")
589
+ p_report.add_argument("--limit", "-n", type=int, default=20)
590
+ p_report.add_argument("--min-score", type=float, default=0)
591
+
592
+ p_file = sub.add_parser("file")
593
+ p_file.add_argument("filepath")
594
+
595
+ sub.add_parser("alerts")
596
+
597
+ p_dismiss = sub.add_parser("dismiss")
598
+ p_dismiss.add_argument("alert_id", type=int)
599
+
600
+ p_todos = sub.add_parser("todos")
601
+ p_todos.add_argument("--min-age", type=int, default=0)
602
+
603
+ p_start = sub.add_parser("start")
604
+ p_start.add_argument("--dir", default=".")
605
+ p_start.add_argument("--port", type=int, default=57017)
606
+
607
+ p_clean = sub.add_parser("clean")
608
+ p_clean.add_argument("--days", type=int, default=90)
609
+ # dashboard
610
+ p_dash = sub.add_parser("dashboard", help="Generate HTML dashboard")
611
+ p_dash.add_argument("--output", default="debtpilot_dashboard.html")
612
+
613
+ # ai
614
+ p_ai = sub.add_parser("ai", help="AI fix suggestions for a file")
615
+ p_ai.add_argument("filepath")
616
+
617
+ # predict
618
+ p_predict = sub.add_parser("predict", help="Bug prediction for all files")
619
+ p_predict.add_argument("--limit", type=int, default=10)
620
+
621
+ # digest
622
+ p_digest = sub.add_parser("digest", help="Weekly email digest")
623
+ p_digest.add_argument("--send", action="store_true")
624
+ p_digest.add_argument("--preview", action="store_true")
625
+ p_digest.add_argument("--project", default="My Project")
626
+
627
+ # report
628
+ p_pdf = sub.add_parser("report-pdf", help="Generate PDF report")
629
+ p_pdf.add_argument("--output", default="debtpilot_report.html")
630
+ p_pdf.add_argument("--project", default="My Project")
631
+
632
+ # hook
633
+ p_hook = sub.add_parser("hook", help="Install git pre-commit hook")
634
+ p_hook.add_argument("--install", action="store_true")
635
+ p_hook.add_argument("--uninstall", action="store_true")
636
+ p_hook.add_argument("--status", action="store_true")
637
+ p_hook.add_argument("--threshold", type=int, default=70)
638
+
639
+ # config
640
+ p_config = sub.add_parser("config", help="Configure API keys and email")
641
+ p_config.add_argument("--gemini-key", type=str)
642
+ p_config.add_argument("--gmail", type=str)
643
+ p_config.add_argument("--gmail-password", type=str)
644
+ p_config.add_argument("--recipients", type=str, help="Comma-separated emails")
645
+
646
+ # autostart
647
+ p_auto = sub.add_parser("autostart", help="Auto-start daemon on login")
648
+ p_auto.add_argument("--install", action="store_true")
649
+ p_auto.add_argument("--uninstall", action="store_true")
650
+ p_auto.add_argument("--status", action="store_true")
651
+ p_auto.add_argument("--dir", default=None)
652
+ p_auto.add_argument("--port", type=int, default=57017)
653
+
654
+ # status
655
+ sub.add_parser("status", help="Full health check of DebtPilot")
656
+
657
+ args = parser.parse_args()
658
+
659
+ if not args.command:
660
+ parser.print_help()
661
+ sys.exit(0)
662
+
663
+ handlers = {
664
+ "scan": cmd_scan, "report": cmd_report, "file": cmd_file,
665
+ "alerts": cmd_alerts, "dismiss": cmd_dismiss, "todos": cmd_todos,
666
+ "start": cmd_start, "clean": cmd_clean,
667
+ "dashboard": cmd_dashboard, "ai": cmd_ai, "predict": cmd_predict,
668
+ "digest": cmd_digest, "report-pdf": cmd_report_pdf,
669
+ "hook": cmd_hook, "config": cmd_config,
670
+ "autostart": cmd_autostart,
671
+ "status": cmd_status,
672
+ }
673
+ fn = handlers.get(args.command)
674
+ if fn:
675
+ sys.exit(fn(args))
676
+ else:
677
+ parser.print_help()
678
+ sys.exit(1)
679
+
680
+
681
+ if __name__ == "__main__":
682
+ main()