Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
21 changes: 21 additions & 0 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,27 @@ 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.

## Report Integration Status
Comment thread
PascalThuet marked this conversation as resolved.

```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;
it also reports the raw recorded integrations and the integration manifests that
were checked when state repair heuristics differ from the recorded file.
The command exits 0 when the report status is `ok` or `warning`; it exits 1
only when the report status is `error`. In JSON output, `multi_install_safe`
is `null` when no installed integration set can be evaluated, such as when the
integration state is missing, unreadable, lacks a valid recorded integration
list, or records no installed integrations.

## Integration-Specific Options

Some integrations accept additional options via `--integration-options`:
Expand Down
80 changes: 80 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1615,6 +1616,85 @@ 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(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'}")
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'))}"
)
Comment thread
PascalThuet marked this conversation as resolved.
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)"),
Expand Down
45 changes: 37 additions & 8 deletions src/specify_cli/integration_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Comment thread
PascalThuet marked this conversation as resolved.
"""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
Expand Down Expand Up @@ -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
Comment thread
mnriem marked this conversation as resolved.


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():
Expand Down
Loading
Loading