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.
- python_checkup/__init__.py +9 -0
- python_checkup/__main__.py +3 -0
- python_checkup/analysis_request.py +35 -0
- python_checkup/analyzer_catalog.py +100 -0
- python_checkup/analyzers/__init__.py +54 -0
- python_checkup/analyzers/bandit.py +158 -0
- python_checkup/analyzers/basedpyright.py +103 -0
- python_checkup/analyzers/cached.py +106 -0
- python_checkup/analyzers/dependency_vulns.py +298 -0
- python_checkup/analyzers/deptry.py +142 -0
- python_checkup/analyzers/detect_secrets.py +101 -0
- python_checkup/analyzers/mypy.py +217 -0
- python_checkup/analyzers/radon.py +150 -0
- python_checkup/analyzers/registry.py +69 -0
- python_checkup/analyzers/ruff.py +256 -0
- python_checkup/analyzers/typos.py +80 -0
- python_checkup/analyzers/vulture.py +151 -0
- python_checkup/cache.py +244 -0
- python_checkup/cli.py +763 -0
- python_checkup/config.py +87 -0
- python_checkup/dedup.py +119 -0
- python_checkup/dependencies/discovery.py +192 -0
- python_checkup/detection.py +298 -0
- python_checkup/diff.py +130 -0
- python_checkup/discovery.py +180 -0
- python_checkup/formatters/__init__.py +0 -0
- python_checkup/formatters/badge.py +38 -0
- python_checkup/formatters/json_fmt.py +22 -0
- python_checkup/formatters/terminal.py +396 -0
- python_checkup/mcp/__init__.py +3 -0
- python_checkup/mcp/installer.py +119 -0
- python_checkup/mcp/server.py +411 -0
- python_checkup/models.py +114 -0
- python_checkup/plan.py +109 -0
- python_checkup/progress.py +95 -0
- python_checkup/runner.py +438 -0
- python_checkup/scoring/__init__.py +0 -0
- python_checkup/scoring/engine.py +397 -0
- python_checkup/skills/SKILL.md +416 -0
- python_checkup/skills/__init__.py +0 -0
- python_checkup/skills/agents.py +98 -0
- python_checkup/skills/installer.py +248 -0
- python_checkup/skills/rule_db.py +806 -0
- python_checkup/web/__init__.py +0 -0
- python_checkup/web/server.py +285 -0
- python_checkup/web/static/__init__.py +0 -0
- python_checkup/web/static/index.html +959 -0
- python_checkup/web/template.py +26 -0
- python_checkup-0.0.1.dist-info/METADATA +250 -0
- python_checkup-0.0.1.dist-info/RECORD +53 -0
- python_checkup-0.0.1.dist-info/WHEEL +4 -0
- python_checkup-0.0.1.dist-info/entry_points.txt +14 -0
- python_checkup-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from python_checkup.models import Category, HealthReport, Severity
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from python_checkup.models import CategoryScore, Diagnostic
|
|
15
|
+
|
|
16
|
+
# Severity icons
|
|
17
|
+
SEVERITY_ICON: dict[Severity, str] = {
|
|
18
|
+
Severity.ERROR: "[red bold]ERR[/red bold]",
|
|
19
|
+
Severity.WARNING: "[yellow]WRN[/yellow]",
|
|
20
|
+
Severity.INFO: "[blue]INF[/blue]",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Category display names
|
|
24
|
+
CATEGORY_LABEL: dict[Category, str] = {
|
|
25
|
+
Category.QUALITY: "Code Quality",
|
|
26
|
+
Category.TYPE_SAFETY: "Type Safety",
|
|
27
|
+
Category.SECURITY: "Security",
|
|
28
|
+
Category.COMPLEXITY: "Complexity",
|
|
29
|
+
Category.DEAD_CODE: "Dead Code",
|
|
30
|
+
Category.DEPENDENCIES: "Dependencies",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def print_report(
|
|
35
|
+
report: HealthReport,
|
|
36
|
+
*,
|
|
37
|
+
verbose: bool = False,
|
|
38
|
+
show_fix: bool = False,
|
|
39
|
+
diff_mode: bool = False,
|
|
40
|
+
changed_file_count: int | None = None,
|
|
41
|
+
total_file_count: int | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Print a complete health report to the terminal.
|
|
44
|
+
|
|
45
|
+
All Rich output goes to stderr so stdout stays clean for piping.
|
|
46
|
+
"""
|
|
47
|
+
console = Console(file=sys.stderr)
|
|
48
|
+
|
|
49
|
+
console.print()
|
|
50
|
+
|
|
51
|
+
# 1. Score panel
|
|
52
|
+
_print_score_panel(console, report)
|
|
53
|
+
|
|
54
|
+
# 2. Diff mode notice
|
|
55
|
+
if diff_mode and changed_file_count is not None:
|
|
56
|
+
total = total_file_count or "?"
|
|
57
|
+
console.print(
|
|
58
|
+
f"\n [dim]Analyzed {changed_file_count} changed "
|
|
59
|
+
f"files (out of {total} total)[/dim]"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# 3. Category breakdown table
|
|
63
|
+
if report.category_scores:
|
|
64
|
+
console.print()
|
|
65
|
+
_print_category_table(console, report.category_scores)
|
|
66
|
+
|
|
67
|
+
if report.coverage:
|
|
68
|
+
console.print()
|
|
69
|
+
_print_coverage(console, report.coverage)
|
|
70
|
+
|
|
71
|
+
# 4. Top issues
|
|
72
|
+
issues = _sort_issues(report.diagnostics)
|
|
73
|
+
if issues:
|
|
74
|
+
console.print()
|
|
75
|
+
_print_top_issues(
|
|
76
|
+
console,
|
|
77
|
+
issues,
|
|
78
|
+
verbose=verbose,
|
|
79
|
+
show_fix=show_fix,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# 5. "What to fix first" -- 3 highest-impact issues
|
|
83
|
+
if not verbose and issues:
|
|
84
|
+
high_impact = _select_high_impact(issues, report.category_scores)
|
|
85
|
+
if high_impact:
|
|
86
|
+
console.print()
|
|
87
|
+
_print_fix_first(console, high_impact)
|
|
88
|
+
|
|
89
|
+
# 6. Footer
|
|
90
|
+
console.print()
|
|
91
|
+
_print_footer(console, report)
|
|
92
|
+
console.print()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _print_score_panel(console: Console, report: HealthReport) -> None:
|
|
96
|
+
score = report.score
|
|
97
|
+
color = _score_color(score)
|
|
98
|
+
|
|
99
|
+
score_text = Text()
|
|
100
|
+
score_text.append(f" {score}", style=f"bold {color}")
|
|
101
|
+
score_text.append(" / 100 ", style="dim")
|
|
102
|
+
score_text.append(f"{report.label}", style=f"bold {color}")
|
|
103
|
+
|
|
104
|
+
panel = Panel(
|
|
105
|
+
score_text,
|
|
106
|
+
title="[bold white]python-checkup[/bold white]",
|
|
107
|
+
subtitle=_score_bar(score, width=30),
|
|
108
|
+
border_style=color,
|
|
109
|
+
expand=False,
|
|
110
|
+
padding=(1, 3),
|
|
111
|
+
)
|
|
112
|
+
console.print(panel)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _score_bar(score: float, width: int = 30) -> str:
|
|
116
|
+
filled = int(score / 100 * width)
|
|
117
|
+
empty = width - filled
|
|
118
|
+
color = _score_color(score)
|
|
119
|
+
return f"[{color}]{'━' * filled}[/{color}][dim]{'━' * empty}[/dim]"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _print_category_table(
|
|
123
|
+
console: Console,
|
|
124
|
+
category_scores: list[CategoryScore],
|
|
125
|
+
) -> None:
|
|
126
|
+
table = Table(
|
|
127
|
+
show_header=True,
|
|
128
|
+
header_style="bold",
|
|
129
|
+
expand=False,
|
|
130
|
+
title="Category Breakdown",
|
|
131
|
+
title_style="bold",
|
|
132
|
+
)
|
|
133
|
+
table.add_column("Category", style="cyan", min_width=14)
|
|
134
|
+
table.add_column("Score", justify="right", min_width=7)
|
|
135
|
+
table.add_column("Weight", justify="right", style="dim", min_width=7)
|
|
136
|
+
table.add_column("Issues", justify="right", min_width=7)
|
|
137
|
+
table.add_column("Status", style="dim", min_width=8)
|
|
138
|
+
table.add_column("Details", style="dim")
|
|
139
|
+
|
|
140
|
+
for cs in sorted(
|
|
141
|
+
category_scores,
|
|
142
|
+
key=lambda c: (c.category == Category.COMPLEXITY, c.score),
|
|
143
|
+
):
|
|
144
|
+
color = _score_color(cs.score)
|
|
145
|
+
label = CATEGORY_LABEL.get(cs.category, cs.category.value)
|
|
146
|
+
bar = _inline_bar(cs.score, width=12)
|
|
147
|
+
|
|
148
|
+
table.add_row(
|
|
149
|
+
label,
|
|
150
|
+
f"[{color}]{cs.score}[/{color}]",
|
|
151
|
+
f"{cs.weight}%",
|
|
152
|
+
str(cs.issue_count),
|
|
153
|
+
cs.status,
|
|
154
|
+
f"{bar} {cs.details}"
|
|
155
|
+
+ (f" ({cs.coverage_note})" if cs.coverage_note else ""),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
console.print(table)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _inline_bar(score: float, width: int = 12) -> str:
|
|
162
|
+
filled = int(score / 100 * width)
|
|
163
|
+
empty = width - filled
|
|
164
|
+
color = _score_color(score)
|
|
165
|
+
return f"[{color}]{'█' * filled}[/{color}][dim]{'░' * empty}[/dim]"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _sort_issues(
|
|
169
|
+
diagnostics: list[Diagnostic],
|
|
170
|
+
) -> list[Diagnostic]:
|
|
171
|
+
"""Sort issues by severity (errors first), then file and line."""
|
|
172
|
+
severity_order = {
|
|
173
|
+
Severity.ERROR: 0,
|
|
174
|
+
Severity.WARNING: 1,
|
|
175
|
+
Severity.INFO: 2,
|
|
176
|
+
}
|
|
177
|
+
return sorted(
|
|
178
|
+
diagnostics,
|
|
179
|
+
key=lambda d: (
|
|
180
|
+
severity_order.get(d.severity, 9),
|
|
181
|
+
str(d.file_path),
|
|
182
|
+
d.line,
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _print_top_issues(
|
|
188
|
+
console: Console,
|
|
189
|
+
issues: list[Diagnostic],
|
|
190
|
+
*,
|
|
191
|
+
verbose: bool,
|
|
192
|
+
show_fix: bool,
|
|
193
|
+
) -> None:
|
|
194
|
+
# Filter to errors and warnings (skip info unless verbose)
|
|
195
|
+
if not verbose:
|
|
196
|
+
issues = [d for d in issues if d.severity != Severity.INFO]
|
|
197
|
+
|
|
198
|
+
max_issues = len(issues) if verbose else 10
|
|
199
|
+
shown = issues[:max_issues]
|
|
200
|
+
remaining = len(issues) - len(shown)
|
|
201
|
+
|
|
202
|
+
table = Table(
|
|
203
|
+
show_header=True,
|
|
204
|
+
header_style="bold",
|
|
205
|
+
expand=True,
|
|
206
|
+
title=f"{'All' if verbose else 'Top'} Issues",
|
|
207
|
+
title_style="bold",
|
|
208
|
+
)
|
|
209
|
+
table.add_column("", width=3)
|
|
210
|
+
table.add_column("Location", style="cyan", no_wrap=True)
|
|
211
|
+
table.add_column("Rule", style="bold", no_wrap=True, min_width=6)
|
|
212
|
+
table.add_column("Message")
|
|
213
|
+
|
|
214
|
+
for d in shown:
|
|
215
|
+
icon = SEVERITY_ICON.get(d.severity, "")
|
|
216
|
+
location = f"{d.file_path}:{d.line}:{d.column}"
|
|
217
|
+
|
|
218
|
+
# Truncate long file paths
|
|
219
|
+
if len(location) > 45:
|
|
220
|
+
parts = str(d.file_path).split("/")
|
|
221
|
+
if len(parts) > 3:
|
|
222
|
+
short_path = f".../{'/'.join(parts[-2:])}"
|
|
223
|
+
else:
|
|
224
|
+
short_path = str(d.file_path)
|
|
225
|
+
location = f"{short_path}:{d.line}:{d.column}"
|
|
226
|
+
|
|
227
|
+
message = d.message
|
|
228
|
+
if show_fix and d.fix:
|
|
229
|
+
message += f"\n[dim green] Fix: {d.fix}[/dim green]"
|
|
230
|
+
|
|
231
|
+
table.add_row(icon, location, d.rule_id, message)
|
|
232
|
+
|
|
233
|
+
console.print(table)
|
|
234
|
+
|
|
235
|
+
if remaining > 0:
|
|
236
|
+
console.print(
|
|
237
|
+
f" [dim]...and {remaining} more issues. "
|
|
238
|
+
f"Run with --verbose to see all.[/dim]"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _select_high_impact(
|
|
243
|
+
issues: list[Diagnostic],
|
|
244
|
+
category_scores: list[CategoryScore],
|
|
245
|
+
) -> list[Diagnostic]:
|
|
246
|
+
"""Select the 3 highest-impact issues to fix first.
|
|
247
|
+
|
|
248
|
+
"High impact" = errors in the lowest-scoring category.
|
|
249
|
+
"""
|
|
250
|
+
if not category_scores:
|
|
251
|
+
return issues[:3]
|
|
252
|
+
|
|
253
|
+
worst_categories = sorted(category_scores, key=lambda cs: cs.score)
|
|
254
|
+
|
|
255
|
+
selected: list[Diagnostic] = []
|
|
256
|
+
for cs in worst_categories:
|
|
257
|
+
if len(selected) >= 3:
|
|
258
|
+
break
|
|
259
|
+
category_errors = [
|
|
260
|
+
d
|
|
261
|
+
for d in issues
|
|
262
|
+
if d.category == cs.category
|
|
263
|
+
and d.severity == Severity.ERROR
|
|
264
|
+
and d not in selected
|
|
265
|
+
]
|
|
266
|
+
selected.extend(category_errors[: 3 - len(selected)])
|
|
267
|
+
|
|
268
|
+
# Fill with remaining errors if needed
|
|
269
|
+
if len(selected) < 3:
|
|
270
|
+
remaining_errors = [
|
|
271
|
+
d for d in issues if d.severity == Severity.ERROR and d not in selected
|
|
272
|
+
]
|
|
273
|
+
selected.extend(remaining_errors[: 3 - len(selected)])
|
|
274
|
+
|
|
275
|
+
return selected[:3]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _print_fix_first(console: Console, issues: list[Diagnostic]) -> None:
|
|
279
|
+
console.print("[bold]What to fix first:[/bold]")
|
|
280
|
+
console.print("[dim]These issues have the highest impact on your score:[/dim]")
|
|
281
|
+
console.print()
|
|
282
|
+
|
|
283
|
+
for i, d in enumerate(issues, 1):
|
|
284
|
+
icon = SEVERITY_ICON.get(d.severity, "")
|
|
285
|
+
category_label = CATEGORY_LABEL.get(d.category, d.category.value)
|
|
286
|
+
|
|
287
|
+
console.print(
|
|
288
|
+
f" {i}. {icon} [bold]{d.rule_id}[/bold] in "
|
|
289
|
+
f"[cyan]{d.file_path}:{d.line}[/cyan]"
|
|
290
|
+
)
|
|
291
|
+
console.print(f" {d.message}")
|
|
292
|
+
if d.fix:
|
|
293
|
+
console.print(f" [green]Fix: {d.fix}[/green]")
|
|
294
|
+
console.print(f" [dim]Category: {category_label}[/dim]")
|
|
295
|
+
console.print()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _print_footer(console: Console, report: HealthReport) -> None:
|
|
299
|
+
files_str = f"{report.project.total_files} files"
|
|
300
|
+
lines_str = f"{report.project.total_lines:,} lines"
|
|
301
|
+
time_str = _format_duration(report.duration_ms)
|
|
302
|
+
analyzers_str = ", ".join(report.analyzers_used) or "none"
|
|
303
|
+
|
|
304
|
+
console.print(
|
|
305
|
+
f" [dim]Scanned {files_str} ({lines_str}) in "
|
|
306
|
+
f"{time_str} using {analyzers_str}[/dim]"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if report.analyzers_skipped:
|
|
310
|
+
skipped = ", ".join(report.analyzers_skipped)
|
|
311
|
+
console.print(f" [dim]Skipped: {skipped}[/dim]")
|
|
312
|
+
|
|
313
|
+
if report.cache_stats and report.cache_stats.get("hits", 0) > 0:
|
|
314
|
+
hits = report.cache_stats["hits"]
|
|
315
|
+
misses = report.cache_stats["misses"]
|
|
316
|
+
hit_rate = report.cache_stats.get("hit_rate_pct", 0)
|
|
317
|
+
console.print(
|
|
318
|
+
f" [dim]Cache: {hits} hits, {misses} misses ({hit_rate}% hit rate)[/dim]"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
if report.project.framework:
|
|
322
|
+
console.print(f" [dim]Framework detected: {report.project.framework}[/dim]")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# Map optional analyzer names to their pip extras for install hints.
|
|
326
|
+
ANALYZER_EXTRA: dict[str, str] = {
|
|
327
|
+
"dependency-vulns": "vulns",
|
|
328
|
+
"detect-secrets": "secrets",
|
|
329
|
+
"basedpyright": "pyright",
|
|
330
|
+
"typos": "quality-extra",
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _print_coverage(console: Console, coverage: object) -> None:
|
|
335
|
+
confidence = getattr(coverage, "confidence", "unknown")
|
|
336
|
+
profile = getattr(coverage, "profile", "default")
|
|
337
|
+
console.print(f" [dim]Coverage: {confidence} ({profile} profile)[/dim]")
|
|
338
|
+
|
|
339
|
+
partial_reasons = getattr(coverage, "partial_reasons", [])
|
|
340
|
+
if partial_reasons:
|
|
341
|
+
for reason in partial_reasons:
|
|
342
|
+
console.print(f" [dim]- {reason}[/dim]")
|
|
343
|
+
|
|
344
|
+
# Explain weight redistribution when categories are not scored.
|
|
345
|
+
cat_coverage: list[object] = getattr(coverage, "category_coverage", [])
|
|
346
|
+
redistributed_cats: list[str] = []
|
|
347
|
+
for cc in cat_coverage:
|
|
348
|
+
status = getattr(cc, "status", "")
|
|
349
|
+
if status in ("unavailable", "skipped_by_user"):
|
|
350
|
+
cat = getattr(cc, "category", None)
|
|
351
|
+
label = CATEGORY_LABEL.get(cat, str(cat)) if cat is not None else "unknown"
|
|
352
|
+
redistributed_cats.append(f"{label} ({status.replace('_', ' ')})")
|
|
353
|
+
if redistributed_cats:
|
|
354
|
+
console.print(
|
|
355
|
+
" [dim]Weight redistributed from: "
|
|
356
|
+
+ ", ".join(redistributed_cats)
|
|
357
|
+
+ "[/dim]"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Provenance notes (e.g. "scanned from uv.lock (42 packages)")
|
|
361
|
+
provenance: list[str] = getattr(coverage, "provenance", [])
|
|
362
|
+
for note in provenance:
|
|
363
|
+
console.print(f" [dim]{note}[/dim]")
|
|
364
|
+
|
|
365
|
+
optional: list[str] = getattr(coverage, "analyzers_optional_unavailable", [])
|
|
366
|
+
if optional:
|
|
367
|
+
console.print(
|
|
368
|
+
" [dim]Optional analyzers not installed: " + ", ".join(optional) + "[/dim]"
|
|
369
|
+
)
|
|
370
|
+
extras = {ANALYZER_EXTRA[a] for a in optional if a in ANALYZER_EXTRA}
|
|
371
|
+
if extras:
|
|
372
|
+
extras_str = ",".join(sorted(extras))
|
|
373
|
+
console.print(
|
|
374
|
+
f" [dim]Enable with: "
|
|
375
|
+
f"uvx --from 'python-checkup[{extras_str}]' python-checkup .[/dim]"
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _format_duration(ms: int) -> str:
|
|
380
|
+
if ms < 1000:
|
|
381
|
+
return f"{ms}ms"
|
|
382
|
+
elif ms < 60_000:
|
|
383
|
+
return f"{ms / 1000:.1f}s"
|
|
384
|
+
else:
|
|
385
|
+
minutes = ms // 60_000
|
|
386
|
+
seconds = (ms % 60_000) / 1000
|
|
387
|
+
return f"{minutes}m {seconds:.0f}s"
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _score_color(score: float) -> str:
|
|
391
|
+
if score >= 75:
|
|
392
|
+
return "green"
|
|
393
|
+
elif score >= 50:
|
|
394
|
+
return "yellow"
|
|
395
|
+
else:
|
|
396
|
+
return "red"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
console = Console(file=sys.stderr)
|
|
10
|
+
|
|
11
|
+
# MCP config entry to install for each editor
|
|
12
|
+
MCP_CONFIG_ENTRY: dict[str, object] = {
|
|
13
|
+
"command": "uvx",
|
|
14
|
+
"args": ["python-checkup", "--mcp"],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# Editor config file locations and their JSON key for MCP servers
|
|
18
|
+
EDITOR_TARGETS: list[dict[str, object]] = [
|
|
19
|
+
{
|
|
20
|
+
"name": "Claude Code (project)",
|
|
21
|
+
"path": Path(".mcp.json"),
|
|
22
|
+
"key": "mcpServers",
|
|
23
|
+
"relative": True,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "Cursor",
|
|
27
|
+
"path": Path(".cursor/mcp.json"),
|
|
28
|
+
"key": "mcpServers",
|
|
29
|
+
"relative": True,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "VS Code",
|
|
33
|
+
"path": Path(".vscode/mcp.json"),
|
|
34
|
+
"key": "servers", # VS Code uses "servers" not "mcpServers"
|
|
35
|
+
"relative": True,
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def install_mcp_config(project_root: Path | None = None) -> list[str]:
|
|
41
|
+
"""Detect editors and write MCP server config.
|
|
42
|
+
|
|
43
|
+
Only installs into editor directories that already exist (so we
|
|
44
|
+
don't create .cursor/ for someone who doesn't use Cursor), except
|
|
45
|
+
for .mcp.json which is always created for Claude Code.
|
|
46
|
+
|
|
47
|
+
Returns list of paths where config was written.
|
|
48
|
+
"""
|
|
49
|
+
root = project_root or Path.cwd()
|
|
50
|
+
installed: list[str] = []
|
|
51
|
+
|
|
52
|
+
for target in EDITOR_TARGETS:
|
|
53
|
+
is_relative = bool(target["relative"])
|
|
54
|
+
target_path = target["path"]
|
|
55
|
+
if not isinstance(target_path, Path):
|
|
56
|
+
continue # pragma: no cover
|
|
57
|
+
target_key = target["key"]
|
|
58
|
+
if not isinstance(target_key, str):
|
|
59
|
+
continue # pragma: no cover
|
|
60
|
+
target_name = target["name"]
|
|
61
|
+
if not isinstance(target_name, str):
|
|
62
|
+
continue # pragma: no cover
|
|
63
|
+
|
|
64
|
+
config_path = root / target_path if is_relative else Path.home() / target_path
|
|
65
|
+
|
|
66
|
+
# Only install if the editor directory already exists
|
|
67
|
+
# (don't create .cursor/ for someone who doesn't use Cursor)
|
|
68
|
+
if is_relative:
|
|
69
|
+
parent = config_path.parent
|
|
70
|
+
if parent.name in (".cursor", ".vscode") and not parent.exists():
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
_write_config(config_path, target_key)
|
|
75
|
+
installed.append(str(config_path))
|
|
76
|
+
console.print(
|
|
77
|
+
f" [green]Installed[/green] -> {config_path} ({target_name})"
|
|
78
|
+
)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
console.print(f" [red]Failed[/red] -> {config_path}: {e}")
|
|
81
|
+
|
|
82
|
+
# Always install .mcp.json (Claude Code project-level)
|
|
83
|
+
claude_path = root / ".mcp.json"
|
|
84
|
+
if str(claude_path) not in installed:
|
|
85
|
+
try:
|
|
86
|
+
_write_config(claude_path, "mcpServers")
|
|
87
|
+
installed.append(str(claude_path))
|
|
88
|
+
console.print(f" [green]Installed[/green] -> {claude_path}")
|
|
89
|
+
except Exception as e:
|
|
90
|
+
console.print(f" [red]Failed[/red] -> {claude_path}: {e}")
|
|
91
|
+
|
|
92
|
+
return installed
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _write_config(path: Path, server_key: str) -> None:
|
|
96
|
+
"""Write or merge MCP config into a JSON file.
|
|
97
|
+
|
|
98
|
+
Preserves existing server entries -- only adds/updates the
|
|
99
|
+
``python-checkup`` entry under the appropriate key.
|
|
100
|
+
"""
|
|
101
|
+
existing: dict[str, object] = {}
|
|
102
|
+
if path.exists():
|
|
103
|
+
try:
|
|
104
|
+
raw = path.read_text()
|
|
105
|
+
loaded = json.loads(raw)
|
|
106
|
+
if isinstance(loaded, dict):
|
|
107
|
+
existing = loaded
|
|
108
|
+
except (json.JSONDecodeError, OSError):
|
|
109
|
+
existing = {}
|
|
110
|
+
|
|
111
|
+
# Merge -- don't overwrite other servers
|
|
112
|
+
servers = existing.get(server_key)
|
|
113
|
+
if not isinstance(servers, dict):
|
|
114
|
+
servers = {}
|
|
115
|
+
servers["python-checkup"] = MCP_CONFIG_ENTRY
|
|
116
|
+
existing[server_key] = servers
|
|
117
|
+
|
|
118
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
path.write_text(json.dumps(existing, indent=2) + "\n")
|