python-checkup 0.0.1__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.
Files changed (53) hide show
  1. python_checkup/__init__.py +9 -0
  2. python_checkup/__main__.py +3 -0
  3. python_checkup/analysis_request.py +35 -0
  4. python_checkup/analyzer_catalog.py +100 -0
  5. python_checkup/analyzers/__init__.py +54 -0
  6. python_checkup/analyzers/bandit.py +158 -0
  7. python_checkup/analyzers/basedpyright.py +103 -0
  8. python_checkup/analyzers/cached.py +106 -0
  9. python_checkup/analyzers/dependency_vulns.py +298 -0
  10. python_checkup/analyzers/deptry.py +142 -0
  11. python_checkup/analyzers/detect_secrets.py +101 -0
  12. python_checkup/analyzers/mypy.py +217 -0
  13. python_checkup/analyzers/radon.py +150 -0
  14. python_checkup/analyzers/registry.py +69 -0
  15. python_checkup/analyzers/ruff.py +256 -0
  16. python_checkup/analyzers/typos.py +80 -0
  17. python_checkup/analyzers/vulture.py +151 -0
  18. python_checkup/cache.py +244 -0
  19. python_checkup/cli.py +763 -0
  20. python_checkup/config.py +87 -0
  21. python_checkup/dedup.py +119 -0
  22. python_checkup/dependencies/discovery.py +192 -0
  23. python_checkup/detection.py +298 -0
  24. python_checkup/diff.py +130 -0
  25. python_checkup/discovery.py +180 -0
  26. python_checkup/formatters/__init__.py +0 -0
  27. python_checkup/formatters/badge.py +38 -0
  28. python_checkup/formatters/json_fmt.py +22 -0
  29. python_checkup/formatters/terminal.py +396 -0
  30. python_checkup/mcp/__init__.py +3 -0
  31. python_checkup/mcp/installer.py +119 -0
  32. python_checkup/mcp/server.py +411 -0
  33. python_checkup/models.py +114 -0
  34. python_checkup/plan.py +109 -0
  35. python_checkup/progress.py +95 -0
  36. python_checkup/runner.py +438 -0
  37. python_checkup/scoring/__init__.py +0 -0
  38. python_checkup/scoring/engine.py +397 -0
  39. python_checkup/skills/SKILL.md +416 -0
  40. python_checkup/skills/__init__.py +0 -0
  41. python_checkup/skills/agents.py +98 -0
  42. python_checkup/skills/installer.py +248 -0
  43. python_checkup/skills/rule_db.py +806 -0
  44. python_checkup/web/__init__.py +0 -0
  45. python_checkup/web/server.py +285 -0
  46. python_checkup/web/static/__init__.py +0 -0
  47. python_checkup/web/static/index.html +959 -0
  48. python_checkup/web/template.py +26 -0
  49. python_checkup-0.0.1.dist-info/METADATA +250 -0
  50. python_checkup-0.0.1.dist-info/RECORD +53 -0
  51. python_checkup-0.0.1.dist-info/WHEEL +4 -0
  52. python_checkup-0.0.1.dist-info/entry_points.txt +14 -0
  53. python_checkup-0.0.1.dist-info/licenses/LICENSE +21 -0
python_checkup/cli.py ADDED
@@ -0,0 +1,763 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from rich.console import Console
9
+
10
+ from python_checkup import __version__
11
+ from python_checkup.config import CheckupConfig
12
+ from python_checkup.models import Category, HealthReport
13
+ from python_checkup.plan import (
14
+ PROFILE_DEFAULT,
15
+ PROFILE_FULL,
16
+ PROFILE_QUICK,
17
+ TYPE_BACKEND_AUTO,
18
+ TYPE_BACKEND_BASEDPYRIGHT,
19
+ TYPE_BACKEND_MYPY,
20
+ ScanPlan,
21
+ build_scan_plan,
22
+ parse_categories,
23
+ )
24
+
25
+ err_console = Console(stderr=True)
26
+
27
+
28
+ class _SubcommandFirstGroup(click.Group):
29
+ """Click group that recognises subcommands before consuming PATH.
30
+
31
+ Without this, ``python-checkup install-skill`` would feed
32
+ ``install-skill`` to the positional PATH argument and never
33
+ dispatch the subcommand. We override :meth:`parse_args` to
34
+ peek at the first non-option token and, if it matches a known
35
+ command name, strip any default PATH value so Click routes
36
+ correctly.
37
+ """
38
+
39
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
40
+ """Rewrite args so subcommand names aren't swallowed by PATH."""
41
+ # Find the first non-option token
42
+ for token in args:
43
+ if token.startswith("-"):
44
+ continue
45
+ if token in self.commands:
46
+ # Insert an explicit default for PATH so the arg
47
+ # isn't consumed by the subcommand name.
48
+ args = [".", *args]
49
+ break
50
+ # First non-option is NOT a command → let Click handle normally
51
+ break
52
+ return super().parse_args(ctx, args)
53
+
54
+
55
+ @click.group(
56
+ cls=_SubcommandFirstGroup,
57
+ invoke_without_command=True,
58
+ context_settings={"allow_interspersed_args": True},
59
+ )
60
+ @click.argument(
61
+ "path",
62
+ default=".",
63
+ required=False,
64
+ type=click.Path(),
65
+ )
66
+ @click.option(
67
+ "--json",
68
+ "json_output",
69
+ is_flag=True,
70
+ help="Output full results as JSON.",
71
+ )
72
+ @click.option(
73
+ "--score",
74
+ "score_only",
75
+ is_flag=True,
76
+ help="Output only the numeric score.",
77
+ )
78
+ @click.option(
79
+ "--verbose",
80
+ "-v",
81
+ is_flag=True,
82
+ help="Show all issues with file:line.",
83
+ )
84
+ @click.option(
85
+ "--diff",
86
+ "diff_base",
87
+ default=None,
88
+ is_eager=False,
89
+ flag_value="main",
90
+ is_flag=False,
91
+ help="Only analyze files changed vs BASE (default: main).",
92
+ )
93
+ @click.option(
94
+ "--fail-under",
95
+ type=float,
96
+ default=None,
97
+ help="Exit 1 if score is below SCORE.",
98
+ )
99
+ @click.option(
100
+ "--profile",
101
+ type=click.Choice([PROFILE_QUICK, PROFILE_DEFAULT, PROFILE_FULL]),
102
+ default=PROFILE_DEFAULT,
103
+ help="Scan profile: quick, default, or full.",
104
+ )
105
+ @click.option(
106
+ "--only",
107
+ "only_categories",
108
+ default=None,
109
+ help="Only run these categories (comma-separated).",
110
+ )
111
+ @click.option(
112
+ "--skip",
113
+ "skip_categories",
114
+ default=None,
115
+ help="Skip these categories (comma-separated).",
116
+ )
117
+ @click.option(
118
+ "--analyzers",
119
+ "analyzer_names",
120
+ default=None,
121
+ help="Only run these analyzers (comma-separated). Overrides --only/--skip.",
122
+ )
123
+ @click.option(
124
+ "--quick",
125
+ is_flag=True,
126
+ help="Compatibility alias for --profile quick.",
127
+ )
128
+ @click.option(
129
+ "--no-lint",
130
+ is_flag=True,
131
+ help="Skip linting (Ruff).",
132
+ )
133
+ @click.option(
134
+ "--no-typecheck",
135
+ is_flag=True,
136
+ help="Skip type checking (mypy).",
137
+ )
138
+ @click.option(
139
+ "--no-security",
140
+ is_flag=True,
141
+ help="Skip security scanning (Bandit).",
142
+ )
143
+ @click.option(
144
+ "--no-complexity",
145
+ is_flag=True,
146
+ help="Skip complexity analysis (Radon).",
147
+ )
148
+ @click.option(
149
+ "--no-dead-code",
150
+ is_flag=True,
151
+ help="Skip dead code detection (Vulture).",
152
+ )
153
+ @click.option(
154
+ "--type-backend",
155
+ type=click.Choice(
156
+ [TYPE_BACKEND_AUTO, TYPE_BACKEND_MYPY, TYPE_BACKEND_BASEDPYRIGHT]
157
+ ),
158
+ default=TYPE_BACKEND_AUTO,
159
+ help="Type checking backend: auto (default), mypy, or basedpyright.",
160
+ )
161
+ @click.option(
162
+ "--fix",
163
+ is_flag=True,
164
+ help="Apply safe Ruff fixes before rescanning.",
165
+ )
166
+ @click.option(
167
+ "--show-fixes",
168
+ is_flag=True,
169
+ help="Show fix suggestions inline.",
170
+ )
171
+ @click.option(
172
+ "--config",
173
+ "config_path",
174
+ type=click.Path(exists=True),
175
+ default=None,
176
+ help="Path to pyproject.toml.",
177
+ )
178
+ @click.option(
179
+ "--no-cache",
180
+ "no_cache",
181
+ is_flag=True,
182
+ help="Skip the cache and force a fresh analysis.",
183
+ )
184
+ @click.option(
185
+ "--clear-cache",
186
+ "clear_cache",
187
+ is_flag=True,
188
+ help="Delete all cached results and exit.",
189
+ )
190
+ @click.option(
191
+ "--badge",
192
+ is_flag=True,
193
+ help="Output a shields.io badge URL for the score.",
194
+ )
195
+ @click.option(
196
+ "--web",
197
+ is_flag=True,
198
+ help="Serve an interactive HTML report in the browser.",
199
+ )
200
+ @click.option(
201
+ "--port",
202
+ type=int,
203
+ default=8765,
204
+ help="Port for the web report server (default: 8765).",
205
+ )
206
+ @click.option(
207
+ "--mcp",
208
+ is_flag=True,
209
+ help="Start as MCP server (stdio transport).",
210
+ )
211
+ @click.version_option(version=__version__, prog_name="python-checkup")
212
+ @click.pass_context
213
+ def main(
214
+ ctx: click.Context,
215
+ path: str,
216
+ json_output: bool,
217
+ score_only: bool,
218
+ verbose: bool,
219
+ diff_base: str | None,
220
+ fail_under: float | None,
221
+ profile: str,
222
+ only_categories: str | None,
223
+ skip_categories: str | None,
224
+ analyzer_names: str | None,
225
+ quick: bool,
226
+ no_lint: bool,
227
+ no_typecheck: bool,
228
+ no_security: bool,
229
+ no_complexity: bool,
230
+ no_dead_code: bool,
231
+ type_backend: str,
232
+ fix: bool,
233
+ show_fixes: bool,
234
+ config_path: str | None,
235
+ no_cache: bool,
236
+ clear_cache: bool,
237
+ badge: bool,
238
+ web: bool,
239
+ port: int,
240
+ mcp: bool,
241
+ ) -> None:
242
+ """python-checkup -- fast, local-first Python code health checker.
243
+
244
+ Analyzes a Python project and produces a 0-100 health score with
245
+ categorized diagnostics covering code quality, type safety,
246
+ security, complexity, and dead code.
247
+
248
+ \b
249
+ Examples:
250
+ python-checkup . # Scan current directory
251
+ python-checkup src/ --verbose # Scan src/ with all issues
252
+ python-checkup . --score # Just the number (for CI)
253
+ python-checkup . --diff develop # Only changed files
254
+ python-checkup . --fail-under 70 # Exit 1 if score < 70
255
+ python-checkup . --profile quick # Fast feedback
256
+ python-checkup . --analyzers ruff,mypy # Run specific analyzers
257
+ """
258
+ ctx.ensure_object(dict)
259
+
260
+ # Handle early-exit modes (MCP, cache clear, subcommands)
261
+ if _handle_early_exits(ctx, path, mcp, clear_cache):
262
+ return
263
+
264
+ # Validate that PATH exists (we can't use click.Path(exists=True)
265
+ # because it would reject subcommand names like "mcp" and "install-skill")
266
+ resolved = Path(path).resolve()
267
+ if not resolved.exists():
268
+ raise click.BadParameter(
269
+ f"Path '{path}' does not exist.",
270
+ param_hint="'PATH'",
271
+ )
272
+
273
+ # --score wins over --json (simpler request)
274
+ if score_only:
275
+ json_output = False
276
+
277
+ only_set = parse_categories(only_categories)
278
+ skip_set = _build_skip_categories(
279
+ skip_categories,
280
+ no_lint,
281
+ no_typecheck,
282
+ no_security,
283
+ no_complexity,
284
+ no_dead_code,
285
+ )
286
+
287
+ plan, skip_analyzers = _resolve_analyzers(
288
+ analyzer_names=analyzer_names,
289
+ profile=profile,
290
+ only_set=only_set,
291
+ skip_set=skip_set,
292
+ quick=quick,
293
+ fix=fix,
294
+ show_fixes=show_fixes,
295
+ diff_base=diff_base,
296
+ type_backend=type_backend,
297
+ )
298
+
299
+ # Load config
300
+ from python_checkup.config import load_config
301
+
302
+ project_root = Path(path).resolve()
303
+ config = load_config(Path(config_path).parent if config_path else project_root)
304
+
305
+ if fix:
306
+ _apply_safe_ruff_fixes(project_root)
307
+
308
+ # Discover files
309
+ files, total_project_files = _discover_files(
310
+ project_root,
311
+ config,
312
+ diff_base,
313
+ skip_analyzers,
314
+ )
315
+
316
+ # Run analysis and output results
317
+ report = _run_analysis(
318
+ project_root=project_root,
319
+ config=config,
320
+ files=files,
321
+ skip_analyzers=skip_analyzers,
322
+ score_only=score_only,
323
+ json_output=json_output,
324
+ badge=badge,
325
+ no_cache=no_cache,
326
+ plan=plan,
327
+ diff_base=diff_base,
328
+ )
329
+
330
+ _output_report(
331
+ report,
332
+ score_only=score_only,
333
+ badge=badge,
334
+ json_output=json_output,
335
+ web=web,
336
+ verbose=verbose,
337
+ show_fixes=show_fixes,
338
+ diff_base=diff_base,
339
+ files=files,
340
+ total_project_files=total_project_files,
341
+ project_root=project_root,
342
+ config=config,
343
+ plan=plan,
344
+ no_cache=no_cache,
345
+ port=port,
346
+ )
347
+
348
+ # Exit code
349
+ if fail_under is not None and report.score < fail_under:
350
+ sys.exit(1)
351
+
352
+
353
+ def _handle_early_exits(
354
+ ctx: click.Context,
355
+ path: str,
356
+ mcp: bool,
357
+ clear_cache: bool,
358
+ ) -> bool:
359
+ """Handle MCP, cache-clear, and subcommand early-exit modes.
360
+
361
+ Returns ``True`` if the caller should return immediately.
362
+ """
363
+ if mcp:
364
+ from python_checkup.mcp.server import start_mcp_server
365
+
366
+ start_mcp_server()
367
+ return True
368
+
369
+ if clear_cache:
370
+ from python_checkup.cache import AnalysisCache
371
+
372
+ resolved = Path(path).resolve()
373
+ cache = AnalysisCache(resolved, enabled=True)
374
+ count = cache.clear()
375
+ err_console.print(f"[green]Cleared {count} cached entries.[/green]")
376
+ return True
377
+
378
+ return ctx.invoked_subcommand is not None
379
+
380
+
381
+ def _build_skip_categories(
382
+ skip_categories: str | None,
383
+ no_lint: bool,
384
+ no_typecheck: bool,
385
+ no_security: bool,
386
+ no_complexity: bool,
387
+ no_dead_code: bool,
388
+ ) -> set[Category]:
389
+ """Build the set of categories to skip from CLI flags."""
390
+ skip_set = set(parse_categories(skip_categories) or set())
391
+
392
+ flag_to_category = [
393
+ (no_lint, Category.QUALITY),
394
+ (no_typecheck, Category.TYPE_SAFETY),
395
+ (no_security, Category.SECURITY),
396
+ (no_complexity, Category.COMPLEXITY),
397
+ (no_dead_code, Category.DEAD_CODE),
398
+ ]
399
+ for flag, category in flag_to_category:
400
+ if flag:
401
+ skip_set.add(category)
402
+
403
+ return skip_set
404
+
405
+
406
+ def _resolve_analyzers(
407
+ *,
408
+ analyzer_names: str | None,
409
+ profile: str,
410
+ only_set: frozenset[Category] | None,
411
+ skip_set: set[Category],
412
+ quick: bool,
413
+ fix: bool,
414
+ show_fixes: bool,
415
+ diff_base: str | None,
416
+ type_backend: str,
417
+ ) -> tuple[ScanPlan, set[str]]:
418
+ """Resolve the scan plan and the set of analyzers to skip.
419
+
420
+ Returns ``(plan, skip_analyzers)``.
421
+ """
422
+ if analyzer_names:
423
+ return _resolve_explicit_analyzers(
424
+ analyzer_names,
425
+ profile,
426
+ quick,
427
+ fix,
428
+ show_fixes,
429
+ diff_base,
430
+ type_backend,
431
+ )
432
+
433
+ plan = build_scan_plan(
434
+ profile=profile,
435
+ only_categories=only_set,
436
+ skip_categories=frozenset(skip_set),
437
+ quick=quick,
438
+ include_optional=profile == PROFILE_FULL,
439
+ apply_fixes=fix,
440
+ show_fix_suggestions=show_fixes,
441
+ diff_mode=diff_base is not None,
442
+ type_backend=type_backend,
443
+ )
444
+ skip_analyzers = _derive_skip_analyzers_from_plan(plan)
445
+ return plan, skip_analyzers
446
+
447
+
448
+ def _resolve_explicit_analyzers(
449
+ analyzer_names: str,
450
+ profile: str,
451
+ quick: bool,
452
+ fix: bool,
453
+ show_fixes: bool,
454
+ diff_base: str | None,
455
+ type_backend: str,
456
+ ) -> tuple[ScanPlan, set[str]]:
457
+ """Handle --analyzers: validate names and build a plan from them."""
458
+ from python_checkup.analyzer_catalog import ANALYZER_CATALOG
459
+
460
+ requested = {a.strip() for a in analyzer_names.split(",") if a.strip()}
461
+ unknown = requested - set(ANALYZER_CATALOG.keys())
462
+ if unknown:
463
+ raise click.BadParameter(
464
+ f"Unknown analyzer(s): {', '.join(sorted(unknown))}. "
465
+ f"Available: {', '.join(sorted(ANALYZER_CATALOG.keys()))}",
466
+ param_hint="'--analyzers'",
467
+ )
468
+ skip_analyzers = set(ANALYZER_CATALOG.keys()) - requested
469
+
470
+ selected_categories: set[Category] = set()
471
+ for name in requested:
472
+ selected_categories.update(ANALYZER_CATALOG[name].categories)
473
+
474
+ plan = build_scan_plan(
475
+ profile=profile,
476
+ only_categories=frozenset(selected_categories),
477
+ skip_categories=frozenset(),
478
+ quick=quick,
479
+ include_optional=True,
480
+ apply_fixes=fix,
481
+ show_fix_suggestions=show_fixes,
482
+ diff_mode=diff_base is not None,
483
+ type_backend=type_backend,
484
+ )
485
+ return plan, skip_analyzers
486
+
487
+
488
+ def _derive_skip_analyzers_from_plan(plan: ScanPlan) -> set[str]:
489
+ """Derive the analyzer skip-set from a scan plan's categories/profile."""
490
+ skip: set[str] = set()
491
+
492
+ # Map absent categories to the analyzers they control
493
+ category_analyzers: dict[Category, set[str]] = {
494
+ Category.QUALITY: {"typos"},
495
+ Category.TYPE_SAFETY: {"mypy", "basedpyright"},
496
+ Category.SECURITY: {"bandit", "detect-secrets"},
497
+ Category.COMPLEXITY: {"radon"},
498
+ Category.DEAD_CODE: {"vulture"},
499
+ Category.DEPENDENCIES: {"deptry", "dependency-vulns", "pip-audit"},
500
+ }
501
+ for category, analyzers in category_analyzers.items():
502
+ if category not in plan.categories:
503
+ skip.update(analyzers)
504
+
505
+ # Enforce type backend mutual exclusion when TYPE_SAFETY is active
506
+ if Category.TYPE_SAFETY in plan.categories:
507
+ if plan.type_backend == TYPE_BACKEND_MYPY:
508
+ skip.add("basedpyright")
509
+ elif plan.type_backend == TYPE_BACKEND_BASEDPYRIGHT:
510
+ skip.add("mypy")
511
+
512
+ # Ruff is only needed if at least one of these categories is active
513
+ ruff_categories = {
514
+ Category.QUALITY,
515
+ Category.SECURITY,
516
+ Category.COMPLEXITY,
517
+ Category.DEAD_CODE,
518
+ }
519
+ if not (plan.categories & ruff_categories):
520
+ skip.add("ruff")
521
+
522
+ if plan.profile == PROFILE_QUICK:
523
+ skip.update({"mypy", "bandit", "radon", "vulture"})
524
+
525
+ return skip
526
+
527
+
528
+ def _discover_files(
529
+ project_root: Path,
530
+ config: CheckupConfig,
531
+ diff_base: str | None,
532
+ skip_analyzers: set[str],
533
+ ) -> tuple[list[Path], int]:
534
+ """Discover Python files to analyze.
535
+
536
+ Returns ``(files, total_project_files)``.
537
+ """
538
+ from python_checkup.discovery import discover_python_files
539
+
540
+ if diff_base is not None:
541
+ from python_checkup.diff import get_changed_files
542
+
543
+ files = get_changed_files(project_root, base=diff_base)
544
+ total_project_files = len(
545
+ discover_python_files(project_root, config.ignore_files)
546
+ )
547
+ # Skip Vulture in diff mode -- dead code detection
548
+ # on a subset is misleading
549
+ skip_analyzers.add("vulture")
550
+ else:
551
+ files = discover_python_files(project_root, config.ignore_files)
552
+ total_project_files = len(files)
553
+
554
+ return files, total_project_files
555
+
556
+
557
+ def _run_analysis(
558
+ *,
559
+ project_root: Path,
560
+ config: CheckupConfig,
561
+ files: list[Path],
562
+ skip_analyzers: set[str],
563
+ score_only: bool,
564
+ json_output: bool,
565
+ badge: bool,
566
+ no_cache: bool,
567
+ plan: ScanPlan,
568
+ diff_base: str | None,
569
+ ) -> HealthReport:
570
+ """Run the async analysis pipeline and return the report."""
571
+ from python_checkup.runner import run_analysis
572
+
573
+ quiet = score_only or json_output or badge
574
+ return asyncio.run(
575
+ run_analysis(
576
+ project_root=project_root,
577
+ config=config,
578
+ files=files,
579
+ skip_analyzers=skip_analyzers,
580
+ quiet=quiet,
581
+ no_cache=no_cache,
582
+ plan=plan,
583
+ diff_base=diff_base,
584
+ )
585
+ )
586
+
587
+
588
+ def _output_report(
589
+ report: HealthReport,
590
+ *,
591
+ score_only: bool,
592
+ badge: bool,
593
+ json_output: bool,
594
+ web: bool,
595
+ verbose: bool,
596
+ show_fixes: bool,
597
+ diff_base: str | None,
598
+ files: list[Path],
599
+ total_project_files: int,
600
+ project_root: Path,
601
+ config: CheckupConfig,
602
+ plan: ScanPlan,
603
+ no_cache: bool,
604
+ port: int,
605
+ ) -> None:
606
+ """Render the analysis report in the requested format."""
607
+ if score_only:
608
+ click.echo(report.score)
609
+ elif badge:
610
+ from python_checkup.formatters.badge import generate_badge_url
611
+
612
+ click.echo(generate_badge_url(report.score))
613
+ elif json_output:
614
+ from python_checkup.formatters.json_fmt import format_json
615
+
616
+ click.echo(format_json(report))
617
+ elif web:
618
+ from python_checkup.web.server import RunContext, serve_report
619
+
620
+ run_context = RunContext(
621
+ project_root=project_root,
622
+ config=config,
623
+ files=files,
624
+ plan=plan,
625
+ no_cache=no_cache,
626
+ diff_base=diff_base,
627
+ )
628
+ serve_report(report, port=port, run_context=run_context)
629
+ else:
630
+ from python_checkup.formatters.terminal import print_report
631
+
632
+ print_report(
633
+ report,
634
+ verbose=verbose,
635
+ show_fix=show_fixes,
636
+ diff_mode=diff_base is not None,
637
+ changed_file_count=len(files) if diff_base else None,
638
+ total_file_count=total_project_files,
639
+ )
640
+
641
+
642
+ def _apply_safe_ruff_fixes(project_root: Path) -> None:
643
+ import subprocess
644
+
645
+ try:
646
+ ruff_bin = "ruff"
647
+ subprocess.run( # noqa: S603
648
+ [ruff_bin, "check", "--fix", "--no-unsafe-fixes", str(project_root)],
649
+ check=False,
650
+ cwd=project_root,
651
+ capture_output=True,
652
+ text=True,
653
+ )
654
+ except FileNotFoundError:
655
+ err_console.print(
656
+ "[yellow]Ruff is not installed, so no fixes were applied.[/yellow]"
657
+ )
658
+
659
+
660
+ @main.group("mcp")
661
+ def mcp_cmd() -> None:
662
+ """MCP server management commands."""
663
+
664
+
665
+ @mcp_cmd.command("install")
666
+ @click.option(
667
+ "--editor",
668
+ type=click.Choice(["auto", "claude-code", "cursor", "vscode"]),
669
+ default="auto",
670
+ help="Target editor (default: auto-detect).",
671
+ )
672
+ def mcp_install(editor: str) -> None:
673
+ """Install python-checkup as an MCP server for your AI coding agent."""
674
+ from python_checkup.mcp.installer import install_mcp_config
675
+
676
+ err_console.print("[bold]Installing python-checkup MCP server...[/bold]")
677
+ installed = install_mcp_config()
678
+ if installed:
679
+ err_console.print(
680
+ f"\n[green]Installed to {len(installed)} location(s).[/green]"
681
+ )
682
+ err_console.print("[dim]Restart your editor to activate.[/dim]")
683
+ else:
684
+ err_console.print(
685
+ "[yellow]No editor configs found. "
686
+ "Created .mcp.json for Claude Code.[/yellow]"
687
+ )
688
+
689
+
690
+ @main.command("install-skill")
691
+ @click.option(
692
+ "--agent",
693
+ "-a",
694
+ multiple=True,
695
+ help="Specific agent(s) to install for (e.g., claude-code, cursor).",
696
+ )
697
+ @click.option(
698
+ "--project",
699
+ "-p",
700
+ is_flag=True,
701
+ help="Also install to .agents/python-checkup/ in current directory.",
702
+ )
703
+ @click.option(
704
+ "--force",
705
+ "-f",
706
+ is_flag=True,
707
+ help="Overwrite existing skill files.",
708
+ )
709
+ @click.option(
710
+ "--list-agents",
711
+ is_flag=True,
712
+ help="List all supported agents and their directories.",
713
+ )
714
+ def install_skill_cmd(
715
+ agent: tuple[str, ...],
716
+ project: bool,
717
+ force: bool,
718
+ list_agents: bool,
719
+ ) -> None:
720
+ """Install python-checkup skill files for AI coding agents."""
721
+ from rich.table import Table
722
+
723
+ from python_checkup.skills.agents import detect_installed_agents, get_agent_targets
724
+
725
+ if list_agents:
726
+ table = Table(title="Supported AI Coding Agents", show_header=True)
727
+ table.add_column("Agent", style="cyan")
728
+ table.add_column("Directory", style="dim")
729
+ table.add_column("Detected", justify="center")
730
+
731
+ all_targets = get_agent_targets()
732
+ detected = {t.name for t in detect_installed_agents()}
733
+
734
+ for target in all_targets:
735
+ is_detected = "\u2713" if target.name in detected else ""
736
+ if target.is_append and target.global_rules_file:
737
+ dir_str = f"{target.global_rules_file} (append)"
738
+ else:
739
+ dir_str = str(target.skill_dir)
740
+ table.add_row(target.display_name, dir_str, is_detected)
741
+
742
+ err_console.print(table)
743
+ return
744
+
745
+ from python_checkup.skills.installer import install_skill
746
+
747
+ agents_list = list(agent) if agent else None
748
+ install_skill(agents=agents_list, project_level=project, force=force)
749
+
750
+
751
+ @main.command("uninstall-skill")
752
+ @click.option(
753
+ "--agent",
754
+ "-a",
755
+ multiple=True,
756
+ help="Specific agent(s) to uninstall from.",
757
+ )
758
+ def uninstall_skill_cmd(agent: tuple[str, ...]) -> None:
759
+ """Remove installed python-checkup skill files."""
760
+ from python_checkup.skills.installer import uninstall_skill
761
+
762
+ agents_list = list(agent) if agent else None
763
+ uninstall_skill(agents=agents_list)