From 6c7277d361c2653c9428d313a9ea4d815eda65f4 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 20 Aug 2026 16:03:06 +0800 Subject: [PATCH 1/2] feat(cli): add JSON output for installed lists --- docs/reference/extensions.md | 11 ++ docs/reference/presets.md | 8 + src/specify_cli/_installed_list_json.py | 47 +++++ src/specify_cli/_project.py | 59 ++++-- src/specify_cli/extensions/__init__.py | 30 ++++ src/specify_cli/extensions/_commands.py | 13 ++ src/specify_cli/presets/__init__.py | 26 +++ src/specify_cli/presets/_commands.py | 20 ++- tests/test_installed_list_json.py | 228 ++++++++++++++++++++++++ 9 files changed, 424 insertions(+), 18 deletions(-) create mode 100644 src/specify_cli/_installed_list_json.py create mode 100644 tests/test_installed_list_json.py diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 8de2c18c86..70bd14ac28 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -50,15 +50,26 @@ 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 either +`{"kind":"local"}` or `{"kind":"catalog"}`. 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..deeb941c30 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -43,10 +43,18 @@ 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 either +`{"kind":"local"}` or `{"kind":"catalog"}`. 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..836a71b67c --- /dev/null +++ b/src/specify_cli/_installed_list_json.py @@ -0,0 +1,47 @@ +"""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 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": {"kind": record["_json_source_kind"]}, + "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..a853e07147 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3413,6 +3413,18 @@ def list_installed(self) -> List[Dict[str, Any]]: try: manifest = ExtensionManifest(manifest_path) + source = metadata.get("source") + source_kind = source.get("kind") if isinstance(source, dict) else source + source_kind = ( + source_kind + if isinstance(source_kind, str) and source_kind in {"local", "catalog"} + else "local" + ) + 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,10 +3436,25 @@ 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_kind": source_kind, + "_json_provides": { + "commands": len(manifest.commands), + "templates": len(manifest.templates), + "scripts": len(manifest.scripts), + "hooks": json_hook_count, + }, } ) except ValidationError: # Corrupted extension + source = metadata.get("source") + source_kind = source.get("kind") if isinstance(source, dict) else source + source_kind = ( + source_kind + if isinstance(source_kind, str) and source_kind in {"local", "catalog"} + else "local" + ) result.append( { "id": ext_id, @@ -3439,6 +3466,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_kind": source_kind, + "_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..f30d289a73 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -4063,6 +4063,19 @@ 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 + source = metadata.get("source") + source_kind = ( + source.get("kind") if isinstance(source, dict) else source + ) + source_kind = ( + source_kind + if isinstance(source_kind, str) and source_kind in {"local", "catalog"} + else "local" + ) + author = manifest.author result.append({ "id": pack_id, "name": manifest.name, @@ -4073,8 +4086,18 @@ 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_kind": source_kind, + "_json_provides": provided_counts, }) except PresetValidationError: + source = metadata.get("source") + source_kind = source.get("kind") if isinstance(source, dict) else source + source_kind = ( + source_kind + if isinstance(source_kind, str) and source_kind in {"local", "catalog"} + else "local" + ) result.append({ "id": pack_id, "name": pack_id, @@ -4085,6 +4108,9 @@ def list_installed(self) -> List[Dict[str, Any]]: "template_count": 0, "tags": [], "priority": normalize_priority(metadata.get("priority")), + "_json_author": None, + "_json_source_kind": source_kind, + "_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..a6e5748d89 --- /dev/null +++ b/tests/test_installed_list_json.py @@ -0,0 +1,228 @@ +"""Public JSON contracts for installed preset and extension lists.""" + +from __future__ import annotations + +import json + +from typer.testing import CliRunner + +from specify_cli import app +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) + manager.registry.add("test-preset", {"version": "1.0.0", "source": "catalog", "priority": 3}) + + record = manager.list_installed()[0] + assert record["template_count"] == 3 + 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"] == {"kind": "catalog"} + 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") + ExtensionManager(project).registry.add("example-ext", {"version": "1.0.0", "source": "unknown"}) + + 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"] == {"kind": "local"} + 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_degrades_corrupt_records_and_malformed_sources(tmp_path, monkeypatch): + project = _project(tmp_path) + PresetManager(project).registry.add("broken-preset", {"version": "1.0.0", "source": []}) + + monkeypatch.chdir(project) + item = _json_result(runner.invoke(app, ["preset", "list", "--json"]))[0] + + assert item["author"] is None + assert item["source"] == {"kind": "local"} + assert item["provides"] == {"commands": 0, "templates": 0, "scripts": 0} + + +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"} From 1e4f38c2e51dff5308c204740d7450cf6b52a5ee Mon Sep 17 00:00:00 2001 From: root Date: Fri, 21 Aug 2026 14:18:43 +0800 Subject: [PATCH 2/2] fix(cli): preserve installed source provenance in JSON Preserve valid catalog provenance in installed preset and extension JSON output while retaining the local fallback for missing, legacy, unknown, and malformed records. Carry raw registry source metadata through healthy and corrupt manager records, whitelist the public kind/catalog shape in the shared adapter, and document and test the contract without changing provenance producers. --- docs/reference/extensions.md | 13 +++-- docs/reference/presets.md | 10 ++-- src/specify_cli/_installed_list_json.py | 18 +++++- src/specify_cli/extensions/__init__.py | 18 +----- src/specify_cli/presets/__init__.py | 20 +------ tests/test_installed_list_json.py | 74 ++++++++++++++++++++++--- 6 files changed, 101 insertions(+), 52 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 70bd14ac28..1811e5b498 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -63,12 +63,13 @@ 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 either -`{"kind":"local"}` or `{"kind":"catalog"}`. 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. +`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 diff --git a/docs/reference/presets.md b/docs/reference/presets.md index deeb941c30..9b515a6555 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -50,10 +50,12 @@ Lists installed presets with their versions, descriptions, template counts, and `--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 either -`{"kind":"local"}` or `{"kind":"catalog"}`. Preset `provides` contains -`commands`, `templates`, and `scripts` counts. For runtime failures after -option parsing, `--json` writes `{"error":"..."}` to stderr and exits nonzero. +`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. diff --git a/src/specify_cli/_installed_list_json.py b/src/specify_cli/_installed_list_json.py index 836a71b67c..c0893d553c 100644 --- a/src/specify_cli/_installed_list_json.py +++ b/src/specify_cli/_installed_list_json.py @@ -12,6 +12,22 @@ 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"] @@ -30,7 +46,7 @@ def installed_list_item(record: dict[str, Any], *, include_hooks: bool) -> dict[ "author": record["_json_author"], "priority": record["priority"], "enabled": record["enabled"], - "source": {"kind": record["_json_source_kind"]}, + "source": _normalized_source(record["_json_source"]), "provides": provides, } diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index a853e07147..614d5fe31d 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3413,13 +3413,6 @@ def list_installed(self) -> List[Dict[str, Any]]: try: manifest = ExtensionManifest(manifest_path) - source = metadata.get("source") - source_kind = source.get("kind") if isinstance(source, dict) else source - source_kind = ( - source_kind - if isinstance(source_kind, str) and source_kind in {"local", "catalog"} - else "local" - ) author = manifest.data["extension"].get("author") json_hook_count = sum( len(coerce_hook_entries(hook_config)) @@ -3437,7 +3430,7 @@ def list_installed(self) -> List[Dict[str, Any]]: "command_count": len(manifest.commands), "hook_count": len(manifest.hooks), "_json_author": author if isinstance(author, str) and author else None, - "_json_source_kind": source_kind, + "_json_source": metadata.get("source"), "_json_provides": { "commands": len(manifest.commands), "templates": len(manifest.templates), @@ -3448,13 +3441,6 @@ def list_installed(self) -> List[Dict[str, Any]]: ) except ValidationError: # Corrupted extension - source = metadata.get("source") - source_kind = source.get("kind") if isinstance(source, dict) else source - source_kind = ( - source_kind - if isinstance(source_kind, str) and source_kind in {"local", "catalog"} - else "local" - ) result.append( { "id": ext_id, @@ -3467,7 +3453,7 @@ def list_installed(self) -> List[Dict[str, Any]]: "command_count": 0, "hook_count": 0, "_json_author": None, - "_json_source_kind": source_kind, + "_json_source": metadata.get("source"), "_json_provides": {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0}, } ) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index f30d289a73..4738f05276 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -4066,15 +4066,6 @@ def list_installed(self) -> List[Dict[str, Any]]: provided_counts = {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0} for template in manifest.templates: provided_counts[f"{template['type']}s"] += 1 - source = metadata.get("source") - source_kind = ( - source.get("kind") if isinstance(source, dict) else source - ) - source_kind = ( - source_kind - if isinstance(source_kind, str) and source_kind in {"local", "catalog"} - else "local" - ) author = manifest.author result.append({ "id": pack_id, @@ -4087,17 +4078,10 @@ def list_installed(self) -> List[Dict[str, Any]]: "tags": manifest.tags, "priority": normalize_priority(metadata.get("priority")), "_json_author": author if isinstance(author, str) and author else None, - "_json_source_kind": source_kind, + "_json_source": metadata.get("source"), "_json_provides": provided_counts, }) except PresetValidationError: - source = metadata.get("source") - source_kind = source.get("kind") if isinstance(source, dict) else source - source_kind = ( - source_kind - if isinstance(source_kind, str) and source_kind in {"local", "catalog"} - else "local" - ) result.append({ "id": pack_id, "name": pack_id, @@ -4109,7 +4093,7 @@ def list_installed(self) -> List[Dict[str, Any]]: "tags": [], "priority": normalize_priority(metadata.get("priority")), "_json_author": None, - "_json_source_kind": source_kind, + "_json_source": metadata.get("source"), "_json_provides": {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0}, }) diff --git a/tests/test_installed_list_json.py b/tests/test_installed_list_json.py index a6e5748d89..53c725f493 100644 --- a/tests/test_installed_list_json.py +++ b/tests/test_installed_list_json.py @@ -4,9 +4,11 @@ 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 @@ -81,10 +83,12 @@ def test_preset_list_json_uses_canonical_wire_object_and_keeps_flat_manager_keys project = _project(tmp_path) _preset(project, "test-preset") manager = PresetManager(project) - manager.registry.add("test-preset", {"version": "1.0.0", "source": "catalog", "priority": 3}) + 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) @@ -96,7 +100,7 @@ def test_preset_list_json_uses_canonical_wire_object_and_keeps_flat_manager_keys "id", "name", "description", "version", "author", "priority", "enabled", "source", "provides" } assert item["author"] == "Preset Author" - assert item["source"] == {"kind": "catalog"} + assert item["source"] == source assert item["provides"] == {"commands": 1, "templates": 1, "scripts": 1} assert "hooks" not in item["provides"] @@ -116,7 +120,8 @@ def test_preset_list_json_defaults_legacy_source_and_author(tmp_path, monkeypatc def test_extension_list_json_is_installed_only_for_available_and_all(tmp_path, monkeypatch): project = _project(tmp_path) _extension(project, "example-ext") - ExtensionManager(project).registry.add("example-ext", {"version": "1.0.0", "source": "unknown"}) + 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"])) @@ -129,7 +134,7 @@ def test_extension_list_json_is_installed_only_for_available_and_all(tmp_path, m "id", "name", "description", "version", "author", "priority", "enabled", "source", "provides" } assert item["author"] is None - assert item["source"] == {"kind": "local"} + assert item["source"] == source assert item["provides"] == {"commands": 1, "templates": 0, "scripts": 0, "hooks": 0} @@ -141,18 +146,73 @@ def test_empty_json_lists_are_successful_arrays(tmp_path, monkeypatch): assert _json_result(runner.invoke(app, ["extension", "list", "--json"])) == [] -def test_preset_list_json_degrades_corrupt_records_and_malformed_sources(tmp_path, monkeypatch): +def test_preset_list_json_preserves_catalog_source_for_corrupt_records(tmp_path, monkeypatch): project = _project(tmp_path) - PresetManager(project).registry.add("broken-preset", {"version": "1.0.0", "source": []}) + 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"] == {"kind": "local"} + 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"