Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 11 additions & 0 deletions docs/reference/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions src/specify_cli/_installed_list_json.py
Original file line number Diff line number Diff line change
@@ -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"]},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 1e4f38c2: structured catalog sources are now preserved, with safe fallback for legacy or malformed records. Added regression coverage; 17 focused and 1138 related tests pass locally.

"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)
59 changes: 42 additions & 17 deletions src/specify_cli/_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
30 changes: 30 additions & 0 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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},
}
)

Expand Down
13 changes: 13 additions & 0 deletions src/specify_cli/extensions/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
26 changes: 26 additions & 0 deletions src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand Down
20 changes: 19 additions & 1 deletion src/specify_cli/presets/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
Loading