Skip to content
Open
265 changes: 128 additions & 137 deletions docs/community/extensions.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions pr_body.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"body": "## What changed\n- Added a generator for `docs/community/extensions.md` backed by `extensions/catalog.community.json`.\n- Replaced the hand-maintained community extensions table with a generated index block.\n- Added a regression test that checks the committed page stays in sync with the generator.\n- Added `--markdown` flag to the `specify extension search` command to generate the docs.\n- **Compatibility Behavior Change:** Presets and extensions now evaluate `prereleases=True` if `current.is_devrelease` is True, allowing source/dev installations to satisfy version specifiers without accidentally accepting normal RC/beta builds.\n\n## Why\n- The community extensions page was a large manual table and was easy to drift from the catalog source of truth.\n- Moving the page to generated output keeps the community index aligned with the catalog as entries change.\n- This reduces maintainer overhead and makes the published list more trustworthy for contributors and users.\n\n## Impact\n- Contributors now update the catalog JSON instead of editing the rendered table by hand.\n- The community extensions page is more consistent and less likely to go stale.\n- CI and local tests can detect drift before it lands.\n\n## Validation\n- `specify extension search --markdown > docs/community/extensions.md` (to update the page)\n- `pytest tests/test_community_catalog_docs.py -q`\n"
}
20 changes: 20 additions & 0 deletions pr_body.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## What changed
- Added a generator for `docs/community/extensions.md` backed by `extensions/catalog.community.json`.
- Replaced the hand-maintained community extensions table with a generated index block.
- Added a regression test that checks the committed page stays in sync with the generator.
- Added `--markdown` flag to the `specify extension search` command to generate the docs.
- **Compatibility Behavior Change:** Presets and extensions now evaluate `prereleases=True` if `current.is_devrelease` is True, allowing source/dev installations to satisfy version specifiers without accidentally accepting normal RC/beta builds.

## Why
- The community extensions page was a large manual table and was easy to drift from the catalog source of truth.
- Moving the page to generated output keeps the community index aligned with the catalog as entries change.
- This reduces maintainer overhead and makes the published list more trustworthy for contributors and users.

## Impact
- Contributors now update the catalog JSON instead of editing the rendered table by hand.
- The community extensions page is more consistent and less likely to go stale.
- CI and local tests can detect drift before it lands.

## Validation
- `specify extension search --markdown > docs/community/extensions.md` (to update the page)
- `pytest tests/test_community_catalog_docs.py -q`
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 thread
DyanGalih marked this conversation as resolved.
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"
5 changes: 4 additions & 1 deletion src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1284,7 +1284,10 @@ def check_compatibility(
# Parse version specifier (e.g., ">=0.1.0,<2.0.0")
try:
specifier = SpecifierSet(required)
if current not in specifier:
# Intentionally allow prereleases only for source/dev spec-kit installs
# so they can satisfy extension compatibility checks, while still
# preserving normal PEP 440 rules for RC/beta builds.
if not specifier.contains(current, prereleases=True if current.is_devrelease else None):
raise CompatibilityError(
Comment thread
DyanGalih marked this conversation as resolved.
Comment thread
DyanGalih marked this conversation as resolved.
f"Extension requires spec-kit {required}, "
f"but {speckit_version} is installed.\n"
Expand Down
24 changes: 24 additions & 0 deletions src/specify_cli/extensions/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -704,8 +704,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=(
"Contributor-only utility to output the full community catalog "
"as a markdown table (cannot be used with filters)"
),
),
):
"""Search for available extensions in catalog."""
if markdown:
if query or tag or author or verified:
console.print(
"[red]Error:[/red] The --markdown flag outputs the full community catalog "
"and cannot be used with filters (query, --tag, --author, --verified)."
)
raise typer.Exit(1)
from ..community_catalog_docs import render_community_extensions_table

try:
typer.echo(render_community_extensions_table())
except (ValueError, FileNotFoundError) as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
return

from . import ExtensionCatalog, ExtensionError

project_root = _require_specify_project()
Expand Down
5 changes: 4 additions & 1 deletion src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,10 @@ def check_compatibility(

try:
specifier = SpecifierSet(required)
if current not in specifier:
# Intentionally allow prereleases only for source/dev spec-kit installs
# so they can satisfy preset compatibility checks, while still
# preserving normal PEP 440 rules for RC/beta builds.
if not specifier.contains(current, prereleases=True if current.is_devrelease else None):
raise PresetCompatibilityError(
Comment thread
DyanGalih marked this conversation as resolved.
f"Preset requires spec-kit {required}, "
f"but {speckit_version} is installed.\n"
Expand Down
178 changes: 178 additions & 0 deletions tests/test_community_catalog_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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)

Comment thread
DyanGalih marked this conversation as resolved.

# ---------------------------------------------------------------------------
# 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

def test_community_extensions_markdown_rejects_filters() -> None:
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, ["extension", "search", "--markdown", "--tag", "foo"])
assert result.exit_code == 1
assert "The --markdown flag outputs the full community catalog" in result.stdout

def test_docs_extensions_md_is_up_to_date() -> None:
from pathlib import Path
from specify_cli.community_catalog_docs import render_community_extensions_table

root_dir = Path(__file__).resolve().parents[1]
docs_path = root_dir / "docs" / "community" / "extensions.md"

assert docs_path.exists(), "docs/community/extensions.md not found"
docs_content = docs_path.read_text(encoding="utf-8")

generated_table = render_community_extensions_table()

# Extract the block between markers and compare it exactly
start_marker = "<!-- BEGIN GENERATED TABLE -->\n"
end_marker = "<!-- END GENERATED TABLE -->"

start_idx = docs_content.find(start_marker)
end_idx = docs_content.find(end_marker)

assert start_idx != -1, f"Missing '{start_marker.strip()}' in docs/community/extensions.md"
assert end_idx != -1, f"Missing '{end_marker}' in docs/community/extensions.md"

actual_table = docs_content[start_idx + len(start_marker):end_idx]

assert actual_table.strip() == generated_table.strip(), (
"docs/community/extensions.md is out of sync with catalog.community.json. "
"Please run `specify extension search --markdown` and update the docs file."
)
8 changes: 8 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,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 @@ -710,6 +710,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
Loading
Loading