diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 8de2c18c86..1811e5b498 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -50,15 +50,27 @@ Removes an installed extension. Configuration files are backed up by default; us ```bash specify extension list +specify extension list --json ``` | Option | Description | | ------------- | -------------------------------------------------- | | `--available` | Show available (uninstalled) extensions | | `--all` | Show both installed and available extensions | +| `--json` | Write installed extensions as JSON | Lists installed extensions with their status, version, and command counts. +`--json` writes a JSON array to stdout. Every item has the keys `id`, `name`, +`description`, `version`, `author`, `priority`, `enabled`, `source`, and +`provides`. `author` is `null` when absent; `source` is `{"kind":"local"}` +for local, legacy, or malformed provenance, or +`{"kind":"catalog","catalog":""}` for a valid catalog source. +Extension `provides` contains `commands`, `templates`, `scripts`, and `hooks` +counts. `--available` and `--all` do not broaden JSON output beyond installed +extensions. For runtime failures after option parsing, `--json` writes +`{"error":"..."}` to stderr and exits nonzero. + ## Extension Info ```bash diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 1098abfb42..9b515a6555 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -43,10 +43,20 @@ Removes an installed preset and cleans up its registered commands. ```bash specify preset list +specify preset list --json ``` Lists installed presets with their versions, descriptions, template counts, and current status. +`--json` writes a JSON array to stdout. Every item has the keys `id`, `name`, +`description`, `version`, `author`, `priority`, `enabled`, `source`, and +`provides`. `author` is `null` when absent; `source` is `{"kind":"local"}` +for local, legacy, or malformed provenance, or +`{"kind":"catalog","catalog":""}` for a valid catalog source. +Preset `provides` contains `commands`, `templates`, and `scripts` counts. For +runtime failures after option parsing, `--json` writes `{"error":"..."}` to +stderr and exits nonzero. + Presets are printed in **resolution/precedence order**: the highest-precedence preset (lowest priority number) is listed first, and ties on priority are broken alphabetically by preset id. This matches the order used when composing commands and resolving templates, so the top entry is the one that wins for overlapping files. ## Preset Info diff --git a/src/specify_cli/_installed_list_json.py b/src/specify_cli/_installed_list_json.py new file mode 100644 index 0000000000..c0893d553c --- /dev/null +++ b/src/specify_cli/_installed_list_json.py @@ -0,0 +1,63 @@ +"""Private JSON output helpers for installed preset and extension lists. + +This module intentionally serves only the two installed-list commands. Their +human-facing renderers retain the legacy manager records, while this adapter +defines the public machine-readable wire contract. +""" +from __future__ import annotations + +import json +from typing import Any, NoReturn + +import typer + + +def _normalized_source(source: Any) -> dict[str, str]: + """Return the stable public source shape for an installed record.""" + if not isinstance(source, dict): + return {"kind": "local"} + + kind = source.get("kind") + if kind == "local": + return {"kind": "local"} + if kind == "catalog": + catalog = source.get("catalog") + if isinstance(catalog, str) and catalog.strip(): + return {"kind": "catalog", "catalog": catalog} + + return {"kind": "local"} + + +def installed_list_item(record: dict[str, Any], *, include_hooks: bool) -> dict[str, Any]: + """Return the canonical public JSON object for one installed record.""" + provides = record["_json_provides"] + if not include_hooks: + provides = { + "commands": provides["commands"], + "templates": provides["templates"], + "scripts": provides["scripts"], + } + + return { + "id": record["id"], + "name": record["name"], + "description": record["description"], + "version": record["version"], + "author": record["_json_author"], + "priority": record["priority"], + "enabled": record["enabled"], + "source": _normalized_source(record["_json_source"]), + "provides": provides, + } + + +def emit_json(value: Any) -> None: + """Write one JSON value to stdout without Rich rendering.""" + typer.echo(json.dumps(value, ensure_ascii=False)) + + +def emit_json_error(error: Exception) -> NoReturn: + """Write the list-command error contract and terminate unsuccessfully.""" + message = str(error).strip() or error.__class__.__name__ + typer.echo(json.dumps({"error": message}, ensure_ascii=False), err=True) + raise typer.Exit(code=1) diff --git a/src/specify_cli/_project.py b/src/specify_cli/_project.py index 1a583809b5..9ed2fe1508 100644 --- a/src/specify_cli/_project.py +++ b/src/specify_cli/_project.py @@ -10,6 +10,28 @@ from ._console import err_console +class ProjectResolutionError(RuntimeError): + """A project-root error that callers can render for their own surface.""" + + +def _resolve_init_dir_override_unrendered() -> Path | None: + """Resolve ``SPECIFY_INIT_DIR`` without emitting user-facing output.""" + raw = os.environ.get("SPECIFY_INIT_DIR", "") + if not raw: + return None + init_root = (Path.cwd() / raw).resolve() + if not init_root.is_dir(): + raise ProjectResolutionError( + f"SPECIFY_INIT_DIR does not point to an existing directory: {raw}" + ) + if not (init_root / ".specify").is_dir(): + raise ProjectResolutionError( + "SPECIFY_INIT_DIR is not a Spec Kit project " + f"(no .specify/ directory): {init_root}" + ) + return init_root + + def _resolve_init_dir_override() -> Path | None: """Resolve the ``SPECIFY_INIT_DIR`` project override for the Python CLI. @@ -33,21 +55,24 @@ def _resolve_init_dir_override() -> Path | None: here (a stable project identity), so this is a deliberate, documented variance, not a parity guarantee on the resolved string. """ - raw = os.environ.get("SPECIFY_INIT_DIR", "") - if not raw: - return None - # Relative values resolve against cwd; an absolute value stands alone (Path's - # `/` drops the left operand when the right is absolute). resolve() also - # collapses a trailing slash and canonicalizes symlinks. - init_root = (Path.cwd() / raw).resolve() - if not init_root.is_dir(): - err_console.print( - f"[red]Error:[/red] SPECIFY_INIT_DIR does not point to an existing directory: {raw}" - ) + try: + return _resolve_init_dir_override_unrendered() + except ProjectResolutionError as error: + err_console.print(f"[red]Error:[/red] {error}") raise typer.Exit(1) - if not (init_root / ".specify").is_dir(): - err_console.print( - f"[red]Error:[/red] SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): {init_root}" - ) - raise typer.Exit(1) - return init_root + + +def resolve_specify_project_root() -> Path: + """Return the active project root without rendering errors. + + This is deliberately separate from ``_require_specify_project`` so the + installed-list JSON contract can send structured failures to stderr without + changing the Rich diagnostics used by every other project-scoped command. + """ + override = _resolve_init_dir_override_unrendered() + if override is not None: + return override + project_root = Path.cwd() + if not (project_root / ".specify").is_dir(): + raise ProjectResolutionError("Not a Spec Kit project (no .specify/ directory)") + return project_root diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index fb4a30519d..614d5fe31d 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3413,6 +3413,11 @@ def list_installed(self) -> List[Dict[str, Any]]: try: manifest = ExtensionManifest(manifest_path) + author = manifest.data["extension"].get("author") + json_hook_count = sum( + len(coerce_hook_entries(hook_config)) + for hook_config in manifest.hooks.values() + ) result.append( { "id": ext_id, @@ -3424,6 +3429,14 @@ def list_installed(self) -> List[Dict[str, Any]]: "installed_at": metadata.get("installed_at"), "command_count": len(manifest.commands), "hook_count": len(manifest.hooks), + "_json_author": author if isinstance(author, str) and author else None, + "_json_source": metadata.get("source"), + "_json_provides": { + "commands": len(manifest.commands), + "templates": len(manifest.templates), + "scripts": len(manifest.scripts), + "hooks": json_hook_count, + }, } ) except ValidationError: @@ -3439,6 +3452,9 @@ def list_installed(self) -> List[Dict[str, Any]]: "installed_at": metadata.get("installed_at"), "command_count": 0, "hook_count": 0, + "_json_author": None, + "_json_source": metadata.get("source"), + "_json_provides": {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0}, } ) diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 7f7933e934..11ec17b8fb 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -25,6 +25,8 @@ from rich.table import Table from .._console import console +from .._installed_list_json import emit_json, emit_json_error, installed_list_item +from .._project import resolve_specify_project_root from .._assets import get_speckit_version from .._download_security import ( archive_format_from_name, @@ -419,10 +421,21 @@ def _resolve_catalog_extension( def extension_list( available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"), all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"), + json_output: bool = typer.Option(False, "--json", help="Output installed extensions as JSON"), ): """List installed extensions.""" from . import ExtensionManager + if json_output: + try: + project_root = resolve_specify_project_root() + manager = ExtensionManager(project_root) + installed = manager.list_installed() + emit_json([installed_list_item(ext, include_hooks=True) for ext in installed]) + return + except Exception as error: + emit_json_error(error) + project_root = _require_specify_project() manager = ExtensionManager(project_root) installed = manager.list_installed() diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 3d37f6fb74..4738f05276 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -4063,6 +4063,10 @@ def list_installed(self) -> List[Dict[str, Any]]: try: manifest = PresetManifest(manifest_path) + provided_counts = {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0} + for template in manifest.templates: + provided_counts[f"{template['type']}s"] += 1 + author = manifest.author result.append({ "id": pack_id, "name": manifest.name, @@ -4073,6 +4077,9 @@ def list_installed(self) -> List[Dict[str, Any]]: "template_count": len(manifest.templates), "tags": manifest.tags, "priority": normalize_priority(metadata.get("priority")), + "_json_author": author if isinstance(author, str) and author else None, + "_json_source": metadata.get("source"), + "_json_provides": provided_counts, }) except PresetValidationError: result.append({ @@ -4085,6 +4092,9 @@ def list_installed(self) -> List[Dict[str, Any]]: "template_count": 0, "tags": [], "priority": normalize_priority(metadata.get("priority")), + "_json_author": None, + "_json_source": metadata.get("source"), + "_json_provides": {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0}, }) return result diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index 48d5c9f14f..87107c3539 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -17,6 +17,8 @@ from rich.markup import escape as _escape_markup from .._console import console +from .._installed_list_json import emit_json, emit_json_error, installed_list_item +from .._project import resolve_specify_project_root from .._download_security import ( archive_format_from_name, archive_suffix, @@ -44,11 +46,27 @@ @preset_app.command("list") -def preset_list(): +def preset_list( + json_output: bool = typer.Option(False, "--json", help="Output installed presets as JSON"), +): """List installed presets.""" from .. import _require_specify_project from . import PresetManager + if json_output: + try: + project_root = resolve_specify_project_root() + manager = PresetManager(project_root) + installed = manager.list_installed() + installed = sorted( + installed, + key=lambda pack: (pack.get("priority", 10), str(pack.get("id", ""))), + ) + emit_json([installed_list_item(pack, include_hooks=False) for pack in installed]) + return + except Exception as error: + emit_json_error(error) + project_root = _require_specify_project() manager = PresetManager(project_root) installed = manager.list_installed() diff --git a/tests/test_installed_list_json.py b/tests/test_installed_list_json.py new file mode 100644 index 0000000000..53c725f493 --- /dev/null +++ b/tests/test_installed_list_json.py @@ -0,0 +1,288 @@ +"""Public JSON contracts for installed preset and extension lists.""" + +from __future__ import annotations + +import json + +import pytest +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli._installed_list_json import _normalized_source +from specify_cli.extensions import ExtensionManager +from specify_cli.presets import PresetManager + + +runner = CliRunner() + + +def _project(tmp_path): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + return project + + +def _preset(project, preset_id, *, author="Preset Author"): + preset_dir = project / ".specify" / "presets" / preset_id + preset_dir.mkdir(parents=True) + author_line = f' author: "{author}"\n' if author is not None else "" + (preset_dir / "preset.yml").write_text( + "schema_version: \"1.0\"\n" + "preset:\n" + f" id: {preset_id}\n" + f" name: {preset_id} name\n" + " version: \"1.0.0\"\n" + " description: preset description\n" + f"{author_line}" + "requires:\n" + " speckit_version: \">=0.1.0\"\n" + "provides:\n" + " templates:\n" + " - type: template\n" + " name: base-template\n" + " file: templates/base.md\n" + " - type: command\n" + " name: speckit.example\n" + " file: commands/example.md\n" + " - type: script\n" + " name: setup-script\n" + " file: scripts/setup.py\n", + encoding="utf-8", + ) + + +def _extension(project, extension_id, *, author=None): + extension_dir = project / ".specify" / "extensions" / extension_id + extension_dir.mkdir(parents=True) + author_line = f' author: "{author}"\n' if author is not None else "" + (extension_dir / "extension.yml").write_text( + "schema_version: \"1.0\"\n" + "extension:\n" + f" id: {extension_id}\n" + f" name: {extension_id} name\n" + " version: \"1.0.0\"\n" + " description: extension description\n" + f"{author_line}" + "requires:\n" + " speckit_version: \">=0.1.0\"\n" + "provides:\n" + " commands:\n" + " - name: speckit.example-ext.example\n" + " file: commands/example.md\n", + encoding="utf-8", + ) + + +def _json_result(result): + assert result.exit_code == 0, result.output + assert result.stderr == "" + return json.loads(result.stdout) + + +def test_preset_list_json_uses_canonical_wire_object_and_keeps_flat_manager_keys(tmp_path, monkeypatch): + project = _project(tmp_path) + _preset(project, "test-preset") + manager = PresetManager(project) + source = {"kind": "catalog", "catalog": "speckit-official"} + manager.registry.add("test-preset", {"version": "1.0.0", "source": source, "priority": 3}) + + record = manager.list_installed()[0] + assert record["template_count"] == 3 + assert record["_json_source"] == source + assert record["_json_provides"] == {"commands": 1, "templates": 1, "scripts": 1, "hooks": 0} + + monkeypatch.chdir(project) + payload = _json_result(runner.invoke(app, ["preset", "list", "--json"])) + + assert len(payload) == 1 + item = payload[0] + assert set(item) == { + "id", "name", "description", "version", "author", "priority", "enabled", "source", "provides" + } + assert item["author"] == "Preset Author" + assert item["source"] == source + assert item["provides"] == {"commands": 1, "templates": 1, "scripts": 1} + assert "hooks" not in item["provides"] + + +def test_preset_list_json_defaults_legacy_source_and_author(tmp_path, monkeypatch): + project = _project(tmp_path) + _preset(project, "legacy-preset", author=None) + PresetManager(project).registry.add("legacy-preset", {"version": "1.0.0"}) + + monkeypatch.chdir(project) + item = _json_result(runner.invoke(app, ["preset", "list", "--json"]))[0] + + assert item["author"] is None + assert item["source"] == {"kind": "local"} + + +def test_extension_list_json_is_installed_only_for_available_and_all(tmp_path, monkeypatch): + project = _project(tmp_path) + _extension(project, "example-ext") + source = {"kind": "catalog", "catalog": "speckit-official"} + ExtensionManager(project).registry.add("example-ext", {"version": "1.0.0", "source": source}) + + monkeypatch.chdir(project) + expected = _json_result(runner.invoke(app, ["extension", "list", "--json"])) + available = _json_result(runner.invoke(app, ["extension", "list", "--json", "--available"])) + all_extensions = _json_result(runner.invoke(app, ["extension", "list", "--json", "--all"])) + + assert available == expected == all_extensions + item = expected[0] + assert set(item) == { + "id", "name", "description", "version", "author", "priority", "enabled", "source", "provides" + } + assert item["author"] is None + assert item["source"] == source + assert item["provides"] == {"commands": 1, "templates": 0, "scripts": 0, "hooks": 0} + + +def test_empty_json_lists_are_successful_arrays(tmp_path, monkeypatch): + project = _project(tmp_path) + monkeypatch.chdir(project) + + assert _json_result(runner.invoke(app, ["preset", "list", "--json"])) == [] + assert _json_result(runner.invoke(app, ["extension", "list", "--json"])) == [] + + +def test_preset_list_json_preserves_catalog_source_for_corrupt_records(tmp_path, monkeypatch): + project = _project(tmp_path) + source = {"kind": "catalog", "catalog": "speckit-official"} + PresetManager(project).registry.add("broken-preset", {"version": "1.0.0", "source": source}) + + monkeypatch.chdir(project) + item = _json_result(runner.invoke(app, ["preset", "list", "--json"]))[0] + + assert item["author"] is None + assert item["source"] == source + assert item["provides"] == {"commands": 0, "templates": 0, "scripts": 0} + + +def test_extension_list_json_preserves_catalog_source_for_corrupt_records(tmp_path, monkeypatch): + project = _project(tmp_path) + source = {"kind": "catalog", "catalog": "speckit-official"} + ExtensionManager(project).registry.add("broken-extension", {"version": "1.0.0", "source": source}) + + monkeypatch.chdir(project) + item = _json_result(runner.invoke(app, ["extension", "list", "--json"]))[0] + + assert item["source"] == source + assert item["provides"] == {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0} + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + ({"kind": "local", "catalog": "ignored", "extra": "ignored"}, {"kind": "local"}), + ( + {"kind": "catalog", "catalog": "speckit-official", "extra": "ignored"}, + {"kind": "catalog", "catalog": "speckit-official"}, + ), + ([], {"kind": "local"}), + ({"kind": "catalog"}, {"kind": "local"}), + ({"kind": "catalog", "catalog": " "}, {"kind": "local"}), + ({"kind": "catalog", "catalog": 1}, {"kind": "local"}), + ], +) +def test_normalized_source_whitelists_valid_shapes_and_falls_back(source, expected): + assert _normalized_source(source) == expected + + +def test_installed_list_json_falls_back_for_legacy_unknown_and_malformed_sources(tmp_path, monkeypatch): + project = _project(tmp_path) + _preset(project, "legacy-preset") + _preset(project, "malformed-preset") + _extension(project, "unknown-ext") + PresetManager(project).registry.add("legacy-preset", {"version": "1.0.0", "source": "catalog"}) + PresetManager(project).registry.add( + "malformed-preset", {"version": "1.0.0", "source": {"kind": "catalog", "catalog": []}} + ) + ExtensionManager(project).registry.add( + "unknown-ext", {"version": "1.0.0", "source": {"kind": "remote", "catalog": "other"}} + ) + + monkeypatch.chdir(project) + presets = _json_result(runner.invoke(app, ["preset", "list", "--json"])) + extension = _json_result(runner.invoke(app, ["extension", "list", "--json"]))[0] + + assert {item["id"]: item["source"] for item in presets} == { + "legacy-preset": {"kind": "local"}, + "malformed-preset": {"kind": "local"}, + } + assert extension["source"] == {"kind": "local"} + + +def test_extension_json_counts_multiple_hooks_for_one_event(tmp_path, monkeypatch): + project = _project(tmp_path) + extension_dir = project / ".specify" / "extensions" / "multi-hook" + extension_dir.mkdir(parents=True) + (extension_dir / "extension.yml").write_text( + "schema_version: \"1.0\"\n" + "extension:\n" + " id: multi-hook\n" + " name: Multi Hook\n" + " version: \"1.0.0\"\n" + " description: Multiple hooks on one event\n" + "requires:\n" + " speckit_version: \">=0.1.0\"\n" + "provides:\n" + " commands:\n" + " - name: speckit.multi-hook.one\n" + " file: commands/one.md\n" + "hooks:\n" + " after_plan:\n" + " - command: speckit.multi-hook.one\n" + " - command: speckit.multi-hook.two\n", + encoding="utf-8", + ) + manager = ExtensionManager(project) + manager.registry.add("multi-hook", {"version": "1.0.0"}) + + assert manager.list_installed()[0]["hook_count"] == 1 + + monkeypatch.chdir(project) + item = _json_result(runner.invoke(app, ["extension", "list", "--json"]))[0] + + assert item["provides"]["hooks"] == 2 + + +def test_text_list_rendering_retains_legacy_flat_counts(tmp_path, monkeypatch): + project = _project(tmp_path) + _preset(project, "text-preset") + PresetManager(project).registry.add("text-preset", {"version": "1.0.0"}) + _extension(project, "example-ext") + ExtensionManager(project).registry.add("example-ext", {"version": "1.0.0"}) + + monkeypatch.chdir(project) + preset_result = runner.invoke(app, ["preset", "list"]) + extension_result = runner.invoke(app, ["extension", "list"]) + + assert preset_result.exit_code == extension_result.exit_code == 0 + assert "Templates: 3" in preset_result.stdout + assert "Commands: 1 | Hooks: 0" in extension_result.stdout + + +def test_preset_json_project_resolution_error_is_stderr_only(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["preset", "list", "--json"]) + + assert result.exit_code != 0 + assert result.stdout == "" + assert json.loads(result.stderr) == {"error": "Not a Spec Kit project (no .specify/ directory)"} + + +def test_extension_json_runtime_error_is_stderr_only(tmp_path, monkeypatch): + project = _project(tmp_path) + monkeypatch.chdir(project) + + def fail_list(_self): + raise RuntimeError("list failed") + + monkeypatch.setattr(ExtensionManager, "list_installed", fail_list) + result = runner.invoke(app, ["extension", "list", "--json"]) + + assert result.exit_code != 0 + assert result.stdout == "" + assert json.loads(result.stderr) == {"error": "list failed"}