Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions docs/reference/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ including for help, the existing human-readable behavior is unchanged.
```bash
specify extension info <name>
specify extension info <name> --versions
specify extension info <name> --json
```

Shows detailed information about an installed or available extension, including its description, version, commands, and configuration.
Expand Down Expand Up @@ -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. `<name>` 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
Expand Down
14 changes: 14 additions & 0 deletions docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,24 @@ Presets are printed in **resolution/precedence order**: the highest-precedence p

```bash
specify preset info <preset_id>
specify preset info <preset_id> --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
Expand Down
110 changes: 110 additions & 0 deletions src/specify_cli/_installed_info_json.py
Original file line number Diff line number Diff line change
@@ -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,
}
20 changes: 19 additions & 1 deletion src/specify_cli/extensions/command_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 15 additions & 1 deletion src/specify_cli/presets/command_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,32 @@
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

if json_output:
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
Expand Down
Loading
Loading