spawnio 1.0.5__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 (56) hide show
  1. spawn/__init__.py +6 -0
  2. spawn/cli/__init__.py +0 -0
  3. spawn/cli/app.py +218 -0
  4. spawn/cli/noninteractive.py +204 -0
  5. spawn/cli/prompts.py +267 -0
  6. spawn/core/__init__.py +0 -0
  7. spawn/core/exceptions.py +7 -0
  8. spawn/core/models.py +21 -0
  9. spawn/core/registry.py +152 -0
  10. spawn/generators/__init__.py +0 -0
  11. spawn/generators/custom_structure.py +508 -0
  12. spawn/generators/project_generator.py +115 -0
  13. spawn/github/__init__.py +0 -0
  14. spawn/github/exceptions.py +5 -0
  15. spawn/github/publisher.py +69 -0
  16. spawn/github/validators.py +23 -0
  17. spawn/templates/__init__.py +0 -0
  18. spawn/templates/agent/__init__.py +182 -0
  19. spawn/templates/agent/content.py +374 -0
  20. spawn/templates/automation/__init__.py +100 -0
  21. spawn/templates/automation/content.py +181 -0
  22. spawn/templates/backend_api/__init__.py +243 -0
  23. spawn/templates/backend_api/content.py +509 -0
  24. spawn/templates/base.py +58 -0
  25. spawn/templates/chatbot/__init__.py +243 -0
  26. spawn/templates/chatbot/content.py +668 -0
  27. spawn/templates/cli_application/__init__.py +211 -0
  28. spawn/templates/cli_application/content.py +512 -0
  29. spawn/templates/data_project/__init__.py +125 -0
  30. spawn/templates/data_project/analysis/__init__.py +74 -0
  31. spawn/templates/data_project/analysis/content.py +190 -0
  32. spawn/templates/data_project/dashboard/__init__.py +61 -0
  33. spawn/templates/data_project/dashboard/content.py +135 -0
  34. spawn/templates/data_project/etl/__init__.py +66 -0
  35. spawn/templates/data_project/etl/content.py +239 -0
  36. spawn/templates/data_project/ml/__init__.py +62 -0
  37. spawn/templates/data_project/ml/content.py +239 -0
  38. spawn/templates/mcp_server/__init__.py +83 -0
  39. spawn/templates/mcp_server/content.py +161 -0
  40. spawn/templates/rag/__init__.py +124 -0
  41. spawn/templates/rag/content.py +463 -0
  42. spawn/templates/shared_content.py +89 -0
  43. spawn/utils/__init__.py +0 -0
  44. spawn/utils/banner.py +41 -0
  45. spawn/utils/console.py +3 -0
  46. spawn/utils/doctor.py +594 -0
  47. spawn/utils/git.py +132 -0
  48. spawn/utils/success.py +47 -0
  49. spawn/utils/theme.py +25 -0
  50. spawn/utils/uv.py +61 -0
  51. spawn/utils/validators.py +15 -0
  52. spawnio-1.0.5.dist-info/METADATA +798 -0
  53. spawnio-1.0.5.dist-info/RECORD +56 -0
  54. spawnio-1.0.5.dist-info/WHEEL +4 -0
  55. spawnio-1.0.5.dist-info/entry_points.txt +2 -0
  56. spawnio-1.0.5.dist-info/licenses/LICENSE +21 -0
spawn/utils/doctor.py ADDED
@@ -0,0 +1,594 @@
1
+ """Project health checker for Spawn CLI.
2
+
3
+ This module provides comprehensive project health analysis,
4
+ checking for best practices and common project standards.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Callable, List
10
+
11
+ from rich.panel import Panel
12
+ from rich.text import Text
13
+
14
+ from spawn.utils.console import console
15
+ from spawn.utils.theme import BORDER_COLOR
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Tier sets for recommendations
19
+ # ---------------------------------------------------------------------------
20
+
21
+ _CRITICAL: set[str] = {"Git Repository", "Tests"}
22
+
23
+ _RECOMMENDED: set[str] = {
24
+ "README.md",
25
+ "AGENTS.md",
26
+ ".gitignore",
27
+ "LICENSE",
28
+ "Ruff",
29
+ "Pytest",
30
+ "GitHub Actions",
31
+ "pyproject.toml",
32
+ }
33
+
34
+ _OPTIONAL: set[str] = {
35
+ "Dockerfile",
36
+ ".env.example",
37
+ "CHANGELOG.md",
38
+ "Type Checking",
39
+ "Pre-commit",
40
+ }
41
+
42
+ # Complete-sentence recommendation text keyed by check name
43
+ _REC_TEXT: dict[str, str] = {
44
+ "Git Repository": "Initialize a git repository with 'git init'.",
45
+ "Tests": "Create a tests/ directory and add test files.",
46
+ "README.md": "Add a README.md file to document your project.",
47
+ "AGENTS.md": "Add an AGENTS.md file so coding agents understand this project's conventions.",
48
+ ".gitignore": "Add a .gitignore file to exclude generated files from version control.",
49
+ "LICENSE": "Add a LICENSE file to specify usage terms for your project.",
50
+ "Ruff": "Configure Ruff in pyproject.toml for automated code quality checks.",
51
+ "Pytest": "Configure Pytest in pyproject.toml or create pytest.ini.",
52
+ "GitHub Actions": "Set up GitHub Actions in .github/workflows/ for automated CI/CD.",
53
+ "pyproject.toml": "Add a pyproject.toml to centralise project configuration.",
54
+ "Dockerfile": "Add a Dockerfile for reproducible, containerised deployment.",
55
+ ".env.example": "Create a .env.example file to document required environment variables.",
56
+ "CHANGELOG.md": "Add a CHANGELOG.md to document project history and releases.",
57
+ "Type Checking": "Configure a type checker (mypy or pyright) to catch type errors early.",
58
+ "Pre-commit": "Add a .pre-commit-config.yaml to enforce checks before every commit.",
59
+ }
60
+
61
+ # Estimated effort per check name
62
+ _EFFORT: dict[str, str] = {
63
+ "Git Repository": "30 seconds",
64
+ "README.md": "2 minutes",
65
+ "AGENTS.md": "1 minute",
66
+ "LICENSE": "1 minute",
67
+ "CHANGELOG.md": "2 minutes",
68
+ ".gitignore": "1 minute",
69
+ "pyproject.toml": "2 minutes",
70
+ ".env.example": "2 minutes",
71
+ "Tests": "10 minutes",
72
+ "Pytest": "5 minutes",
73
+ "Ruff": "5 minutes",
74
+ "Type Checking": "5 minutes",
75
+ "Pre-commit": "5 minutes",
76
+ "GitHub Actions": "10 minutes",
77
+ "Dockerfile": "15 minutes",
78
+ }
79
+
80
+
81
+ @dataclass
82
+ class HealthCheck:
83
+ """Represents a single health check result."""
84
+
85
+ name: str
86
+ category: str
87
+ passed: bool
88
+ message: str
89
+ weight: int = 10
90
+
91
+
92
+ class ProjectHealthChecker:
93
+ """Analyzes project health and best-practices compliance."""
94
+
95
+ def __init__(self, project_path: Path | None = None):
96
+ self.project_path = project_path or Path.cwd()
97
+
98
+ # ------------------------------------------------------------------
99
+ # Documentation Checks
100
+ # ------------------------------------------------------------------
101
+
102
+ def check_readme(self) -> HealthCheck:
103
+ p = self.project_path / "README.md"
104
+ ok = p.exists() and p.is_file()
105
+ return HealthCheck(
106
+ name="README.md",
107
+ category="Documentation",
108
+ passed=ok,
109
+ message="Documentation file present" if ok else "Missing README.md",
110
+ weight=10,
111
+ )
112
+
113
+ def check_agents_md(self) -> HealthCheck:
114
+ p = self.project_path / "AGENTS.md"
115
+ ok = p.exists() and p.is_file()
116
+ return HealthCheck(
117
+ name="AGENTS.md",
118
+ category="Documentation",
119
+ passed=ok,
120
+ message="Agent context file present" if ok else "Missing AGENTS.md",
121
+ weight=5,
122
+ )
123
+
124
+ def check_license(self) -> HealthCheck:
125
+ p = self.project_path / "LICENSE"
126
+ ok = p.exists() and p.is_file()
127
+ return HealthCheck(
128
+ name="LICENSE",
129
+ category="Documentation",
130
+ passed=ok,
131
+ message="License file present" if ok else "Missing LICENSE file",
132
+ weight=5,
133
+ )
134
+
135
+ def check_changelog(self) -> HealthCheck:
136
+ p = self.project_path / "CHANGELOG.md"
137
+ ok = p.exists() and p.is_file()
138
+ return HealthCheck(
139
+ name="CHANGELOG.md",
140
+ category="Documentation",
141
+ passed=ok,
142
+ message="Changelog present" if ok else "Missing CHANGELOG.md",
143
+ weight=5,
144
+ )
145
+
146
+ # ------------------------------------------------------------------
147
+ # Version Control Checks
148
+ # ------------------------------------------------------------------
149
+
150
+ def check_git_repository(self) -> HealthCheck:
151
+ p = self.project_path / ".git"
152
+ ok = p.exists() and p.is_dir()
153
+ return HealthCheck(
154
+ name="Git Repository",
155
+ category="Version Control",
156
+ passed=ok,
157
+ message="Git initialized" if ok else "Not a git repository",
158
+ weight=15,
159
+ )
160
+
161
+ def check_gitignore(self) -> HealthCheck:
162
+ p = self.project_path / ".gitignore"
163
+ ok = p.exists() and p.is_file()
164
+ return HealthCheck(
165
+ name=".gitignore",
166
+ category="Version Control",
167
+ passed=ok,
168
+ message="Git ignore configured" if ok else "Missing .gitignore",
169
+ weight=10,
170
+ )
171
+
172
+ # ------------------------------------------------------------------
173
+ # Configuration Checks
174
+ # ------------------------------------------------------------------
175
+
176
+ def check_env_example(self) -> HealthCheck:
177
+ p = self.project_path / ".env.example"
178
+ ok = p.exists() and p.is_file()
179
+ return HealthCheck(
180
+ name=".env.example",
181
+ category="Configuration",
182
+ passed=ok,
183
+ message="Environment template present" if ok else "Missing .env.example",
184
+ weight=5,
185
+ )
186
+
187
+ def check_pyproject(self) -> HealthCheck:
188
+ p = self.project_path / "pyproject.toml"
189
+ ok = p.exists() and p.is_file()
190
+ return HealthCheck(
191
+ name="pyproject.toml",
192
+ category="Configuration",
193
+ passed=ok,
194
+ message="Project configuration present" if ok else "Missing pyproject.toml",
195
+ weight=10,
196
+ )
197
+
198
+ # ------------------------------------------------------------------
199
+ # Testing Checks
200
+ # ------------------------------------------------------------------
201
+
202
+ def check_tests_directory(self) -> HealthCheck:
203
+ p = self.project_path / "tests"
204
+ ok = p.exists() and p.is_dir()
205
+ return HealthCheck(
206
+ name="Tests",
207
+ category="Testing",
208
+ passed=ok,
209
+ message="Test directory configured" if ok else "Missing tests directory",
210
+ weight=15,
211
+ )
212
+
213
+ def check_pytest_configured(self) -> HealthCheck:
214
+ pyproject = self.project_path / "pyproject.toml"
215
+ pytest_ini = self.project_path / "pytest.ini"
216
+ setup_cfg = self.project_path / "setup.cfg"
217
+
218
+ ok = False
219
+ location = None
220
+
221
+ if pytest_ini.exists():
222
+ ok, location = True, "pytest.ini"
223
+ elif setup_cfg.exists():
224
+ try:
225
+ content = setup_cfg.read_text(encoding="utf-8")
226
+ if "[pytest]" in content or "[tool:pytest]" in content:
227
+ ok, location = True, "setup.cfg"
228
+ except (OSError, ValueError):
229
+ pass
230
+
231
+ if not ok and pyproject.exists():
232
+ try:
233
+ content = pyproject.read_text(encoding="utf-8")
234
+ if "[tool.pytest" in content:
235
+ ok, location = True, "pyproject.toml [tool.pytest]"
236
+ elif "pytest>=" in content or '"pytest"' in content:
237
+ ok, location = True, "pyproject.toml (dependency)"
238
+ except (OSError, ValueError):
239
+ pass
240
+
241
+ msg = f"Pytest configured in {location}" if ok else "Pytest not configured"
242
+ return HealthCheck(name="Pytest", category="Testing", passed=ok, message=msg, weight=10)
243
+
244
+ # ------------------------------------------------------------------
245
+ # Automation Checks
246
+ # ------------------------------------------------------------------
247
+
248
+ def check_dockerfile(self) -> HealthCheck:
249
+ p = self.project_path / "Dockerfile"
250
+ ok = p.exists() and p.is_file()
251
+ return HealthCheck(
252
+ name="Dockerfile",
253
+ category="Automation",
254
+ passed=ok,
255
+ message="Docker configuration present" if ok else "Missing Dockerfile",
256
+ weight=10,
257
+ )
258
+
259
+ def check_github_actions(self) -> HealthCheck:
260
+ workflows = self.project_path / ".github" / "workflows"
261
+ count = 0
262
+ if workflows.exists() and workflows.is_dir():
263
+ count = len(list(workflows.glob("*.yml")) + list(workflows.glob("*.yaml")))
264
+
265
+ ok = count > 0
266
+ msg = (
267
+ f"GitHub Actions configured ({count} workflow{'s' if count != 1 else ''})"
268
+ if ok
269
+ else "GitHub Actions not configured"
270
+ )
271
+ return HealthCheck(
272
+ name="GitHub Actions",
273
+ category="Automation",
274
+ passed=ok,
275
+ message=msg,
276
+ weight=10,
277
+ )
278
+
279
+ # ------------------------------------------------------------------
280
+ # Code Quality Checks
281
+ # ------------------------------------------------------------------
282
+
283
+ def check_ruff_configured(self) -> HealthCheck:
284
+ pyproject = self.project_path / "pyproject.toml"
285
+ ruff_toml = self.project_path / "ruff.toml"
286
+ ruff_toml_alt = self.project_path / ".ruff.toml"
287
+
288
+ ok = False
289
+ location = None
290
+
291
+ if ruff_toml.exists():
292
+ ok, location = True, "ruff.toml"
293
+ elif ruff_toml_alt.exists():
294
+ ok, location = True, ".ruff.toml"
295
+ elif pyproject.exists():
296
+ try:
297
+ content = pyproject.read_text(encoding="utf-8")
298
+ if "[tool.ruff]" in content:
299
+ ok, location = True, "pyproject.toml [tool.ruff]"
300
+ elif "ruff>=" in content or '"ruff"' in content:
301
+ ok, location = True, "pyproject.toml (dependency)"
302
+ except (OSError, ValueError):
303
+ pass
304
+
305
+ msg = f"Ruff configured in {location}" if ok else "Ruff not configured"
306
+ return HealthCheck(
307
+ name="Ruff", category="Code Quality", passed=ok, message=msg, weight=10
308
+ )
309
+
310
+ def check_type_checker_configured(self) -> HealthCheck:
311
+ """Check for mypy or pyright configuration."""
312
+ pyproject = self.project_path / "pyproject.toml"
313
+ mypy_ini = self.project_path / "mypy.ini"
314
+ pyright_cfg = self.project_path / "pyrightconfig.json"
315
+
316
+ ok = False
317
+ location = None
318
+
319
+ if mypy_ini.exists():
320
+ ok, location = True, "mypy.ini"
321
+ elif pyright_cfg.exists():
322
+ ok, location = True, "pyrightconfig.json"
323
+ elif pyproject.exists():
324
+ try:
325
+ content = pyproject.read_text(encoding="utf-8")
326
+ if "[tool.mypy]" in content:
327
+ ok, location = True, "pyproject.toml [tool.mypy]"
328
+ elif "[tool.pyright]" in content:
329
+ ok, location = True, "pyproject.toml [tool.pyright]"
330
+ except (OSError, ValueError):
331
+ pass
332
+
333
+ msg = f"Type checking configured ({location})" if ok else "Type checking not configured"
334
+ return HealthCheck(
335
+ name="Type Checking",
336
+ category="Code Quality",
337
+ passed=ok,
338
+ message=msg,
339
+ weight=10,
340
+ )
341
+
342
+ def check_precommit_configured(self) -> HealthCheck:
343
+ """Check for .pre-commit-config.yaml."""
344
+ p = self.project_path / ".pre-commit-config.yaml"
345
+ ok = p.exists() and p.is_file()
346
+ return HealthCheck(
347
+ name="Pre-commit",
348
+ category="Code Quality",
349
+ passed=ok,
350
+ message="Pre-commit configured" if ok else "Pre-commit not configured",
351
+ weight=5,
352
+ )
353
+
354
+ # ------------------------------------------------------------------
355
+ # Aggregation
356
+ # ------------------------------------------------------------------
357
+
358
+ def get_all_checks(self) -> List[Callable[[], HealthCheck]]:
359
+ return [
360
+ self.check_readme,
361
+ self.check_agents_md,
362
+ self.check_license,
363
+ self.check_changelog,
364
+ self.check_git_repository,
365
+ self.check_gitignore,
366
+ self.check_env_example,
367
+ self.check_pyproject,
368
+ self.check_tests_directory,
369
+ self.check_pytest_configured,
370
+ self.check_dockerfile,
371
+ self.check_github_actions,
372
+ self.check_ruff_configured,
373
+ self.check_type_checker_configured,
374
+ self.check_precommit_configured,
375
+ ]
376
+
377
+ def run_all_checks(self) -> List[HealthCheck]:
378
+ return [check() for check in self.get_all_checks()]
379
+
380
+ def calculate_score(self, checks: List[HealthCheck]) -> tuple[int, int]:
381
+ if not checks:
382
+ return 0, 0
383
+ total = sum(c.weight for c in checks)
384
+ earned = sum(c.weight for c in checks if c.passed)
385
+ return earned, total
386
+
387
+ def calculate_category_scores(
388
+ self, checks: List[HealthCheck]
389
+ ) -> dict[str, tuple[int, int]]:
390
+ """Return {category: (earned_weight, total_weight)} per category."""
391
+ cats = self.group_checks_by_category(checks)
392
+ return {
393
+ cat: (
394
+ sum(c.weight for c in cs if c.passed),
395
+ sum(c.weight for c in cs),
396
+ )
397
+ for cat, cs in cats.items()
398
+ }
399
+
400
+ def group_checks_by_category(
401
+ self, checks: List[HealthCheck]
402
+ ) -> dict[str, List[HealthCheck]]:
403
+ result: dict[str, List[HealthCheck]] = {}
404
+ for check in checks:
405
+ result.setdefault(check.category, []).append(check)
406
+ return result
407
+
408
+ def get_health_rating(self, score_percent: int) -> tuple[str, str, str]:
409
+ """Return (emoji, label, description) for a 0-100 score."""
410
+ if score_percent >= 90:
411
+ return ("🟢", "Excellent", "Project follows most recommended practices.")
412
+ if score_percent >= 70:
413
+ return ("🟡", "Good", "Some improvements recommended.")
414
+ if score_percent >= 50:
415
+ return ("🟠", "Fair", "Several recommended practices are missing.")
416
+ return ("🔴", "Needs Attention", "Core project setup is incomplete.")
417
+
418
+ def get_next_best_step(
419
+ self, checks: List[HealthCheck]
420
+ ) -> tuple[str, str] | None:
421
+ """Return (recommendation, effort) for the single highest-priority failed check."""
422
+ failed = [c for c in checks if not c.passed]
423
+ if not failed:
424
+ return None
425
+
426
+ def _tier_key(c: HealthCheck) -> tuple[int, int]:
427
+ if c.name in _CRITICAL:
428
+ return (0, -c.weight)
429
+ if c.name in _RECOMMENDED:
430
+ return (1, -c.weight)
431
+ return (2, -c.weight)
432
+
433
+ top = min(failed, key=_tier_key)
434
+ rec = _REC_TEXT.get(top.name, f"Address: {top.name}.")
435
+ effort = _EFFORT.get(top.name, "a few minutes")
436
+ return (rec, effort)
437
+
438
+ def generate_recommendations(
439
+ self, checks: List[HealthCheck]
440
+ ) -> List[str]:
441
+ """Return tiered recommendations for all failed checks.
442
+
443
+ Returns a flat list ordered Critical → Recommended → Optional,
444
+ with each item sorted by weight (descending) within its tier.
445
+ Only failed checks are included.
446
+ """
447
+ failed = [c for c in checks if not c.passed]
448
+
449
+ def _sort_key(c: HealthCheck) -> tuple[int, int]:
450
+ if c.name in _CRITICAL:
451
+ tier = 0
452
+ elif c.name in _RECOMMENDED:
453
+ tier = 1
454
+ else:
455
+ tier = 2
456
+ return (tier, -c.weight)
457
+
458
+ return [
459
+ _REC_TEXT.get(c.name, f"Address: {c.name}.")
460
+ for c in sorted(failed, key=_sort_key)
461
+ ]
462
+
463
+ def _tiered_recs(
464
+ self, checks: List[HealthCheck]
465
+ ) -> tuple[List[str], List[str], List[str]]:
466
+ """Return (critical_recs, recommended_recs, optional_recs) — failed checks only."""
467
+ failed = [c for c in checks if not c.passed]
468
+ critical, recommended, optional = [], [], []
469
+ for c in sorted(failed, key=lambda x: -x.weight):
470
+ text = _REC_TEXT.get(c.name, f"Address: {c.name}.")
471
+ if c.name in _CRITICAL:
472
+ critical.append(text)
473
+ elif c.name in _RECOMMENDED:
474
+ recommended.append(text)
475
+ else:
476
+ optional.append(text)
477
+ return critical, recommended, optional
478
+
479
+ def format_report(self, checks: List[HealthCheck]) -> None:
480
+ score, max_score = self.calculate_score(checks)
481
+ score_percent = int((score / max_score * 100) if max_score > 0 else 0)
482
+ emoji, label, description = self.get_health_rating(score_percent)
483
+ categories = self.group_checks_by_category(checks)
484
+ category_scores = self.calculate_category_scores(checks)
485
+
486
+ content = Text()
487
+
488
+ # Health rating
489
+ content.append(f"{emoji} {label}\n", style="bold")
490
+ content.append(f"{description}\n", style="dim")
491
+ content.append("\n")
492
+
493
+ # Score
494
+ if score_percent >= 80:
495
+ score_color = "green"
496
+ elif score_percent >= 50:
497
+ score_color = "yellow"
498
+ else:
499
+ score_color = "red"
500
+ content.append("Project Score: ", style="bold")
501
+ content.append(f"{score_percent}% ({score}/{max_score})\n", style=f"bold {score_color}")
502
+
503
+ category_order = [
504
+ "Documentation",
505
+ "Version Control",
506
+ "Configuration",
507
+ "Testing",
508
+ "Automation",
509
+ "Code Quality",
510
+ ]
511
+
512
+ for category in category_order:
513
+ if category not in categories:
514
+ continue
515
+ cat_earned, cat_total = category_scores.get(category, (0, 0))
516
+ cat_pct = int((cat_earned / cat_total * 100) if cat_total else 0)
517
+ content.append(f"\n{category} — {cat_pct}%\n", style="bold cyan")
518
+
519
+ for check in categories[category]:
520
+ if check.passed:
521
+ status, style = "✓", "green"
522
+ else:
523
+ status, style = "⚠", "yellow"
524
+ content.append(f" {status} ", style=style)
525
+ content.append(check.name, style=f"bold {style}")
526
+ content.append(f" — {check.message}\n", style=style)
527
+
528
+ console.print()
529
+ console.print(
530
+ Panel(
531
+ content,
532
+ title="[bold cyan]🏥 Project Health Report[/bold cyan]",
533
+ border_style=BORDER_COLOR,
534
+ padding=(1, 2),
535
+ )
536
+ )
537
+
538
+ # Tiered recommendations panel
539
+ critical, recommended, optional = self._tiered_recs(checks)
540
+ if critical or recommended or optional:
541
+ rec_content = Text()
542
+ if critical:
543
+ rec_content.append("Critical\n", style="bold red")
544
+ for r in critical:
545
+ rec_content.append(f" • {r}\n", style="red")
546
+ if recommended:
547
+ if critical:
548
+ rec_content.append("\n")
549
+ rec_content.append("Recommended\n", style="bold yellow")
550
+ for r in recommended:
551
+ rec_content.append(f" • {r}\n", style="yellow")
552
+ if optional:
553
+ if critical or recommended:
554
+ rec_content.append("\n")
555
+ rec_content.append("Optional\n", style="bold dim")
556
+ for r in optional:
557
+ rec_content.append(f" • {r}\n", style="dim")
558
+
559
+ console.print()
560
+ console.print(
561
+ Panel(
562
+ rec_content,
563
+ title="[bold yellow]💡 Recommendations[/bold yellow]",
564
+ border_style="yellow",
565
+ padding=(1, 2),
566
+ )
567
+ )
568
+
569
+ # Next Best Step panel
570
+ nbs = self.get_next_best_step(checks)
571
+ if nbs:
572
+ rec_text, effort = nbs
573
+ nbs_content = Text()
574
+ nbs_content.append(f"{rec_text}\n\n", style="bold")
575
+ nbs_content.append("Estimated effort: ", style="dim")
576
+ nbs_content.append(effort, style="bold dim")
577
+ console.print()
578
+ console.print(
579
+ Panel(
580
+ nbs_content,
581
+ title="[bold green]🎯 Next Best Step[/bold green]",
582
+ border_style="green",
583
+ padding=(1, 2),
584
+ )
585
+ )
586
+
587
+ console.print()
588
+
589
+
590
+ def run_health_check(project_path: Path | None = None) -> None:
591
+ """CLI entry point for `spawn doctor`."""
592
+ checker = ProjectHealthChecker(project_path)
593
+ checks = checker.run_all_checks()
594
+ checker.format_report(checks)