mirror of
https://github.com/github/spec-kit.git
synced 2026-03-16 18:33:07 +00:00
Add specify doctor command for project health diagnostics
This commit is contained in:
@@ -1695,6 +1695,309 @@ def check():
|
||||
if not any(agent_results.values()):
|
||||
console.print("[dim]Tip: Install an AI assistant for the best experience[/dim]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def doctor():
|
||||
"""Diagnose a Specify project and report health issues."""
|
||||
show_banner()
|
||||
console.print("[bold]Running project diagnostics...[/bold]\n")
|
||||
|
||||
project_root = Path.cwd()
|
||||
issues = [] # (severity, message) tuples: "error", "warning", "info"
|
||||
|
||||
# ── 1. Project structure ──────────────────────────────────────────
|
||||
tracker = StepTracker("Project Structure")
|
||||
|
||||
specify_dir = project_root / ".specify"
|
||||
tracker.add("specify_dir", ".specify/ directory")
|
||||
if specify_dir.is_dir():
|
||||
tracker.complete("specify_dir", "found")
|
||||
else:
|
||||
tracker.error("specify_dir", "missing")
|
||||
issues.append(("error", "No .specify/ directory — run 'specify init --here' to initialize"))
|
||||
|
||||
specs_dir = project_root / "specs"
|
||||
tracker.add("specs_dir", "specs/ directory")
|
||||
if specs_dir.is_dir():
|
||||
tracker.complete("specs_dir", "found")
|
||||
else:
|
||||
tracker.skip("specs_dir", "not created yet")
|
||||
issues.append(("info", "No specs/ directory — created when you run /speckit.specify"))
|
||||
|
||||
scripts_dir = project_root / "scripts"
|
||||
tracker.add("scripts_dir", "scripts/ directory")
|
||||
if scripts_dir.is_dir():
|
||||
tracker.complete("scripts_dir", "found")
|
||||
else:
|
||||
tracker.error("scripts_dir", "missing")
|
||||
issues.append(("error", "No scripts/ directory — project may not be initialized"))
|
||||
|
||||
templates_dir = project_root / "templates"
|
||||
tracker.add("templates_dir", "templates/ directory")
|
||||
if templates_dir.is_dir():
|
||||
tracker.complete("templates_dir", "found")
|
||||
else:
|
||||
tracker.error("templates_dir", "missing")
|
||||
issues.append(("error", "No templates/ directory — project may not be initialized"))
|
||||
|
||||
memory_dir = project_root / "memory"
|
||||
tracker.add("memory_dir", "memory/ directory")
|
||||
if memory_dir.is_dir():
|
||||
tracker.complete("memory_dir", "found")
|
||||
constitution = memory_dir / "constitution.md"
|
||||
tracker.add("constitution", "memory/constitution.md")
|
||||
if constitution.is_file():
|
||||
tracker.complete("constitution", "found")
|
||||
else:
|
||||
tracker.error("constitution", "missing")
|
||||
issues.append(("warning", "No constitution.md in memory/ — project governance rules are missing"))
|
||||
else:
|
||||
tracker.error("memory_dir", "missing")
|
||||
issues.append(("error", "No memory/ directory — project may not be initialized"))
|
||||
|
||||
console.print(tracker.render())
|
||||
console.print()
|
||||
|
||||
# ── 2. AI agent detection ─────────────────────────────────────────
|
||||
agent_tracker = StepTracker("AI Agent Configuration")
|
||||
detected_agents = []
|
||||
|
||||
for agent_key, agent_config in AGENT_CONFIG.items():
|
||||
if agent_key == "generic":
|
||||
continue
|
||||
agent_folder = agent_config["folder"]
|
||||
if agent_folder and (project_root / agent_folder).is_dir():
|
||||
detected_agents.append(agent_key)
|
||||
agent_tracker.add(agent_key, agent_config["name"])
|
||||
commands_dir = project_root / agent_folder / agent_config["commands_subdir"]
|
||||
if commands_dir.is_dir() and any(commands_dir.iterdir()):
|
||||
agent_tracker.complete(agent_key, f"commands in {agent_folder}{agent_config['commands_subdir']}/")
|
||||
else:
|
||||
agent_tracker.error(agent_key, f"folder exists but no commands in {agent_config['commands_subdir']}/")
|
||||
issues.append(("warning", f"Agent '{agent_config['name']}' folder exists but commands directory is empty"))
|
||||
|
||||
if not detected_agents:
|
||||
agent_tracker.add("none", "No AI agent configured")
|
||||
agent_tracker.skip("none", "run 'specify init --here --ai <agent>' to set up")
|
||||
issues.append(("info", "No AI agent folder detected — this is fine if you use IDE-based agents"))
|
||||
|
||||
console.print(agent_tracker.render())
|
||||
console.print()
|
||||
|
||||
# ── 3. Feature specs ──────────────────────────────────────────────
|
||||
feature_tracker = StepTracker("Feature Specifications")
|
||||
|
||||
if specs_dir.is_dir():
|
||||
feature_dirs = sorted(
|
||||
[d for d in specs_dir.iterdir() if d.is_dir()],
|
||||
key=lambda d: d.name,
|
||||
)
|
||||
if not feature_dirs:
|
||||
feature_tracker.add("empty", "No feature directories")
|
||||
feature_tracker.skip("empty", "run /speckit.specify to create one")
|
||||
else:
|
||||
for fdir in feature_dirs:
|
||||
key = fdir.name
|
||||
feature_tracker.add(key, key)
|
||||
|
||||
spec_file = fdir / "spec.md"
|
||||
plan_file = fdir / "plan.md"
|
||||
tasks_file = fdir / "tasks.md"
|
||||
|
||||
artifacts = []
|
||||
missing = []
|
||||
for name, path in [("spec", spec_file), ("plan", plan_file), ("tasks", tasks_file)]:
|
||||
if path.is_file():
|
||||
artifacts.append(name)
|
||||
else:
|
||||
missing.append(name)
|
||||
|
||||
if missing:
|
||||
detail = f"{', '.join(artifacts)} present; missing {', '.join(missing)}"
|
||||
if "spec" in missing:
|
||||
feature_tracker.error(key, detail)
|
||||
issues.append(("error", f"Feature '{key}' is missing spec.md"))
|
||||
else:
|
||||
feature_tracker.complete(key, detail)
|
||||
for m in missing:
|
||||
issues.append(("info", f"Feature '{key}' has no {m}.md — run /speckit.{m} to generate"))
|
||||
else:
|
||||
feature_tracker.complete(key, "spec, plan, tasks all present")
|
||||
else:
|
||||
feature_tracker.add("none", "No specs/ directory")
|
||||
feature_tracker.skip("none", "not applicable")
|
||||
|
||||
console.print(feature_tracker.render())
|
||||
console.print()
|
||||
|
||||
# ── 4. Scripts health ─────────────────────────────────────────────
|
||||
script_tracker = StepTracker("Scripts")
|
||||
|
||||
bash_dir = project_root / "scripts" / "bash"
|
||||
ps_dir = project_root / "scripts" / "powershell"
|
||||
|
||||
expected_scripts = ["common", "check-prerequisites", "create-new-feature", "setup-plan", "update-agent-context"]
|
||||
|
||||
if bash_dir.is_dir():
|
||||
for name in expected_scripts:
|
||||
key = f"sh_{name}"
|
||||
script_path = bash_dir / f"{name}.sh"
|
||||
script_tracker.add(key, f"bash/{name}.sh")
|
||||
if script_path.is_file():
|
||||
if os.name != "nt" and not os.access(script_path, os.X_OK):
|
||||
script_tracker.error(key, "not executable")
|
||||
issues.append(("warning", f"scripts/bash/{name}.sh is not executable — run chmod +x"))
|
||||
else:
|
||||
script_tracker.complete(key, "ok")
|
||||
else:
|
||||
script_tracker.error(key, "missing")
|
||||
issues.append(("error", f"scripts/bash/{name}.sh is missing"))
|
||||
else:
|
||||
script_tracker.add("no_bash", "scripts/bash/")
|
||||
script_tracker.skip("no_bash", "not found")
|
||||
|
||||
if ps_dir.is_dir():
|
||||
for name in expected_scripts:
|
||||
key = f"ps_{name}"
|
||||
script_path = ps_dir / f"{name}.ps1"
|
||||
script_tracker.add(key, f"powershell/{name}.ps1")
|
||||
if script_path.is_file():
|
||||
script_tracker.complete(key, "ok")
|
||||
else:
|
||||
script_tracker.error(key, "missing")
|
||||
issues.append(("error", f"scripts/powershell/{name}.ps1 is missing"))
|
||||
else:
|
||||
script_tracker.add("no_ps", "scripts/powershell/")
|
||||
script_tracker.skip("no_ps", "not found")
|
||||
|
||||
console.print(script_tracker.render())
|
||||
console.print()
|
||||
|
||||
# ── 5. Extensions health ──────────────────────────────────────────
|
||||
ext_tracker = StepTracker("Extensions")
|
||||
|
||||
extensions_yml = specify_dir / "extensions.yml" if specify_dir.is_dir() else None
|
||||
registry_json = specify_dir / "extensions" / "registry.json" if specify_dir.is_dir() else None
|
||||
|
||||
if extensions_yml and extensions_yml.is_file():
|
||||
ext_tracker.add("config", "extensions.yml")
|
||||
try:
|
||||
with open(extensions_yml) as f:
|
||||
ext_config = yaml.safe_load(f)
|
||||
if ext_config and isinstance(ext_config, dict):
|
||||
ext_tracker.complete("config", "valid YAML")
|
||||
hooks = ext_config.get("hooks", {})
|
||||
if hooks:
|
||||
hook_count = sum(len(v) if isinstance(v, list) else 0 for v in hooks.values())
|
||||
ext_tracker.add("hooks", "Hook registrations")
|
||||
ext_tracker.complete("hooks", f"{hook_count} hook(s) registered")
|
||||
else:
|
||||
ext_tracker.complete("config", "empty or no hooks")
|
||||
except Exception as e:
|
||||
ext_tracker.error("config", f"invalid YAML: {e}")
|
||||
issues.append(("warning", f"extensions.yml has invalid YAML: {e}"))
|
||||
else:
|
||||
ext_tracker.add("config", "extensions.yml")
|
||||
ext_tracker.skip("config", "no extensions configured")
|
||||
|
||||
if registry_json and registry_json.is_file():
|
||||
ext_tracker.add("registry", "Extension registry")
|
||||
try:
|
||||
with open(registry_json) as f:
|
||||
registry = json.load(f)
|
||||
installed = [k for k, v in registry.items() if isinstance(v, dict)]
|
||||
enabled = [k for k, v in registry.items() if isinstance(v, dict) and v.get("enabled", True)]
|
||||
ext_tracker.complete("registry", f"{len(installed)} installed, {len(enabled)} enabled")
|
||||
except Exception as e:
|
||||
ext_tracker.error("registry", f"corrupt: {e}")
|
||||
issues.append(("error", f"Extension registry is corrupt: {e}"))
|
||||
else:
|
||||
ext_tracker.add("registry", "Extension registry")
|
||||
ext_tracker.skip("registry", "no extensions installed")
|
||||
|
||||
console.print(ext_tracker.render())
|
||||
console.print()
|
||||
|
||||
# ── 6. Git status ─────────────────────────────────────────────────
|
||||
git_tracker = StepTracker("Git Repository")
|
||||
git_tracker.add("git", "Git repository")
|
||||
|
||||
git_ok = shutil.which("git") is not None
|
||||
in_git_repo = False
|
||||
if git_ok:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--is-inside-work-tree"],
|
||||
capture_output=True, text=True, cwd=str(project_root)
|
||||
)
|
||||
in_git_repo = result.returncode == 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if in_git_repo:
|
||||
git_tracker.complete("git", "inside git repository")
|
||||
try:
|
||||
branch = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True, text=True, cwd=str(project_root)
|
||||
).stdout.strip()
|
||||
git_tracker.add("branch", "Current branch")
|
||||
git_tracker.complete("branch", branch)
|
||||
except Exception:
|
||||
pass
|
||||
elif git_ok:
|
||||
git_tracker.skip("git", "not a git repository")
|
||||
issues.append(("info", "Not inside a git repository — git features like branching won't work"))
|
||||
else:
|
||||
git_tracker.skip("git", "git not installed")
|
||||
issues.append(("info", "Git is not installed — branching and version control unavailable"))
|
||||
|
||||
console.print(git_tracker.render())
|
||||
console.print()
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────
|
||||
errors = [msg for sev, msg in issues if sev == "error"]
|
||||
warnings = [msg for sev, msg in issues if sev == "warning"]
|
||||
infos = [msg for sev, msg in issues if sev == "info"]
|
||||
|
||||
if not issues:
|
||||
console.print(Panel(
|
||||
"[bold green]All checks passed — project looks healthy![/bold green]",
|
||||
border_style="green",
|
||||
padding=(1, 2),
|
||||
))
|
||||
else:
|
||||
summary_lines = []
|
||||
|
||||
if errors:
|
||||
summary_lines.append(f"[bold red]{len(errors)} error(s)[/bold red]")
|
||||
for msg in errors:
|
||||
summary_lines.append(f" [red]●[/red] {msg}")
|
||||
summary_lines.append("")
|
||||
|
||||
if warnings:
|
||||
summary_lines.append(f"[bold yellow]{len(warnings)} warning(s)[/bold yellow]")
|
||||
for msg in warnings:
|
||||
summary_lines.append(f" [yellow]●[/yellow] {msg}")
|
||||
summary_lines.append("")
|
||||
|
||||
if infos:
|
||||
summary_lines.append(f"[bold blue]{len(infos)} note(s)[/bold blue]")
|
||||
for msg in infos:
|
||||
summary_lines.append(f" [blue]○[/blue] {msg}")
|
||||
|
||||
border = "red" if errors else "yellow" if warnings else "blue"
|
||||
console.print(Panel(
|
||||
"\n".join(summary_lines),
|
||||
title="Diagnostic Summary",
|
||||
border_style=border,
|
||||
padding=(1, 2),
|
||||
))
|
||||
|
||||
if errors:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def version():
|
||||
"""Display version and system information."""
|
||||
|
||||
Reference in New Issue
Block a user