feat(integration): add status reporting (#2674)

* feat(integration): add status reporting

* docs(integration): include status in query command docstring

* fix(integration): handle Windows extended-length paths in status containment

On Windows, os.readlink() (and sometimes Path.resolve()) return paths with
the \\?\ extended-length prefix. Comparing such a target against a plain
project root via Path.relative_to() spuriously fails, so an in-project
dangling symlink was classified as `invalid` instead of `missing` — failing
test_status_treats_dangling_symlink_as_missing and the windows-style variant
on the Windows CI runners.

Centralize the containment check in _is_within_project() and strip the
\\?\ / \\?\UNC\ prefix from both sides before relative_to(). Add portable
regression tests for the prefix-stripping helper and the containment contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* test(integration): restore top-level pytest import after rebase

A three-way merge / rebase onto main silently dropped the module-level
`import pytest` from test_integration_subcommand.py: main reorganized the
import block without it (using only a local `import pytest as _pytest`),
while this branch added top-level fixtures and `pytest.skip`/`pytest.raises`
usage. The overlapping import-hunk edits resolved by dropping the import,
breaking collection with `NameError: name 'pytest' is not defined` on every
runner. Re-add the import in the third-party group.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(integration): fix Windows UNC path assertion in status helper test

`test_strip_extended_length_prefix_normalizes_windows_paths` compared the
str() form of the helper's output against a hand-built string. On Windows,
pathlib renders a UNC root with a trailing separator (`\\server\share\`),
so the exact string match failed there (`\\server\share\` != `\\server\share`)
even though `_strip_extended_length_prefix` behaves correctly — the trailing
separator is irrelevant to the `relative_to` containment check it feeds.

Compare Path objects (semantic equality) instead of exact strings so the
assertion holds on both POSIX and Windows. No production code change needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(integration): make shared-manifest remediation specify --integration

The fallback `_manifest_suggestion` for the shared `speckit` manifest (used
when no usable default integration is recorded) suggested
`specify init --here --force`, which can trigger interactive integration
selection. For CI/agent consumers of `integration status`, surface an
explicit `--integration <key>` placeholder, matching the file's existing
`<key>` suggestion style.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Pascal THUET
2026-06-09 14:20:43 +02:00
committed by GitHub
parent ad9f047aaa
commit f65d9f9382
6 changed files with 1673 additions and 14 deletions

View File

@@ -25,17 +25,14 @@ 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.
"""Read raw integration state without normalizing or 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.
Returns ``(data, None)`` when the JSON object is readable and supported,
``(None, None)`` when the file is absent, and ``(None, error)`` for parse,
schema, encoding, or filesystem failures.
"""
path = project_root / INTEGRATION_JSON
# Avoid Path.exists() / Path.is_file() as a pre-check: both return False
@@ -70,9 +67,41 @@ 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 helper delegates file I/O and raw JSON validation to
``_read_integration_json_data`` so callers that need raw state can share
the same low-level reader instead of duplicating parse logic.
"""
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.
Returns ``(normalized_state, raw_state, None)`` when the file is readable,
``(None, None, None)`` when it is absent, and ``(None, None, error)`` for
parse, schema, encoding, or filesystem failures.
"""
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():

View File

@@ -0,0 +1,663 @@
"""Read-only status reporting for project integration state."""
from __future__ import annotations
import hashlib
import re
import stat
from pathlib import Path
from typing import Any
from .integration_state import (
INTEGRATION_JSON,
INTEGRATION_STATE_SCHEMA,
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, OSError)
_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 _with_error_detail(message: str, error: IntegrationReadError) -> str:
if error.detail:
return f"{message} Detail: {error.detail}"
return message
def _integration_state_error_message(error: IntegrationReadError) -> str:
if error.kind == "decode":
return _with_error_detail(
f"{INTEGRATION_JSON} contains invalid JSON or is not valid UTF-8.",
error,
)
if error.kind == "os":
return _with_error_detail(f"Could not read {INTEGRATION_JSON}.", error)
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}, "
f"which is newer than this CLI supports; supported schema: {INTEGRATION_STATE_SCHEMA}."
)
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 _strip_extended_length_prefix(path: Path) -> Path:
"""Drop the Windows ``\\\\?\\`` extended-length prefix for path comparison.
``os.readlink`` and ``Path.resolve`` can return extended-length paths on
Windows (e.g. ``\\\\?\\C:\\proj``). Comparing such a path against a plain
``C:\\proj`` root via :meth:`Path.relative_to` would spuriously fail, so we
normalise both sides through this helper before containment checks.
"""
raw = str(path)
if raw.startswith("\\\\?\\UNC\\"):
return Path("\\\\" + raw[len("\\\\?\\UNC\\"):])
if raw.startswith("\\\\?\\"):
return Path(raw[len("\\\\?\\"):])
return path
def _is_within_project(project_root_resolved: Path, candidate: Path) -> bool:
"""Return ``True`` when *candidate* stays within *project_root_resolved*.
Both paths are stripped of any Windows extended-length prefix first so that
a target produced by ``os.readlink`` (which may be ``\\\\?\\``-prefixed) is
still recognised as living inside an unprefixed project root.
"""
try:
_strip_extended_length_prefix(candidate).relative_to(
_strip_extended_length_prefix(project_root_resolved)
)
except ValueError:
return False
return True
def _safe_manifest_file(
project_root: Path,
project_root_resolved: Path,
rel: str,
*,
project_root_is_resolved: bool = True,
) -> Path | None:
rel_path = Path(rel)
if rel_path.is_absolute() or ".." in rel_path.parts:
return None
candidate = project_root / rel_path
if not project_root_is_resolved:
walk = project_root
for part in rel_path.parts[:-1]:
walk = walk / part
try:
if walk.is_symlink():
return None
except OSError:
return None
try:
candidate_parent = (
candidate.parent.resolve(strict=False)
if project_root_is_resolved
else candidate.parent.absolute()
)
except (OSError, RuntimeError):
return None
if not _is_within_project(project_root_resolved, candidate_parent):
return None
return candidate
def _tracked_symlink_manifest_status(
path: Path,
project_root_resolved: Path,
*,
project_root_is_resolved: bool = True,
) -> str:
"""Classify a tracked symlink without following it outside the project.
Manifests store content hashes for regular files, so an existing in-project
symlink is still reported as modified. Escaping targets are invalid, and
dangling in-project targets are missing.
"""
try:
target = path.readlink()
except OSError:
return "modified"
target_path = target if target.is_absolute() else path.parent / target
try:
contained_parent = (
target_path.parent.resolve(strict=False)
if project_root_is_resolved
else target_path.parent.absolute()
)
except (OSError, RuntimeError):
return "invalid"
if not _is_within_project(project_root_resolved, contained_parent):
return "invalid"
try:
target_path.lstat()
except FileNotFoundError:
return "missing"
except OSError:
return "modified"
return "modified"
def _resolve_project_root_for_status(
project_root: Path,
findings: list[dict[str, str]],
) -> tuple[Path, bool]:
try:
return project_root.resolve(), True
except (OSError, RuntimeError) as exc:
findings.append(
_finding(
"warning",
"project-root-unresolved",
f"Could not fully resolve project root: {exc}",
suggestion="Check project path permissions and symlinks before relying on manifest path checks.",
)
)
return project_root.absolute(), False
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,
*,
project_root_is_resolved: bool = True,
) -> 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,
project_root_is_resolved=project_root_is_resolved,
)
if path is None:
invalid.append(rel)
continue
try:
path_stat = path.lstat()
except FileNotFoundError:
valid.append(rel)
missing.append(rel)
continue
except OSError:
valid.append(rel)
modified.append(rel)
continue
is_symlink = stat.S_ISLNK(path_stat.st_mode)
if not is_symlink:
try:
is_symlink = path.is_symlink()
except OSError:
is_symlink = False
if is_symlink:
symlink_status = _tracked_symlink_manifest_status(
path,
project_root_resolved,
project_root_is_resolved=project_root_is_resolved,
)
if symlink_status == "invalid":
invalid.append(rel)
continue
valid.append(rel)
if symlink_status == "missing":
missing.append(rel)
continue
modified.append(rel)
continue
valid.append(rel)
if not stat.S_ISREG(path_stat.st_mode):
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 --integration <key>` to regenerate "
"shared managed files."
)
if key not in INTEGRATION_REGISTRY:
return (
"Upgrade Spec Kit, reinstall with a supported CLI version, "
f"or remove the stale integration entry from {INTEGRATION_JSON}."
)
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_is_resolved = _resolve_project_root_for_status(
project_root,
findings,
)
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, {}, None)
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, {}, None)
assert raw_state is not None
raw_default_key = default_integration_key(raw_state)
raw_installed_value = raw_state.get("installed_integrations")
raw_installed_is_list = isinstance(raw_installed_value, list)
raw_installed_keys = (
installed_integration_keys(raw_state)
if raw_installed_is_list
else []
)
default_key = raw_default_key or default_integration_key(state)
installed_keys = installed_integration_keys(state)
raw_default_not_installed = _default_not_installed_from_raw_state(raw_state)
if raw_installed_is_list and raw_default_not_installed and raw_installed_keys:
check_installed_keys = raw_installed_keys
else:
check_installed_keys = installed_keys
recorded_installed_keys = raw_installed_keys
if "installed_integrations" in raw_state and not raw_installed_is_list:
findings.append(
_finding(
"warning",
"installed-integrations-invalid",
(
"installed_integrations must be a list, "
f"got {type(raw_installed_value).__name__}."
),
path=INTEGRATION_JSON,
suggestion=f"Fix {INTEGRATION_JSON}, then retry.",
)
)
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.",
)
)
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 <key>` after choosing an installed integration.",
)
)
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 <key>` for an installed integration, or reinstall the default integration.",
)
)
known_installed = [key for key in check_installed_keys if key in INTEGRATION_REGISTRY]
unknown_installed: list[str] = []
for key in check_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, reinstall with a supported CLI version, "
f"or remove the stale integration entry from {INTEGRATION_JSON}."
),
)
)
unsafe = [
key for key in known_installed
if not getattr(INTEGRATION_REGISTRY[key], "multi_install_safe", False)
]
if len(check_installed_keys) > 1:
unsafe.extend(unknown_installed)
if len(check_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 `specify integration switch <key>` only when replacing integrations."
),
)
)
manifest_files_by_path: dict[str, list[str]] = {}
manifest_summaries: dict[str, dict[str, Any]] = {}
attempted_manifest_keys: list[str] = []
manifest_keys = list(check_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
attempted_manifest_keys.append(key)
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
try:
manifest = IntegrationManifest.load(
key,
project_root_resolved,
resolve_project_root=False,
)
except FileNotFoundError:
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
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,
project_root_is_resolved=project_root_is_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.",
)
)
if not raw_installed_is_list or not raw_installed_keys:
multi_install_safe = None
else:
multi_install_safe = not (len(check_installed_keys) > 1 and unsafe)
return _build_report(
default_key,
installed_keys,
findings,
manifest_summaries,
multi_install_safe,
manifest_checked_keys=attempted_manifest_keys,
recorded_installed_keys=recorded_installed_keys,
)
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 | None,
*,
manifest_checked_keys: list[str] | None = None,
recorded_installed_keys: list[str] | None = None,
) -> 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,
"recorded_installed_integrations": (
installed_keys if recorded_installed_keys is None else recorded_installed_keys
),
"manifest_checked_integrations": (
installed_keys if manifest_checked_keys is None else manifest_checked_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,
}

View File

@@ -1,10 +1,12 @@
"""specify integration list/use/search/info + catalog list/add/remove command handlers."""
"""specify integration list/status/use/search/info + catalog list/add/remove command handlers."""
from __future__ import annotations
import json
import os
from typing import Optional
from typing import Any, Optional
import typer
from rich.markup import escape as _rich_escape
from rich.table import Table
from .._console import console
@@ -120,6 +122,86 @@ def integration_list(
console.print("Install one with: [cyan]specify integration install <key>[/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(str(status), str(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'}")
multi_install_safe = report.get("multi_install_safe")
if multi_install_safe is None:
multi_install_safe_display = "unknown"
else:
multi_install_safe_display = "yes" if multi_install_safe else "no"
console.print(f"Multi-install safe: {multi_install_safe_display}")
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 .. import _require_specify_project
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("use")
def integration_use(
key: str = typer.Argument(help="Installed integration key to make the default"),

View File

@@ -108,11 +108,23 @@ class IntegrationManifest:
key: Integration identifier (e.g. ``"copilot"``).
project_root: Absolute path to the project directory.
version: CLI version string recorded in the manifest.
resolve_project_root: Resolve ``project_root`` before using it.
"""
def __init__(self, key: str, project_root: Path, version: str = "") -> None:
def __init__(
self,
key: str,
project_root: Path,
version: str = "",
*,
resolve_project_root: bool = True,
) -> None:
self.key = key
self.project_root = project_root.resolve()
self.project_root = (
project_root.resolve()
if resolve_project_root
else project_root.absolute()
)
self.version = version
self._files: dict[str, str] = {} # rel_path → sha256 hex
self._recovered_files: set[str] = set()
@@ -387,12 +399,18 @@ class IntegrationManifest:
return path
@classmethod
def load(cls, key: str, project_root: Path) -> IntegrationManifest:
def load(
cls,
key: str,
project_root: Path,
*,
resolve_project_root: bool = True,
) -> IntegrationManifest:
"""Load an existing manifest from disk.
Raises ``FileNotFoundError`` if the manifest does not exist.
"""
inst = cls(key, project_root)
inst = cls(key, project_root, resolve_project_root=resolve_project_root)
path = inst.manifest_path
try:
data = json.loads(path.read_text(encoding="utf-8"))