diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 8d9eaaada7..7af8ec9d32 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -90,6 +90,7 @@ including for help, the existing human-readable behavior is unchanged. ```bash specify extension info specify extension info --versions +specify extension info --json ``` Shows detailed information about an installed or available extension, including its description, version, commands, and configuration. @@ -131,6 +132,25 @@ rejected. Bundle pins still use the current catalog resolution path until the separate bundle work described in [#4719](https://github.com/github/spec-kit/issues/4719) adds exact-version component lookup. +`--json` describes one installed extension and writes a single JSON object to +stdout. `` is matched as without `--json`: the extension ID first, then a +unique display name, ignoring case. The object has the same `id`, `name`, +`description`, `version`, `author`, `priority`, `enabled`, and `source` keys as +the matching `specify extension list --json` item. In place of the `provides` +counts it has `commands`, `templates`, `scripts`, and `hooks` arrays. Command, +template, and script entries have `name`, `description` (`""` when absent), +`source` (the extension's `source`), and `sourcePath` (the manifest `file`, +relative to the extension directory); script entries also have `runtimes` when +the manifest declares them. Extension entries carry no `strategy`, because +extension-provided files always replace. Each hook entry has `trigger` (the +hook event, such as `after_tasks`), `targetCommand`, `optional` (default +`true`), and `priority` (default `10`). When an event declares the same command +more than once, the last declaration wins, as it does when hooks are +registered. `--json` cannot be combined with `--versions` (usage error, exit 2). +An extension that is not installed, an ambiguous name, or a missing project +writes one `{"error":"..."}` object to stderr and exits 1; usage errors keep +their exit code (normally 2), as with `list --json`. + ## Update Extensions ```bash diff --git a/docs/reference/presets.md b/docs/reference/presets.md index d51d3ace8a..0661e9d9e9 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -105,10 +105,24 @@ Presets are printed in **resolution/precedence order**: the highest-precedence p ```bash specify preset info +specify preset info --json ``` Shows detailed information about an installed or available preset, including its templates, metadata, and tags. +`--json` describes one installed preset, matched by ID, and writes a single +JSON object to stdout. The object has the same `id`, `name`, `description`, +`version`, `author`, `priority`, `enabled`, and `source` keys as the matching +`specify preset list --json` item. In place of the `provides` counts it has +`commands`, `templates`, and `scripts` arrays, one entry per manifest +contribution in declaration order. Each entry has `name`, `description` (`""` +when absent), `source` (the preset's `source`), `sourcePath` (the manifest +`file`, relative to the preset directory), and `strategy` (`replace`, +`prepend`, `append`, or `wrap`; `replace` when the manifest omits it). A preset +that is not installed (including one that is only in a catalog), or a missing +project, writes one `{"error":"..."}` object to stderr and exits 1; usage errors +keep their exit code (normally 2), as with `list --json`. + ## Resolve a File ```bash diff --git a/src/specify_cli/_installed_info_json.py b/src/specify_cli/_installed_info_json.py new file mode 100644 index 0000000000..cbffc6613d --- /dev/null +++ b/src/specify_cli/_installed_info_json.py @@ -0,0 +1,110 @@ +"""Private JSON output helpers for installed preset and extension info. + +``preset info --json`` and ``extension info --json`` describe one installed +pack. The top-level fields are the ``list --json`` item for that pack (see +``_installed_list_json``); its ``provides`` counts are replaced by one array +per contribution kind, so the detail view and the summary view never drift. +""" +from __future__ import annotations + +from typing import Any + +from ._installed_list_json import installed_list_item + + +def _find_installed( + records: list[dict[str, Any]], key: str, kind: str, *, match_names: bool +) -> dict[str, Any]: + """Return the installed record for an ID, or (optionally) a unique display name. + + The lookup mirrors the human-readable ``info`` commands: presets resolve by + ID only, extensions also accept a case-insensitive display name. + """ + for record in records: + if record["id"] == key: + return record + by_name = [] + if match_names: + by_name = [record for record in records if str(record["name"]).lower() == key.lower()] + if len(by_name) == 1: + return by_name[0] + if by_name: + raise ValueError( + f"{kind.capitalize()} name '{key}' is ambiguous; use one of the IDs: " + + ", ".join(sorted(str(record["id"]) for record in by_name)) + ) + raise ValueError(f"{kind.capitalize()} '{key}' is not installed") + + +def _contribution(entry: dict[str, Any], source: dict[str, str]) -> dict[str, Any]: + """Return one command, template or script entry of an installed pack.""" + return { + "name": entry["name"], + "description": entry.get("description", ""), + "source": dict(source), + "sourcePath": entry["file"], + } + + +def preset_info_item(records: list[dict[str, Any]], manager: Any, key: str) -> dict[str, Any]: + """Return the JSON object for one installed preset.""" + record = _find_installed(records, key, "preset", match_names=False) + manifest = manager.get_pack(record["id"]) + if manifest is None: + raise ValueError(f"Preset '{record['id']}' has an unreadable manifest") + + item = installed_list_item(record, include_hooks=False) + del item["provides"] + groups: dict[str, list[dict[str, Any]]] = {"commands": [], "templates": [], "scripts": []} + for template in manifest.templates: + entry = _contribution(template, item["source"]) + entry["strategy"] = template.get("strategy", "replace") + groups[f"{template['type']}s"].append(entry) + return {**item, **groups} + + +def extension_info_item(records: list[dict[str, Any]], manager: Any, key: str) -> dict[str, Any]: + """Return the JSON object for one installed extension.""" + from .extensions import DEFAULT_HOOK_PRIORITY, coerce_hook_entries, normalize_priority + + record = _find_installed(records, key, "extension", match_names=True) + manifest = manager.get_extension(record["id"]) + if manifest is None: + raise ValueError(f"Extension '{record['id']}' has an unreadable manifest") + + item = installed_list_item(record, include_hooks=True) + del item["provides"] + scripts = [] + for script in manifest.scripts: + entry = _contribution(script, item["source"]) + if "runtimes" in script: + entry["runtimes"] = list(script["runtimes"]) + scripts.append(entry) + + # Read hooks the way hook registration does: per event, a later + # declaration for the same command replaces the earlier one, and the + # priority/optional defaults are the ones the hook executor applies. + hooks = [] + for event_name, hook_config in manifest.hooks.items(): + by_command: dict[str, dict[str, Any]] = {} + for entry in coerce_hook_entries(hook_config): + if isinstance(entry, dict) and entry.get("command"): + by_command.pop(entry["command"], None) + by_command[entry["command"]] = entry + for command, entry in by_command.items(): + hooks.append( + { + "trigger": event_name, + "targetCommand": command, + "optional": bool(entry.get("optional", True)), + "priority": normalize_priority(entry.get("priority"), DEFAULT_HOOK_PRIORITY), + } + ) + + return { + **item, + "commands": [_contribution(command, item["source"]) for command in manifest.commands], + "templates": [_contribution(template, item["source"]) for template in manifest.templates], + "scripts": scripts, + "hooks": hooks, + } diff --git a/src/specify_cli/extensions/command_info.py b/src/specify_cli/extensions/command_info.py index d1acd599a9..c0c6d31cc7 100644 --- a/src/specify_cli/extensions/command_info.py +++ b/src/specify_cli/extensions/command_info.py @@ -8,17 +8,35 @@ import typer from rich.markup import escape as _escape_markup +from .._installed_info_json import extension_info_item +from .._installed_list_json import InstalledListJSONCommand, emit_json, emit_json_error +from .._project import resolve_specify_project_root from . import _commands -@_commands.extension_app.command("info") +@_commands.extension_app.command("info", cls=InstalledListJSONCommand) def extension_info( extension: str = typer.Argument(help="Extension ID or name"), versions: bool = typer.Option(False, "--versions", help="List catalog versions"), + json_output: bool = typer.Option( + False, "--json", help="Output the installed extension as JSON" + ), ): """Show detailed information about an extension.""" from . import ExtensionCatalog, ExtensionManager, ExtensionError, normalize_priority + # Direct compatibility callers receive Typer's OptionInfo default rather + # than a parsed bool; only the CLI's explicit True enables these views. + if json_output is True: + if versions is True: + emit_json_error(ValueError("--json cannot be combined with --versions"), exit_code=2) + try: + manager = ExtensionManager(resolve_specify_project_root()) + emit_json(extension_info_item(manager.list_installed(), manager, extension)) + return + except Exception as error: # noqa: BLE001 - emit the JSON error contract + emit_json_error(error) + project_root = _commands._require_specify_project() catalog = ExtensionCatalog(project_root) manager = ExtensionManager(project_root) diff --git a/src/specify_cli/presets/command_info.py b/src/specify_cli/presets/command_info.py index a3920a6605..b771dc74bb 100644 --- a/src/specify_cli/presets/command_info.py +++ b/src/specify_cli/presets/command_info.py @@ -6,18 +6,34 @@ from rich.markup import escape as _escape_markup from .._console import console +from .._installed_info_json import preset_info_item +from .._installed_list_json import InstalledListJSONCommand, emit_json, emit_json_error +from .._project import resolve_specify_project_root from ._commands import preset_app -@preset_app.command("info") +@preset_app.command("info", cls=InstalledListJSONCommand) def preset_info( preset_id: str = typer.Argument(..., help="Preset ID to get info about"), + json_output: bool = typer.Option( + False, "--json", help="Output the installed preset as JSON" + ), ): """Show detailed information about a preset.""" from .. import _require_specify_project from ..extensions import normalize_priority from . import PresetCatalog, PresetError, PresetManager + # Direct compatibility callers receive Typer's OptionInfo default rather + # than a parsed bool; only the CLI's explicit True enables JSON output. + if json_output is True: + try: + manager = PresetManager(resolve_specify_project_root()) + emit_json(preset_info_item(manager.list_installed(), manager, preset_id)) + return + except Exception as error: # noqa: BLE001 - emit the JSON error contract + emit_json_error(error) + project_root = _require_specify_project() safe_preset_id = _escape_markup(str(preset_id)) # Check if installed locally first diff --git a/tests/test_installed_info_json.py b/tests/test_installed_info_json.py new file mode 100644 index 0000000000..f0fc5d41f6 --- /dev/null +++ b/tests/test_installed_info_json.py @@ -0,0 +1,304 @@ +"""Public JSON contracts for installed preset and extension info.""" + +from __future__ import annotations + +import json + +import pytest +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.extensions import ExtensionManager +from specify_cli.presets import PresetManager + + +runner = CliRunner() + +SOURCE = {"kind": "catalog", "catalog": "speckit-official"} + + +def _project(tmp_path): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + return project + + +def _preset(project, preset_id="info-preset"): + preset_dir = project / ".specify" / "presets" / preset_id + preset_dir.mkdir(parents=True) + (preset_dir / "preset.yml").write_text( + "schema_version: \"1.0\"\n" + "preset:\n" + f" id: {preset_id}\n" + " name: Info Preset\n" + " version: \"1.0.0\"\n" + " description: preset description\n" + " author: Preset Author\n" + "requires:\n" + " speckit_version: \">=0.1.0\"\n" + "provides:\n" + " templates:\n" + " - type: command\n" + " name: speckit.plan\n" + " file: commands/plan.md\n" + " description: Wrapped plan\n" + " strategy: WRAP\n" + " - type: template\n" + " name: spec-template\n" + " file: templates/spec.md\n" + " - type: script\n" + " name: setup-plan\n" + " file: scripts/setup-plan.sh\n" + " strategy: wrap\n", + encoding="utf-8", + ) + PresetManager(project).registry.add( + preset_id, {"version": "1.0.0", "source": SOURCE, "priority": 3} + ) + + +def _extension(project, extension_id="info-ext"): + extension_dir = project / ".specify" / "extensions" / extension_id + extension_dir.mkdir(parents=True) + (extension_dir / "extension.yml").write_text( + "schema_version: \"1.0\"\n" + "extension:\n" + f" id: {extension_id}\n" + " name: Info Extension\n" + " version: \"1.0.0\"\n" + " description: extension description\n" + "requires:\n" + " speckit_version: \">=0.1.0\"\n" + "provides:\n" + " commands:\n" + f" - name: speckit.{extension_id}.check\n" + " file: commands/check.md\n" + " description: Run the check\n" + " templates:\n" + " - name: report\n" + " file: templates/report.md\n" + " scripts:\n" + " - name: collect\n" + " file: scripts/collect.sh\n" + " runtimes: [bash, python]\n" + " - name: notify\n" + " file: scripts/notify.sh\n" + "hooks:\n" + " before_plan:\n" + f" command: speckit.{extension_id}.check\n" + " after_tasks:\n" + f" - command: speckit.{extension_id}.check\n" + " priority: 5\n" + " optional: false\n" + f" - command: speckit.{extension_id}.check\n" + " priority: 20\n" + " - command: speckit.tasks\n" + " optional: false\n", + encoding="utf-8", + ) + ExtensionManager(project).registry.add( + extension_id, {"version": "1.0.0", "source": SOURCE, "priority": 4} + ) + + +def _json_result(result): + assert result.exit_code == 0, result.output + assert result.stderr == "" + return json.loads(result.stdout) + + +def _without_provides(item): + return {key: value for key, value in item.items() if key != "provides"} + + +def test_preset_info_json_expands_the_list_item(tmp_path, monkeypatch): + project = _project(tmp_path) + _preset(project) + monkeypatch.chdir(project) + + listed = _json_result(runner.invoke(app, ["preset", "list", "--json"]))[0] + info = _json_result(runner.invoke(app, ["preset", "info", "info-preset", "--json"])) + + assert set(info) == set(_without_provides(listed)) | {"commands", "templates", "scripts"} + assert {key: info[key] for key in _without_provides(listed)} == _without_provides(listed) + assert info["source"] == SOURCE + assert info["commands"] == [ + { + "name": "speckit.plan", + "description": "Wrapped plan", + "source": SOURCE, + "sourcePath": "commands/plan.md", + "strategy": "wrap", + } + ] + assert info["templates"] == [ + { + "name": "spec-template", + "description": "", + "source": SOURCE, + "sourcePath": "templates/spec.md", + "strategy": "replace", + } + ] + assert info["scripts"] == [ + { + "name": "setup-plan", + "description": "", + "source": SOURCE, + "sourcePath": "scripts/setup-plan.sh", + "strategy": "wrap", + } + ] + + + +def test_a_direct_call_to_preset_info_keeps_the_human_readable_view(tmp_path, monkeypatch, capsys): + from specify_cli.presets.command_info import preset_info + + project = _project(tmp_path) + _preset(project) + monkeypatch.chdir(project) + + preset_info("info-preset") + + out = capsys.readouterr().out + assert "Preset: Info Preset" in out + with pytest.raises(json.JSONDecodeError): + json.loads(out) + +def test_extension_info_json_expands_the_list_item(tmp_path, monkeypatch): + project = _project(tmp_path) + _extension(project) + monkeypatch.chdir(project) + + listed = _json_result(runner.invoke(app, ["extension", "list", "--json"]))[0] + info = _json_result(runner.invoke(app, ["extension", "info", "info-ext", "--json"])) + + assert set(info) == set(_without_provides(listed)) | { + "commands", "templates", "scripts", "hooks" + } + assert {key: info[key] for key in _without_provides(listed)} == _without_provides(listed) + assert info["commands"] == [ + { + "name": "speckit.info-ext.check", + "description": "Run the check", + "source": SOURCE, + "sourcePath": "commands/check.md", + } + ] + assert info["templates"] == [ + {"name": "report", "description": "", "source": SOURCE, "sourcePath": "templates/report.md"} + ] + assert info["scripts"] == [ + { + "name": "collect", + "description": "", + "source": SOURCE, + "sourcePath": "scripts/collect.sh", + "runtimes": ["bash", "python"], + }, + {"name": "notify", "description": "", "source": SOURCE, "sourcePath": "scripts/notify.sh"}, + ] + + +def test_extension_info_json_hooks_use_registration_defaults_and_last_declaration( + tmp_path, monkeypatch +): + project = _project(tmp_path) + _extension(project) + monkeypatch.chdir(project) + + info = _json_result(runner.invoke(app, ["extension", "info", "info-ext", "--json"])) + + assert info["hooks"] == [ + { + "trigger": "before_plan", + "targetCommand": "speckit.info-ext.check", + "optional": True, + "priority": 10, + }, + { + "trigger": "after_tasks", + "targetCommand": "speckit.info-ext.check", + "optional": True, + "priority": 20, + }, + { + "trigger": "after_tasks", + "targetCommand": "speckit.tasks", + "optional": False, + "priority": 10, + }, + ] + + +def test_extension_info_json_resolves_a_unique_display_name(tmp_path, monkeypatch): + project = _project(tmp_path) + _extension(project) + monkeypatch.chdir(project) + + info = _json_result(runner.invoke(app, ["extension", "info", "info extension", "--json"])) + + assert info["id"] == "info-ext" + + +def test_extension_info_json_rejects_an_ambiguous_display_name(tmp_path, monkeypatch): + project = _project(tmp_path) + _extension(project, "info-ext") + _extension(project, "other-ext") + monkeypatch.chdir(project) + + result = runner.invoke(app, ["extension", "info", "Info Extension", "--json"]) + + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == { + "error": "Extension name 'Info Extension' is ambiguous; use one of the IDs: info-ext, other-ext" + } + + +@pytest.mark.parametrize(("command", "kind"), [("preset", "Preset"), ("extension", "Extension")]) +def test_info_json_for_a_pack_that_is_not_installed_is_stderr_only( + command, kind, tmp_path, monkeypatch +): + monkeypatch.chdir(_project(tmp_path)) + + result = runner.invoke(app, [command, "info", "missing", "--json"]) + + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == {"error": f"{kind} 'missing' is not installed"} + + +@pytest.mark.parametrize("command", ["preset", "extension"]) +def test_info_json_outside_a_project_is_stderr_only(command, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, [command, "info", "anything", "--json"]) + + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr)["error"].startswith("Not a Spec Kit project") + + +@pytest.mark.parametrize( + ("command", "parameter"), [("preset", "preset_id"), ("extension", "extension")] +) +def test_info_json_usage_errors_are_stderr_only(command, parameter): + result = runner.invoke(app, [command, "info", "--json"]) + + assert result.exit_code == 2 + assert result.stdout == "" + assert json.loads(result.stderr) == {"error": f"Missing parameter: {parameter}"} + + +def test_extension_info_json_cannot_be_combined_with_versions(tmp_path, monkeypatch): + project = _project(tmp_path) + _extension(project) + monkeypatch.chdir(project) + + result = runner.invoke(app, ["extension", "info", "info-ext", "--json", "--versions"]) + + assert result.exit_code == 2 + assert result.stdout == "" + assert result.stderr == '{"error": "--json cannot be combined with --versions"}\n'