From 848cb964fa64e939d4e4285c4c8eccd0f5dadeb1 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Thu, 14 May 2026 16:33:28 +0000 Subject: [PATCH 01/26] docs: generate integrations reference from catalog --- docs/reference/integrations.md | 69 ++++---- scripts/generate_integrations_reference.py | 68 ++++++++ src/specify_cli/catalog_docs.py | 182 +++++++++++++++++++++ tests/test_catalog_docs.py | 32 ++++ 4 files changed, 319 insertions(+), 32 deletions(-) create mode 100644 scripts/generate_integrations_reference.py create mode 100644 src/specify_cli/catalog_docs.py create mode 100644 tests/test_catalog_docs.py diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index ec6c894652..68cf7e688e 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -4,38 +4,43 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify ## Supported AI Coding Agents -| Agent | Key | Notes | -| ------------------------------------------------------------------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| [Amp](https://ampcode.com/) | `amp` | | -| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically | -| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | | -| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` | -| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | | -| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-` | -| [Cursor](https://cursor.sh/) | `cursor-agent` | | -| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-` | -| [Forge](https://forgecode.dev/) | `forge` | | -| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | | -| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | | -| [Goose](https://block.github.io/goose/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | -| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent | -| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | | -| [Junie](https://junie.jetbrains.com/) | `junie` | | -| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | | -| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration | -| [Kiro CLI](https://kiro.dev/docs/cli/) | `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](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | -| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | -| [opencode](https://opencode.ai/) | `opencode` | | -| [Pi Coding Agent](https://pi.dev) | `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) | -| [Qoder CLI](https://qoder.com/cli) | `qodercli` | | -| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | | -| [Roo Code](https://roocode.com/) | `roo` | | -| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | | -| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | | -| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically | -| [Windsurf](https://windsurf.com/) | `windsurf` | | -| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir "` for AI coding agents not listed above | +This table is generated from [`integrations/catalog.json`](../../integrations/catalog.json). Update the catalog and rerun `python scripts/generate_integrations_reference.py --write` to refresh it. + + + +| Agent | Key | Notes | +| ------------------------------------------------------------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically | +| [Amp](https://ampcode.com/) | `amp` | | +| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | | +| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent | +| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` | +| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | | +| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-` | +| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | | +| [Cursor](https://cursor.sh/) | `cursor-agent` | | +| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-` | +| [Forge](https://forgecode.dev/) | `forge` | | +| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | | +| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir "` for AI coding agents not listed above | +| [Goose](https://block.github.io/goose/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | +| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | | +| [Junie](https://junie.jetbrains.com/) | `junie` | | +| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | | +| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration | +| [Kiro CLI](https://kiro.dev/docs/cli/) | `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](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | +| [opencode](https://opencode.ai/) | `opencode` | | +| [Pi Coding Agent](https://pi.dev) | `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) | +| [Qoder CLI](https://qoder.com/cli) | `qodercli` | | +| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | | +| [Roo Code](https://roocode.com/) | `roo` | | +| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | | +| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | | +| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically | +| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | +| [Windsurf](https://windsurf.com/) | `windsurf` | | + ## List Available Integrations diff --git a/scripts/generate_integrations_reference.py b/scripts/generate_integrations_reference.py new file mode 100644 index 0000000000..b7851c2f63 --- /dev/null +++ b/scripts/generate_integrations_reference.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Generate the integrations reference table from integrations/catalog.json.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +ROOT_DIR = Path(__file__).resolve().parents[1] +SRC_DIR = ROOT_DIR / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from specify_cli.catalog_docs import ( # noqa: E402 + INTEGRATIONS_CATALOG_PATH, + INTEGRATIONS_REFERENCE_PATH, + render_integrations_reference, +) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--write", + action="store_true", + help="Rewrite docs/reference/integrations.md in place", + ) + parser.add_argument( + "--check", + action="store_true", + help="Exit non-zero if the generated file would differ from the committed file", + ) + parser.add_argument( + "--catalog", + type=Path, + default=INTEGRATIONS_CATALOG_PATH, + help="Path to integrations/catalog.json", + ) + parser.add_argument( + "--doc", + type=Path, + default=INTEGRATIONS_REFERENCE_PATH, + help="Path to docs/reference/integrations.md", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + generated = render_integrations_reference(args.catalog, args.doc) + + if args.check: + current = args.doc.read_text(encoding="utf-8") + if current != generated: + return 1 + return 0 + + if args.write: + args.doc.write_text(generated, encoding="utf-8") + return 0 + + sys.stdout.write(generated) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py new file mode 100644 index 0000000000..6e428e8400 --- /dev/null +++ b/src/specify_cli/catalog_docs.py @@ -0,0 +1,182 @@ +"""Helpers for generating catalog-backed reference docs.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +ROOT_DIR = Path(__file__).resolve().parents[2] +INTEGRATIONS_CATALOG_PATH = ROOT_DIR / "integrations" / "catalog.json" +INTEGRATIONS_REFERENCE_PATH = ROOT_DIR / "docs" / "reference" / "integrations.md" + +GENERATED_START_MARKER = "" +GENERATED_END_MARKER = "" + + +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 load_integrations_catalog(path: Path = INTEGRATIONS_CATALOG_PATH) -> dict[str, Any]: + """Load and validate the integrations catalog JSON file.""" + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"Expected {path} to contain a JSON object") + integrations = data.get("integrations") + if not isinstance(integrations, dict): + raise ValueError(f"Expected {path} to contain an 'integrations' object") + return data + + +def _render_cell(value: str) -> str: + return value.replace("\n", " ") + + +def _get_integration_registry() -> dict[str, Any]: + from specify_cli.integrations import INTEGRATION_REGISTRY + + return INTEGRATION_REGISTRY + + +def _iter_integrations_for_docs() -> list[tuple[str, str, str | None, str]]: + registry = _get_integration_registry() + rows: list[tuple[str, str, str | None, str]] = [] + + for key, integration in registry.items(): + config = integration.config if isinstance(integration.config, dict) else {} + label = INTEGRATION_LABEL_OVERRIDES.get(key, str(config.get("name") or key)) + url = INTEGRATION_DOC_URLS.get(key) + notes = INTEGRATION_NOTES.get(key, "") + rows.append((key, label, url, notes)) + + return rows + + +def render_integrations_table(catalog: dict[str, Any]) -> str: + """Render the integrations reference table from the catalog data.""" + integrations = catalog.get("integrations", {}) + rows: list[list[str]] = [] + + doc_rows = _iter_integrations_for_docs() + doc_keys = [key for key, _, _, _ in doc_rows] + extra_keys = [key for key in integrations if key not in doc_keys] + if extra_keys: + raise KeyError( + "No integrations reference metadata found for catalog entries: " + + ", ".join(repr(key) for key in extra_keys) + ) + + missing_keys = [key for key in doc_keys if key not in integrations] + if missing_keys: + raise KeyError( + "Catalog is missing integrations needed for the reference table: " + + ", ".join(repr(key) for key in missing_keys) + ) + + for key, label, url, notes in doc_rows: + agent = f"[{label}]({url})" if url else label + rows.append([agent, f"`{key}`", notes]) + + widths = [ + max(len(header), *(len(_render_cell(row[index])) for row in rows)) + for index, header in enumerate(("Agent", "Key", "Notes")) + ] + + def render_row(values: list[str]) -> str: + return "| " + " | ".join( + _render_cell(value).ljust(widths[index]) for index, value in enumerate(values) + ) + " |" + + lines = [ + render_row(["Agent", "Key", "Notes"]), + "| " + " | ".join("-" * width for width in widths) + " |", + ] + lines.extend(render_row(row) for row in rows) + return "\n".join(lines) + + +def render_integrations_reference( + catalog_path: Path = INTEGRATIONS_CATALOG_PATH, + doc_path: Path = INTEGRATIONS_REFERENCE_PATH, +) -> str: + """Return the integrations reference markdown with the generated table updated.""" + catalog = load_integrations_catalog(catalog_path) + table = render_integrations_table(catalog) + + content = doc_path.read_text(encoding="utf-8") + start = content.find(GENERATED_START_MARKER) + end = content.find(GENERATED_END_MARKER) + if start == -1 or end == -1 or end < start: + raise ValueError( + f"Could not find generated table markers in {doc_path}" + ) + + start_end = start + len(GENERATED_START_MARKER) + before = content[:start_end] + after = content[end:] + generated_block = f"\n\n{table}\n" + return before + generated_block + after + + +def update_integrations_reference( + catalog_path: Path = INTEGRATIONS_CATALOG_PATH, + doc_path: Path = INTEGRATIONS_REFERENCE_PATH, +) -> str: + """Rewrite the integrations reference markdown file and return the new content.""" + updated = render_integrations_reference(catalog_path, doc_path) + doc_path.write_text(updated, encoding="utf-8") + return updated diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py new file mode 100644 index 0000000000..d46c1f7469 --- /dev/null +++ b/tests/test_catalog_docs.py @@ -0,0 +1,32 @@ +"""Tests for catalog-backed documentation generation.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +from specify_cli.catalog_docs import _iter_integrations_for_docs, render_integrations_reference + + +def test_integrations_reference_matches_generator(): + doc_path = Path("docs/reference/integrations.md") + assert doc_path.read_text(encoding="utf-8") == render_integrations_reference() + + +def test_integrations_reference_generator_check_mode(): + result = subprocess.run( + [sys.executable, "scripts/generate_integrations_reference.py", "--check"], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + +def test_integrations_reference_rows_follow_registry_metadata(): + rows = dict((key, (label, url)) for key, label, url, _notes in _iter_integrations_for_docs()) + assert rows["copilot"][0] == "GitHub Copilot" + assert rows["copilot"][1] == "https://code.visualstudio.com/" + assert rows["codex"][0] == "Codex CLI" + assert rows["codex"][1] == "https://github.com/openai/codex" From 2a72b538c3837c64b78944015be55c4ae2822fe5 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Thu, 14 May 2026 22:10:24 +0000 Subject: [PATCH 02/26] refactor: integrate table rendering into specify integration search --markdown - Remove standalone scripts/generate_integrations_reference.py - Strip doc injection machinery from catalog_docs.py; keep only table rendering - Wire render_integrations_table() into existing --markdown flag of integration search - Remove old simple markdown table block from integration_search (was Name|ID|Version|Description|Author) - Simplify tests: drop subprocess/doc-path tests, keep table rendering and metadata tests - Clean up docs/reference/integrations.md: remove generated markers, update note --- docs/reference/integrations.md | 5 +- scripts/generate_integrations_reference.py | 68 ----------------- src/specify_cli/__init__.py | 8 ++ src/specify_cli/catalog_docs.py | 86 ++-------------------- tests/test_catalog_docs.py | 26 ++----- 5 files changed, 24 insertions(+), 169 deletions(-) delete mode 100644 scripts/generate_integrations_reference.py diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 68cf7e688e..9dd3d3eb2e 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -4,9 +4,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify ## Supported AI Coding Agents -This table is generated from [`integrations/catalog.json`](../../integrations/catalog.json). Update the catalog and rerun `python scripts/generate_integrations_reference.py --write` to refresh it. - - +Run `specify integration search --markdown` to print this table as markdown. | Agent | Key | Notes | | ------------------------------------------------------------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -40,7 +38,6 @@ This table is generated from [`integrations/catalog.json`](../../integrations/ca | [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | | [Windsurf](https://windsurf.com/) | `windsurf` | | - ## List Available Integrations diff --git a/scripts/generate_integrations_reference.py b/scripts/generate_integrations_reference.py deleted file mode 100644 index b7851c2f63..0000000000 --- a/scripts/generate_integrations_reference.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -"""Generate the integrations reference table from integrations/catalog.json.""" - -from __future__ import annotations - -import argparse -from pathlib import Path -import sys - -ROOT_DIR = Path(__file__).resolve().parents[1] -SRC_DIR = ROOT_DIR / "src" -if str(SRC_DIR) not in sys.path: - sys.path.insert(0, str(SRC_DIR)) - -from specify_cli.catalog_docs import ( # noqa: E402 - INTEGRATIONS_CATALOG_PATH, - INTEGRATIONS_REFERENCE_PATH, - render_integrations_reference, -) - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--write", - action="store_true", - help="Rewrite docs/reference/integrations.md in place", - ) - parser.add_argument( - "--check", - action="store_true", - help="Exit non-zero if the generated file would differ from the committed file", - ) - parser.add_argument( - "--catalog", - type=Path, - default=INTEGRATIONS_CATALOG_PATH, - help="Path to integrations/catalog.json", - ) - parser.add_argument( - "--doc", - type=Path, - default=INTEGRATIONS_REFERENCE_PATH, - help="Path to docs/reference/integrations.md", - ) - return parser.parse_args(argv) - - -def main(argv: list[str] | None = None) -> int: - args = parse_args(sys.argv[1:] if argv is None else argv) - generated = render_integrations_reference(args.catalog, args.doc) - - if args.check: - current = args.doc.read_text(encoding="utf-8") - if current != generated: - return 1 - return 0 - - if args.write: - args.doc.write_text(generated, encoding="utf-8") - return 0 - - sys.stdout.write(generated) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 41fb994726..b0eecfd67b 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2377,8 +2377,16 @@ 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 results as a markdown table" + ), ): """Search for integrations in the active catalog stack.""" + if markdown: + from .catalog_docs import render_integrations_table + typer.echo(render_integrations_table()) + return + from .integrations import INTEGRATION_REGISTRY from .integrations.catalog import ( IntegrationCatalog, diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index 6e428e8400..1634c550eb 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -1,20 +1,10 @@ -"""Helpers for generating catalog-backed reference docs.""" +"""Helpers for rendering the built-in integrations reference table.""" from __future__ import annotations -import json -from pathlib import Path from typing import Any -ROOT_DIR = Path(__file__).resolve().parents[2] -INTEGRATIONS_CATALOG_PATH = ROOT_DIR / "integrations" / "catalog.json" -INTEGRATIONS_REFERENCE_PATH = ROOT_DIR / "docs" / "reference" / "integrations.md" - -GENERATED_START_MARKER = "" -GENERATED_END_MARKER = "" - - INTEGRATION_DOC_URLS: dict[str, str | None] = { "amp": "https://ampcode.com/", "agy": "https://antigravity.google/", @@ -71,19 +61,9 @@ } -def load_integrations_catalog(path: Path = INTEGRATIONS_CATALOG_PATH) -> dict[str, Any]: - """Load and validate the integrations catalog JSON file.""" - data = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(data, dict): - raise ValueError(f"Expected {path} to contain a JSON object") - integrations = data.get("integrations") - if not isinstance(integrations, dict): - raise ValueError(f"Expected {path} to contain an 'integrations' object") - return data - - def _render_cell(value: str) -> str: - return value.replace("\n", " ") + value = value.replace("\r\n", " ").replace("\r", " ").replace("\n", " ") + return value.replace("|", "\\|") def _get_integration_registry() -> dict[str, Any]: @@ -92,7 +72,7 @@ def _get_integration_registry() -> dict[str, Any]: return INTEGRATION_REGISTRY -def _iter_integrations_for_docs() -> list[tuple[str, str, str | None, str]]: +def list_integrations_for_docs() -> list[tuple[str, str, str | None, str]]: registry = _get_integration_registry() rows: list[tuple[str, str, str | None, str]] = [] @@ -103,31 +83,14 @@ def _iter_integrations_for_docs() -> list[tuple[str, str, str | None, str]]: notes = INTEGRATION_NOTES.get(key, "") rows.append((key, label, url, notes)) - return rows + return sorted(rows, key=lambda r: r[0]) -def render_integrations_table(catalog: dict[str, Any]) -> str: - """Render the integrations reference table from the catalog data.""" - integrations = catalog.get("integrations", {}) +def render_integrations_table() -> str: + """Render the built-in integrations reference table as markdown.""" rows: list[list[str]] = [] - doc_rows = _iter_integrations_for_docs() - doc_keys = [key for key, _, _, _ in doc_rows] - extra_keys = [key for key in integrations if key not in doc_keys] - if extra_keys: - raise KeyError( - "No integrations reference metadata found for catalog entries: " - + ", ".join(repr(key) for key in extra_keys) - ) - - missing_keys = [key for key in doc_keys if key not in integrations] - if missing_keys: - raise KeyError( - "Catalog is missing integrations needed for the reference table: " - + ", ".join(repr(key) for key in missing_keys) - ) - - for key, label, url, notes in doc_rows: + for key, label, url, notes in list_integrations_for_docs(): agent = f"[{label}]({url})" if url else label rows.append([agent, f"`{key}`", notes]) @@ -147,36 +110,3 @@ def render_row(values: list[str]) -> str: ] lines.extend(render_row(row) for row in rows) return "\n".join(lines) - - -def render_integrations_reference( - catalog_path: Path = INTEGRATIONS_CATALOG_PATH, - doc_path: Path = INTEGRATIONS_REFERENCE_PATH, -) -> str: - """Return the integrations reference markdown with the generated table updated.""" - catalog = load_integrations_catalog(catalog_path) - table = render_integrations_table(catalog) - - content = doc_path.read_text(encoding="utf-8") - start = content.find(GENERATED_START_MARKER) - end = content.find(GENERATED_END_MARKER) - if start == -1 or end == -1 or end < start: - raise ValueError( - f"Could not find generated table markers in {doc_path}" - ) - - start_end = start + len(GENERATED_START_MARKER) - before = content[:start_end] - after = content[end:] - generated_block = f"\n\n{table}\n" - return before + generated_block + after - - -def update_integrations_reference( - catalog_path: Path = INTEGRATIONS_CATALOG_PATH, - doc_path: Path = INTEGRATIONS_REFERENCE_PATH, -) -> str: - """Rewrite the integrations reference markdown file and return the new content.""" - updated = render_integrations_reference(catalog_path, doc_path) - doc_path.write_text(updated, encoding="utf-8") - return updated diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index d46c1f7469..7255a98dea 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -2,30 +2,18 @@ from __future__ import annotations -import subprocess -import sys -from pathlib import Path +from specify_cli.catalog_docs import list_integrations_for_docs, render_integrations_table -from specify_cli.catalog_docs import _iter_integrations_for_docs, render_integrations_reference - -def test_integrations_reference_matches_generator(): - doc_path = Path("docs/reference/integrations.md") - assert doc_path.read_text(encoding="utf-8") == render_integrations_reference() - - -def test_integrations_reference_generator_check_mode(): - result = subprocess.run( - [sys.executable, "scripts/generate_integrations_reference.py", "--check"], - check=False, - capture_output=True, - text=True, - ) - assert result.returncode == 0, result.stderr +def test_integrations_table_renders(): + table = render_integrations_table() + assert "| Agent" in table + assert "| Key" in table + assert "| Notes" in table def test_integrations_reference_rows_follow_registry_metadata(): - rows = dict((key, (label, url)) for key, label, url, _notes in _iter_integrations_for_docs()) + rows = dict((key, (label, url)) for key, label, url, _notes in list_integrations_for_docs()) assert rows["copilot"][0] == "GitHub Copilot" assert rows["copilot"][1] == "https://code.visualstudio.com/" assert rows["codex"][0] == "Codex CLI" From df40ef8926c63019b12b8350d769b82cbd388319 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Thu, 14 May 2026 23:15:10 +0000 Subject: [PATCH 03/26] fix: address Copilot review feedback on catalog_docs and integration_search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Warn when --markdown is combined with filters (query/--tag/--author) which are silently ignored; catch ValueError/FileNotFoundError and surface clean error via console instead of raw traceback (r3244821516) - Add coverage enforcement in list_integrations_for_docs(): raises ValueError with actionable message if any registry key is missing from INTEGRATION_DOC_URLS, preventing silently incomplete doc tables (r3244821589) - Rename test to accurately reflect sources: label derives from registry config, URL comes from INTEGRATION_DOC_URLS doc map — not solely from registry (r3244821607) - Simplify test dict construction to idiomatic dict comprehension (r3244821619) --- src/specify_cli/__init__.py | 13 +++++++++++-- src/specify_cli/catalog_docs.py | 8 ++++++++ tests/test_catalog_docs.py | 4 ++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index b0eecfd67b..034f95e271 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2378,13 +2378,22 @@ def integration_search( 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 results as a markdown table" + False, "--markdown", help="Output the full built-in integrations table as markdown (ignores filters)" ), ): """Search for integrations in the active catalog stack.""" if markdown: + if query or tag or author: + console.print( + "[yellow]Warning:[/yellow] --markdown outputs the full built-in integrations table " + "and ignores query/--tag/--author filters." + ) from .catalog_docs import render_integrations_table - typer.echo(render_integrations_table()) + try: + typer.echo(render_integrations_table()) + except (ValueError, FileNotFoundError) as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) return from .integrations import INTEGRATION_REGISTRY diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index 1634c550eb..e401b2bbf4 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -74,6 +74,14 @@ def _get_integration_registry() -> dict[str, Any]: def list_integrations_for_docs() -> list[tuple[str, str, str | None, str]]: registry = _get_integration_registry() + + missing = [key for key in registry if key not in INTEGRATION_DOC_URLS] + if missing: + raise ValueError( + f"Integration(s) missing from INTEGRATION_DOC_URLS: {', '.join(sorted(missing))}. " + "Add each key to INTEGRATION_DOC_URLS in catalog_docs.py (use None if no URL applies)." + ) + rows: list[tuple[str, str, str | None, str]] = [] for key, integration in registry.items(): diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index 7255a98dea..6c8e7e079e 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -12,8 +12,8 @@ def test_integrations_table_renders(): assert "| Notes" in table -def test_integrations_reference_rows_follow_registry_metadata(): - rows = dict((key, (label, url)) for key, label, url, _notes in list_integrations_for_docs()) +def test_integrations_reference_label_derives_from_registry_url_from_doc_map(): + rows = {key: (label, url) for key, label, url, _notes in list_integrations_for_docs()} assert rows["copilot"][0] == "GitHub Copilot" assert rows["copilot"][1] == "https://code.visualstudio.com/" assert rows["codex"][0] == "Codex CLI" From 73602ca0bf3f9a343dc0b1e67521ee600c20d9c6 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 13:29:54 +0000 Subject: [PATCH 04/26] fix: add sync test, INTEGRATIONS_REFERENCE_PATH constant, and fix naming --- src/specify_cli/catalog_docs.py | 8 +++++++- tests/test_catalog_docs.py | 18 ++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index e401b2bbf4..20ca2fb941 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -1,10 +1,16 @@ -"""Helpers for rendering the built-in integrations reference table.""" +"""Helpers for rendering the built-in integrations reference table from the integration registry.""" from __future__ import annotations +from pathlib import Path from typing import Any +INTEGRATIONS_REFERENCE_PATH = ( + Path(__file__).parent.parent.parent / "docs" / "reference" / "integrations.md" +) + + INTEGRATION_DOC_URLS: dict[str, str | None] = { "amp": "https://ampcode.com/", "agy": "https://antigravity.google/", diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index 6c8e7e079e..dc80350004 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -1,8 +1,12 @@ -"""Tests for catalog-backed documentation generation.""" +"""Tests for the integration registry documentation generation.""" from __future__ import annotations -from specify_cli.catalog_docs import list_integrations_for_docs, render_integrations_table +from specify_cli.catalog_docs import ( + INTEGRATIONS_REFERENCE_PATH, + list_integrations_for_docs, + render_integrations_table, +) def test_integrations_table_renders(): @@ -18,3 +22,13 @@ def test_integrations_reference_label_derives_from_registry_url_from_doc_map(): assert rows["copilot"][1] == "https://code.visualstudio.com/" assert rows["codex"][0] == "Codex CLI" assert rows["codex"][1] == "https://github.com/openai/codex" + + +def test_integrations_reference_doc_is_in_sync(): + """Committed docs/reference/integrations.md must contain the rendered table.""" + expected_table = render_integrations_table() + content = INTEGRATIONS_REFERENCE_PATH.read_text(encoding="utf-8") + assert expected_table in content, ( + "docs/reference/integrations.md is out of sync with the integration registry. " + "Re-run `specify integration search --markdown` and update the file." + ) From c621732cfd81dc2251d37270b9e82dfba7701137 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 13:50:18 +0000 Subject: [PATCH 05/26] revert: restore docs/reference/integrations.md to upstream/main; remove sync test (GH Actions job will handle) --- docs/reference/integrations.md | 66 +++++++++++++++++----------------- tests/test_catalog_docs.py | 16 +-------- 2 files changed, 33 insertions(+), 49 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 9dd3d3eb2e..ec6c894652 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -4,40 +4,38 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify ## Supported AI Coding Agents -Run `specify integration search --markdown` to print this table as markdown. - -| Agent | Key | Notes | -| ------------------------------------------------------------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically | -| [Amp](https://ampcode.com/) | `amp` | | -| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | | -| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent | -| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` | -| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | | -| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-` | -| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | | -| [Cursor](https://cursor.sh/) | `cursor-agent` | | -| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-` | -| [Forge](https://forgecode.dev/) | `forge` | | -| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | | -| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir "` for AI coding agents not listed above | -| [Goose](https://block.github.io/goose/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | -| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | | -| [Junie](https://junie.jetbrains.com/) | `junie` | | -| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | | -| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration | -| [Kiro CLI](https://kiro.dev/docs/cli/) | `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](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | -| [opencode](https://opencode.ai/) | `opencode` | | -| [Pi Coding Agent](https://pi.dev) | `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) | -| [Qoder CLI](https://qoder.com/cli) | `qodercli` | | -| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | | -| [Roo Code](https://roocode.com/) | `roo` | | -| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | | -| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | | -| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically | -| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | -| [Windsurf](https://windsurf.com/) | `windsurf` | | +| Agent | Key | Notes | +| ------------------------------------------------------------------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| [Amp](https://ampcode.com/) | `amp` | | +| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically | +| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | | +| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` | +| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | | +| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-` | +| [Cursor](https://cursor.sh/) | `cursor-agent` | | +| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-` | +| [Forge](https://forgecode.dev/) | `forge` | | +| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | | +| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | | +| [Goose](https://block.github.io/goose/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | +| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent | +| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | | +| [Junie](https://junie.jetbrains.com/) | `junie` | | +| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | | +| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration | +| [Kiro CLI](https://kiro.dev/docs/cli/) | `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](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | +| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | +| [opencode](https://opencode.ai/) | `opencode` | | +| [Pi Coding Agent](https://pi.dev) | `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) | +| [Qoder CLI](https://qoder.com/cli) | `qodercli` | | +| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | | +| [Roo Code](https://roocode.com/) | `roo` | | +| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | | +| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | | +| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically | +| [Windsurf](https://windsurf.com/) | `windsurf` | | +| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir "` for AI coding agents not listed above | ## List Available Integrations diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index dc80350004..e815e7d8b0 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -2,11 +2,7 @@ from __future__ import annotations -from specify_cli.catalog_docs import ( - INTEGRATIONS_REFERENCE_PATH, - list_integrations_for_docs, - render_integrations_table, -) +from specify_cli.catalog_docs import list_integrations_for_docs, render_integrations_table def test_integrations_table_renders(): @@ -22,13 +18,3 @@ def test_integrations_reference_label_derives_from_registry_url_from_doc_map(): assert rows["copilot"][1] == "https://code.visualstudio.com/" assert rows["codex"][0] == "Codex CLI" assert rows["codex"][1] == "https://github.com/openai/codex" - - -def test_integrations_reference_doc_is_in_sync(): - """Committed docs/reference/integrations.md must contain the rendered table.""" - expected_table = render_integrations_table() - content = INTEGRATIONS_REFERENCE_PATH.read_text(encoding="utf-8") - assert expected_table in content, ( - "docs/reference/integrations.md is out of sync with the integration registry. " - "Re-run `specify integration search --markdown` and update the file." - ) From b4bd56f69c95d217c8176053584160fc0cee0680 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 13:56:58 +0000 Subject: [PATCH 06/26] fix: remove dead INTEGRATIONS_REFERENCE_PATH, drop URL-length padding, fix docstring, drop FileNotFoundError --- src/specify_cli/__init__.py | 4 ++-- src/specify_cli/catalog_docs.py | 16 ++-------------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 034f95e271..3de650d5f0 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2381,7 +2381,7 @@ def integration_search( False, "--markdown", help="Output the full built-in integrations table as markdown (ignores 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: console.print( @@ -2391,7 +2391,7 @@ def integration_search( from .catalog_docs import render_integrations_table try: typer.echo(render_integrations_table()) - except (ValueError, FileNotFoundError) as exc: + except ValueError as exc: console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(1) return diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index 20ca2fb941..d93ed11537 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -2,14 +2,9 @@ from __future__ import annotations -from pathlib import Path from typing import Any -INTEGRATIONS_REFERENCE_PATH = ( - Path(__file__).parent.parent.parent / "docs" / "reference" / "integrations.md" -) - INTEGRATION_DOC_URLS: dict[str, str | None] = { "amp": "https://ampcode.com/", @@ -108,19 +103,12 @@ def render_integrations_table() -> str: agent = f"[{label}]({url})" if url else label rows.append([agent, f"`{key}`", notes]) - widths = [ - max(len(header), *(len(_render_cell(row[index])) for row in rows)) - for index, header in enumerate(("Agent", "Key", "Notes")) - ] - def render_row(values: list[str]) -> str: - return "| " + " | ".join( - _render_cell(value).ljust(widths[index]) for index, value in enumerate(values) - ) + " |" + return "| " + " | ".join(_render_cell(value) for value in values) + " |" lines = [ render_row(["Agent", "Key", "Notes"]), - "| " + " | ".join("-" * width for width in widths) + " |", + "| " + " | ".join(["---", "---", "---"]) + " |", ] lines.extend(render_row(row) for row in rows) return "\n".join(lines) From 7caace8fc86fd1f9ad2814e714c90b704da281ac Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 14:53:03 +0000 Subject: [PATCH 07/26] fix: send --markdown warnings/errors to stderr, rename test for clarity --- src/specify_cli/__init__.py | 9 +++++---- tests/test_catalog_docs.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 3de650d5f0..5884fdc564 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2384,15 +2384,16 @@ def integration_search( """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: - console.print( - "[yellow]Warning:[/yellow] --markdown outputs the full built-in integrations table " - "and ignores query/--tag/--author filters." + 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()) except ValueError as exc: - console.print(f"[red]Error:[/red] {exc}") + typer.echo(f"Error: {exc}", err=True) raise typer.Exit(1) return diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index e815e7d8b0..b06d68ce2e 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -12,7 +12,7 @@ def test_integrations_table_renders(): assert "| Notes" in table -def test_integrations_reference_label_derives_from_registry_url_from_doc_map(): +def test_integrations_docs_label_and_url_sources(): rows = {key: (label, url) for key, label, url, _notes in list_integrations_for_docs()} assert rows["copilot"][0] == "GitHub Copilot" assert rows["copilot"][1] == "https://code.visualstudio.com/" From 01be38f97b9de13ecf6cd8454968d99a1341095f Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 15:01:38 +0000 Subject: [PATCH 08/26] fix: detect stale doc-map keys, test _render_cell escaping, strengthen header assertion --- src/specify_cli/catalog_docs.py | 12 ++++++++++++ tests/test_catalog_docs.py | 16 ++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index d93ed11537..f29872e688 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -75,6 +75,7 @@ def _get_integration_registry() -> dict[str, Any]: def list_integrations_for_docs() -> list[tuple[str, str, str | None, str]]: registry = _get_integration_registry() + registry_keys = set(registry) missing = [key for key in registry if key not in INTEGRATION_DOC_URLS] if missing: @@ -83,6 +84,17 @@ def list_integrations_for_docs() -> list[tuple[str, str, str | None, str]]: "Add each key to INTEGRATION_DOC_URLS in catalog_docs.py (use None if no URL applies)." ) + stale: set[str] = ( + (set(INTEGRATION_DOC_URLS) - registry_keys) + | (set(INTEGRATION_LABEL_OVERRIDES) - registry_keys) + | (set(INTEGRATION_NOTES) - registry_keys) + ) + if stale: + raise ValueError( + f"Stale key(s) in doc maps no longer present in registry: {', '.join(sorted(stale))}. " + "Remove them from INTEGRATION_DOC_URLS / INTEGRATION_LABEL_OVERRIDES / INTEGRATION_NOTES." + ) + rows: list[tuple[str, str, str | None, str]] = [] for key, integration in registry.items(): diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index b06d68ce2e..8b2dbe91cd 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -2,14 +2,22 @@ from __future__ import annotations -from specify_cli.catalog_docs import list_integrations_for_docs, render_integrations_table +from specify_cli.catalog_docs import _render_cell, list_integrations_for_docs, render_integrations_table def test_integrations_table_renders(): table = render_integrations_table() - assert "| Agent" in table - assert "| Key" in table - assert "| Notes" in table + lines = table.splitlines() + assert lines[0] == "| Agent | Key | Notes |" + assert lines[1] == "| --- | --- | --- |" + + +def test_render_cell_escapes_pipes_and_normalizes_newlines(): + assert _render_cell("a|b") == "a\\|b" + assert _render_cell("a\nb") == "a b" + assert _render_cell("a\r\nb") == "a b" + assert _render_cell("a\rb") == "a b" + assert _render_cell("a|b\nc") == "a\\|b c" def test_integrations_docs_label_and_url_sources(): From 70afa5c7e3871f0745d5c492a89fef789a32e88e Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 18:15:32 +0000 Subject: [PATCH 09/26] refactor: promote _render_cell to public render_cell function --- src/specify_cli/catalog_docs.py | 9 +++++++-- tests/test_catalog_docs.py | 12 ++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index f29872e688..6287cb2a59 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -62,7 +62,12 @@ } -def _render_cell(value: str) -> str: +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("|", "\\|") @@ -116,7 +121,7 @@ def render_integrations_table() -> str: rows.append([agent, f"`{key}`", notes]) def render_row(values: list[str]) -> str: - return "| " + " | ".join(_render_cell(value) for value in values) + " |" + return "| " + " | ".join(render_cell(value) for value in values) + " |" lines = [ render_row(["Agent", "Key", "Notes"]), diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index 8b2dbe91cd..92a3f42db0 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -2,7 +2,7 @@ from __future__ import annotations -from specify_cli.catalog_docs import _render_cell, list_integrations_for_docs, render_integrations_table +from specify_cli.catalog_docs import render_cell, list_integrations_for_docs, render_integrations_table def test_integrations_table_renders(): @@ -13,11 +13,11 @@ def test_integrations_table_renders(): def test_render_cell_escapes_pipes_and_normalizes_newlines(): - assert _render_cell("a|b") == "a\\|b" - assert _render_cell("a\nb") == "a b" - assert _render_cell("a\r\nb") == "a b" - assert _render_cell("a\rb") == "a b" - assert _render_cell("a|b\nc") == "a\\|b c" + assert render_cell("a|b") == "a\\|b" + assert render_cell("a\nb") == "a b" + assert render_cell("a\r\nb") == "a b" + assert render_cell("a\rb") == "a b" + assert render_cell("a|b\nc") == "a\\|b c" def test_integrations_docs_label_and_url_sources(): From 1c5af189cf39c6d824d84627b7b6e83bd0cf6da0 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 18:26:27 +0000 Subject: [PATCH 10/26] test: mock registry and doc maps to avoid brittle live registry coupling --- tests/test_catalog_docs.py | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index 92a3f42db0..36b11a1483 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -2,7 +2,15 @@ from __future__ import annotations -from specify_cli.catalog_docs import render_cell, list_integrations_for_docs, render_integrations_table +from unittest.mock import MagicMock, patch + +from specify_cli.catalog_docs import ( + render_cell, + list_integrations_for_docs, + render_integrations_table, + INTEGRATION_DOC_URLS, + INTEGRATION_LABEL_OVERRIDES, +) def test_integrations_table_renders(): @@ -21,8 +29,24 @@ def test_render_cell_escapes_pipes_and_normalizes_newlines(): def test_integrations_docs_label_and_url_sources(): - rows = {key: (label, url) for key, label, url, _notes in list_integrations_for_docs()} - assert rows["copilot"][0] == "GitHub Copilot" - assert rows["copilot"][1] == "https://code.visualstudio.com/" - assert rows["codex"][0] == "Codex CLI" - assert rows["codex"][1] == "https://github.com/openai/codex" + """Test with a mocked registry and doc maps to avoid brittleness to live registry changes.""" + # Create a minimal fake registry with two known integrations + fake_registry = { + "copilot": MagicMock(config={"name": "GitHub Copilot"}), + "codex": MagicMock(config={"name": "Codex CLI"}), + } + + # Mock the doc maps to only contain entries for the fake registry + fake_doc_urls = {"copilot": "https://code.visualstudio.com/", "codex": "https://github.com/openai/codex"} + fake_label_overrides = {} + fake_notes = {} + + with patch("specify_cli.catalog_docs._get_integration_registry", return_value=fake_registry): + with patch("specify_cli.catalog_docs.INTEGRATION_DOC_URLS", fake_doc_urls): + with patch("specify_cli.catalog_docs.INTEGRATION_LABEL_OVERRIDES", fake_label_overrides): + with patch("specify_cli.catalog_docs.INTEGRATION_NOTES", fake_notes): + rows = {key: (label, url) for key, label, url, _notes in list_integrations_for_docs()} + assert rows["copilot"][0] == "GitHub Copilot" + assert rows["copilot"][1] == "https://code.visualstudio.com/" + assert rows["codex"][0] == "Codex CLI" + assert rows["codex"][1] == "https://github.com/openai/codex" From b64cc3b9c3a1b3a6a4e7ac75ce5e4d479c695fba Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 19:52:00 +0000 Subject: [PATCH 11/26] refactor: flatten patches, remove unused imports, fix trailing whitespace, optimize missing calculation --- src/specify_cli/catalog_docs.py | 4 ++-- tests/test_catalog_docs.py | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index 6287cb2a59..ffd8cbc191 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -64,7 +64,7 @@ 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). """ @@ -82,7 +82,7 @@ def list_integrations_for_docs() -> list[tuple[str, str, str | None, str]]: registry = _get_integration_registry() registry_keys = set(registry) - missing = [key for key in registry if key not in INTEGRATION_DOC_URLS] + missing = [key for key in registry_keys if key not in INTEGRATION_DOC_URLS] if missing: raise ValueError( f"Integration(s) missing from INTEGRATION_DOC_URLS: {', '.join(sorted(missing))}. " diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index 36b11a1483..fabd17806b 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -8,8 +8,6 @@ render_cell, list_integrations_for_docs, render_integrations_table, - INTEGRATION_DOC_URLS, - INTEGRATION_LABEL_OVERRIDES, ) @@ -41,12 +39,14 @@ def test_integrations_docs_label_and_url_sources(): fake_label_overrides = {} fake_notes = {} - with patch("specify_cli.catalog_docs._get_integration_registry", return_value=fake_registry): - with patch("specify_cli.catalog_docs.INTEGRATION_DOC_URLS", fake_doc_urls): - with patch("specify_cli.catalog_docs.INTEGRATION_LABEL_OVERRIDES", fake_label_overrides): - with patch("specify_cli.catalog_docs.INTEGRATION_NOTES", fake_notes): - rows = {key: (label, url) for key, label, url, _notes in list_integrations_for_docs()} - assert rows["copilot"][0] == "GitHub Copilot" - assert rows["copilot"][1] == "https://code.visualstudio.com/" - assert rows["codex"][0] == "Codex CLI" - assert rows["codex"][1] == "https://github.com/openai/codex" + with ( + patch("specify_cli.catalog_docs._get_integration_registry", return_value=fake_registry), + patch("specify_cli.catalog_docs.INTEGRATION_DOC_URLS", fake_doc_urls), + patch("specify_cli.catalog_docs.INTEGRATION_LABEL_OVERRIDES", fake_label_overrides), + patch("specify_cli.catalog_docs.INTEGRATION_NOTES", fake_notes), + ): + rows = {key: (label, url) for key, label, url, _notes in list_integrations_for_docs()} + assert rows["copilot"][0] == "GitHub Copilot" + assert rows["copilot"][1] == "https://code.visualstudio.com/" + assert rows["codex"][0] == "Codex CLI" + assert rows["codex"][1] == "https://github.com/openai/codex" From c351766981a70507e3753e573db4e4b7677933b6 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 20:11:17 +0000 Subject: [PATCH 12/26] refactor: make validation non-fatal, fix context manager syntax, add CLI tests --- src/specify_cli/catalog_docs.py | 31 ++++++++++---------- tests/test_catalog_docs.py | 50 +++++++++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 21 deletions(-) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index ffd8cbc191..187bf4951c 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -79,30 +79,31 @@ def _get_integration_registry() -> dict[str, Any]: def list_integrations_for_docs() -> list[tuple[str, str, str | None, str]]: + """List integrations with their documentation URLs and notes. + + Skips any integrations not in INTEGRATION_DOC_URLS (logs warning if any are missing). + Gracefully handles missing URL or notes entries by defaulting to None/empty string. + """ registry = _get_integration_registry() registry_keys = set(registry) - missing = [key for key in registry_keys if key not in INTEGRATION_DOC_URLS] + # Warn if there are integrations missing from INTEGRATION_DOC_URLS, but don't fail + missing = sorted(registry_keys - set(INTEGRATION_DOC_URLS)) if missing: - raise ValueError( - f"Integration(s) missing from INTEGRATION_DOC_URLS: {', '.join(sorted(missing))}. " - "Add each key to INTEGRATION_DOC_URLS in catalog_docs.py (use None if no URL applies)." - ) - - stale: set[str] = ( - (set(INTEGRATION_DOC_URLS) - registry_keys) - | (set(INTEGRATION_LABEL_OVERRIDES) - registry_keys) - | (set(INTEGRATION_NOTES) - registry_keys) - ) - if stale: - raise ValueError( - f"Stale key(s) in doc maps no longer present in registry: {', '.join(sorted(stale))}. " - "Remove them from INTEGRATION_DOC_URLS / INTEGRATION_LABEL_OVERRIDES / INTEGRATION_NOTES." + import warnings + warnings.warn( + f"Integration(s) missing from INTEGRATION_DOC_URLS: {', '.join(missing)}. " + "These will be skipped in the docs table. Add them to INTEGRATION_DOC_URLS in catalog_docs.py.", + stacklevel=2 ) rows: list[tuple[str, str, str | None, str]] = [] for key, integration in registry.items(): + # Skip integrations not in the doc maps + if key not in INTEGRATION_DOC_URLS: + continue + config = integration.config if isinstance(integration.config, dict) else {} label = INTEGRATION_LABEL_OVERRIDES.get(key, str(config.get("name") or key)) url = INTEGRATION_DOC_URLS.get(key) diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index fabd17806b..a40dfa6e6a 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -4,11 +4,17 @@ from unittest.mock import MagicMock, patch +from typer.testing import CliRunner + from specify_cli.catalog_docs import ( render_cell, list_integrations_for_docs, render_integrations_table, ) +from specify_cli import app + + +runner = CliRunner() def test_integrations_table_renders(): @@ -39,14 +45,46 @@ def test_integrations_docs_label_and_url_sources(): fake_label_overrides = {} fake_notes = {} - with ( - patch("specify_cli.catalog_docs._get_integration_registry", return_value=fake_registry), - patch("specify_cli.catalog_docs.INTEGRATION_DOC_URLS", fake_doc_urls), - patch("specify_cli.catalog_docs.INTEGRATION_LABEL_OVERRIDES", fake_label_overrides), - patch("specify_cli.catalog_docs.INTEGRATION_NOTES", fake_notes), - ): + patch_registry = patch("specify_cli.catalog_docs._get_integration_registry", return_value=fake_registry) + patch_urls = patch("specify_cli.catalog_docs.INTEGRATION_DOC_URLS", fake_doc_urls) + patch_labels = patch("specify_cli.catalog_docs.INTEGRATION_LABEL_OVERRIDES", fake_label_overrides) + patch_notes = patch("specify_cli.catalog_docs.INTEGRATION_NOTES", fake_notes) + + with patch_registry, patch_urls, patch_labels, patch_notes: rows = {key: (label, url) for key, label, url, _notes in list_integrations_for_docs()} assert rows["copilot"][0] == "GitHub Copilot" assert rows["copilot"][1] == "https://code.visualstudio.com/" assert rows["codex"][0] == "Codex CLI" assert rows["codex"][1] == "https://github.com/openai/codex" + + +def test_cli_integration_search_markdown_success(): + """Test that `integration search --markdown` outputs the markdown table.""" + result = runner.invoke(app, ["integration", "search", "--markdown"]) + assert result.exit_code == 0 + lines = result.stdout.splitlines() + assert len(lines) > 2 # At least header, separator, and one data row + assert lines[0] == "| Agent | Key | Notes |" + assert lines[1] == "| --- | --- | --- |" + + +def test_cli_integration_search_markdown_with_filters_warns(): + """Test that `integration search --markdown` with filters emits a warning to stderr.""" + result = runner.invoke(app, ["integration", "search", "test-query", "--markdown", "--tag", "some-tag"]) + assert result.exit_code == 0 + # Warning should be on stderr, table should be on stdout + assert "Warning" in result.stderr or "ignores" 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 (no extraneous output).""" + result = runner.invoke(app, ["integration", "search", "--markdown"]) + assert result.exit_code == 0 + stdout = result.stdout + # Stdout should start with the markdown table header + assert stdout.startswith("| Agent | Key | Notes |") + # Stdout should not contain any error or warning messages + assert "Error" not in stdout + assert "error" not in stdout.lower() From e9c4bc441e89e1bbd170d18aea1f5507d5e48815 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 20:21:45 +0000 Subject: [PATCH 13/26] fix: improve docstring clarity, test robustness, and exception handling --- src/specify_cli/__init__.py | 2 +- src/specify_cli/catalog_docs.py | 2 +- tests/test_catalog_docs.py | 13 +++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 5884fdc564..139fb38889 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2392,7 +2392,7 @@ def integration_search( from .catalog_docs import render_integrations_table try: typer.echo(render_integrations_table()) - except ValueError as exc: + except Exception as exc: typer.echo(f"Error: {exc}", err=True) raise typer.Exit(1) return diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index 187bf4951c..be9bb71c34 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -81,7 +81,7 @@ def _get_integration_registry() -> dict[str, Any]: def list_integrations_for_docs() -> list[tuple[str, str, str | None, str]]: """List integrations with their documentation URLs and notes. - Skips any integrations not in INTEGRATION_DOC_URLS (logs warning if any are missing). + Skips any integrations not in INTEGRATION_DOC_URLS (emits a Python warning if any are missing). Gracefully handles missing URL or notes entries by defaulting to None/empty string. """ registry = _get_integration_registry() diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index a40dfa6e6a..93f89a2d5a 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -79,12 +79,13 @@ def test_cli_integration_search_markdown_with_filters_warns(): def test_cli_integration_search_markdown_stdout_is_clean(): - """Test that stdout contains only the markdown table (no extraneous output).""" + """Test that stdout contains only the markdown table with proper format.""" result = runner.invoke(app, ["integration", "search", "--markdown"]) assert result.exit_code == 0 stdout = result.stdout - # Stdout should start with the markdown table header - assert stdout.startswith("| Agent | Key | Notes |") - # Stdout should not contain any error or warning messages - assert "Error" not in stdout - assert "error" not in stdout.lower() + 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() From 7d1a401e4c33113c5be222f48a0b5b50730f61d0 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 20:34:48 +0000 Subject: [PATCH 14/26] fix: improve test assertions, disable warnings by default, enhance exception handling --- src/specify_cli/__init__.py | 4 ++-- src/specify_cli/catalog_docs.py | 9 +++++---- tests/test_catalog_docs.py | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 139fb38889..095957696b 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2393,8 +2393,8 @@ def integration_search( try: typer.echo(render_integrations_table()) except Exception as exc: - typer.echo(f"Error: {exc}", err=True) - raise typer.Exit(1) + typer.echo(f"Error rendering integrations table: {exc}", err=True) + raise typer.Exit(code=1) from exc return from .integrations import INTEGRATION_REGISTRY diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index be9bb71c34..991e242eda 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -78,18 +78,19 @@ def _get_integration_registry() -> dict[str, Any]: return INTEGRATION_REGISTRY -def list_integrations_for_docs() -> list[tuple[str, str, str | None, str]]: +def list_integrations_for_docs(warn_on_missing: bool = False) -> list[tuple[str, str, str | None, str]]: """List integrations with their documentation URLs and notes. - Skips any integrations not in INTEGRATION_DOC_URLS (emits a Python warning if any are missing). + Skips any integrations not in INTEGRATION_DOC_URLS. If `warn_on_missing` is True, + emits a Python warning for any missing entries. Otherwise, silently skips them. Gracefully handles missing URL or notes entries by defaulting to None/empty string. """ registry = _get_integration_registry() registry_keys = set(registry) - # Warn if there are integrations missing from INTEGRATION_DOC_URLS, but don't fail + # Warn if there are integrations missing from INTEGRATION_DOC_URLS (when enabled) missing = sorted(registry_keys - set(INTEGRATION_DOC_URLS)) - if missing: + if missing and warn_on_missing: import warnings warnings.warn( f"Integration(s) missing from INTEGRATION_DOC_URLS: {', '.join(missing)}. " diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index 93f89a2d5a..95fd9dbef3 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -72,8 +72,8 @@ def test_cli_integration_search_markdown_with_filters_warns(): """Test that `integration search --markdown` with filters emits a warning to stderr.""" result = runner.invoke(app, ["integration", "search", "test-query", "--markdown", "--tag", "some-tag"]) assert result.exit_code == 0 - # Warning should be on stderr, table should be on stdout - assert "Warning" in result.stderr or "ignores" in result.stderr + # Check for the specific Typer warning message (not generic Python warnings) + assert "ignores query/--tag/--author filters" in result.stderr lines = result.stdout.splitlines() assert lines[0] == "| Agent | Key | Notes |" From dd32eb110f31f3b0296635a0df6e48a1cda74210 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 20:44:50 +0000 Subject: [PATCH 15/26] fix: make CLI tests deterministic and improve config access resilience --- src/specify_cli/catalog_docs.py | 4 +- tests/test_catalog_docs.py | 82 ++++++++++++++++++++++++--------- 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index 991e242eda..1211ff53a7 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -105,7 +105,9 @@ def list_integrations_for_docs(warn_on_missing: bool = False) -> list[tuple[str, if key not in INTEGRATION_DOC_URLS: continue - config = integration.config if isinstance(integration.config, dict) else {} + 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) notes = INTEGRATION_NOTES.get(key, "") diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index 95fd9dbef3..c638d02940 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -17,6 +17,25 @@ runner = CliRunner() +def _get_mocked_cli_runner(): + """Set up a context with mocked registry and doc maps for CLI tests.""" + fake_registry = { + "copilot": MagicMock(config={"name": "GitHub Copilot"}), + "codex": MagicMock(config={"name": "Codex CLI"}), + } + fake_doc_urls = {"copilot": "https://code.visualstudio.com/", "codex": "https://github.com/openai/codex"} + fake_label_overrides = {} + fake_notes = {"copilot": "Test note"} + + patches = [ + patch("specify_cli.catalog_docs._get_integration_registry", return_value=fake_registry), + patch("specify_cli.catalog_docs.INTEGRATION_DOC_URLS", fake_doc_urls), + patch("specify_cli.catalog_docs.INTEGRATION_LABEL_OVERRIDES", fake_label_overrides), + patch("specify_cli.catalog_docs.INTEGRATION_NOTES", fake_notes), + ] + return patches + + def test_integrations_table_renders(): table = render_integrations_table() lines = table.splitlines() @@ -60,32 +79,53 @@ def test_integrations_docs_label_and_url_sources(): def test_cli_integration_search_markdown_success(): """Test that `integration search --markdown` outputs the markdown table.""" - result = runner.invoke(app, ["integration", "search", "--markdown"]) - assert result.exit_code == 0 - lines = result.stdout.splitlines() - assert len(lines) > 2 # At least header, separator, and one data row - assert lines[0] == "| Agent | Key | Notes |" - assert lines[1] == "| --- | --- | --- |" + patches = _get_mocked_cli_runner() + for p in patches: + p.start() + try: + result = runner.invoke(app, ["integration", "search", "--markdown"]) + assert result.exit_code == 0 + lines = result.stdout.splitlines() + assert len(lines) > 2 # At least header, separator, and one data row + assert lines[0] == "| Agent | Key | Notes |" + assert lines[1] == "| --- | --- | --- |" + finally: + for p in patches: + p.stop() def test_cli_integration_search_markdown_with_filters_warns(): """Test that `integration search --markdown` with filters emits a warning to stderr.""" - result = runner.invoke(app, ["integration", "search", "test-query", "--markdown", "--tag", "some-tag"]) - assert result.exit_code == 0 - # Check for the specific Typer warning message (not generic Python warnings) - assert "ignores query/--tag/--author filters" in result.stderr - lines = result.stdout.splitlines() - assert lines[0] == "| Agent | Key | Notes |" + patches = _get_mocked_cli_runner() + for p in patches: + p.start() + try: + result = runner.invoke(app, ["integration", "search", "test-query", "--markdown", "--tag", "some-tag"]) + assert result.exit_code == 0 + # Check for the specific Typer warning message (not generic Python warnings) + assert "ignores query/--tag/--author filters" in result.stderr + lines = result.stdout.splitlines() + assert lines[0] == "| Agent | Key | Notes |" + finally: + for p in patches: + p.stop() def test_cli_integration_search_markdown_stdout_is_clean(): """Test that stdout contains only the markdown table with proper format.""" - 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() + patches = _get_mocked_cli_runner() + for p in patches: + p.start() + try: + 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() + finally: + for p in patches: + p.stop() From d9bda8ab46a6055f58255e889db73899a9c901e7 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Sat, 16 May 2026 01:56:59 +0000 Subject: [PATCH 16/26] fix: remove extra blank line, add stale keys validation, add regression test for docs sync --- src/specify_cli/catalog_docs.py | 20 ++++++++++++++--- tests/test_catalog_docs.py | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index 1211ff53a7..e99488a739 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -5,7 +5,6 @@ from typing import Any - INTEGRATION_DOC_URLS: dict[str, str | None] = { "amp": "https://ampcode.com/", "agy": "https://antigravity.google/", @@ -78,11 +77,12 @@ def _get_integration_registry() -> dict[str, Any]: return INTEGRATION_REGISTRY -def list_integrations_for_docs(warn_on_missing: bool = False) -> list[tuple[str, str, str | None, str]]: +def list_integrations_for_docs(warn_on_missing: bool = False, warn_on_extra: bool = False) -> list[tuple[str, str, str | None, str]]: """List integrations with their documentation URLs and notes. Skips any integrations not in INTEGRATION_DOC_URLS. If `warn_on_missing` is True, - emits a Python warning for any missing entries. Otherwise, silently skips them. + emits a Python warning for any missing entries. If `warn_on_extra` is True, + emits a warning for stale keys in the doc maps that are no longer in the registry. Gracefully handles missing URL or notes entries by defaulting to None/empty string. """ registry = _get_integration_registry() @@ -98,6 +98,20 @@ def list_integrations_for_docs(warn_on_missing: bool = False) -> list[tuple[str, 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 + warnings.warn( + f"Stale key(s) found in doc maps (no longer in registry): {sorted(set(extra_in_urls + extra_in_labels + extra_in_notes))}. " + "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(): diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index c638d02940..99004b4d13 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -129,3 +129,41 @@ def test_cli_integration_search_markdown_stdout_is_clean(): finally: for p in patches: p.stop() + + +def test_docs_reference_integrations_md_stays_in_sync(): + """Regression test: committed docs/reference/integrations.md table should exist. + + This ensures the integration reference docs file is present and contains expected markers. + If this test fails, run: poetry run python scripts/generate_integrations_reference.py --write + """ + from pathlib import Path + + # Find the committed integrations.md file + repo_root = Path(__file__).parent.parent + docs_file = repo_root / "docs" / "reference" / "integrations.md" + + assert docs_file.exists(), \ + f"The committed integrations.md file doesn't exist at {docs_file}. \n" \ + "Run: poetry run python scripts/generate_integrations_reference.py --write" + + # Read the committed file + with open(docs_file) as f: + committed_content = f.read() + + # Verify the file contains table markers (the table structure) + assert "| Agent" in committed_content, \ + "The committed integrations.md doesn't contain 'Agent' column marker. \n" \ + "Run: poetry run python scripts/generate_integrations_reference.py --write" + + assert "| Key" in committed_content, \ + "The committed integrations.md doesn't contain 'Key' column marker. \n" \ + "Run: poetry run python scripts/generate_integrations_reference.py --write" + + assert "| Notes" in committed_content, \ + "The committed integrations.md doesn't contain 'Notes' column marker. \n" \ + "Run: poetry run python scripts/generate_integrations_reference.py --write" + + # The generated table should also have these markers + generated_table = render_integrations_table() + assert "| Agent | Key | Notes |" in generated_table From f11bca9be59029234b629e4e2ff65d5d30bdad07 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Sat, 16 May 2026 06:44:42 +0000 Subject: [PATCH 17/26] Fix 5 remaining feedback items: - Rename _get_mocked_cli_runner() to _get_catalog_docs_patches() for clarity - Use ExitStack context manager for guaranteed patch cleanup - Add explicit UTF-8 encoding to file reads - Skip doc sync test gracefully when docs aren't present - Remove exception chaining from typer.Exit to avoid noisy tracebacks --- src/specify_cli/__init__.py | 2 +- src/specify_cli/community_catalog_docs.py | 94 +++++++++++++++++++ tests/test_catalog_docs.py | 56 +++++------ tests/test_community_catalog_docs.py | 107 ++++++++++++++++++++++ 4 files changed, 223 insertions(+), 36 deletions(-) create mode 100644 src/specify_cli/community_catalog_docs.py create mode 100644 tests/test_community_catalog_docs.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 095957696b..7c2218f089 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2394,7 +2394,7 @@ def integration_search( typer.echo(render_integrations_table()) except Exception as exc: typer.echo(f"Error rendering integrations table: {exc}", err=True) - raise typer.Exit(code=1) from exc + raise typer.Exit(code=1) return from .integrations import INTEGRATION_REGISTRY diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py new file mode 100644 index 0000000000..d505d8d8f1 --- /dev/null +++ b/src/specify_cli/community_catalog_docs.py @@ -0,0 +1,94 @@ +"""Helpers for rendering the community extensions reference table.""" + +from __future__ import annotations + +import json +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_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: {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"]) + link = ( + f"[{safe_name}]({row['repository']})" + if row["repository"] + else safe_name + ) + table_rows.append( + [ + link, + f"`{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 index 99004b4d13..8530fcc590 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import ExitStack from unittest.mock import MagicMock, patch from typer.testing import CliRunner @@ -17,8 +18,9 @@ runner = CliRunner() -def _get_mocked_cli_runner(): - """Set up a context with mocked registry and doc maps for CLI tests.""" +def _get_catalog_docs_patches(): + """Return context manager with mocked registry and doc maps for CLI tests.""" + fake_registry = { "copilot": MagicMock(config={"name": "GitHub Copilot"}), "codex": MagicMock(config={"name": "Codex CLI"}), @@ -27,13 +29,12 @@ def _get_mocked_cli_runner(): fake_label_overrides = {} fake_notes = {"copilot": "Test note"} - patches = [ - patch("specify_cli.catalog_docs._get_integration_registry", return_value=fake_registry), - patch("specify_cli.catalog_docs.INTEGRATION_DOC_URLS", fake_doc_urls), - patch("specify_cli.catalog_docs.INTEGRATION_LABEL_OVERRIDES", fake_label_overrides), - patch("specify_cli.catalog_docs.INTEGRATION_NOTES", fake_notes), - ] - return patches + stack = ExitStack() + 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)) + return stack def test_integrations_table_renders(): @@ -79,44 +80,29 @@ def test_integrations_docs_label_and_url_sources(): def test_cli_integration_search_markdown_success(): """Test that `integration search --markdown` outputs the markdown table.""" - patches = _get_mocked_cli_runner() - for p in patches: - p.start() - try: + with _get_catalog_docs_patches(): result = runner.invoke(app, ["integration", "search", "--markdown"]) assert result.exit_code == 0 lines = result.stdout.splitlines() assert len(lines) > 2 # At least header, separator, and one data row assert lines[0] == "| Agent | Key | Notes |" assert lines[1] == "| --- | --- | --- |" - finally: - for p in patches: - p.stop() def test_cli_integration_search_markdown_with_filters_warns(): """Test that `integration search --markdown` with filters emits a warning to stderr.""" - patches = _get_mocked_cli_runner() - for p in patches: - p.start() - try: + 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 (not generic Python warnings) assert "ignores query/--tag/--author filters" in result.stderr lines = result.stdout.splitlines() assert lines[0] == "| Agent | Key | Notes |" - finally: - for p in patches: - p.stop() def test_cli_integration_search_markdown_stdout_is_clean(): """Test that stdout contains only the markdown table with proper format.""" - patches = _get_mocked_cli_runner() - for p in patches: - p.start() - try: + with _get_catalog_docs_patches(): result = runner.invoke(app, ["integration", "search", "--markdown"]) assert result.exit_code == 0 stdout = result.stdout @@ -126,9 +112,6 @@ def test_cli_integration_search_markdown_stdout_is_clean(): assert lines[0] == "| Agent | Key | Notes |" # Ensure stderr has no error messages assert "error" not in result.stderr.lower() - finally: - for p in patches: - p.stop() def test_docs_reference_integrations_md_stays_in_sync(): @@ -137,18 +120,21 @@ def test_docs_reference_integrations_md_stays_in_sync(): This ensures the integration reference docs file is present and contains expected markers. If this test fails, run: poetry run python scripts/generate_integrations_reference.py --write """ + import pytest from pathlib import Path # Find the committed integrations.md file repo_root = Path(__file__).parent.parent docs_file = repo_root / "docs" / "reference" / "integrations.md" - assert docs_file.exists(), \ - f"The committed integrations.md file doesn't exist at {docs_file}. \n" \ - "Run: poetry run python scripts/generate_integrations_reference.py --write" + if not docs_file.exists(): + pytest.skip( + f"Integration reference docs not found at {docs_file}. " + "Skipping sync test (expected in CI, acceptable in isolated test environments)." + ) - # Read the committed file - with open(docs_file) as f: + # Read the committed file with explicit UTF-8 encoding + with open(docs_file, encoding="utf-8") as f: committed_content = f.read() # Verify the file contains table markers (the table structure) diff --git a/tests/test_community_catalog_docs.py b/tests/test_community_catalog_docs.py new file mode 100644 index 0000000000..15d9c7be69 --- /dev/null +++ b/tests/test_community_catalog_docs.py @@ -0,0 +1,107 @@ +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_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 From 4dab389a884de566c1408b14f2bea334781f4ab1 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 13:36:43 +0000 Subject: [PATCH 18/26] address all outstanding copilot review feedback on PR 2563 --- src/specify_cli/__init__.py | 7 +- src/specify_cli/catalog_docs.py | 61 +++++++-- src/specify_cli/community_catalog_docs.py | 12 +- tests/test_catalog_docs.py | 152 +++++++++++++++++----- 4 files changed, 181 insertions(+), 51 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 7c2218f089..eca7ea8ffb 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2378,7 +2378,12 @@ def integration_search( 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 filters)" + False, + "--markdown", + help=( + "Output the full built-in integrations table as markdown " + "(ignores filters)" + ), ), ): """Search for integrations in the active catalog stack, or output the built-in reference table with --markdown.""" diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index e99488a739..151f3deefe 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -1,4 +1,4 @@ -"""Helpers for rendering the built-in integrations reference table from the integration registry.""" +"""Helpers for rendering the built-in integrations reference table.""" from __future__ import annotations @@ -48,15 +48,39 @@ 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-`", + "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-`", + "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`", + "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", + "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", } @@ -77,7 +101,10 @@ def _get_integration_registry() -> dict[str, Any]: 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]]: +def list_integrations_for_docs( + warn_on_missing: bool = False, + warn_on_extra: bool = False, +) -> list[tuple[str, str, str | None, str]]: """List integrations with their documentation URLs and notes. Skips any integrations not in INTEGRATION_DOC_URLS. If `warn_on_missing` is True, @@ -93,22 +120,30 @@ def list_integrations_for_docs(warn_on_missing: bool = False, warn_on_extra: boo if missing and warn_on_missing: import warnings warnings.warn( - f"Integration(s) missing from INTEGRATION_DOC_URLS: {', '.join(missing)}. " - "These will be skipped in the docs table. Add them to INTEGRATION_DOC_URLS in catalog_docs.py.", + f"Integration(s) missing from INTEGRATION_DOC_URLS: " + f"{', '.join(missing)}. These will be skipped in the docs table. " + "Add them to INTEGRATION_DOC_URLS in catalog_docs.py.", 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_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): {sorted(set(extra_in_urls + extra_in_labels + extra_in_notes))}. " - "Consider removing them from INTEGRATION_DOC_URLS, INTEGRATION_LABEL_OVERRIDES, and INTEGRATION_NOTES.", + f"Stale key(s) found in doc maps (no longer in registry): " + f"{stale_keys}. Consider removing them from " + "INTEGRATION_DOC_URLS, INTEGRATION_LABEL_OVERRIDES, and " + "INTEGRATION_NOTES.", stacklevel=2 ) diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py index d505d8d8f1..8e17e4f194 100644 --- a/src/specify_cli/community_catalog_docs.py +++ b/src/specify_cli/community_catalog_docs.py @@ -12,7 +12,8 @@ def _render_cell(value: str) -> str: - return value.replace("\r\n", " ").replace("\r", " ").replace("\n", " ").replace("|", "\\|") + cleaned = value.replace("\r\n", " ").replace("\r", " ").replace("\n", " ") + return cleaned.replace("|", "\\|") def _format_tags(tags: Any) -> str: @@ -24,7 +25,9 @@ def _format_tags(tags: Any) -> str: return ", ".join(cleaned) if cleaned else "—" -def list_community_extensions(path: Path = COMMUNITY_CATALOG_PATH) -> list[dict[str, Any]]: +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( @@ -53,7 +56,10 @@ def list_community_extensions(path: Path = COMMUNITY_CATALOG_PATH) -> list[dict[ } ) - return sorted(rows, key=lambda row: (row["name"].casefold(), row["id"].casefold())) + return sorted( + rows, + key=lambda row: (row["name"].casefold(), row["id"].casefold()), + ) def render_community_extensions_table(path: Path = COMMUNITY_CATALOG_PATH) -> str: diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index 8530fcc590..ff7a80032e 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -25,15 +25,32 @@ def _get_catalog_docs_patches(): "copilot": MagicMock(config={"name": "GitHub Copilot"}), "codex": MagicMock(config={"name": "Codex CLI"}), } - fake_doc_urls = {"copilot": "https://code.visualstudio.com/", "codex": "https://github.com/openai/codex"} + fake_doc_urls = { + "copilot": "https://code.visualstudio.com/", + "codex": "https://github.com/openai/codex", + } fake_label_overrides = {} fake_notes = {"copilot": "Test note"} stack = ExitStack() - 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)) + 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) + ) return stack @@ -53,7 +70,7 @@ def test_render_cell_escapes_pipes_and_normalizes_newlines(): def test_integrations_docs_label_and_url_sources(): - """Test with a mocked registry and doc maps to avoid brittleness to live registry changes.""" + """Test using mocked registry/doc maps to avoid test brittleness.""" # Create a minimal fake registry with two known integrations fake_registry = { "copilot": MagicMock(config={"name": "GitHub Copilot"}), @@ -61,17 +78,33 @@ def test_integrations_docs_label_and_url_sources(): } # Mock the doc maps to only contain entries for the fake registry - fake_doc_urls = {"copilot": "https://code.visualstudio.com/", "codex": "https://github.com/openai/codex"} + fake_doc_urls = { + "copilot": "https://code.visualstudio.com/", + "codex": "https://github.com/openai/codex", + } fake_label_overrides = {} fake_notes = {} - patch_registry = patch("specify_cli.catalog_docs._get_integration_registry", return_value=fake_registry) - patch_urls = patch("specify_cli.catalog_docs.INTEGRATION_DOC_URLS", fake_doc_urls) - patch_labels = patch("specify_cli.catalog_docs.INTEGRATION_LABEL_OVERRIDES", fake_label_overrides) - patch_notes = patch("specify_cli.catalog_docs.INTEGRATION_NOTES", fake_notes) + patch_registry = patch( + "specify_cli.catalog_docs._get_integration_registry", + return_value=fake_registry, + ) + patch_urls = patch( + "specify_cli.catalog_docs.INTEGRATION_DOC_URLS", fake_doc_urls + ) + patch_labels = patch( + "specify_cli.catalog_docs.INTEGRATION_LABEL_OVERRIDES", + fake_label_overrides, + ) + patch_notes = patch( + "specify_cli.catalog_docs.INTEGRATION_NOTES", fake_notes + ) with patch_registry, patch_urls, patch_labels, patch_notes: - rows = {key: (label, url) for key, label, url, _notes in list_integrations_for_docs()} + rows = { + key: (label, url) + for key, label, url, _notes in list_integrations_for_docs() + } assert rows["copilot"][0] == "GitHub Copilot" assert rows["copilot"][1] == "https://code.visualstudio.com/" assert rows["codex"][0] == "Codex CLI" @@ -90,11 +123,21 @@ def test_cli_integration_search_markdown_success(): def test_cli_integration_search_markdown_with_filters_warns(): - """Test that `integration search --markdown` with filters emits a warning to stderr.""" + """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"]) + result = runner.invoke( + app, + [ + "integration", + "search", + "test-query", + "--markdown", + "--tag", + "some-tag", + ], + ) assert result.exit_code == 0 - # Check for the specific Typer warning message (not generic Python warnings) + # 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 |" @@ -115,10 +158,12 @@ def test_cli_integration_search_markdown_stdout_is_clean(): def test_docs_reference_integrations_md_stays_in_sync(): - """Regression test: committed docs/reference/integrations.md table should exist. + """Regression test: committed docs/reference/integrations.md stays in sync. - This ensures the integration reference docs file is present and contains expected markers. - If this test fails, run: poetry run python scripts/generate_integrations_reference.py --write + This ensures that the integration reference docs file contains the exact + list of integrations defined in the registry. + If this test fails, run: specify integration search --markdown + and update the table in docs/reference/integrations.md accordingly. """ import pytest from pathlib import Path @@ -130,26 +175,65 @@ def test_docs_reference_integrations_md_stays_in_sync(): if not docs_file.exists(): pytest.skip( f"Integration reference docs not found at {docs_file}. " - "Skipping sync test (expected in CI, acceptable in isolated test environments)." + "Skipping sync test (expected in CI, acceptable in isolated " + "test environments)." ) # Read the committed file with explicit UTF-8 encoding with open(docs_file, encoding="utf-8") as f: committed_content = f.read() - # Verify the file contains table markers (the table structure) - assert "| Agent" in committed_content, \ - "The committed integrations.md doesn't contain 'Agent' column marker. \n" \ - "Run: poetry run python scripts/generate_integrations_reference.py --write" - - assert "| Key" in committed_content, \ - "The committed integrations.md doesn't contain 'Key' column marker. \n" \ - "Run: poetry run python scripts/generate_integrations_reference.py --write" - - assert "| Notes" in committed_content, \ - "The committed integrations.md doesn't contain 'Notes' column marker. \n" \ - "Run: poetry run python scripts/generate_integrations_reference.py --write" - - # The generated table should also have these markers + # Extract rows from the H2 section ## Supported AI Coding Agents + def parse_first_markdown_table(text: str) -> set[tuple[str, str, str]]: + lines = text.splitlines() + in_target_section = False + in_table = False + rows = [] + for line in lines: + if line.startswith("## Supported AI Coding Agents"): + in_target_section = True + continue + if in_target_section: + if line.startswith("## "): + break + if line.strip().startswith("|"): + in_table = True + parts = [p.strip() for p in line.split("|")[1:-1]] + if ( + all(p.startswith("---") or p == "" for p in parts) + or parts == ["Agent", "Key", "Notes"] + ): + continue + rows.append((parts[0], parts[1], parts[2])) + elif in_table: + break + return set(rows) + + def parse_markdown_table_rows(text: str) -> set[tuple[str, str, str]]: + rows = [] + for line in text.splitlines(): + if line.strip().startswith("|"): + parts = [p.strip() for p in line.split("|")[1:-1]] + if ( + all(p.startswith("---") or p == "" for p in parts) + or parts == ["Agent", "Key", "Notes"] + ): + continue + rows.append((parts[0], parts[1], parts[2])) + return set(rows) + + committed_rows = parse_first_markdown_table(committed_content) generated_table = render_integrations_table() - assert "| Agent | Key | Notes |" in generated_table + generated_rows = parse_markdown_table_rows(generated_table) + + # Assert they are in perfect sync + diff_missing = generated_rows - committed_rows + diff_extra = committed_rows - generated_rows + + error_msg = ( + "The committed integrations.md table is out of sync with the registry.\n" + f"Missing from docs: {diff_missing}\n" + f"Extra in docs: {diff_extra}\n" + "To update the docs table, run: specify integration search --markdown" + ) + assert not diff_missing and not diff_extra, error_msg From be4b7a6888c7d0e139bf82b57df0817ec636d9f6 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 14:20:21 +0000 Subject: [PATCH 19/26] Address Copilot feedback: escape URLs in markdown links, deduplicate cell rendering, fix table parser for escaped pipes --- src/specify_cli/community_catalog_docs.py | 23 ++++++++---- tests/test_catalog_docs.py | 43 ++++++++++++++++++----- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py index 8e17e4f194..bc733a4706 100644 --- a/src/specify_cli/community_catalog_docs.py +++ b/src/specify_cli/community_catalog_docs.py @@ -6,14 +6,20 @@ from pathlib import Path from typing import Any +from .catalog_docs import render_cell + ROOT_DIR = Path(__file__).resolve().parents[2] COMMUNITY_CATALOG_PATH = ROOT_DIR / "extensions" / "catalog.community.json" -def _render_cell(value: str) -> str: - cleaned = value.replace("\r\n", " ").replace("\r", " ").replace("\n", " ") - return cleaned.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. + """ + # Escape ) and | which can break markdown link [text](url) syntax + return url.replace(")", "\\)").replace("|", "\\|") def _format_tags(tags: Any) -> str: @@ -25,6 +31,10 @@ def _format_tags(tags: Any) -> str: return ", ".join(cleaned) if cleaned else "—" +# For backwards compatibility and clarity +_render_cell = render_cell + + def list_community_extensions( path: Path = COMMUNITY_CATALOG_PATH, ) -> list[dict[str, Any]]: @@ -72,9 +82,10 @@ def render_community_extensions_table(path: Path = COMMUNITY_CATALOG_PATH) -> st 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_name = render_cell(row["name"]) + safe_repo = _escape_url_for_markdown_link(row["repository"]) link = ( - f"[{safe_name}]({row['repository']})" + f"[{safe_name}]({safe_repo})" if row["repository"] else safe_name ) @@ -82,7 +93,7 @@ def render_community_extensions_table(path: Path = COMMUNITY_CATALOG_PATH) -> st [ link, f"`{row['id']}`", - _render_cell(row["description"]), + render_cell(row["description"]), _format_tags(row["tags"]), row["verified"], ] diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index ff7a80032e..cb3909420d 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -210,16 +210,43 @@ def parse_first_markdown_table(text: str) -> set[tuple[str, str, str]]: return set(rows) def parse_markdown_table_rows(text: str) -> set[tuple[str, str, str]]: + """Parse markdown table rows, respecting escaped pipes.""" rows = [] for line in text.splitlines(): - if line.strip().startswith("|"): - parts = [p.strip() for p in line.split("|")[1:-1]] - if ( - all(p.startswith("---") or p == "" for p in parts) - or parts == ["Agent", "Key", "Notes"] - ): - continue - rows.append((parts[0], parts[1], parts[2])) + if not line.strip().startswith("|"): + continue + + # Split on pipes, but account for escaped pipes (\|) + # A cell ending with \| has an escaped pipe and should not split there + parts = [] + current = "" + for i, char in enumerate(line): + if char == "|" and (i == 0 or line[i-1] != "\\"): + parts.append(current.strip()) + current = "" + else: + current += char + if current: + parts.append(current.strip()) + + # Remove empty leading/trailing parts from outer pipes + if parts and parts[0] == "": + parts = parts[1:] + if parts and parts[-1] == "": + parts = parts[:-1] + + # Skip header and separator rows + if ( + all(p.startswith("---") or p == "" for p in parts) + or parts == ["Agent", "Key", "Notes"] + ): + continue + + # Validate we have the expected 3 columns + if len(parts) != 3: + continue + + rows.append((parts[0], parts[1], parts[2])) return set(rows) committed_rows = parse_first_markdown_table(committed_content) From 28e68d6ea148fd0f1b61c6dd141396e1e6c257f7 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 14:29:19 +0000 Subject: [PATCH 20/26] Address 3 new Copilot feedback: add URL escaping test, fix parse_first_markdown_table for escaped pipes, guard community tests with skip --- tests/test_catalog_docs.py | 25 ++++++++++++++++++++++++- tests/test_community_catalog_docs.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index cb3909420d..adc46d7507 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -185,6 +185,7 @@ def test_docs_reference_integrations_md_stays_in_sync(): # Extract rows from the H2 section ## Supported AI Coding Agents def parse_first_markdown_table(text: str) -> set[tuple[str, str, str]]: + """Parse the first markdown table in a section, respecting escaped pipes.""" lines = text.splitlines() in_target_section = False in_table = False @@ -198,12 +199,34 @@ def parse_first_markdown_table(text: str) -> set[tuple[str, str, str]]: break if line.strip().startswith("|"): in_table = True - parts = [p.strip() for p in line.split("|")[1:-1]] + # Parse respecting escaped pipes (\|) + parts = [] + current = "" + for i, char in enumerate(line): + if char == "|" and (i == 0 or line[i-1] != "\\"): + parts.append(current.strip()) + current = "" + else: + current += char + if current: + parts.append(current.strip()) + + # Remove empty leading/trailing parts from outer pipes + if parts and parts[0] == "": + parts = parts[1:] + if parts and parts[-1] == "": + parts = parts[:-1] + if ( all(p.startswith("---") or p == "" for p in parts) or parts == ["Agent", "Key", "Notes"] ): continue + + # Validate we have 3 columns + if len(parts) != 3: + continue + rows.append((parts[0], parts[1], parts[2])) elif in_table: break diff --git a/tests/test_community_catalog_docs.py b/tests/test_community_catalog_docs.py index 15d9c7be69..9252fe2cca 100644 --- a/tests/test_community_catalog_docs.py +++ b/tests/test_community_catalog_docs.py @@ -19,6 +19,12 @@ def _write_catalog(tmp_path: Path, extensions: dict) -> Path: # --------------------------------------------------------------------------- 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 @@ -28,6 +34,12 @@ def test_community_extensions_table_renders() -> None: 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) @@ -105,3 +117,19 @@ def test_non_list_tags_renders_em_dash(tmp_path: Path) -> None: }) 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 From 2b27eed1101ec003cdf4697968bbe07995252ed9 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 14:51:10 +0000 Subject: [PATCH 21/26] Address 3 new Copilot feedback: escape id field, remove unused alias, escape integration URLs --- src/specify_cli/catalog_docs.py | 10 +++++++++- src/specify_cli/community_catalog_docs.py | 6 +----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index 151f3deefe..761f72102c 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -95,6 +95,14 @@ def render_cell(value: str) -> str: 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 _get_integration_registry() -> dict[str, Any]: from specify_cli.integrations import INTEGRATION_REGISTRY @@ -170,7 +178,7 @@ def render_integrations_table() -> str: rows: list[list[str]] = [] for key, label, url, notes in list_integrations_for_docs(): - agent = f"[{label}]({url})" if url else label + agent = f"[{label}]({_escape_url_for_markdown_link(url)})" if url else label rows.append([agent, f"`{key}`", notes]) def render_row(values: list[str]) -> str: diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py index bc733a4706..ab5cd484fd 100644 --- a/src/specify_cli/community_catalog_docs.py +++ b/src/specify_cli/community_catalog_docs.py @@ -31,10 +31,6 @@ def _format_tags(tags: Any) -> str: return ", ".join(cleaned) if cleaned else "—" -# For backwards compatibility and clarity -_render_cell = render_cell - - def list_community_extensions( path: Path = COMMUNITY_CATALOG_PATH, ) -> list[dict[str, Any]]: @@ -92,7 +88,7 @@ def render_community_extensions_table(path: Path = COMMUNITY_CATALOG_PATH) -> st table_rows.append( [ link, - f"`{row['id']}`", + f"`{render_cell(row['id'])}`", render_cell(row["description"]), _format_tags(row["tags"]), row["verified"], From 295dcb6d65b848762c222191f31086ea7ed12287 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 15:04:32 +0000 Subject: [PATCH 22/26] Address 3 new Copilot feedback: fix comment name, include all integrations in list --- src/specify_cli/catalog_docs.py | 16 ++++++---------- src/specify_cli/community_catalog_docs.py | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index 761f72102c..dd29e29a54 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -113,12 +113,12 @@ def list_integrations_for_docs( warn_on_missing: bool = False, warn_on_extra: bool = False, ) -> list[tuple[str, str, str | None, str]]: - """List integrations with their documentation URLs and notes. + """List all integrations with their documentation URLs and notes. - Skips any integrations not in INTEGRATION_DOC_URLS. If `warn_on_missing` is True, - emits a Python warning for any missing entries. If `warn_on_extra` is True, - emits a warning for stale keys in the doc maps that are no longer in the registry. - Gracefully handles missing URL or notes entries by defaulting to None/empty string. + 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) @@ -158,15 +158,11 @@ def list_integrations_for_docs( rows: list[tuple[str, str, str | None, str]] = [] for key, integration in registry.items(): - # Skip integrations not in the doc maps - if key not in INTEGRATION_DOC_URLS: - continue - 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) + url = INTEGRATION_DOC_URLS.get(key) # None if not in map notes = INTEGRATION_NOTES.get(key, "") rows.append((key, label, url, notes)) diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py index ab5cd484fd..7b32e77a99 100644 --- a/src/specify_cli/community_catalog_docs.py +++ b/src/specify_cli/community_catalog_docs.py @@ -98,7 +98,7 @@ def render_community_extensions_table(path: Path = COMMUNITY_CATALOG_PATH) -> st headers = ("Extension", "ID", "Description", "Tags", "Verified") def render_row(values: list[str]) -> str: - # Values are already escaped; do not re-apply _render_cell here. + # Values are already escaped; do not re-apply render_cell here. return "| " + " | ".join(values) + " |" separator = "| " + " | ".join("---" for _ in headers) + " |" From 826fbf57c102661fe3a742f7d5c88d3d3765f454 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 15:16:43 +0000 Subject: [PATCH 23/26] Fix architectural issue: escape raw fields before composing Markdown to prevent double-escaping --- src/specify_cli/catalog_docs.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index dd29e29a54..e71be5bb93 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -171,18 +171,28 @@ def list_integrations_for_docs( def render_integrations_table() -> str: """Render the built-in integrations reference table as markdown.""" - rows: list[list[str]] = [] + table_rows: list[list[str]] = [] for key, label, url, notes in list_integrations_for_docs(): - agent = f"[{label}]({_escape_url_for_markdown_link(url)})" if url else label - rows.append([agent, f"`{key}`", notes]) + # 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 = 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: - return "| " + " | ".join(render_cell(value) for value in values) + " |" + # Values are already escaped; do not re-apply render_cell here. + return "| " + " | ".join(values) + " |" - lines = [ - render_row(["Agent", "Key", "Notes"]), - "| " + " | ".join(["---", "---", "---"]) + " |", - ] - lines.extend(render_row(row) for row in rows) + 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) From 77c732741e7cd1dfb9670ed335b0885d92283d93 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 15:18:56 +0000 Subject: [PATCH 24/26] Deduplicate _escape_url_for_markdown_link and add URL escaping test --- src/specify_cli/community_catalog_docs.py | 11 +---------- tests/test_catalog_docs.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py index 7b32e77a99..43c2ca49f8 100644 --- a/src/specify_cli/community_catalog_docs.py +++ b/src/specify_cli/community_catalog_docs.py @@ -6,22 +6,13 @@ from pathlib import Path from typing import Any -from .catalog_docs import render_cell +from .catalog_docs import _escape_url_for_markdown_link, render_cell ROOT_DIR = Path(__file__).resolve().parents[2] COMMUNITY_CATALOG_PATH = ROOT_DIR / "extensions" / "catalog.community.json" -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. - """ - # Escape ) and | which can break markdown link [text](url) syntax - return url.replace(")", "\\)").replace("|", "\\|") - - def _format_tags(tags: Any) -> str: if not isinstance(tags, list) or not tags: return "—" diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index adc46d7507..ba4e600ed1 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -8,6 +8,7 @@ from typer.testing import CliRunner from specify_cli.catalog_docs import ( + _escape_url_for_markdown_link, render_cell, list_integrations_for_docs, render_integrations_table, @@ -69,6 +70,24 @@ def test_render_cell_escapes_pipes_and_normalizes_newlines(): assert render_cell("a|b\nc") == "a\\|b c" +def test_escape_url_for_markdown_link(): + """Test that URLs with special characters are properly escaped for Markdown links.""" + # URLs containing ) and | should be escaped + assert _escape_url_for_markdown_link("https://example.com/path)") == ( + "https://example.com/path\\)" + ) + assert _escape_url_for_markdown_link("https://example.com/path|query") == ( + "https://example.com/path\\|query" + ) + assert _escape_url_for_markdown_link("https://example.com/path)|query") == ( + "https://example.com/path\\)\\|query" + ) + # URLs without special characters should be unchanged + assert _escape_url_for_markdown_link("https://example.com/path") == ( + "https://example.com/path" + ) + + def test_integrations_docs_label_and_url_sources(): """Test using mocked registry/doc maps to avoid test brittleness.""" # Create a minimal fake registry with two known integrations From 34fff7c99c8cb8fef4df3fa6be7fc1071b0d2248 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 15:23:13 +0000 Subject: [PATCH 25/26] Address 4 new Copilot feedback: add trailing newline, fix test helper ExitStack, update warning message --- src/specify_cli/catalog_docs.py | 7 +++--- tests/test_catalog_docs.py | 41 +++++++++++++++++---------------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index e71be5bb93..18712ae8ab 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -129,8 +129,9 @@ def list_integrations_for_docs( import warnings warnings.warn( f"Integration(s) missing from INTEGRATION_DOC_URLS: " - f"{', '.join(missing)}. These will be skipped in the docs table. " - "Add them to INTEGRATION_DOC_URLS in catalog_docs.py.", + 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 ) @@ -195,4 +196,4 @@ def render_row(values: list[str]) -> str: 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) + return "\n".join(lines) + "\n" diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index ba4e600ed1..ce5021cea6 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -2,7 +2,7 @@ from __future__ import annotations -from contextlib import ExitStack +from contextlib import ExitStack, contextmanager from unittest.mock import MagicMock, patch from typer.testing import CliRunner @@ -19,8 +19,9 @@ runner = CliRunner() +@contextmanager def _get_catalog_docs_patches(): - """Return context manager with mocked registry and doc maps for CLI tests.""" + """Context manager that applies mocked registry and doc maps for tests.""" fake_registry = { "copilot": MagicMock(config={"name": "GitHub Copilot"}), @@ -33,26 +34,26 @@ def _get_catalog_docs_patches(): fake_label_overrides = {} fake_notes = {"copilot": "Test note"} - stack = ExitStack() - stack.enter_context( - patch( - "specify_cli.catalog_docs._get_integration_registry", - return_value=fake_registry, + 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_DOC_URLS", fake_doc_urls) ) - ) - stack.enter_context( - patch("specify_cli.catalog_docs.INTEGRATION_NOTES", fake_notes) - ) - return stack + 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(): From 60fc9260a661c5c30cd26e5d86de5b9b01892a7a Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 15:30:25 +0000 Subject: [PATCH 26/26] Address 4 new Copilot feedback: make escape function public, fix error message, validate test rows, prevent double newline --- src/specify_cli/__init__.py | 2 +- src/specify_cli/catalog_docs.py | 4 ++-- src/specify_cli/community_catalog_docs.py | 8 ++++---- tests/test_catalog_docs.py | 5 +++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index eca7ea8ffb..1f4b097a5c 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2396,7 +2396,7 @@ def integration_search( ) from .catalog_docs import render_integrations_table try: - typer.echo(render_integrations_table()) + typer.echo(render_integrations_table(), nl=False) except Exception as exc: typer.echo(f"Error rendering integrations table: {exc}", err=True) raise typer.Exit(code=1) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index 18712ae8ab..c2ec5fb7bb 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -95,7 +95,7 @@ def render_cell(value: str) -> str: return value.replace("|", "\\|") -def _escape_url_for_markdown_link(url: str) -> str: +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. @@ -179,7 +179,7 @@ def render_integrations_table() -> str: # a pipe inside a label or notes doesn't break a link target. safe_label = render_cell(label) safe_notes = render_cell(notes) - safe_url = _escape_url_for_markdown_link(url) if url else None + safe_url = escape_url_for_markdown_link(url) if url else None agent = ( f"[{safe_label}]({safe_url})" if safe_url diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py index 43c2ca49f8..a5ca769e7b 100644 --- a/src/specify_cli/community_catalog_docs.py +++ b/src/specify_cli/community_catalog_docs.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any -from .catalog_docs import _escape_url_for_markdown_link, render_cell +from .catalog_docs import escape_url_for_markdown_link, render_cell ROOT_DIR = Path(__file__).resolve().parents[2] @@ -28,8 +28,8 @@ def list_community_extensions( """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." + 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): @@ -70,7 +70,7 @@ def render_community_extensions_table(path: Path = COMMUNITY_CATALOG_PATH) -> st # 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_repo = _escape_url_for_markdown_link(row["repository"]) + safe_repo = escape_url_for_markdown_link(row["repository"]) link = ( f"[{safe_name}]({safe_repo})" if row["repository"] diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index ce5021cea6..395cb1b5d4 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -244,8 +244,9 @@ def parse_first_markdown_table(text: str) -> set[tuple[str, str, str]]: continue # Validate we have 3 columns - if len(parts) != 3: - continue + assert ( + len(parts) == 3 + ), f"Malformed row in integrations.md: {line!r} (expected 3 columns, got {len(parts)})" rows.append((parts[0], parts[1], parts[2])) elif in_table: