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
16 changes: 16 additions & 0 deletions docs/reference/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,31 @@ 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. On success, `--json` writes exactly one array to stdout and exits
0. A runtime failure after option parsing writes exactly one
`{"error":"..."}` object to stderr and exits 1. If parsing raises a usage
error and the raw `--json` token is present, it writes that JSON error object
to stderr and preserves the usage exit code (normally 2). Without `--json`,
including for help, the existing human-readable behavior is unchanged.

## Extension Info

```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 @@ -43,10 +43,24 @@ 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. On
success, `--json` writes exactly one array to stdout and exits 0. A runtime
failure after option parsing writes exactly one `{"error":"..."}` object to
stderr and exits 1. If parsing raises a usage error and the raw `--json` token
is present, it writes that JSON error object to stderr and preserves the usage
exit code (normally 2). Without `--json`, including for help, the existing
human-readable behavior is unchanged.

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
84 changes: 84 additions & 0 deletions src/specify_cli/_installed_list_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""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
from typer.core import TyperCommand

try:
from typer._click.exceptions import UsageError as _UsageError
except ModuleNotFoundError as error:
if error.name != "typer._click":
raise
from click import UsageError as _UsageError


class InstalledListJSONCommand(TyperCommand):
"""Keep JSON list parse failures on the JSON error contract."""

def make_context(self, info_name, args, parent=None, **extra):
json_output = "--json" in args
try:
return super().make_context(info_name, args, parent=parent, **extra)
except _UsageError as error:
if json_output:
emit_json_error(error, exit_code=error.exit_code)
raise


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"]),
Comment thread
Copilot marked this conversation as resolved.
"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, exit_code: int = 1) -> 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=exit_code)
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 @@ -295,7 +299,11 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None:
zip_path = catalog.download_extension(component.id)
try:
manifest = 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"),
)
self._manager.scaffold_config(manifest.id)
finally:
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 @@ -3490,6 +3507,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 @@ -3501,6 +3523,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 @@ -3516,6 +3546,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