Skip to content
24 changes: 24 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3204,8 +3204,32 @@ def extension_search(
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"),
author: Optional[str] = typer.Option(None, "--author", help="Filter by author"),
verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"),
markdown: bool = typer.Option(
False,
"--markdown",
help=(
"Output the full community catalog as a markdown table "
"(ignores query/tag/author/verified filters)"
),
),
):
"""Search for available extensions in catalog."""
if markdown:
if query or tag or author or verified:
typer.echo(
"Warning: --markdown outputs the full community catalog and ignores filters "
"(query, --tag, --author, --verified).",
err=True,
)
from .community_catalog_docs import render_community_extensions_table

try:
typer.echo(render_community_extensions_table())
except (ValueError, FileNotFoundError) as exc:
typer.echo(f"Error: {exc}", err=True)
raise typer.Exit(1)
return

from .extensions import ExtensionCatalog, ExtensionError

project_root = _require_specify_project()
Expand Down
107 changes: 107 additions & 0 deletions src/specify_cli/community_catalog_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Helpers for rendering the community extensions reference table."""

from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any


ROOT_DIR = Path(__file__).resolve().parents[2]
COMMUNITY_CATALOG_PATH = ROOT_DIR / "extensions" / "catalog.community.json"


def _render_cell(value: str) -> str:
return value.replace("\r\n", " ").replace("\r", " ").replace("\n", " ").replace("|", "\\|")


def _format_inline_code(value: str) -> str:
text = _render_cell(value)
runs = [len(match) for match in re.findall(r"`+", text)]
fence = "`" * (max(runs, default=0) + 1)
return f"{fence}{text}{fence}"


def _sanitize_link_target(value: str) -> str:
return value.replace("\r\n", "").replace("\r", "").replace("\n", "").replace("|", "%7C")


def _format_tags(tags: Any) -> str:
if not isinstance(tags, list) or not tags:
return "—"
# Clean first, then filter: a tag of " | " would pass str(tag).strip() but produce
# an empty code span after pipe removal, so filter on the cleaned value.
cleaned = [_format_inline_code(c) for tag in tags if (c := str(tag).replace("|", "").strip())]
return ", ".join(cleaned) if cleaned else "—"


def list_community_extensions(path: Path = COMMUNITY_CATALOG_PATH) -> list[dict[str, Any]]:
"""Return community extensions sorted alphabetically by name then ID."""
if not path.exists():
raise FileNotFoundError(
f"Community catalog not found: {path}. "
"The --markdown flag requires a spec-kit source checkout."
)
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"Expected {path} to contain a JSON object")
extensions = data.get("extensions")
if not isinstance(extensions, dict):
raise ValueError(f"Expected {path} to contain an 'extensions' object")

rows: list[dict[str, Any]] = []
for ext_id, ext in extensions.items():
if not isinstance(ext, dict):
raise ValueError(f"Community extension {ext_id!r} must be a mapping")
rows.append(
{
"name": str(ext.get("name") or ext_id),
"id": str(ext.get("id") or ext_id),
"description": str(ext.get("description") or ""),
"tags": ext.get("tags") or [],
"verified": "Yes" if bool(ext.get("verified")) else "No",
"repository": str(ext.get("repository") or ""),
}
)

return sorted(rows, key=lambda row: (row["name"].casefold(), row["id"].casefold()))


def render_community_extensions_table(path: Path = COMMUNITY_CATALOG_PATH) -> str:
"""Render the community extensions table from catalog.community.json."""
rows = list_community_extensions(path=path)
if not rows:
raise ValueError("Community catalog has no extensions")

table_rows: list[list[str]] = []
for row in rows:
# Escape raw field values *before* composing Markdown syntax so that
# a pipe inside a name or description doesn't break a link target.
safe_name = _render_cell(row["name"])
safe_repository = _sanitize_link_target(row["repository"])
link = (
f"[{safe_name}]({safe_repository})"
if safe_repository
else safe_name
)
Comment on lines +79 to +87
table_rows.append(
[
link,
_format_inline_code(row["id"]),
_render_cell(row["description"]),
_format_tags(row["tags"]),
row["verified"],
]
)

headers = ("Extension", "ID", "Description", "Tags", "Verified")

def render_row(values: list[str]) -> str:
# Values are already escaped; do not re-apply _render_cell here.
return "| " + " | ".join(values) + " |"

separator = "| " + " | ".join("---" for _ in headers) + " |"
lines = [render_row(list(headers)), separator]
lines.extend(render_row(row) for row in table_rows)
return "\n".join(lines) + "\n"
2 changes: 1 addition & 1 deletion src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1134,7 +1134,7 @@ def check_compatibility(
# Parse version specifier (e.g., ">=0.1.0,<2.0.0")
try:
specifier = SpecifierSet(required)
if current not in specifier:
if not specifier.contains(current, prereleases=True):
raise CompatibilityError(
f"Extension requires spec-kit {required}, "
f"but {speckit_version} is installed.\n"
Expand Down
2 changes: 1 addition & 1 deletion src/specify_cli/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,7 @@ def check_compatibility(

try:
specifier = SpecifierSet(required)
if current not in specifier:
if not specifier.contains(current, prereleases=True):
raise PresetCompatibilityError(
f"Preset requires spec-kit {required}, "
f"but {speckit_version} is installed.\n"
Expand Down
141 changes: 141 additions & 0 deletions tests/test_community_catalog_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
from __future__ import annotations

import json
from pathlib import Path

import pytest
from specify_cli.community_catalog_docs import list_community_extensions, render_community_extensions_table


def _write_catalog(tmp_path: Path, extensions: dict) -> Path:
p = tmp_path / "catalog.community.json"
p.write_text(json.dumps({"extensions": extensions}), encoding="utf-8")
return p


# ---------------------------------------------------------------------------
# Happy-path tests against the real catalog
# ---------------------------------------------------------------------------

def test_community_extensions_table_renders() -> None:
table = render_community_extensions_table()
assert "| Extension" in table
assert "| ID" in table
assert "| Description" in table
assert "| Tags" in table
assert "| Verified" in table


def test_community_extensions_are_sorted_by_name() -> None:
rows = list_community_extensions()
names = [row["name"] for row in rows]
assert names == sorted(names, key=str.casefold)


# ---------------------------------------------------------------------------
# Edge-case tests using synthetic catalogs
# ---------------------------------------------------------------------------

def test_missing_catalog_file(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError, match="spec-kit source checkout"):
list_community_extensions(path=tmp_path / "missing.json")


def test_malformed_json(tmp_path: Path) -> None:
bad = tmp_path / "bad.json"
bad.write_text("not valid json", encoding="utf-8")
with pytest.raises(json.JSONDecodeError):
list_community_extensions(path=bad)


def test_non_dict_root(tmp_path: Path) -> None:
f = tmp_path / "catalog.json"
f.write_text(json.dumps([{"id": "foo"}]), encoding="utf-8")
with pytest.raises(ValueError, match="JSON object"):
list_community_extensions(path=f)


def test_missing_extensions_key(tmp_path: Path) -> None:
f = tmp_path / "catalog.json"
f.write_text(json.dumps({"other": {}}), encoding="utf-8")
with pytest.raises(ValueError, match="'extensions' object"):
list_community_extensions(path=f)


def test_non_dict_extension_value(tmp_path: Path) -> None:
f = _write_catalog(tmp_path, {"foo": "not-a-dict"})
with pytest.raises(ValueError, match="must be a mapping"):
list_community_extensions(path=f)


def test_empty_catalog_raises(tmp_path: Path) -> None:
f = _write_catalog(tmp_path, {})
with pytest.raises(ValueError, match="no extensions"):
render_community_extensions_table(path=f)


def test_extension_without_repository(tmp_path: Path) -> None:
f = _write_catalog(tmp_path, {
"foo": {"name": "Foo", "id": "foo", "description": "A foo tool", "tags": [], "verified": False, "repository": ""},
})
table = render_community_extensions_table(path=f)
assert "Foo" in table
assert "[Foo](" not in table # plain name, no link


def test_backticks_in_ids_and_tags_render_safely(tmp_path: Path) -> None:
f = _write_catalog(tmp_path, {
"foo": {
"name": "Foo",
"id": "foo`bar",
"description": "",
"tags": ["a`b"],
"verified": False,
"repository": "",
},
})
table = render_community_extensions_table(path=f)
assert "``foo`bar``" in table
assert "``a`b``" in table
foo_row = next(line for line in table.split("\n") if line.startswith("| ") and "Foo" in line)
assert foo_row.count("|") == 6


def test_repository_values_are_sanitized_for_table_cells(tmp_path: Path) -> None:
f = _write_catalog(tmp_path, {
"foo": {
"name": "Foo",
"id": "foo",
"description": "",
"tags": [],
"verified": False,
"repository": "https://example.com/a|b\nnext",
},
})
table = render_community_extensions_table(path=f)
assert "https://example.com/a%7Cbnext" in table
foo_row = next(line for line in table.split("\n") if line.startswith("| ") and "Foo" in line)
assert foo_row.count("|") == 6


def test_tags_containing_pipe_do_not_break_table(tmp_path: Path) -> None:
f = _write_catalog(tmp_path, {
# No "id" field — exercises ext_id fallback; tag has pipe — exercises stripping
"foo": {"name": "Foo", "description": "", "tags": ["foo|bar"], "verified": False, "repository": ""},
})
table = render_community_extensions_table(path=f)
# pipe stripped from tag value
assert "`foobar`" in table
# id falls back to the dict key when "id" field is absent
assert "`foo`" in table
# row is well-formed: 5-column table has exactly 6 pipe separators per row
foo_row = next(line for line in table.split("\n") if line.startswith("| ") and "Foo" in line)
assert foo_row.count("|") == 6


def test_non_list_tags_renders_em_dash(tmp_path: Path) -> None:
f = _write_catalog(tmp_path, {
"foo": {"name": "Foo", "description": "", "tags": "not-a-list", "verified": False, "repository": ""},
})
table = render_community_extensions_table(path=f)
assert "—" in table
8 changes: 8 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,14 @@ def test_check_compatibility_valid(self, extension_dir, project_dir):
result = manager.check_compatibility(manifest, "0.1.0")
assert result is True

def test_check_compatibility_allows_prerelease_dev_version(self, extension_dir, project_dir):
"""Test compatibility check allows source/dev prerelease versions."""
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(extension_dir / "extension.yml")

result = manager.check_compatibility(manifest, "0.8.15.dev0")
assert result is True

def test_check_compatibility_invalid(self, extension_dir, project_dir):
"""Test compatibility check with invalid version."""
manager = ExtensionManager(project_dir)
Expand Down
6 changes: 6 additions & 0 deletions tests/test_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,12 @@ def test_check_compatibility_valid(self, pack_dir, temp_dir):
manifest = PresetManifest(pack_dir / "preset.yml")
assert manager.check_compatibility(manifest, "0.1.5") is True

def test_check_compatibility_allows_prerelease_dev_version(self, pack_dir, temp_dir):
"""Test compatibility check allows source/dev prerelease versions."""
manager = PresetManager(temp_dir)
manifest = PresetManifest(pack_dir / "preset.yml")
assert manager.check_compatibility(manifest, "0.8.15.dev0") is True

def test_check_compatibility_invalid(self, pack_dir, temp_dir):
"""Test compatibility check with invalid specifier."""
manager = PresetManager(temp_dir)
Expand Down