From a7660a5ba278a4ce4382527027a1196e07443ccc Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Sun, 24 May 2026 15:46:19 +0000 Subject: [PATCH 1/2] fix: keep integrations docs changes scoped to PR 2563 Isolate the integrations docs rendering and test updates from the unrelated prerelease compatibility gate fix so the PR 2563 branch stays focused. Tests: targeted docs tests expected; full suite covered on the separate compatibility branch Reference: upstream/main; source patch /tmp/spec-kit-changes.patch --- src/specify_cli/catalog_docs.py | 209 +++++++++++++++++++++ src/specify_cli/community_catalog_docs.py | 103 ++++++++++ tests/test_catalog_docs.py | 218 ++++++++++++++++++++++ tests/test_community_catalog_docs.py | 162 ++++++++++++++++ 4 files changed, 692 insertions(+) create mode 100644 src/specify_cli/catalog_docs.py create mode 100644 src/specify_cli/community_catalog_docs.py create mode 100644 tests/test_catalog_docs.py create mode 100644 tests/test_community_catalog_docs.py diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py new file mode 100644 index 0000000000..d443e1c0b7 --- /dev/null +++ b/src/specify_cli/catalog_docs.py @@ -0,0 +1,209 @@ +"""Helpers for rendering the built-in integrations reference table.""" + +from __future__ import annotations + +from typing import Any + +from ._assets import _repo_root + +ROOT_DIR = _repo_root() +INTEGRATIONS_REFERENCE_PATH = ROOT_DIR / "docs" / "reference" / "integrations.md" + + +INTEGRATION_DOC_URLS: dict[str, str | None] = { + "amp": "https://ampcode.com/", + "agy": "https://antigravity.google/", + "auggie": "https://docs.augmentcode.com/cli/overview", + "bob": "https://www.ibm.com/products/bob", + "claude": "https://www.anthropic.com/claude-code", + "codebuddy": "https://www.codebuddy.ai/cli", + "codex": "https://github.com/openai/codex", + "copilot": "https://code.visualstudio.com/", + "cursor-agent": "https://cursor.sh/", + "devin": "https://cli.devin.ai/docs", + "forge": "https://forgecode.dev/", + "gemini": "https://github.com/google-gemini/gemini-cli", + "generic": None, + "goose": "https://block.github.io/goose/", + "iflow": "https://docs.iflow.cn/en/cli/quickstart", + "junie": "https://junie.jetbrains.com/", + "kilocode": "https://github.com/Kilo-Org/kilocode", + "kimi": "https://code.kimi.com/", + "kiro-cli": "https://kiro.dev/docs/cli/", + "lingma": "https://lingma.aliyun.com/", + "opencode": "https://opencode.ai/", + "pi": "https://pi.dev", + "qodercli": "https://qoder.com/cli", + "qwen": "https://github.com/QwenLM/qwen-code", + "roo": "https://roocode.com/", + "shai": "https://github.com/ovh/shai", + "tabnine": "https://docs.tabnine.com/main/getting-started/tabnine-cli", + "trae": "https://www.trae.ai/", + "vibe": "https://github.com/mistralai/mistral-vibe", + "windsurf": "https://windsurf.com/", +} + +INTEGRATION_LABEL_OVERRIDES: dict[str, str] = { + "agy": "Antigravity (agy)", + "codebuddy": "CodeBuddy CLI", + "generic": "Generic", + "shai": "SHAI (OVHcloud)", +} + +INTEGRATION_NOTES: dict[str, str] = { + "agy": "Skills-based integration; skills are installed automatically", + "claude": "Skills-based integration; installs skills in `.claude/skills`", + "codex": ( + "Skills-based integration; installs skills into `.agents/skills` " + "and invokes them as `$speckit-`" + ), + "bob": "IDE-based agent", + "devin": ( + "Skills-based integration; installs skills into `.devin/skills/` " + "and invokes them as `/speckit-`" + ), + "goose": "Uses YAML recipe format in `.goose/recipes/`", + "kimi": ( + "Skills-based integration; supports `--migrate-legacy` " + "for dotted→hyphenated directory migration" + ), + "kiro-cli": ( + "Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, " + "so Spec Kit ships a prose fallback at render time " + "(see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) " + "and issue [#1926](https://github.com/github/spec-kit/issues/1926)). " + "Alias: `--integration kiro`" + ), + "lingma": "Skills-based integration; skills are installed automatically", + "pi": ( + "Pi doesn't have MCP support out of the box, so `taskstoissues` " + "won't work as intended. MCP support can be added via " + "[extensions](https://github.com/badlogic/pi-mono/tree/main/" + "packages/coding-agent#extensions)" + ), + "generic": ( + "Bring your own agent — use `--integration generic " + "--integration-options=\"--commands-dir \"` " + "for AI coding agents not listed above" + ), + "trae": "Skills-based integration; skills are installed automatically", +} + + +def render_cell(value: str) -> str: + r"""Escape markdown special characters (pipes) and normalize newlines to spaces. + + This ensures table cells remain valid markdown even if they contain + pipes (escaped as \|) or carriage returns (normalized to spaces). + """ + value = value.replace("\r\n", " ").replace("\r", " ").replace("\n", " ") + return value.replace("|", "\\|") + + +def escape_url_for_markdown_link(url: str) -> str: + """Escape characters that can break Markdown link syntax. + + Escapes `)` and `|` which can terminate or corrupt the link destination. + """ + return url.replace(")", "\\)").replace("|", "\\|") + + +def escape_markdown_link_text(text: str) -> str: + """Escape characters that can break Markdown link text.""" + return text.replace("[", "\\[").replace("]", "\\]") + + +def _get_integration_registry() -> dict[str, Any]: + from specify_cli.integrations import INTEGRATION_REGISTRY + + return INTEGRATION_REGISTRY + + +def list_integrations_for_docs( + warn_on_missing: bool = False, + warn_on_extra: bool = False, +) -> list[tuple[str, str, str | None, str]]: + """List all integrations with their documentation URLs and notes. + + Returns all integrations in the registry. Missing entries in INTEGRATION_DOC_URLS + default to None; if `warn_on_missing` is True, emits a warning for these. + If `warn_on_extra` is True, emits a warning for stale keys in the doc maps that + are no longer in the registry. Missing notes entries default to empty string. + """ + registry = _get_integration_registry() + registry_keys = set(registry) + + # Warn if there are integrations missing from INTEGRATION_DOC_URLS (when enabled) + missing = sorted(registry_keys - set(INTEGRATION_DOC_URLS)) + if missing and warn_on_missing: + import warnings + warnings.warn( + f"Integration(s) missing from INTEGRATION_DOC_URLS: " + f"{', '.join(missing)}. They will be included in the docs table " + "without documentation links. Add them to INTEGRATION_DOC_URLS in " + "catalog_docs.py if a link should be available.", + stacklevel=2 + ) + + # Warn if there are stale keys in doc maps not in the registry (when enabled) + if warn_on_extra: + extra_in_urls = sorted(set(INTEGRATION_DOC_URLS) - registry_keys) + extra_in_labels = sorted( + set(INTEGRATION_LABEL_OVERRIDES) - registry_keys + ) + extra_in_notes = sorted(set(INTEGRATION_NOTES) - registry_keys) + extra_keys = extra_in_urls or extra_in_labels or extra_in_notes + if extra_keys: + import warnings + stale_keys = sorted( + set(extra_in_urls + extra_in_labels + extra_in_notes) + ) + warnings.warn( + f"Stale key(s) found in doc maps (no longer in registry): " + f"{', '.join(stale_keys)}. Consider removing them from " + "INTEGRATION_DOC_URLS, INTEGRATION_LABEL_OVERRIDES, and " + "INTEGRATION_NOTES.", + stacklevel=2 + ) + + rows: list[tuple[str, str, str | None, str]] = [] + + for key, integration in registry.items(): + config = getattr(integration, "config", {}) + if not isinstance(config, dict): + config = {} + label = INTEGRATION_LABEL_OVERRIDES.get(key, str(config.get("name") or key)) + url = INTEGRATION_DOC_URLS.get(key) # None if not in map + notes = INTEGRATION_NOTES.get(key, "") + rows.append((key, label, url, notes)) + + return sorted(rows, key=lambda r: r[0]) + + +def render_integrations_table() -> str: + """Render the built-in integrations reference table as markdown.""" + table_rows: list[list[str]] = [] + + for key, label, url, notes in list_integrations_for_docs(): + # Escape raw field values *before* composing Markdown syntax so that + # a pipe inside a label or notes doesn't break a link target. + safe_label = escape_markdown_link_text(render_cell(label)) + safe_notes = render_cell(notes) + safe_url = escape_url_for_markdown_link(url) if url else None + agent = ( + f"[{safe_label}]({safe_url})" + if safe_url + else safe_label + ) + table_rows.append([agent, f"`{key}`", safe_notes]) + + headers = ("Agent", "Key", "Notes") + + 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" diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py new file mode 100644 index 0000000000..bbf4b5db4c --- /dev/null +++ b/src/specify_cli/community_catalog_docs.py @@ -0,0 +1,103 @@ +"""Helpers for rendering the community extensions reference table.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from ._assets import _repo_root +from .catalog_docs import ( + escape_markdown_link_text, + escape_url_for_markdown_link, + render_cell, +) + + +ROOT_DIR = _repo_root() +COMMUNITY_CATALOG_PATH = ROOT_DIR / "extensions" / "catalog.community.json" + + +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 backtick span after pipe removal, so filter on the cleaned value. + cleaned = [f"`{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 at {path}. " + "Ensure the repository checkout includes the extensions/ directory." + ) + 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 "").strip(), + } + ) + + 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 = escape_markdown_link_text(render_cell(row["name"])) + repository = row["repository"] + if repository: + safe_repo = escape_url_for_markdown_link(repository) + link = f"[{safe_name}]({safe_repo})" + else: + link = safe_name + table_rows.append( + [ + link, + f"`{render_cell(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" diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py new file mode 100644 index 0000000000..617fa300e0 --- /dev/null +++ b/tests/test_catalog_docs.py @@ -0,0 +1,218 @@ +"""Tests for the integration registry documentation generation.""" + +from __future__ import annotations + +from contextlib import ExitStack, contextmanager +import re +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from specify_cli.catalog_docs import ( + escape_url_for_markdown_link, + escape_markdown_link_text, + INTEGRATIONS_REFERENCE_PATH, + render_cell, + list_integrations_for_docs, + render_integrations_table, +) +from specify_cli import app + + +runner = CliRunner() + + +@contextmanager +def _get_catalog_docs_patches( + *, + fake_registry=None, + fake_doc_urls=None, + fake_label_overrides=None, + fake_notes=None, +): + """Context manager that applies mocked registry and doc maps for tests.""" + + if fake_registry is None: + fake_registry = { + "copilot": MagicMock(config={"name": "GitHub Copilot"}), + "codex": MagicMock(config={"name": "Codex CLI"}), + } + if fake_doc_urls is None: + fake_doc_urls = { + "copilot": "https://code.visualstudio.com/", + "codex": "https://github.com/openai/codex", + } + if fake_label_overrides is None: + fake_label_overrides = {} + if fake_notes is None: + fake_notes = {"copilot": "Test note"} + + with ExitStack() as stack: + stack.enter_context( + patch( + "specify_cli.catalog_docs._get_integration_registry", + return_value=fake_registry, + ) + ) + stack.enter_context( + patch("specify_cli.catalog_docs.INTEGRATION_DOC_URLS", fake_doc_urls) + ) + stack.enter_context( + patch( + "specify_cli.catalog_docs.INTEGRATION_LABEL_OVERRIDES", + fake_label_overrides, + ) + ) + stack.enter_context( + patch("specify_cli.catalog_docs.INTEGRATION_NOTES", fake_notes) + ) + yield + + +def test_integrations_table_renders(): + table = render_integrations_table() + lines = table.splitlines() + assert lines[0] == "| Agent | Key | Notes |" + assert lines[1] == "| --- | --- | --- |" + + +def test_integrations_reference_doc_matches_renderer(): + if not INTEGRATIONS_REFERENCE_PATH.exists(): + pytest.skip( + f"Integrations reference not found at {INTEGRATIONS_REFERENCE_PATH}. " + "Skipping (expected when running from sdist/wheel)." + ) + doc_text = INTEGRATIONS_REFERENCE_PATH.read_text(encoding="utf-8") + start_marker = "## Supported AI Coding Agents\n\n" + end_marker = "\n## List Available Integrations\n" + start = doc_text.index(start_marker) + len(start_marker) + end = doc_text.index(end_marker) + committed_table = doc_text[start:end].rstrip("\n") + rendered_table = render_integrations_table().rstrip("\n") + + def parse_table(table: str) -> list[list[str]]: + rows: list[list[str]] = [] + for line in table.splitlines(): + if not line.startswith("| "): + continue + parts = [part.strip() for part in re.split(r"(? 2 # At least header, separator, and one data row + assert lines[0] == "| Agent | Key | Notes |" + assert lines[1] == "| --- | --- | --- |" + + +def test_render_integrations_table_escapes_link_text(): + fake_registry = { + "bracket": MagicMock(config={"name": "Code [Buddy]"}), + } + fake_doc_urls = { + "bracket": "https://example.com/docs", + } + + with _get_catalog_docs_patches( + fake_registry=fake_registry, + fake_doc_urls=fake_doc_urls, + fake_notes={}, + ): + table = render_integrations_table() + + assert "[Code \\[Buddy\\]](https://example.com/docs)" in table + + +def test_cli_integration_search_markdown_with_filters_warns(): + """Test that `integration search --markdown` with filters warns.""" + with _get_catalog_docs_patches(): + result = runner.invoke( + app, + [ + "integration", + "search", + "test-query", + "--markdown", + "--tag", + "some-tag", + ], + ) + assert result.exit_code == 0 + # Check for the specific Typer warning message + assert "ignores query/--tag/--author filters" in result.stderr + lines = result.stdout.splitlines() + assert lines[0] == "| Agent | Key | Notes |" + + +def test_cli_integration_search_markdown_stdout_is_clean(): + """Test that stdout contains only the markdown table with proper format.""" + with _get_catalog_docs_patches(): + result = runner.invoke(app, ["integration", "search", "--markdown"]) + assert result.exit_code == 0 + stdout = result.stdout + lines = stdout.splitlines() + # Verify markdown table header is present + assert len(lines) > 1 + assert lines[0] == "| Agent | Key | Notes |" + # Ensure stderr has no error messages + assert "error" not in result.stderr.lower() diff --git a/tests/test_community_catalog_docs.py b/tests/test_community_catalog_docs.py new file mode 100644 index 0000000000..adcf321249 --- /dev/null +++ b/tests/test_community_catalog_docs.py @@ -0,0 +1,162 @@ +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: + from specify_cli.community_catalog_docs import COMMUNITY_CATALOG_PATH + if not COMMUNITY_CATALOG_PATH.exists(): + pytest.skip( + f"Community catalog not found at {COMMUNITY_CATALOG_PATH}. " + "Skipping (expected when running from sdist/wheel)." + ) + 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: + from specify_cli.community_catalog_docs import COMMUNITY_CATALOG_PATH + if not COMMUNITY_CATALOG_PATH.exists(): + pytest.skip( + f"Community catalog not found at {COMMUNITY_CATALOG_PATH}. " + "Skipping (expected when running from sdist/wheel)." + ) + 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="Ensure the repository checkout includes the extensions/ directory", + ): + 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_whitespace_repository_is_treated_as_missing(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": {"name": "Foo", "id": "foo", "description": "", "tags": [], "verified": False, "repository": " "}, + }) + table = render_community_extensions_table(path=f) + assert "Foo" in table + assert "[Foo](" not in table + + +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_url_escaping_in_repository_links(tmp_path: Path) -> None: + """Test that URLs with `)` and `|` are properly escaped in markdown links.""" + f = _write_catalog(tmp_path, { + "foo": { + "name": "Foo", + "description": "", + "tags": [], + "verified": False, + "repository": "https://example.com/repo?x=1)&y=2|bad", # Contains ) and | + }, + }) + table = render_community_extensions_table(path=f) + # The URL should be escaped: ) → \) and | → \| + assert "[Foo](https://example.com/repo?x=1\\)&y=2\\|bad)" in table + + +def test_extension_id_is_sanitized(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo|bar": { + "name": "Foo", + "id": "foo|bar\n", + "description": "", + "tags": [], + "verified": False, + "repository": "", + }, + }) + table = render_community_extensions_table(path=f) + assert "`foo\\|bar `" in table From eb5ef180b118dece70631fb6698659d9f88415c2 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Sun, 24 May 2026 15:48:39 +0000 Subject: [PATCH 2/2] fix: restore markdown integrations table command on PR branch Keep the CLI path alongside the integrations docs rendering changes so the PR 2563 branch remains testable. Tests: pytest tests/test_catalog_docs.py tests/test_community_catalog_docs.py -q; python3 -m compileall -q src/specify_cli/__init__.py src/specify_cli/catalog_docs.py src/specify_cli/community_catalog_docs.py tests/test_catalog_docs.py tests/test_community_catalog_docs.py Reference: upstream/main; source patch /tmp/spec-kit-changes.patch --- src/specify_cli/__init__.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index c0bdbaabe3..e847402590 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2447,8 +2447,34 @@ def integration_search( query: Optional[str] = typer.Argument(None, help="Search query (optional)"), tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), + markdown: bool = typer.Option( + False, + "--markdown", + help=( + "Output the full built-in integrations table as markdown " + "(ignores query and --tag/--author filters)" + ), + ), ): - """Search for integrations in the active catalog stack.""" + """Search for integrations in the active catalog stack. + + Or output the built-in reference table with --markdown. + """ + if markdown: + if query or tag or author: + typer.echo( + "Warning: --markdown outputs the full built-in integrations table " + "and ignores query/--tag/--author filters.", + err=True, + ) + from .catalog_docs import render_integrations_table + try: + typer.echo(render_integrations_table(), nl=False) + except (FileNotFoundError, ValueError) as exc: + typer.echo(f"Error rendering integrations table: {exc}", err=True) + raise typer.Exit(code=1) + return + from .integrations import INTEGRATION_REGISTRY from .integrations.catalog import ( IntegrationCatalog,