punt-kit 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.
punt_kit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.1.0"
punt_kit/__main__.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ from punt_kit import __version__
7
+
8
+ app = typer.Typer(
9
+ name="punt",
10
+ help="Standards, scaffolding, and compliance tooling for Punt Labs projects.",
11
+ no_args_is_help=True,
12
+ )
13
+ console = Console()
14
+
15
+
16
+ @app.command()
17
+ def init(
18
+ path: str = typer.Argument(".", help="Path to the project root"),
19
+ language: str = typer.Option(
20
+ "",
21
+ "--language",
22
+ "-l",
23
+ help="Override detected language (python, node, swift)",
24
+ ),
25
+ ) -> None:
26
+ """Detect project type, generate missing files, and report manual steps."""
27
+ from punt_kit.init import run_init
28
+
29
+ run_init(path, language=language or None)
30
+
31
+
32
+ @app.command()
33
+ def audit(
34
+ path: str = typer.Argument(".", help="Path to the project root"),
35
+ fix: bool = typer.Option(False, "--fix", help="Create missing mechanical files"),
36
+ ) -> None:
37
+ """Check compliance against Punt Labs standards."""
38
+ from punt_kit.audit import run_audit
39
+
40
+ run_audit(path, fix=fix)
41
+
42
+
43
+ @app.command()
44
+ def version() -> None:
45
+ """Print the punt-kit version."""
46
+ console.print(f"punt-kit {__version__}")
47
+
48
+
49
+ if __name__ == "__main__":
50
+ app()
punt_kit/audit.py ADDED
@@ -0,0 +1,650 @@
1
+ """punt audit — compliance check against Punt Labs standards."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ import json
7
+ import shutil
8
+ import subprocess
9
+ import tomllib
10
+ from pathlib import Path
11
+ from typing import cast
12
+
13
+ from rich.console import Console
14
+
15
+ from punt_kit.detect import ProjectInfo, detect
16
+ from punt_kit.init import build_standard_permissions
17
+
18
+ console = Console()
19
+
20
+ TEMPLATES = importlib.resources.files("punt_kit") / "templates"
21
+
22
+ PASS = "[green]✓[/green]"
23
+ FAIL = "[red]✗[/red]"
24
+ FIXED = "[yellow]⚡[/yellow]"
25
+ INFO = "[dim]○[/dim]"
26
+
27
+
28
+ def run_audit(path: str, *, fix: bool = False) -> None:
29
+ """Check compliance against Punt Labs standards.
30
+
31
+ When *fix* is True, create missing mechanical files from templates.
32
+ """
33
+ root = Path(path).resolve()
34
+ if not root.is_dir():
35
+ console.print(f"[red]Error:[/red] {root} is not a directory")
36
+ raise SystemExit(1)
37
+
38
+ info = detect(root)
39
+
40
+ mode = "[bold]punt audit --fix[/bold]" if fix else "[bold]punt audit[/bold]"
41
+ console.print(f"\n{mode} — {root.name}")
42
+ lang = info.language or "none"
43
+ ptype = info.project_type or "unknown"
44
+ console.print(f" Language: {lang} Type: {ptype}\n")
45
+
46
+ results: list[tuple[str, str, str]] = []
47
+
48
+ results.extend(_check_ci(info))
49
+ results.extend(_check_tool_config(info))
50
+ results.extend(_check_markdownlint(info, fix=fix))
51
+ results.extend(_check_markdownlint_content(info))
52
+ results.extend(_check_py_typed(info, fix=fix))
53
+ results.extend(_check_changelog(info, fix=fix))
54
+ results.extend(_check_beads(info))
55
+ results.extend(_check_claude_md(info))
56
+ results.extend(_check_permissions(info))
57
+ results.extend(_check_plugin_dev_isolation(info))
58
+ results.extend(_check_github_settings(info))
59
+
60
+ # Print results
61
+ passes = 0
62
+ failures = 0
63
+ fixes = 0
64
+ for status, label, detail in results:
65
+ console.print(f" {status} {label}")
66
+ if detail:
67
+ console.print(f" [dim]{detail}[/dim]")
68
+ if status == PASS:
69
+ passes += 1
70
+ elif status == FAIL:
71
+ failures += 1
72
+ elif status == FIXED:
73
+ fixes += 1
74
+
75
+ summary = f" [bold]{passes} passed[/bold], [bold]{failures} failed[/bold]"
76
+ if fixes:
77
+ summary += f", [bold yellow]{fixes} fixed[/bold yellow]"
78
+ console.print(f"\n{summary}\n")
79
+
80
+ if failures > 0:
81
+ raise SystemExit(1)
82
+
83
+
84
+ def _check_ci(info: ProjectInfo) -> list[tuple[str, str, str]]:
85
+ """Check CI workflow existence per project type."""
86
+ results: list[tuple[str, str, str]] = []
87
+ workflows_dir = info.root / ".github" / "workflows"
88
+
89
+ if info.language == "python":
90
+ lint_exists = (workflows_dir / "lint.yml").exists()
91
+ results.append(
92
+ (
93
+ PASS if lint_exists else FAIL,
94
+ "CI lint workflow exists",
95
+ ".github/workflows/lint.yml" if lint_exists else "Missing lint.yml",
96
+ )
97
+ )
98
+
99
+ test_exists = (workflows_dir / "test.yml").exists()
100
+ results.append(
101
+ (
102
+ PASS if test_exists else FAIL,
103
+ "CI test workflow exists",
104
+ ".github/workflows/test.yml" if test_exists else "Missing test.yml",
105
+ )
106
+ )
107
+
108
+ elif info.language == "node":
109
+ lint_exists = (workflows_dir / "lint.yml").exists()
110
+ results.append(
111
+ (
112
+ PASS if lint_exists else FAIL,
113
+ "CI lint workflow exists",
114
+ ".github/workflows/lint.yml" if lint_exists else "Missing lint.yml",
115
+ )
116
+ )
117
+
118
+ elif info.language == "swift":
119
+ build_exists = (workflows_dir / "build.yml").exists()
120
+ results.append(
121
+ (
122
+ PASS if build_exists else FAIL,
123
+ "CI build workflow exists",
124
+ ".github/workflows/build.yml" if build_exists else "Missing build.yml",
125
+ )
126
+ )
127
+
128
+ # All repos should have docs.yml
129
+ docs_exists = (workflows_dir / "docs.yml").exists()
130
+ results.append(
131
+ (
132
+ PASS if docs_exists else FAIL,
133
+ "CI docs workflow exists",
134
+ ".github/workflows/docs.yml" if docs_exists else "Missing docs.yml",
135
+ )
136
+ )
137
+
138
+ return results
139
+
140
+
141
+ def _check_tool_config(info: ProjectInfo) -> list[tuple[str, str, str]]:
142
+ """Check language-specific tool configuration."""
143
+ results: list[tuple[str, str, str]] = []
144
+
145
+ if info.language != "python":
146
+ return results
147
+
148
+ pyproject_path = info.root / "pyproject.toml"
149
+ if not pyproject_path.exists():
150
+ results.append((FAIL, "pyproject.toml exists", "Missing"))
151
+ return results
152
+
153
+ with open(pyproject_path, "rb") as f:
154
+ data = tomllib.load(f)
155
+
156
+ tool = data.get("tool", {})
157
+
158
+ ruff = tool.get("ruff")
159
+ results.append(
160
+ (
161
+ PASS if ruff else FAIL,
162
+ r"Linting configured (\[tool.ruff])",
163
+ r"in pyproject.toml" if ruff else r"Missing \[tool.ruff] in pyproject.toml",
164
+ )
165
+ )
166
+
167
+ mypy = tool.get("mypy")
168
+ results.append(
169
+ (
170
+ PASS if mypy else FAIL,
171
+ r"Type checking configured (\[tool.mypy])",
172
+ r"in pyproject.toml" if mypy else r"Missing \[tool.mypy] in pyproject.toml",
173
+ )
174
+ )
175
+
176
+ pyright = tool.get("pyright")
177
+ results.append(
178
+ (
179
+ PASS if pyright else FAIL,
180
+ r"Type checking configured (\[tool.pyright])",
181
+ r"in pyproject.toml"
182
+ if pyright
183
+ else r"Missing \[tool.pyright] in pyproject.toml",
184
+ )
185
+ )
186
+
187
+ pytest_cfg = tool.get("pytest", {}).get("ini_options")
188
+ results.append(
189
+ (
190
+ PASS if pytest_cfg else FAIL,
191
+ r"Test config exists (\[tool.pytest.ini_options])",
192
+ r"in pyproject.toml"
193
+ if pytest_cfg
194
+ else r"Missing \[tool.pytest.ini_options]",
195
+ )
196
+ )
197
+
198
+ return results
199
+
200
+
201
+ def _check_markdownlint(info: ProjectInfo, *, fix: bool) -> list[tuple[str, str, str]]:
202
+ """Check markdownlint configuration files."""
203
+ results: list[tuple[str, str, str]] = []
204
+
205
+ # Template files stored without dot prefix to avoid setuptools dotfile issues
206
+ configs = {
207
+ ".markdownlint.jsonc": "markdownlint.jsonc",
208
+ ".markdownlint-cli2.jsonc": "markdownlint-cli2.jsonc",
209
+ }
210
+
211
+ for filename, template_name in configs.items():
212
+ target = info.root / filename
213
+ if target.exists():
214
+ results.append((PASS, f"{filename} exists", ""))
215
+ elif fix:
216
+ template_ref = TEMPLATES / template_name
217
+ content = template_ref.read_text(encoding="utf-8")
218
+ target.write_text(content, encoding="utf-8")
219
+ results.append((FIXED, f"{filename} created", ""))
220
+ else:
221
+ results.append(
222
+ (FAIL, f"{filename} exists", "Missing — run punt audit --fix")
223
+ )
224
+
225
+ return results
226
+
227
+
228
+ def _check_markdownlint_content(info: ProjectInfo) -> list[tuple[str, str, str]]:
229
+ """Run markdownlint-cli2 on tracked markdown files to catch content errors."""
230
+ npx = shutil.which("npx")
231
+ if npx is None:
232
+ return [(INFO, "Markdown lint (npx not available)", "Install Node.js to run")]
233
+
234
+ # Only lint git-tracked files to avoid scratch file noise
235
+ tracked_md = _get_tracked_markdown(info.root)
236
+ if not tracked_md:
237
+ return [(INFO, "Markdown lint (no tracked .md files)", "")]
238
+
239
+ try:
240
+ result = subprocess.run(
241
+ [npx, "--yes", "markdownlint-cli2", *tracked_md],
242
+ cwd=str(info.root),
243
+ capture_output=True,
244
+ text=True,
245
+ timeout=60,
246
+ )
247
+ if result.returncode == 0:
248
+ return [
249
+ (PASS, "Markdown lint passes", f"{len(tracked_md)} file(s) checked")
250
+ ]
251
+ # Count errors from output
252
+ error_lines = [
253
+ line
254
+ for line in result.stdout.splitlines()
255
+ if " error " in line or "MD0" in line
256
+ ]
257
+ return [
258
+ (
259
+ FAIL,
260
+ "Markdown lint passes",
261
+ f"{len(error_lines)} error(s) — run: npx markdownlint-cli2 '**/*.md'",
262
+ )
263
+ ]
264
+ except subprocess.TimeoutExpired:
265
+ return [(INFO, "Markdown lint (timed out)", "")]
266
+ except OSError:
267
+ return [(INFO, "Markdown lint (could not run)", "")]
268
+
269
+
270
+ def _get_tracked_markdown(root: Path) -> list[str]:
271
+ """Get list of git-tracked markdown files relative to root."""
272
+ try:
273
+ result = subprocess.run(
274
+ ["git", "ls-files", "--cached", "*.md", "**/*.md"],
275
+ cwd=str(root),
276
+ capture_output=True,
277
+ text=True,
278
+ timeout=5,
279
+ )
280
+ if result.returncode != 0:
281
+ return []
282
+ return [f for f in result.stdout.strip().splitlines() if f.endswith(".md")]
283
+ except (subprocess.TimeoutExpired, OSError):
284
+ return []
285
+
286
+
287
+ def _check_py_typed(info: ProjectInfo, *, fix: bool) -> list[tuple[str, str, str]]:
288
+ """Check py.typed marker file for Python packages."""
289
+ if info.language != "python":
290
+ return []
291
+
292
+ pkg_dir = _find_package_dir(info)
293
+ if pkg_dir is None:
294
+ return [(INFO, "py.typed marker", "Could not determine package directory")]
295
+
296
+ py_typed = pkg_dir / "py.typed"
297
+ if py_typed.exists():
298
+ return [(PASS, "py.typed marker exists", str(_relpath(py_typed, info.root)))]
299
+
300
+ if fix:
301
+ py_typed.write_text("")
302
+ return [(FIXED, "py.typed marker created", str(_relpath(py_typed, info.root)))]
303
+
304
+ return [
305
+ (
306
+ FAIL,
307
+ "py.typed marker exists",
308
+ f"Missing {_relpath(py_typed, info.root)} — run punt audit --fix",
309
+ )
310
+ ]
311
+
312
+
313
+ def _check_changelog(info: ProjectInfo, *, fix: bool) -> list[tuple[str, str, str]]:
314
+ """Check CHANGELOG.md exists."""
315
+ changelog = info.root / "CHANGELOG.md"
316
+ if changelog.exists():
317
+ return [(PASS, "CHANGELOG.md exists", "")]
318
+
319
+ if fix:
320
+ project_name = _get_project_name(info)
321
+ changelog.write_text(
322
+ f"# Changelog\n\nAll notable changes to {project_name} "
323
+ "will be documented in this file.\n\n"
324
+ "The format is based on "
325
+ "[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\n",
326
+ encoding="utf-8",
327
+ )
328
+ return [(FIXED, "CHANGELOG.md created", "")]
329
+
330
+ return [(FAIL, "CHANGELOG.md exists", "Missing — run punt audit --fix")]
331
+
332
+
333
+ def _check_beads(info: ProjectInfo) -> list[tuple[str, str, str]]:
334
+ """Check if beads is initialized."""
335
+ return [
336
+ (
337
+ PASS if info.has_beads else FAIL,
338
+ "Beads initialized",
339
+ ".beads/ directory" if info.has_beads else "Missing .beads/ — run bd init",
340
+ )
341
+ ]
342
+
343
+
344
+ def _check_claude_md(info: ProjectInfo) -> list[tuple[str, str, str]]:
345
+ """Check if CLAUDE.md exists."""
346
+ return [
347
+ (
348
+ PASS if info.has_claude_md else FAIL,
349
+ "CLAUDE.md exists",
350
+ "" if info.has_claude_md else "Missing CLAUDE.md — run punt init",
351
+ )
352
+ ]
353
+
354
+
355
+ def _check_github_settings(info: ProjectInfo) -> list[tuple[str, str, str]]:
356
+ """Check GitHub repo settings via gh API. Falls back gracefully."""
357
+ results: list[tuple[str, str, str]] = []
358
+
359
+ gh = shutil.which("gh")
360
+ if gh is None:
361
+ results.append(
362
+ (
363
+ INFO,
364
+ "GitHub settings (gh CLI not available)",
365
+ "Install gh to check remote settings",
366
+ )
367
+ )
368
+ return results
369
+
370
+ # Detect repo from git remote
371
+ repo = _get_github_repo(info.root)
372
+ if repo is None:
373
+ results.append((INFO, "GitHub settings (no remote detected)", ""))
374
+ return results
375
+
376
+ # Check branch protection
377
+ try:
378
+ result = subprocess.run(
379
+ [gh, "api", f"repos/{repo}/branches/main/protection"],
380
+ capture_output=True,
381
+ text=True,
382
+ timeout=10,
383
+ )
384
+ if result.returncode == 0:
385
+ protection = json.loads(result.stdout)
386
+ pr_required = protection.get("required_pull_request_reviews") is not None
387
+ results.append(
388
+ (
389
+ PASS if pr_required else FAIL,
390
+ "Branch protection: PR required",
391
+ "",
392
+ )
393
+ )
394
+
395
+ status_checks = protection.get("required_status_checks") is not None
396
+ results.append(
397
+ (
398
+ PASS if status_checks else FAIL,
399
+ "Branch protection: status checks required",
400
+ "",
401
+ )
402
+ )
403
+ else:
404
+ results.append(
405
+ (
406
+ FAIL,
407
+ "Branch protection on main",
408
+ "Not configured or no access",
409
+ )
410
+ )
411
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
412
+ results.append((INFO, "Branch protection (could not check)", ""))
413
+
414
+ # Check Dependabot / vulnerability alerts
415
+ try:
416
+ result = subprocess.run(
417
+ [gh, "api", f"repos/{repo}/vulnerability-alerts", "--include", "-X", "GET"],
418
+ capture_output=True,
419
+ text=True,
420
+ timeout=10,
421
+ )
422
+ # 204 means enabled, 404 means disabled
423
+ enabled = "204" in (result.stderr + result.stdout) or result.returncode == 0
424
+ results.append(
425
+ (
426
+ PASS if enabled else FAIL,
427
+ "Dependabot vulnerability alerts",
428
+ "Enabled" if enabled else "Not enabled",
429
+ )
430
+ )
431
+ except (subprocess.TimeoutExpired, OSError):
432
+ results.append((INFO, "Dependabot alerts (could not check)", ""))
433
+
434
+ return results
435
+
436
+
437
+ def _check_permissions(info: ProjectInfo) -> list[tuple[str, str, str]]:
438
+ """Check .claude/settings.json has standard permissions for this project type."""
439
+ results: list[tuple[str, str, str]] = []
440
+ settings_path = info.root / ".claude" / "settings.json"
441
+
442
+ if not settings_path.exists():
443
+ results.append(
444
+ (
445
+ FAIL,
446
+ ".claude/settings.json exists",
447
+ "Missing — run punt init",
448
+ )
449
+ )
450
+ return results
451
+
452
+ results.append((PASS, ".claude/settings.json exists", ""))
453
+
454
+ try:
455
+ data = json.loads(settings_path.read_text(encoding="utf-8"))
456
+ except (json.JSONDecodeError, OSError):
457
+ results.append((FAIL, "settings.json is valid JSON", "Parse error"))
458
+ return results
459
+
460
+ if not isinstance(data, dict):
461
+ results.append((FAIL, "settings.json is valid JSON", "Expected object"))
462
+ return results
463
+
464
+ typed_data = cast("dict[str, object]", data)
465
+
466
+ perms_raw = typed_data.get("permissions")
467
+ if not isinstance(perms_raw, dict):
468
+ results.append((FAIL, "Standard permissions present", "No permissions key"))
469
+ return results
470
+
471
+ perms = cast("dict[str, object]", perms_raw)
472
+ allow_raw = perms.get("allow")
473
+ if not isinstance(allow_raw, list):
474
+ results.append((FAIL, "Standard permissions present", "Missing allow array"))
475
+ return results
476
+
477
+ allow_strs = [str(x) for x in cast("list[object]", allow_raw)]
478
+
479
+ standard = build_standard_permissions(info)
480
+ missing = [p for p in standard if p not in allow_strs]
481
+
482
+ if missing:
483
+ results.append(
484
+ (
485
+ FAIL,
486
+ "Standard permissions present",
487
+ f"Missing {len(missing)}: {', '.join(missing[:5])}"
488
+ + ("..." if len(missing) > 5 else ""),
489
+ )
490
+ )
491
+ else:
492
+ results.append(
493
+ (PASS, "Standard permissions present", f"{len(standard)} standard entries")
494
+ )
495
+
496
+ return results
497
+
498
+
499
+ def _check_plugin_dev_isolation(
500
+ info: ProjectInfo,
501
+ ) -> list[tuple[str, str, str]]:
502
+ """Check plugin repos follow the dev/prod namespace isolation standard.
503
+
504
+ Checks:
505
+ 1. plugin.json name ends in -dev (working tree is the dev namespace)
506
+ 2. Every prod command has a -dev variant
507
+ 3. Release/restore scripts exist
508
+ """
509
+ if not info.is_plugin:
510
+ return []
511
+
512
+ results: list[tuple[str, str, str]] = []
513
+
514
+ # Check plugin name has -dev suffix
515
+ plugin_name = _read_plugin_name(info)
516
+ if plugin_name is not None:
517
+ if plugin_name.endswith("-dev"):
518
+ results.append((PASS, "Plugin name has -dev suffix", plugin_name))
519
+ else:
520
+ results.append(
521
+ (
522
+ FAIL,
523
+ "Plugin name has -dev suffix",
524
+ f"'{plugin_name}' — should be '{plugin_name}-dev'",
525
+ )
526
+ )
527
+
528
+ # Check release/restore scripts exist
529
+ release_script = info.root / "scripts" / "release-plugin.sh"
530
+ restore_script = info.root / "scripts" / "restore-dev-plugin.sh"
531
+ if release_script.exists() and restore_script.exists():
532
+ results.append((PASS, "Release/restore scripts exist", ""))
533
+ else:
534
+ missing_scripts: list[str] = []
535
+ if not release_script.exists():
536
+ missing_scripts.append("scripts/release-plugin.sh")
537
+ if not restore_script.exists():
538
+ missing_scripts.append("scripts/restore-dev-plugin.sh")
539
+ detail = f"Missing: {', '.join(missing_scripts)}"
540
+ results.append((FAIL, "Release/restore scripts exist", detail))
541
+
542
+ # Check -dev command variants
543
+ commands_dir = info.root / "commands"
544
+ if commands_dir.is_dir():
545
+ prod_commands = sorted(
546
+ f.stem for f in commands_dir.glob("*.md") if not f.stem.endswith("-dev")
547
+ )
548
+ dev_commands = sorted(
549
+ f.stem.removesuffix("-dev") for f in commands_dir.glob("*-dev.md")
550
+ )
551
+
552
+ if prod_commands:
553
+ missing = [c for c in prod_commands if c not in dev_commands]
554
+ if missing:
555
+ results.append(
556
+ (
557
+ FAIL,
558
+ "Dev command variants exist",
559
+ f"Missing: {', '.join(f'{c}-dev.md' for c in missing)}",
560
+ )
561
+ )
562
+ else:
563
+ results.append(
564
+ (
565
+ PASS,
566
+ "Dev command variants exist",
567
+ f"{len(dev_commands)} dev commands",
568
+ )
569
+ )
570
+
571
+ return results
572
+
573
+
574
+ def _read_plugin_name(info: ProjectInfo) -> str | None:
575
+ """Read plugin name from plugin.json."""
576
+ for pj_path in (
577
+ info.root / ".claude-plugin" / "plugin.json",
578
+ info.root / "plugin.json",
579
+ ):
580
+ if pj_path.exists():
581
+ try:
582
+ data = json.loads(pj_path.read_text(encoding="utf-8"))
583
+ name = data.get("name")
584
+ if isinstance(name, str):
585
+ return name
586
+ except (json.JSONDecodeError, OSError):
587
+ pass
588
+ return None
589
+
590
+
591
+ def _find_package_dir(info: ProjectInfo) -> Path | None:
592
+ """Find the Python package directory (src layout)."""
593
+ src_dir = info.root / "src"
594
+ if not src_dir.is_dir():
595
+ return None
596
+
597
+ # Look for directories with __init__.py under src/
598
+ for child in sorted(src_dir.iterdir()):
599
+ if child.is_dir() and (child / "__init__.py").exists():
600
+ return child
601
+
602
+ return None
603
+
604
+
605
+ def _get_project_name(info: ProjectInfo) -> str:
606
+ """Extract human-readable project name from metadata."""
607
+ if info.pyproject is not None:
608
+ project_raw = info.pyproject.get("project")
609
+ if isinstance(project_raw, dict):
610
+ project = cast("dict[str, object]", project_raw)
611
+ name = project.get("name")
612
+ if isinstance(name, str):
613
+ return name
614
+
615
+ return info.root.name
616
+
617
+
618
+ def _relpath(path: Path, root: Path) -> str:
619
+ """Return path relative to root as a string."""
620
+ try:
621
+ return str(path.relative_to(root))
622
+ except ValueError:
623
+ return str(path)
624
+
625
+
626
+ def _get_github_repo(root: Path) -> str | None:
627
+ """Extract GitHub owner/repo from git remote."""
628
+ try:
629
+ result = subprocess.run(
630
+ ["git", "remote", "get-url", "origin"],
631
+ cwd=str(root),
632
+ capture_output=True,
633
+ text=True,
634
+ timeout=5,
635
+ )
636
+ if result.returncode != 0:
637
+ return None
638
+
639
+ url = result.stdout.strip()
640
+ # Handle SSH format: git@github.com:owner/repo.git
641
+ if url.startswith("git@github.com:"):
642
+ repo = url.removeprefix("git@github.com:").removesuffix(".git")
643
+ return repo
644
+ # Handle HTTPS format: https://github.com/owner/repo.git
645
+ if "github.com/" in url:
646
+ parts = url.split("github.com/")[1].removesuffix(".git")
647
+ return parts
648
+ return None
649
+ except (subprocess.TimeoutExpired, OSError):
650
+ return None