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
12 changes: 12 additions & 0 deletions docs/reference/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<catalog-name>"}` 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
Expand Down
10 changes: 10 additions & 0 deletions docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<catalog-name>"}` 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
Expand Down
63 changes: 63 additions & 0 deletions src/specify_cli/_installed_list_json.py
Original file line number Diff line number Diff line change
@@ -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"]),

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 dd8b34e3: catalog-based preset and extension installs now carry the resolved catalog name through CLI add, init, bundler install/refresh, archive extraction, and extension update. Managers persist only a trimmed, nonblank catalog source; blank or invalid values remain local. Added regression coverage for persistence, rollback, and (priority, id) ordering for both JSON lists.

"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
12 changes: 10 additions & 2 deletions src/specify_cli/bundler/services/primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,11 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None:
zip_path = catalog.download_pack(component.id)
try:
self._manager.install_from_zip(
zip_path, speckit_version, priority, **({"force": True} if force else {})
zip_path,
speckit_version,
priority,
catalog_name=info.get("_catalog_name"),
**({"force": True} if force else {}),
)
finally:
with contextlib.suppress(Exception):
Expand Down Expand Up @@ -294,7 +298,11 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None:
zip_path = catalog.download_extension(component.id)
try:
self._manager.install_from_zip(
zip_path, speckit_version, priority=priority, force=force
zip_path,
speckit_version,
priority=priority,
force=force,
catalog_name=info.get("_catalog_name"),
)
finally:
with contextlib.suppress(Exception):
Expand Down
10 changes: 8 additions & 2 deletions src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,11 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve

zip_path = catalog.download_extension(resolved_id)
try:
manifest = manager.install_from_zip(zip_path, speckit_version)
manifest = manager.install_from_zip(
zip_path,
speckit_version,
catalog_name=ext_info.get("_catalog_name"),
)
finally:
zip_path.unlink(missing_ok=True)
return f"{manifest.name} v{manifest.version} installed"
Expand Down Expand Up @@ -862,7 +866,9 @@ def init(
try:
zip_path = preset_catalog.download_pack(preset)
preset_manager.install_from_zip(
zip_path, speckit_ver
zip_path,
speckit_ver,
catalog_name=pack_info.get("_catalog_name"),
)
except PresetError as preset_err:
_print_cli_warning(
Expand Down
37 changes: 35 additions & 2 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2052,6 +2052,8 @@ def install_from_directory(
priority: int = 10,
link_commands: bool = False,
force: bool = False,
*,
catalog_name: str | None = None,
) -> ExtensionManifest:
"""Install extension from a local directory.

Expand Down Expand Up @@ -2612,11 +2614,19 @@ def _restore_stranded_config_file(
backup_config_dir.unlink()

# Update registry
normalized_catalog_name = (
catalog_name.strip() if isinstance(catalog_name, str) else ""
)
source = (
{"kind": "catalog", "catalog": normalized_catalog_name}
if normalized_catalog_name
else "local"
)
self.registry.add(
manifest.id,
{
"version": manifest.version,
"source": "local",
"source": source,
"manifest_hash": manifest.get_hash(),
"enabled": True,
"priority": priority,
Expand Down Expand Up @@ -2673,6 +2683,7 @@ def install_from_archive(
archive_file: BinaryIO | None = None,
source_name: str | None = None,
content_type: str | None = None,
catalog_name: str | None = None,
) -> ExtensionManifest:
"""Install an extension from a supported archive.

Expand Down Expand Up @@ -2724,7 +2735,11 @@ def install_from_archive(

# Install from extracted directory
return self.install_from_directory(
extension_dir, speckit_version, priority=priority, force=force
extension_dir,
speckit_version,
priority=priority,
force=force,
catalog_name=catalog_name,
)

def _config_root_is_contained(self, specify_dir: Path) -> bool:
Expand Down Expand Up @@ -2880,6 +2895,7 @@ def install_from_zip(
archive_file: BinaryIO | None = None,
source_name: str | None = None,
content_type: str | None = None,
catalog_name: str | None = None,
) -> ExtensionManifest:
"""Backward-compatible wrapper for archive installation."""
return self.install_from_archive(
Expand All @@ -2890,6 +2906,7 @@ def install_from_zip(
archive_file=archive_file,
source_name=source_name,
content_type=content_type,
catalog_name=catalog_name,
)

def remove(self, extension_id: str, keep_config: bool = False) -> bool:
Expand Down Expand Up @@ -3413,6 +3430,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,
Expand All @@ -3424,6 +3446,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:
Expand All @@ -3439,6 +3469,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},
}
)

Expand Down
Loading