diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index ec6c894652..a679e834f4 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -123,6 +123,22 @@ specify integration upgrade [] 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. +## Report Integration Status + +```bash +specify integration status +specify integration status --json +``` + +Reports the current project's integration status without changing files. The +status report includes the default integration, installed integrations, +multi-install safety, missing managed files, modified managed files, invalid +manifest paths, shared Spec Kit infrastructure health, unchecked manifests, and +the target integration for default-sensitive shared templates. The JSON form is +intended for CI and coding agents that need stable machine-readable status data. +The command exits 0 when the report status is `ok` or `warning`; it exits 1 +only when the report status is `error`. + ## Integration-Specific Options Some integrations accept additional options via `--integration-options`: diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index c0bdbaabe3..8f46311257 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -41,6 +41,7 @@ from rich.panel import Panel from rich.live import Live from rich.align import Align +from rich.markup import escape as _rich_escape from rich.table import Table from .integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, @@ -1615,6 +1616,80 @@ def integration_list( console.print("Install one with: [cyan]specify integration install [/cyan]") +def _print_integration_status_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 [] + installed_display = ", ".join(_rich_escape(str(item)) for item in installed) + + console.print(f"Integration status: {status_label}") + console.print( + f"Default integration: {_rich_escape(str(report.get('default_integration') or 'none'))}" + ) + console.print(f"Installed integrations: {installed_display if installed else 'none'}") + console.print(f"Multi-install safe: {'yes' if report.get('multi_install_safe') else 'no'}") + console.print( + f"Shared templates target alignment: " + f"{_rich_escape(str(report.get('shared_templates_target_alignment') 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)}") + console.print(f"Invalid manifest paths: {report.get('invalid_manifest_paths', 0)}") + console.print(f"Unchecked manifests: {report.get('unchecked_manifests', 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} {_rich_escape(str(item.get('code', '')))}" + if item.get("integration"): + prefix += f" ({_rich_escape(str(item['integration']))})" + console.print( + f"{prefix}: {_rich_escape(str(item.get('message', '')))}", + soft_wrap=True, + ) + if item.get("suggestion"): + console.print( + f" Suggestion: {_rich_escape(str(item['suggestion']))}", + soft_wrap=True, + ) + + +@integration_app.command("status") +def integration_status( + json_output: bool = typer.Option( + False, + "--json", + help="Emit machine-readable integration status.", + ), +): + """Report the current project's integration status without changing files.""" + from .integration_status import build_integration_status_report + + project_root = _require_specify_project() + report = build_integration_status_report(project_root) + + if json_output: + typer.echo(json.dumps(report, indent=2)) + else: + _print_integration_status_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)"), diff --git a/src/specify_cli/integration_state.py b/src/specify_cli/integration_state.py index 2b18324351..eaeb10eb1b 100644 --- a/src/specify_cli/integration_state.py +++ b/src/specify_cli/integration_state.py @@ -25,18 +25,9 @@ class IntegrationReadError: schema: int | None = None -def try_read_integration_json( +def _read_integration_json_data( project_root: Path, ) -> tuple[dict[str, Any] | None, IntegrationReadError | None]: - """Parse ``.specify/integration.json`` without raising. - - Returns ``(normalized_state, None)`` on success, ``(None, None)`` when the - file does not exist, or ``(None, error)`` for any parse / validation - failure. This is the single low-level reader; both the CLI's loud - ``_read_integration_json`` and the workflow engine's silent - ``_load_project_integration`` consume it so the schema guard and parse - logic cannot drift between them. - """ path = project_root / INTEGRATION_JSON # Avoid Path.exists() / Path.is_file() as a pre-check: both return False # on some OSErrors (e.g. permission errors during stat), which would @@ -70,9 +61,37 @@ def try_read_integration_json( and schema > INTEGRATION_STATE_SCHEMA ): return None, IntegrationReadError(kind="schema_too_new", schema=schema) + return data, None + + +def try_read_integration_json( + project_root: Path, +) -> tuple[dict[str, Any] | None, IntegrationReadError | None]: + """Parse ``.specify/integration.json`` without raising. + + Returns ``(normalized_state, None)`` on success, ``(None, None)`` when the + file does not exist, or ``(None, error)`` for any parse / validation + failure. This is the single low-level reader; both the CLI's loud + ``_read_integration_json`` and the workflow engine's silent + ``_load_project_integration`` consume it so the schema guard and parse + logic cannot drift between them. + """ + data, error = _read_integration_json_data(project_root) + if data is None: + return None, error return normalize_integration_state(data), None +def try_read_integration_json_with_raw( + project_root: Path, +) -> tuple[dict[str, Any] | None, dict[str, Any] | None, IntegrationReadError | None]: + """Parse ``integration.json`` and return normalized plus raw state.""" + data, error = _read_integration_json_data(project_root) + if data is None: + return None, None, error + return normalize_integration_state(data), data, None + + def clean_integration_key(key: Any) -> str | None: """Return a stripped integration key, or None for empty/non-string values.""" if not isinstance(key, str) or not key.strip(): diff --git a/src/specify_cli/integration_status.py b/src/specify_cli/integration_status.py new file mode 100644 index 0000000000..965c3afe11 --- /dev/null +++ b/src/specify_cli/integration_status.py @@ -0,0 +1,446 @@ +"""Read-only status reporting for project integration state.""" + +from __future__ import annotations + +import hashlib +import re +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_with_raw, +) +from .integrations import INTEGRATION_REGISTRY +from .integrations.manifest import IntegrationManifest + +_MANIFEST_READ_ERRORS = (ValueError, FileNotFoundError, OSError, UnicodeDecodeError) +_MANIFEST_KEY_RE = re.compile(r"^[A-Za-z0-9._-]+$") +_WINDOWS_RESERVED_MANIFEST_BASENAMES = { + "CON", + "PRN", + "AUX", + "NUL", + *(f"COM{i}" for i in range(1, 10)), + *(f"LPT{i}" for i in range(1, 10)), +} +_SHARED_MANIFEST_KEY = "speckit" + + +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 _sha256_file(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(8192), b""): + h.update(chunk) + return h.hexdigest() + + +def _safe_manifest_file( + project_root: Path, + project_root_resolved: Path, + rel: str, +) -> Path | None: + rel_path = Path(rel) + if rel_path.is_absolute() or ".." in rel_path.parts: + return None + candidate = project_root / rel_path + try: + candidate.parent.resolve(strict=False).relative_to(project_root_resolved) + except (OSError, ValueError): + return None + return candidate + + +def _is_safe_manifest_key(key: str) -> bool: + if key in {"", ".", ".."}: + return False + if key.endswith("."): + return False + if _MANIFEST_KEY_RE.fullmatch(key) is None: + return False + if key.split(".", 1)[0].upper() in _WINDOWS_RESERVED_MANIFEST_BASENAMES: + return False + if "/" in key or "\\" in key: + return False + key_path = Path(key) + return not key_path.is_absolute() and key_path.name == key + + +def _manifest_file_status( + manifest: IntegrationManifest, + project_root_resolved: Path, +) -> tuple[list[str], list[str], list[str], list[str]]: + missing: list[str] = [] + modified: list[str] = [] + invalid: list[str] = [] + valid: list[str] = [] + + for rel, expected_hash in manifest.files.items(): + path = _safe_manifest_file(manifest.project_root, project_root_resolved, rel) + if path is None: + invalid.append(rel) + continue + valid.append(rel) + if not path.exists(): + missing.append(rel) + continue + if path.is_symlink() or not path.is_file(): + modified.append(rel) + continue + try: + if _sha256_file(path) != expected_hash: + modified.append(rel) + except OSError: + modified.append(rel) + + return missing, modified, invalid, valid + + +def _default_not_installed_from_raw_state(raw_state: dict[str, Any]) -> str | None: + if not isinstance(raw_state.get("installed_integrations"), list): + return None + + raw_default = default_integration_key(raw_state) + raw_installed = installed_integration_keys(raw_state) + if raw_default and raw_default not in raw_installed: + return raw_default + return None + + +def _manifest_summary( + manifest_path: Path, + project_root: Path, + *, + readable: bool, + tracked_files: int = 0, + missing_files: list[str] | None = None, + modified_files: list[str] | None = None, + invalid_files: list[str] | None = None, +) -> dict[str, Any]: + return { + "manifest": manifest_path.relative_to(project_root).as_posix(), + "readable": readable, + "tracked_files": tracked_files, + "missing_files": missing_files or [], + "modified_files": modified_files or [], + "invalid_files": invalid_files or [], + } + + +def _manifest_owner(key: str) -> str: + if key == _SHARED_MANIFEST_KEY: + return "shared Spec Kit infrastructure" + return f"integration '{key}'" + + +def _manifest_suggestion(key: str, default_key: str | None) -> str: + if key == _SHARED_MANIFEST_KEY: + if default_key and default_key in INTEGRATION_REGISTRY: + return f"Run `specify integration upgrade {default_key}` to regenerate shared managed files." + return "Run `specify init --here --force` to regenerate shared managed files." + return f"Run `specify integration upgrade {key}` or reinstall the integration." + + +def build_integration_status_report(project_root: Path) -> dict[str, Any]: + """Return a machine-readable integration status report for *project_root*.""" + findings: list[dict[str, str]] = [] + project_root_resolved = project_root.resolve() + state, raw_state, error = try_read_integration_json_with_raw(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 ` to install an integration.", + ) + ) + return _build_report(None, [], findings, {}, True) + + assert raw_state is not None + raw_default_key = default_integration_key(raw_state) + raw_installed_keys = installed_integration_keys(raw_state) + default_key = raw_default_key or 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 ` to install one.", + ) + ) + return _build_report(default_key, installed_keys, findings, {}, True) + + if raw_installed_keys and raw_default_key is None: + default_key = None + findings.append( + _finding( + "error", + "default-integration-missing", + "No default integration is recorded.", + suggestion="Run `specify integration use ` after choosing an installed integration.", + ) + ) + + raw_default_not_installed = _default_not_installed_from_raw_state(raw_state) + if raw_default_not_installed: + findings.append( + _finding( + "error", + "default-integration-not-installed", + ( + f"Default integration '{raw_default_not_installed}' is not listed " + "in installed_integrations." + ), + integration=raw_default_not_installed, + suggestion="Run `specify integration use ` for an installed integration, or reinstall the default integration.", + ) + ) + + known_installed = [key for key in installed_keys if key in INTEGRATION_REGISTRY] + unknown_installed: list[str] = [] + for key in installed_keys: + if key not in INTEGRATION_REGISTRY: + unknown_installed.append(key) + 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: + unsafe.extend(unknown_installed) + + 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 ` to change defaults, or `switch` only when replacing integrations.", + ) + ) + + manifest_files_by_path: dict[str, list[str]] = {} + manifest_summaries: dict[str, dict[str, Any]] = {} + manifest_keys = list(installed_keys) + if _SHARED_MANIFEST_KEY not in manifest_keys: + manifest_keys.append(_SHARED_MANIFEST_KEY) + + for key in manifest_keys: + owner = _manifest_owner(key) + if not _is_safe_manifest_key(key): + findings.append( + _finding( + "error", + "integration-key-invalid", + f"Integration key {key!r} cannot be used as a manifest filename.", + integration=key, + path=INTEGRATION_JSON, + suggestion=f"Fix {INTEGRATION_JSON}, then reinstall the integration.", + ) + ) + continue + + manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" + if not manifest_path.exists(): + findings.append( + _finding( + "error", + "manifest-missing", + f"Manifest for {owner} is missing.", + integration=key, + path=manifest_path.relative_to(project_root).as_posix(), + suggestion=_manifest_suggestion(key, default_key), + ) + ) + manifest_summaries[key] = _manifest_summary( + manifest_path, + project_root, + readable=False, + ) + continue + + try: + manifest = IntegrationManifest.load(key, project_root) + except _MANIFEST_READ_ERRORS as exc: + manifest_summaries[key] = _manifest_summary( + manifest_path, + project_root, + readable=False, + ) + findings.append( + _finding( + "error", + "manifest-unreadable", + f"Manifest for {owner} is unreadable: {exc}", + integration=key, + path=manifest_path.relative_to(project_root).as_posix(), + suggestion=_manifest_suggestion(key, default_key), + ) + ) + continue + + missing, modified, invalid, valid_files = _manifest_file_status( + manifest, + project_root_resolved, + ) + manifest_summaries[key] = _manifest_summary( + manifest_path, + project_root, + readable=True, + tracked_files=len(manifest.files), + missing_files=missing, + modified_files=modified, + invalid_files=invalid, + ) + + for rel in valid_files: + manifest_files_by_path.setdefault(rel, []).append(key) + if invalid: + findings.append( + _finding( + "error", + "manifest-paths-invalid", + f"{len(invalid)} unsafe manifest path(s) are recorded for {owner}.", + integration=key, + path=manifest_path.relative_to(project_root).as_posix(), + suggestion=_manifest_suggestion(key, default_key), + ) + ) + if missing: + findings.append( + _finding( + "error", + "managed-files-missing", + f"{len(missing)} managed file(s) are missing for {owner}.", + integration=key, + suggestion=_manifest_suggestion(key, default_key), + ) + ) + if modified: + findings.append( + _finding( + "warning", + "managed-files-modified", + f"{len(modified)} managed file(s) were modified for {owner}.", + 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()) + invalid_count = sum(len(item.get("invalid_files", [])) for item in manifests.values()) + unchecked_count = sum(1 for item in manifests.values() if not item.get("readable", True)) + return { + "status": _status(findings), + "default_integration": default_key, + "installed_integrations": installed_keys, + "multi_install_safe": multi_install_safe, + "shared_templates_target_alignment": default_key, + "missing_managed_files": missing_count, + "modified_managed_files": modified_count, + "invalid_manifest_paths": invalid_count, + "unchecked_manifests": unchecked_count, + "manifests": manifests, + "findings": findings, + } diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index abff9a5ee1..3d3d489d21 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -126,6 +126,399 @@ def test_list_rejects_newer_integration_state_schema(self, tmp_path): assert "only supports schema 1" in normalized +# ── status ─────────────────────────────────────────────────────────── + + +class TestIntegrationStatus: + def test_status_requires_speckit_project(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["integration", "status"]) + assert result.exit_code != 0 + assert "Not a spec-kit project" in result.output + + def test_status_reports_healthy_project(self, tmp_path): + project = _init_project(tmp_path, "copilot") + result = _run_in_project(project, ["integration", "status"]) + + assert result.exit_code == 0 + assert "Integration status: OK" in result.output + assert "Default integration: copilot" in result.output + assert "Installed integrations: copilot" in result.output + assert "Shared templates target alignment: copilot" in result.output + assert "Modified managed files: 0" in result.output + assert "Missing managed files: 0" in result.output + + def test_status_json_reports_healthy_project(self, tmp_path): + project = _init_project(tmp_path, "copilot") + result = _run_in_project(project, ["integration", "status", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["status"] == "ok" + assert payload["default_integration"] == "copilot" + assert payload["installed_integrations"] == ["copilot"] + assert payload["shared_templates_target_alignment"] == "copilot" + assert "shared_templates_aligned_to" not in payload + assert payload["findings"] == [] + + def test_status_reports_invalid_integration_json(self, tmp_path): + project = _init_project(tmp_path, "copilot") + (project / ".specify" / "integration.json").write_text("{", encoding="utf-8") + + result = _run_in_project(project, ["integration", "status"]) + + assert result.exit_code != 0 + assert "integration-state-unreadable" in result.output + assert "invalid JSON" in result.output + assert "Traceback" not in result.output + + def test_status_reports_missing_integration_json(self, tmp_path): + project = _init_project(tmp_path, "copilot") + (project / ".specify" / "integration.json").unlink() + + result = _run_in_project(project, ["integration", "status"]) + + assert result.exit_code != 0 + assert "integration-state-missing" in result.output + assert ".specify/integration.json is missing" in result.output + + def test_status_json_reports_no_installed_integrations_as_warning(self, tmp_path): + project = _init_project(tmp_path, "copilot") + state_path = project / ".specify" / "integration.json" + state_path.write_text( + json.dumps({ + "version": "test", + "integration_state_schema": 1, + "installed_integrations": [], + }), + encoding="utf-8", + ) + + result = _run_in_project(project, ["integration", "status", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["status"] == "warning" + assert payload["installed_integrations"] == [] + assert payload["findings"][0]["code"] == "no-installed-integrations" + + def test_status_json_reports_missing_default_integration_as_error(self, tmp_path): + project = _init_project(tmp_path, "claude") + state_path = project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state.pop("default_integration", None) + state.pop("integration", None) + state["installed_integrations"] = ["claude"] + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = _run_in_project(project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["status"] == "error" + assert payload["default_integration"] is None + assert any( + item["code"] == "default-integration-missing" + for item in payload["findings"] + ) + + def test_status_reports_default_integration_not_installed(self, tmp_path): + project = _init_project(tmp_path, "claude") + state_path = project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["default_integration"] = "codex" + state["integration"] = "codex" + state["installed_integrations"] = ["claude"] + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = _run_in_project(project, ["integration", "status"]) + + assert result.exit_code != 0 + assert "default-integration-not-installed" in result.output + assert "Default integration 'codex' is not listed" in result.output + + def test_status_reports_missing_manifest(self, tmp_path): + project = _init_project(tmp_path, "copilot") + (project / ".specify" / "integrations" / "copilot.manifest.json").unlink() + + result = _run_in_project(project, ["integration", "status"]) + + assert result.exit_code != 0 + assert "manifest-missing" in result.output + assert "Manifest for integration 'copilot' is missing" in result.output + + def test_status_reports_unreadable_manifest_in_json_summary(self, tmp_path): + project = _init_project(tmp_path, "copilot") + _write_invalid_manifest(project, "copilot") + + result = _run_in_project(project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["unchecked_manifests"] == 1 + assert payload["manifests"]["copilot"]["readable"] is False + assert payload["manifests"]["copilot"]["missing_files"] == [] + assert payload["manifests"]["copilot"]["modified_files"] == [] + + def test_status_reports_modified_managed_files_without_failing(self, tmp_path): + project = _init_project(tmp_path, "copilot") + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"] + first_rel = next(iter(tracked_files)) + (project / first_rel).write_text("MODIFIED CONTENT\n", encoding="utf-8") + + result = _run_in_project(project, ["integration", "status"]) + + assert result.exit_code == 0 + assert "Integration status: WARNING" in result.output + assert "managed-files-modified" in result.output + assert "Modified managed files: 1" in result.output + + def test_status_reports_missing_managed_files(self, tmp_path): + project = _init_project(tmp_path, "copilot") + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"] + first_rel = next(iter(tracked_files)) + (project / first_rel).unlink() + + result = _run_in_project(project, ["integration", "status"]) + + assert result.exit_code != 0 + assert "managed-files-missing" in result.output + assert "Missing managed files: 1" in result.output + + def test_status_reports_missing_shared_managed_files(self, tmp_path): + project = _init_project(tmp_path, "copilot") + shared_file = project / ".specify" / "scripts" / "bash" / "common.sh" + assert shared_file.exists() + shared_file.unlink() + + result = _run_in_project(project, ["integration", "status"]) + + assert result.exit_code != 0 + assert "managed-files-missing" in result.output + assert "shared Spec Kit infrastructure" in result.output + assert "Missing managed files: 1" in result.output + + def test_status_treats_dangling_symlink_as_missing(self, tmp_path): + project = _init_project(tmp_path, "copilot") + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"] + first_rel = next(iter(tracked_files)) + target = project / first_rel + target.unlink() + try: + target.symlink_to(project / "missing-target") + except OSError as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + result = _run_in_project(project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert first_rel in payload["manifests"]["copilot"]["missing_files"] + assert first_rel not in payload["manifests"]["copilot"]["modified_files"] + + def test_status_reports_unsafe_manifest_paths_without_hashing_them(self, tmp_path): + project = _init_project(tmp_path, "copilot") + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.txt").write_text("outside project\n", encoding="utf-8") + link = project / "outside-link" + try: + link.symlink_to(outside, target_is_directory=True) + except OSError as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest_data["files"]["outside-link/secret.txt"] = "wrong" + manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") + + result = _run_in_project(project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["invalid_manifest_paths"] == 1 + assert "outside-link/secret.txt" in payload["manifests"]["copilot"]["invalid_files"] + assert "outside-link/secret.txt" not in payload["manifests"]["copilot"]["modified_files"] + + def test_status_reports_unsafe_multi_install_combination(self, tmp_path): + from specify_cli.integrations.manifest import IntegrationManifest + + project = _init_project(tmp_path, "copilot") + state_path = project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["installed_integrations"] = ["copilot", "claude"] + state["default_integration"] = "copilot" + state["integration"] = "copilot" + state_path.write_text(json.dumps(state), encoding="utf-8") + IntegrationManifest("claude", project, version="test").save() + + result = _run_in_project(project, ["integration", "status"]) + + assert result.exit_code != 0 + assert "unsafe-multi-install" in result.output + assert "Multi-install safe: no" in result.output + + def test_status_treats_unknown_multi_install_as_unsafe(self, tmp_path): + from specify_cli.integrations.manifest import IntegrationManifest + + project = _init_project(tmp_path, "claude") + state_path = project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["installed_integrations"] = ["claude", "mystery"] + state["default_integration"] = "claude" + state["integration"] = "claude" + state_path.write_text(json.dumps(state), encoding="utf-8") + IntegrationManifest("mystery", project, version="test").save() + + result = _run_in_project(project, ["integration", "status"]) + + assert result.exit_code != 0 + assert "unknown-integration" in result.output + assert "unsafe-multi-install" in result.output + assert "Multi-install safe: no" in result.output + + def test_status_rejects_unsafe_integration_keys_before_manifest_lookup(self, tmp_path): + project = _init_project(tmp_path, "claude") + state_path = project / ".specify" / "integration.json" + unsafe_key = "../../../escape" + state_path.write_text( + json.dumps({ + "integration": unsafe_key, + "default_integration": unsafe_key, + "installed_integrations": [unsafe_key], + }), + encoding="utf-8", + ) + outside_manifest = tmp_path / "escape.manifest.json" + outside_manifest.write_text( + json.dumps({"integration": unsafe_key, "files": {}}), + encoding="utf-8", + ) + + result = _run_in_project(project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert unsafe_key not in payload["manifests"] + assert any( + item["code"] == "integration-key-invalid" + and item["integration"] == unsafe_key + for item in payload["findings"] + ) + + def test_status_rejects_filename_invalid_integration_keys(self, tmp_path): + project = _init_project(tmp_path, "claude") + state_path = project / ".specify" / "integration.json" + unsafe_key = "bad:key" + state_path.write_text( + json.dumps({ + "integration": unsafe_key, + "default_integration": unsafe_key, + "installed_integrations": [unsafe_key], + }), + encoding="utf-8", + ) + + result = _run_in_project(project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert any( + item["code"] == "integration-key-invalid" + and item["integration"] == unsafe_key + for item in payload["findings"] + ) + + def test_status_rejects_windows_reserved_integration_keys(self, tmp_path): + project = _init_project(tmp_path, "claude") + state_path = project / ".specify" / "integration.json" + unsafe_key = "CON" + state_path.write_text( + json.dumps({ + "integration": unsafe_key, + "default_integration": unsafe_key, + "installed_integrations": [unsafe_key], + }), + encoding="utf-8", + ) + + result = _run_in_project(project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert any( + item["code"] == "integration-key-invalid" + and item["integration"] == unsafe_key + for item in payload["findings"] + ) + + def test_status_reports_managed_file_collisions(self, tmp_path): + from specify_cli.integrations.manifest import IntegrationManifest + + project = _init_project(tmp_path, "claude") + state_path = project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["installed_integrations"] = ["claude", "codex"] + state["default_integration"] = "claude" + state["integration"] = "claude" + state_path.write_text(json.dumps(state), encoding="utf-8") + + claude_manifest = project / ".specify" / "integrations" / "claude.manifest.json" + tracked_files = json.loads(claude_manifest.read_text(encoding="utf-8"))["files"] + shared_rel = next(iter(tracked_files)) + codex_manifest = IntegrationManifest("codex", project, version="test") + codex_manifest.record_existing(shared_rel) + codex_manifest.save() + + result = _run_in_project(project, ["integration", "status"]) + + assert result.exit_code == 0 + assert "managed-file-collision" in result.output + assert "Integration status: WARNING" in result.output + + def test_status_json_is_not_rich_rendered(self, tmp_path, monkeypatch): + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + (project / ".specify" / "integration.json").write_text( + json.dumps({ + "integration": "[red]x[/red]", + "installed_integrations": ["[red]x[/red]"], + }), + encoding="utf-8", + ) + monkeypatch.chdir(project) + + result = runner.invoke(app, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["default_integration"] == "[red]x[/red]" + assert payload["installed_integrations"] == ["[red]x[/red]"] + + def test_status_text_escapes_rich_markup_from_project_state(self, tmp_path, monkeypatch): + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + (project / ".specify" / "integration.json").write_text( + json.dumps({ + "integration": "[red]x[/red]", + "installed_integrations": ["[red]x[/red]"], + }), + encoding="utf-8", + ) + monkeypatch.chdir(project) + + result = runner.invoke(app, ["integration", "status"]) + + assert result.exit_code != 0 + assert "Default integration: [red]x[/red]" in result.output + assert "Installed integrations: [red]x[/red]" in result.output + + # ── install ──────────────────────────────────────────────────────────