Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0e55a1e
feat(integration): add doctor diagnostics
PascalThuet May 22, 2026
9a70826
fix(integration): address doctor review feedback
PascalThuet May 22, 2026
5d5587a
fix(integration): harden doctor diagnostics
PascalThuet May 22, 2026
d420ce6
fix(integration): rename doctor diagnostics to status
PascalThuet May 26, 2026
81ed372
fix(integration): address status review feedback
PascalThuet May 26, 2026
485b143
fix(integration): validate status manifest keys
PascalThuet May 26, 2026
b3a245b
fix(integration): escape status report output
PascalThuet May 26, 2026
7d505aa
fix(integration): address status review feedback
PascalThuet May 27, 2026
40477a2
fix(integration): harden status manifest checks
PascalThuet May 27, 2026
ee8ddc0
fix(integration): tighten status diagnostics
PascalThuet May 27, 2026
99db1bb
fix(integration): clarify status state sources
PascalThuet May 27, 2026
9dedcf4
fix(integration): report unknown multi-install safety
PascalThuet May 28, 2026
1596fe2
fix(integration): mark empty safety as unknown
PascalThuet May 28, 2026
934b152
fix(integration): ignore invalid raw installed list
PascalThuet May 28, 2026
c8d9faa
fix(integration): report actual manifest checks
PascalThuet May 28, 2026
646ab9d
fix(integration): tighten status contract invariants
PascalThuet May 28, 2026
982e8a6
fix(integration): harden manifest status paths
PascalThuet May 28, 2026
4b40ef2
fix(integration): avoid symlink target stat
PascalThuet May 28, 2026
6d5383e
fix(integration): clarify status edge cases
PascalThuet May 28, 2026
67d28d4
test(integration): pin status filesystem guards
PascalThuet May 28, 2026
928d89d
fix(integration): tighten status path checks
PascalThuet May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,19 @@ specify integration upgrade [<key>]

Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration.

## Diagnose Integration State

```bash
specify integration doctor
specify integration doctor --json
```

Inspects the current project's integration state without changing files. The
diagnostic report includes the default integration, installed integrations,
multi-install safety, missing managed files, modified managed files, and any
manifest or state-file problems. The JSON form is intended for CI and coding
agents that need stable machine-readable diagnostics.

## Integration-Specific Options

Some integrations accept additional options via `--integration-options`:
Expand Down
60 changes: 60 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1615,6 +1615,66 @@ def integration_list(
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")


def _print_integration_doctor_report(report: dict[str, Any]) -> None:
status = report["status"]
status_label = {
"ok": "[green]OK[/green]",
"warning": "[yellow]WARNING[/yellow]",
"error": "[red]ERROR[/red]",
}.get(status, status.upper())
installed = report.get("installed_integrations") or []

console.print(f"Integration state: {status_label}")
console.print(f"Default integration: {report.get('default_integration') or 'none'}")
console.print(f"Installed integrations: {', '.join(installed) if installed else 'none'}")
console.print(f"Multi-install safe: {'yes' if report.get('multi_install_safe') else 'no'}")
console.print(f"Shared templates aligned to: {report.get('shared_templates_aligned_to') or 'none'}")
console.print(f"Modified managed files: {report.get('modified_managed_files', 0)}")
console.print(f"Missing managed files: {report.get('missing_managed_files', 0)}")

findings = report.get("findings") or []
if not findings:
return

console.print()
console.print("[bold]Findings:[/bold]")
for item in findings:
severity = item.get("severity", "")
severity_label = {
"error": "[red]error[/red]",
"warning": "[yellow]warning[/yellow]",
}.get(severity, severity)
prefix = f"- {severity_label} {item.get('code', '')}"
if item.get("integration"):
prefix += f" ({item['integration']})"
console.print(f"{prefix}: {item.get('message', '')}", soft_wrap=True)
if item.get("suggestion"):
console.print(f" Suggestion: {item['suggestion']}", soft_wrap=True)
Comment thread
PascalThuet marked this conversation as resolved.
Outdated


@integration_app.command("doctor")
def integration_doctor(
json_output: bool = typer.Option(
False,
"--json",
help="Emit machine-readable integration diagnostics.",
),
):
"""Diagnose the current project's integration state without changing files."""
from .integration_doctor import diagnose_integration_project

project_root = _require_specify_project()
report = diagnose_integration_project(project_root)

if json_output:
console.print(json.dumps(report, indent=2))
else:
_print_integration_doctor_report(report)

if report["status"] == "error":
raise typer.Exit(1)


@integration_app.command("install")
def integration_install(
key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"),
Expand Down
271 changes: 271 additions & 0 deletions src/specify_cli/integration_doctor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
"""Read-only diagnostics for project integration state."""

from __future__ import annotations

from pathlib import Path
from typing import Any

from .integration_state import (
INTEGRATION_JSON,
IntegrationReadError,
default_integration_key,
installed_integration_keys,
try_read_integration_json,
)
from .integrations import INTEGRATION_REGISTRY
from .integrations.manifest import IntegrationManifest

_MANIFEST_READ_ERRORS = (ValueError, FileNotFoundError, OSError, UnicodeDecodeError)


def _finding(
severity: str,
code: str,
message: str,
*,
integration: str | None = None,
path: str | None = None,
suggestion: str | None = None,
) -> dict[str, str]:
item = {
"severity": severity,
"code": code,
"message": message,
}
if integration:
item["integration"] = integration
if path:
item["path"] = path
if suggestion:
item["suggestion"] = suggestion
return item


def _status(findings: list[dict[str, str]]) -> str:
if any(item["severity"] == "error" for item in findings):
return "error"
if findings:
return "warning"
return "ok"


def _integration_state_error_message(error: IntegrationReadError) -> str:
if error.kind == "decode":
return f"{INTEGRATION_JSON} contains invalid JSON or is not valid UTF-8."
if error.kind == "os":
return f"Could not read {INTEGRATION_JSON}."
if error.kind == "not_object":
return f"{INTEGRATION_JSON} must contain a JSON object, got {error.detail}."
if error.kind == "schema_too_new":
return (
f"{INTEGRATION_JSON} uses integration state schema {error.schema}, "
"which is newer than this CLI supports."
)
return f"Could not inspect {INTEGRATION_JSON}."


def _safe_manifest_file(project_root: Path, rel: str) -> Path | None:
rel_path = Path(rel)
if rel_path.is_absolute() or ".." in rel_path.parts:
return None
return project_root / rel_path


def _manifest_missing_files(manifest: IntegrationManifest) -> list[str]:
missing: list[str] = []
for rel in manifest.files:
path = _safe_manifest_file(manifest.project_root, rel)
if path is None:
continue
if not path.exists() and not path.is_symlink():
Comment thread
PascalThuet marked this conversation as resolved.
Outdated
missing.append(rel)
return missing


def diagnose_integration_project(project_root: Path) -> dict[str, Any]:
"""Return a machine-readable integration health report for *project_root*."""
findings: list[dict[str, str]] = []
state, error = try_read_integration_json(project_root)
if error is not None:
findings.append(
_finding(
"error",
"integration-state-unreadable",
_integration_state_error_message(error),
path=INTEGRATION_JSON,
suggestion=f"Fix or delete {INTEGRATION_JSON}, then retry.",
)
)
return _build_report(None, [], findings, {}, True)

if state is None:
findings.append(
_finding(
"error",
"integration-state-missing",
f"{INTEGRATION_JSON} is missing.",
path=INTEGRATION_JSON,
suggestion="Run `specify integration install <key>` to install an integration.",
)
)
return _build_report(None, [], findings, {}, True)

default_key = default_integration_key(state)
installed_keys = installed_integration_keys(state)
if not installed_keys:
findings.append(
_finding(
"warning",
"no-installed-integrations",
"No installed integrations are recorded.",
suggestion="Run `specify integration install <key>` to install one.",
)
)
return _build_report(default_key, installed_keys, findings, {}, True)

if default_key is None:
findings.append(
_finding(
"error",
"default-integration-missing",
"No default integration is recorded.",
suggestion="Run `specify integration use <key>` after choosing an installed integration.",
)
)

known_installed = [key for key in installed_keys if key in INTEGRATION_REGISTRY]
for key in installed_keys:
if key not in INTEGRATION_REGISTRY:
findings.append(
_finding(
"error",
"unknown-integration",
f"Integration '{key}' is installed but is not known to this CLI.",
integration=key,
suggestion="Upgrade Spec Kit, or uninstall the stale integration metadata.",
)
)

unsafe = [
key for key in known_installed
if not getattr(INTEGRATION_REGISTRY[key], "multi_install_safe", False)
]
if len(installed_keys) > 1 and unsafe:
findings.append(
_finding(
"error",
"unsafe-multi-install",
(
"Installed integrations are not all declared multi-install safe: "
+ ", ".join(sorted(unsafe))
),
suggestion="Use `specify integration use <key>` to change defaults, or `switch` only when replacing integrations.",
)
)

manifest_files_by_path: dict[str, list[str]] = {}
manifest_summaries: dict[str, dict[str, Any]] = {}
for key in installed_keys:
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
if not manifest_path.exists():
findings.append(
_finding(
"error",
"manifest-missing",
f"Manifest for integration '{key}' is missing.",
integration=key,
path=manifest_path.relative_to(project_root).as_posix(),
suggestion=f"Run `specify integration upgrade {key}` or reinstall the integration.",
)
)
manifest_summaries[key] = {
"manifest": manifest_path.relative_to(project_root).as_posix(),
"tracked_files": 0,
"missing_files": [],
"modified_files": [],
}
continue

try:
manifest = IntegrationManifest.load(key, project_root)
except _MANIFEST_READ_ERRORS as exc:
findings.append(
_finding(
"error",
"manifest-unreadable",
f"Manifest for integration '{key}' is unreadable: {exc}",
integration=key,
path=manifest_path.relative_to(project_root).as_posix(),
suggestion=f"Fix the manifest or reinstall integration '{key}'.",
)
)
continue

missing = _manifest_missing_files(manifest)
modified = manifest.check_modified()
manifest_summaries[key] = {
"manifest": manifest_path.relative_to(project_root).as_posix(),
"tracked_files": len(manifest.files),
"missing_files": missing,
"modified_files": modified,
}

for rel in manifest.files:
manifest_files_by_path.setdefault(rel, []).append(key)
if missing:
findings.append(
_finding(
"error",
"managed-files-missing",
f"{len(missing)} managed file(s) are missing for integration '{key}'.",
integration=key,
suggestion=f"Run `specify integration upgrade {key}` to regenerate managed files.",
)
)
if modified:
findings.append(
_finding(
"warning",
"managed-files-modified",
f"{len(modified)} managed file(s) were modified for integration '{key}'.",
integration=key,
suggestion="Review the changes before running `specify integration upgrade --force`.",
)
)

for rel, keys in sorted(manifest_files_by_path.items()):
if len(keys) > 1:
findings.append(
_finding(
"warning",
"managed-file-collision",
f"Managed file '{rel}' is tracked by multiple integrations: {', '.join(sorted(keys))}.",
path=rel,
suggestion="Review the manifests before uninstalling or upgrading these integrations.",
)
)

multi_install_safe = not (len(installed_keys) > 1 and unsafe)
return _build_report(default_key, installed_keys, findings, manifest_summaries, multi_install_safe)


def _build_report(
default_key: str | None,
installed_keys: list[str],
findings: list[dict[str, str]],
manifests: dict[str, dict[str, Any]],
multi_install_safe: bool,
) -> dict[str, Any]:
missing_count = sum(len(item.get("missing_files", [])) for item in manifests.values())
modified_count = sum(len(item.get("modified_files", [])) for item in manifests.values())
return {
"status": _status(findings),
"default_integration": default_key,
"installed_integrations": installed_keys,
"multi_install_safe": multi_install_safe,
"shared_templates_aligned_to": default_key,
"missing_managed_files": missing_count,
"modified_managed_files": modified_count,
"manifests": manifests,
"findings": findings,
}
Loading