From fc6daadc2c84fae7eaada8e0c0243c395793c929 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Thu, 14 May 2026 16:33:28 +0000 Subject: [PATCH 01/38] docs: generate integrations reference from catalog --- docs/reference/integrations.md | 73 +++++---- scripts/generate_integrations_reference.py | 68 ++++++++ src/specify_cli/catalog_docs.py | 182 +++++++++++++++++++++ tests/test_catalog_docs.py | 32 ++++ 4 files changed, 319 insertions(+), 36 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 f538fac86f..29dbb69237 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -4,42 +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` | -| [Cline](https://github.com/cline/cline) | `cline` | IDE-based agent | -| [CodeBuddy CLI](https://www.codebuddy.cn/docs/cli/installation) | `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-` | -| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ | -| [Forge](https://forgecode.dev/) | `forge` | | -| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | | -| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | | -| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | -| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` | -| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent | -| [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; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths | -| [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` | | -| [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) | `omp` | Installs slash commands into `.omp/commands` | -| [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` | | -| [RovoDev](https://www.atlassian.com/software/rovo-dev) | `rovodev` | Generates `.rovodev/skills/`, prompt wrappers, and `prompts.yml`; runtime dispatch uses `acli rovodev` | -| [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 | -| [ZCode](https://zcode.z.ai/) | `zcode` | Skills-based integration; installs skills into `.zcode/skills/` and invokes them as `$speckit-` | -| [Zed](https://zed.dev/) | `zed` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `/speckit-` | -| 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 68831fd0fae76f067644036af5ec4558a5d612ee Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Thu, 14 May 2026 22:10:24 +0000 Subject: [PATCH 02/38] 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 | 1193 ++++++++++++++++++++ src/specify_cli/catalog_docs.py | 86 +- tests/test_catalog_docs.py | 26 +- 5 files changed, 1209 insertions(+), 169 deletions(-) delete mode 100644 scripts/generate_integrations_reference.py diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 29dbb69237..778377fc35 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 f2ed932c46..98813efeb1 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -539,6 +539,1199 @@ def _require_specify_project() -> Path: ) raise typer.Exit(1) +@integration_app.command("list") +def integration_list( + catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), +): + """List available integrations and installed status.""" + from .integrations import INTEGRATION_REGISTRY + + project_root = _require_specify_project() + current = _read_integration_json(project_root) + default_key = _default_integration_key(current) + installed_keys = set(_installed_integration_keys(current)) + + if catalog: + from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + ic = IntegrationCatalog(project_root) + try: + entries = ic.search() + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + if not entries: + console.print("[yellow]No integrations found in catalog.[/yellow]") + return + + table = Table(title="Integration Catalog") + table.add_column("ID", style="cyan") + table.add_column("Name") + table.add_column("Version") + table.add_column("Source") + table.add_column("Status") + table.add_column("Multi-install Safe") + + for entry in sorted(entries, key=lambda e: e["id"]): + eid = entry["id"] + cat_name = entry.get("_catalog_name", "") + install_allowed = entry.get("_install_allowed", True) + if eid == default_key: + status = "[green]installed (default)[/green]" + elif eid in installed_keys: + status = "[green]installed[/green]" + elif eid in INTEGRATION_REGISTRY: + status = "built-in" + elif install_allowed is False: + status = "discovery-only" + else: + status = "" + safe = "" + if eid in INTEGRATION_REGISTRY: + safe = "yes" if getattr(INTEGRATION_REGISTRY[eid], "multi_install_safe", False) else "no" + table.add_row( + eid, + entry.get("name", eid), + entry.get("version", ""), + cat_name, + status, + safe, + ) + + console.print(table) + return + + table = Table(title="Coding Agent Integrations") + table.add_column("Key", style="cyan") + table.add_column("Name") + table.add_column("Status") + table.add_column("CLI Required") + table.add_column("Multi-install Safe") + + for key in sorted(INTEGRATION_REGISTRY.keys()): + integration = INTEGRATION_REGISTRY[key] + cfg = integration.config or {} + name = cfg.get("name", key) + requires_cli = cfg.get("requires_cli", False) + + if key == default_key: + status = "[green]installed (default)[/green]" + elif key in installed_keys: + status = "[green]installed[/green]" + else: + status = "" + + cli_req = "yes" if requires_cli else "no (IDE)" + safe = "yes" if getattr(integration, "multi_install_safe", False) else "no" + table.add_row(key, name, status, cli_req, safe) + + console.print(table) + + if installed_keys: + console.print(f"\n[dim]Default integration:[/dim] [cyan]{default_key or 'none'}[/cyan]") + console.print(f"[dim]Installed integrations:[/dim] [cyan]{', '.join(sorted(installed_keys))}[/cyan]") + else: + console.print("\n[yellow]No integration currently installed.[/yellow]") + console.print("Install one with: [cyan]specify integration install [/cyan]") + + +@integration_app.command("install") +def integration_install( + key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"), + integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), +): + """Install an integration into an existing project.""" + from .integrations import INTEGRATION_REGISTRY, get_integration + from .integrations.manifest import IntegrationManifest + + project_root = _require_specify_project() + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + available = ", ".join(sorted(INTEGRATION_REGISTRY.keys())) + console.print(f"Available integrations: {available}") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + default_key = _default_integration_key(current) + installed_keys = _installed_integration_keys(current) + + if key in installed_keys: + console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]") + if default_key == key: + console.print("It is already the default integration.") + else: + console.print( + f"To make it the default integration, run " + f"[cyan]specify integration use {key}[/cyan]." + ) + console.print( + f"To refresh its managed files or options, run " + f"[cyan]specify integration upgrade {key}[/cyan]." + ) + console.print("No files were changed.") + raise typer.Exit(0) + + if installed_keys and not force: + unsafe_keys = [] + for installed_key in installed_keys: + installed_integration = get_integration(installed_key) + if not installed_integration or not getattr(installed_integration, "multi_install_safe", False): + unsafe_keys.append(installed_key) + if unsafe_keys or not getattr(integration, "multi_install_safe", False): + console.print( + f"[red]Error:[/red] Installed integrations: {', '.join(installed_keys)}." + ) + if default_key: + console.print(f"Default integration: [cyan]{default_key}[/cyan].") + console.print( + "Installing multiple integrations is only automatic when all involved " + "integrations are declared multi-install safe." + ) + console.print( + f"To replace the default integration, run " + f"[cyan]specify integration switch {key}[/cyan]." + ) + console.print( + f"To install '{key}' alongside the existing integrations anyway, " + "retry the same install command with [cyan]--force[/cyan]." + ) + raise typer.Exit(1) + + selected_script = _resolve_script_type(project_root, script) + + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + raw_options, parsed_options = _resolve_integration_options( + integration, current, key, integration_options + ) + + # Ensure shared infrastructure is present (safe to run unconditionally; + # _install_shared_infra merges missing files without overwriting). + infra_integration = integration + infra_key = key + infra_parsed = parsed_options + if default_key: + default_integration = get_integration(default_key) + if default_integration is not None: + infra_integration = default_integration + infra_key = default_key + _, infra_parsed = _resolve_integration_options( + default_integration, current, default_key, None + ) + _install_shared_infra_or_exit( + project_root, + selected_script, + invoke_separator=_invoke_separator_for_integration( + infra_integration, current, infra_key, infra_parsed + ), + ) + if os.name != "nt": + ensure_executable_scripts(project_root) + + manifest = IntegrationManifest( + integration.key, project_root, version=get_speckit_version() + ) + + try: + integration.setup( + project_root, manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=raw_options, + ) + manifest.save() + new_installed = _dedupe_integration_keys([*installed_keys, integration.key]) + new_default = default_key or integration.key + settings = _with_integration_setting( + current, + integration.key, + integration, + script_type=selected_script, + raw_options=raw_options, + parsed_options=parsed_options, + ) + _write_integration_json(project_root, new_default, new_installed, settings) + if new_default == integration.key: + _update_init_options_for_integration(project_root, integration, script_type=selected_script) + + except Exception as e: + # Attempt rollback of any files written by setup + try: + integration.teardown(project_root, manifest, force=True) + except Exception as rollback_err: + # Suppress so the original setup error remains the primary failure + console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration changes: {rollback_err}") + if installed_keys: + _write_integration_json( + project_root, default_key, installed_keys, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + console.print(f"[red]Error:[/red] Failed to install integration: {e}") + raise typer.Exit(1) + + name = (integration.config or {}).get("name", key) + console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully") + if default_key: + console.print(f"[dim]Default integration remains:[/dim] [cyan]{default_key}[/cyan]") + + +def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None: + """Parse --integration-options string into a dict matching the integration's declared options. + + Returns ``None`` when no options are provided. + """ + import shlex + parsed: dict[str, Any] = {} + tokens = shlex.split(raw_options) + declared_options = list(integration.options()) + declared = {opt.name.lstrip("-"): opt for opt in declared_options} + allowed = ", ".join(sorted(opt.name for opt in declared_options)) + i = 0 + while i < len(tokens): + token = tokens[i] + if not token.startswith("-"): + console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.") + if allowed: + console.print(f"Allowed options: {allowed}") + raise typer.Exit(1) + name = token.lstrip("-") + value: str | None = None + # Handle --name=value syntax + if "=" in name: + name, value = name.split("=", 1) + opt = declared.get(name) + if not opt: + console.print(f"[red]Error:[/red] Unknown integration option '{token}'.") + if allowed: + console.print(f"Allowed options: {allowed}") + raise typer.Exit(1) + key = name.replace("-", "_") + if opt.is_flag: + if value is not None: + console.print(f"[red]Error:[/red] Option '{opt.name}' is a flag and does not accept a value.") + raise typer.Exit(1) + parsed[key] = True + i += 1 + elif value is not None: + parsed[key] = value + i += 1 + elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"): + parsed[key] = tokens[i + 1] + i += 2 + else: + console.print(f"[red]Error:[/red] Option '{opt.name}' requires a value.") + raise typer.Exit(1) + return parsed or None + + +def _update_init_options_for_integration( + project_root: Path, + integration: Any, + script_type: str | None = None, +) -> None: + """Update ``init-options.json`` to reflect *integration* as the active one.""" + from .integrations.base import SkillsIntegration + opts = load_init_options(project_root) + opts["integration"] = integration.key + opts["ai"] = integration.key + opts["context_file"] = integration.context_file + if script_type: + opts["script"] = script_type + if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): + opts["ai_skills"] = True + else: + opts.pop("ai_skills", None) + save_init_options(project_root, opts) + + +@integration_app.command("use") +def integration_use( + key: str = typer.Argument(help="Installed integration key to make the default"), + force: bool = typer.Option(False, "--force", help="Overwrite managed shared templates while changing the default"), +): + """Set the default integration without uninstalling other integrations.""" + from .integrations import get_integration + + project_root = _require_specify_project() + current = _read_integration_json(project_root) + installed_keys = _installed_integration_keys(current) + if key not in installed_keys: + console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") + if installed_keys: + console.print(f"[yellow]Installed integrations:[/yellow] {', '.join(installed_keys)}") + else: + console.print("Install one with: [cyan]specify integration install [/cyan]") + raise typer.Exit(1) + + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + raise typer.Exit(1) + + raw_options, parsed_options = _resolve_integration_options(integration, current, key, None) + _set_default_integration_or_exit( + project_root, + current, + key, + integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + refresh_templates_force=force, + ) + console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].") + + +@integration_app.command("uninstall") +def integration_uninstall( + key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"), + force: bool = typer.Option(False, "--force", help="Remove files even if modified"), +): + """Uninstall an integration, safely preserving modified files.""" + from .integrations import get_integration + from .integrations.manifest import IntegrationManifest + + project_root = _require_specify_project() + current = _read_integration_json(project_root) + default_key = _default_integration_key(current) + installed_keys = _installed_integration_keys(current) + + if key is None: + if not default_key: + console.print("[yellow]No integration is currently installed.[/yellow]") + raise typer.Exit(0) + key = default_key + + if key not in installed_keys: + console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") + raise typer.Exit(1) + + integration = get_integration(key) + + manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" + if not manifest_path.exists(): + console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]") + remaining = [installed for installed in installed_keys if installed != key] + new_default = default_key if default_key != key else (remaining[0] if remaining else None) + if remaining: + if default_key == key and new_default and (new_integration := get_integration(new_default)): + raw_options, parsed_options = _resolve_integration_options( + new_integration, current, new_default, None + ) + _set_default_integration_or_exit( + project_root, + current, + new_default, + new_integration, + remaining, + raw_options=raw_options, + parsed_options=parsed_options, + ) + else: + _write_integration_json( + project_root, new_default, remaining, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + if default_key == key: + _clear_init_options_for_integration(project_root, key) + raise typer.Exit(0) + + try: + manifest = IntegrationManifest.load(key, project_root) + except _MANIFEST_READ_ERRORS as exc: + console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable.") + console.print(f"Manifest: {manifest_path}") + console.print( + f"To recover, delete the unreadable manifest, run " + f"[cyan]specify integration uninstall {key}[/cyan] to clear stale metadata, " + f"then run [cyan]specify integration install {key}[/cyan] to regenerate." + ) + console.print(f"[dim]Details:[/dim] {exc}") + raise typer.Exit(1) + + removed, skipped = manifest.uninstall(project_root, force=force) + + # Remove managed context section from the agent context file + if integration: + integration.remove_context_section(project_root) + + remaining = [installed for installed in installed_keys if installed != key] + new_default = default_key if default_key != key else (remaining[0] if remaining else None) + if remaining: + if default_key == key and new_default and (new_integration := get_integration(new_default)): + raw_options, parsed_options = _resolve_integration_options( + new_integration, current, new_default, None + ) + _set_default_integration_or_exit( + project_root, + current, + new_default, + new_integration, + remaining, + raw_options=raw_options, + parsed_options=parsed_options, + ) + else: + _write_integration_json( + project_root, new_default, remaining, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + + if default_key == key: + _clear_init_options_for_integration(project_root, key) + + name = (integration.config or {}).get("name", key) if integration else key + console.print(f"\n[green]✓[/green] Integration '{name}' uninstalled") + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f"\n[yellow]⚠[/yellow] {len(skipped)} modified file(s) were preserved:") + for path in skipped: + rel = _display_project_path(project_root, path) + console.print(f" {rel}") + + +@integration_app.command("switch") +def integration_switch( + target: str = typer.Argument(help="Integration key to switch to"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"), + refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"), + integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'), +): + """Switch from the current integration to a different one.""" + from .integrations import INTEGRATION_REGISTRY, get_integration + from .integrations.manifest import IntegrationManifest + + project_root = _require_specify_project() + target_integration = get_integration(target) + if target_integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{target}'") + available = ", ".join(sorted(INTEGRATION_REGISTRY.keys())) + console.print(f"Available integrations: {available}") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_keys = _installed_integration_keys(current) + installed_key = _default_integration_key(current) + + if installed_key == target: + if integration_options is not None: + console.print( + "[red]Error:[/red] --integration-options cannot be used when switching " + "to an already installed integration." + ) + console.print( + f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] " + "to update managed files/options." + ) + raise typer.Exit(1) + if force: + raw_options, parsed_options = _resolve_integration_options( + target_integration, current, target, None + ) + _set_default_integration_or_exit( + project_root, + current, + target, + target_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + refresh_templates_force=True, + ) + console.print( + f"\n[green]✓[/green] Default integration remains [bold]{target}[/bold]; " + "managed shared templates refreshed." + ) + raise typer.Exit(0) + console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]") + raise typer.Exit(0) + + if target in installed_keys: + if integration_options is not None: + console.print( + "[red]Error:[/red] --integration-options cannot be used when switching " + "to an already installed integration." + ) + console.print( + f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] " + f"to update managed files/options, then [cyan]specify integration use {target}[/cyan]." + ) + raise typer.Exit(1) + raw_options, parsed_options = _resolve_integration_options( + target_integration, current, target, None + ) + _set_default_integration_or_exit( + project_root, + current, + target, + target_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + refresh_templates_force=force, + ) + console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].") + raise typer.Exit(0) + + selected_script = _resolve_script_type(project_root, script) + + # Phase 1: Uninstall current integration (if any) + if installed_key: + current_integration = get_integration(installed_key) + manifest_path = project_root / ".specify" / "integrations" / f"{installed_key}.manifest.json" + + if current_integration and manifest_path.exists(): + console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]") + try: + old_manifest = IntegrationManifest.load(installed_key, project_root) + except _MANIFEST_READ_ERRORS as exc: + console.print(f"[red]Error:[/red] Could not read integration manifest for '{installed_key}': {manifest_path}") + console.print(f"[dim]{exc}[/dim]") + console.print( + f"To recover, delete the unreadable manifest at {manifest_path}, " + f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry." + ) + raise typer.Exit(1) + removed, skipped = old_manifest.uninstall(project_root, force=force) + current_integration.remove_context_section(project_root) + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") + elif not current_integration and manifest_path.exists(): + # Integration removed from registry but manifest exists — use manifest-only uninstall + console.print(f"Uninstalling unknown integration '{installed_key}' via manifest") + try: + old_manifest = IntegrationManifest.load(installed_key, project_root) + removed, skipped = old_manifest.uninstall(project_root, force=force) + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") + except _MANIFEST_READ_ERRORS as exc: + console.print(f"[yellow]Warning:[/yellow] Could not read manifest for '{installed_key}': {exc}") + else: + console.print(f"[red]Error:[/red] Integration '{installed_key}' is installed but has no manifest.") + console.print( + f"Run [cyan]specify integration uninstall {installed_key}[/cyan] to clear metadata, " + f"then retry [cyan]specify integration switch {target}[/cyan]." + ) + raise typer.Exit(1) + + # Unregister extension commands for the old agent so they don't + # remain as orphans in the old agent's directory. + try: + from .extensions import ExtensionManager + + ext_mgr = ExtensionManager(project_root) + ext_mgr.unregister_agent_artifacts(installed_key) + except Exception as ext_err: + console.print( + f"[yellow]Warning:[/yellow] Could not clean up extension artifacts " + f"(commands, skills, registry entries) for '{installed_key}': {ext_err}" + ) + + # Clear metadata so a failed Phase 2 doesn't leave stale references + installed_keys = [installed for installed in installed_keys if installed != installed_key] + _clear_init_options_for_integration(project_root, installed_key) + if installed_keys: + fallback_key = installed_keys[0] + fallback_integration = get_integration(fallback_key) + if fallback_integration is not None: + raw_options, parsed_options = _resolve_integration_options( + fallback_integration, current, fallback_key, None + ) + _set_default_integration_or_exit( + project_root, + current, + fallback_key, + fallback_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + ) + else: + _write_integration_json( + project_root, fallback_key, installed_keys, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + current = _read_integration_json(project_root) + + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + raw_options, parsed_options = _resolve_integration_options( + target_integration, current, target, integration_options + ) + + # Refresh shared infrastructure to the current CLI version. Switching + # integrations is exactly when stale vendored shared scripts (e.g. + # update-agent-context.sh that pre-dates the target integration's + # supported-agent list) would silently break the new integration. + # + # Use refresh_managed=True so only files that match their previously + # recorded hash are overwritten — user customizations are detected via + # hash divergence and preserved with a warning. Pass + # --refresh-shared-infra to overwrite customizations as well. See #2293. + _install_shared_infra_or_exit( + project_root, + selected_script, + force=refresh_shared_infra, + refresh_managed=True, + invoke_separator=_invoke_separator_for_integration( + target_integration, current, target, parsed_options + ), + refresh_hint=( + "To overwrite customizations, re-run with " + "[cyan]specify integration switch ... --refresh-shared-infra[/cyan]." + ), + ) + if os.name != "nt": + ensure_executable_scripts(project_root) + + # Phase 2: Install target integration + console.print(f"Installing integration: [cyan]{target}[/cyan]") + manifest = IntegrationManifest( + target_integration.key, project_root, version=get_speckit_version() + ) + + try: + target_integration.setup( + project_root, manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=raw_options, + ) + manifest.save() + _set_default_integration( + project_root, + current, + target_integration.key, + target_integration, + _dedupe_integration_keys([*installed_keys, target_integration.key]), + script_type=selected_script, + raw_options=raw_options, + parsed_options=parsed_options, + ) + + # Re-register extension commands for the new agent so that + # previously-installed extensions are available in the new integration. + try: + from .extensions import ExtensionManager + + ext_mgr = ExtensionManager(project_root) + ext_mgr.register_enabled_extensions_for_agent(target) + except Exception as ext_err: + console.print( + f"[yellow]Warning:[/yellow] Could not register extension commands, skills, " + f"or related artifacts for '{target}': {ext_err}" + ) + + except Exception as e: + # Attempt rollback of any files written by setup + try: + target_integration.teardown(project_root, manifest, force=True) + except Exception as rollback_err: + # Suppress so the original setup error remains the primary failure + console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration '{target}': {rollback_err}") + if installed_keys: + fallback_key = installed_keys[0] + fallback_integration = get_integration(fallback_key) + if fallback_integration is not None: + raw_options, parsed_options = _resolve_integration_options( + fallback_integration, current, fallback_key, None + ) + try: + _set_default_integration( + project_root, + current, + fallback_key, + fallback_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + ) + except _SharedTemplateRefreshError as restore_err: + console.print( + f"[yellow]Warning:[/yellow] Failed to restore default " + f"integration '{fallback_key}': {restore_err}" + ) + else: + _write_integration_json( + project_root, fallback_key, installed_keys, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + console.print(f"[red]Error:[/red] Failed to install integration '{target}': {e}") + raise typer.Exit(1) + + name = (target_integration.config or {}).get("name", target) + console.print(f"\n[green]✓[/green] Switched to integration '{name}'") + + +@integration_app.command("upgrade") +def integration_upgrade( + key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"), + force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"), +): + """Upgrade an integration by reinstalling with diff-aware file handling. + + Compares manifest hashes to detect locally modified files and + blocks the upgrade unless --force is used. + """ + from .integrations import get_integration + from .integrations.manifest import IntegrationManifest + + project_root = _require_specify_project() + current = _read_integration_json(project_root) + installed_key = _default_integration_key(current) + installed_keys = _installed_integration_keys(current) + + if key is None: + if not installed_key: + console.print("[yellow]No integration is currently installed.[/yellow]") + raise typer.Exit(0) + key = installed_key + + if key not in installed_keys: + console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") + raise typer.Exit(1) + + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + raise typer.Exit(1) + + manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" + if not manifest_path.exists(): + console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to upgrade.[/yellow]") + console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.") + raise typer.Exit(0) + + try: + old_manifest = IntegrationManifest.load(key, project_root) + except _MANIFEST_READ_ERRORS as exc: + console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}") + raise typer.Exit(1) + + # Detect modified files via manifest hashes + modified = old_manifest.check_modified() + if modified and not force: + console.print(f"[yellow]⚠[/yellow] {len(modified)} file(s) have been modified since installation:") + for rel in modified: + console.print(f" {rel}") + console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.") + raise typer.Exit(1) + + selected_script = _resolve_integration_script_type(project_root, current, key, script) + + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + raw_options, parsed_options = _resolve_integration_options( + integration, current, key, integration_options + ) + + # Ensure shared infrastructure is up to date; --force overwrites existing files. + infra_integration = integration + infra_key = key + infra_parsed = parsed_options + if installed_key and installed_key != key: + default_integration = get_integration(installed_key) + if default_integration is not None: + infra_integration = default_integration + infra_key = installed_key + _, infra_parsed = _resolve_integration_options( + default_integration, current, installed_key, None + ) + _install_shared_infra_or_exit( + project_root, + selected_script, + force=force, + invoke_separator=_invoke_separator_for_integration( + infra_integration, current, infra_key, infra_parsed + ), + ) + if os.name != "nt": + ensure_executable_scripts(project_root) + + # Phase 1: Install new files (overwrites existing; old-only files remain) + console.print(f"Upgrading integration: [cyan]{key}[/cyan]") + new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version()) + + try: + integration.setup( + project_root, + new_manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=raw_options, + ) + settings = _with_integration_setting( + current, + key, + integration, + script_type=selected_script, + raw_options=raw_options, + parsed_options=parsed_options, + ) + if installed_key == key: + try: + _refresh_shared_templates( + project_root, + invoke_separator=_invoke_separator_for_integration( + integration, {"integration_settings": settings}, key, parsed_options + ), + force=force, + ) + except (ValueError, OSError) as exc: + raise _SharedTemplateRefreshError( + f"Failed to refresh shared templates for '{key}': {exc}" + ) from exc + new_manifest.save() + _write_integration_json(project_root, installed_key, installed_keys, settings) + if installed_key == key: + _update_init_options_for_integration(project_root, integration, script_type=selected_script) + except Exception as exc: + # Don't teardown — setup overwrites in-place, so teardown would + # delete files that were working before the upgrade. Just report. + console.print(f"[red]Error:[/red] Failed to upgrade integration: {exc}") + console.print("[yellow]The previous integration files may still be in place.[/yellow]") + raise typer.Exit(1) + + # Phase 2: Remove stale files from old manifest that are not in the new one + old_files = old_manifest.files + new_files = new_manifest.files + stale_keys = set(old_files) - set(new_files) + if stale_keys: + stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup") + stale_manifest._files = {k: old_files[k] for k in stale_keys} + stale_removed, _ = stale_manifest.uninstall(project_root, force=True) + if stale_removed: + console.print(f" Removed {len(stale_removed)} stale file(s) from previous install") + + name = (integration.config or {}).get("name", key) + console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") + + +# ===== Integration catalog discovery commands ===== +# +# These commands mirror the workflow catalog CLI shape: +# - `search` / `info` for discovery over the active catalog stack +# - `catalog list/add/remove` for managing catalog sources +# +# They deliberately do NOT add `integration add/remove/enable/disable/ +# set-priority`: integrations are single-active (install / uninstall / switch), +# not additive like extensions and presets. + + +@integration_app.command("search") +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, + IntegrationCatalogError, + IntegrationValidationError, + ) + + project_root = _require_specify_project() + integration_config = _read_integration_json(project_root) + installed_key = integration_config.get("integration") + catalog = IntegrationCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag, author=author) + except IntegrationValidationError as exc: + console.print(f"[red]Error:[/red] {exc}") + console.print( + "\nTip: Check the configuration file path shown above for invalid catalog configuration " + "(for example, .specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)." + ) + raise typer.Exit(1) + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + if os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip(): + console.print( + "\nTip: Check the SPECKIT_INTEGRATION_CATALOG_URL environment variable for an invalid " + "catalog URL, or unset it to use the configured catalog files " + "(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)." + ) + else: + console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") + raise typer.Exit(1) + + if not results: + console.print("\n[yellow]No integrations found matching criteria[/yellow]") + if query or tag or author: + console.print("\nTry:") + console.print(" • Broader search terms") + console.print(" • Remove filters") + console.print(" • specify integration search (show all)") + return + + console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n") + for integ in sorted(results, key=lambda e: e.get("id", "")): + iid = integ.get("id", "?") + name = integ.get("name", iid) + version = integ.get("version", "?") + console.print(f"[bold]{name}[/bold] ({iid}) v{version}") + desc = integ.get("description", "") + if desc: + console.print(f" {desc}") + + console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}") + tags = integ.get("tags", []) + if isinstance(tags, list) and tags: + console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}") + + cat_name = integ.get("_catalog_name", "") + install_allowed = integ.get("_install_allowed", True) + if cat_name: + if install_allowed: + console.print(f" [dim]Catalog:[/dim] {cat_name}") + else: + console.print( + f" [dim]Catalog:[/dim] {cat_name} " + "[yellow](discovery only — not installable)[/yellow]" + ) + + if iid == installed_key: + console.print("\n [green]✓ Installed[/green] (currently active)") + elif iid in INTEGRATION_REGISTRY: + console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}") + elif install_allowed: + console.print( + "\n [yellow]Found in catalog.[/yellow] Only built-in integration IDs " + "can be installed with 'specify integration install'." + ) + else: + console.print( + f"\n [yellow]⚠[/yellow] Not directly installable from '{cat_name}'." + ) + console.print() + + +@integration_app.command("info") +def integration_info( + integration_id: str = typer.Argument(..., help="Integration ID"), +): + """Show catalog details for a single integration.""" + from .integrations import INTEGRATION_REGISTRY + from .integrations.catalog import ( + IntegrationCatalog, + IntegrationCatalogError, + IntegrationValidationError, + ) + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + installed_key = _read_integration_json(project_root).get("integration") + + try: + info = catalog.get_integration_info(integration_id) + except IntegrationCatalogError as exc: + info = None + # Keep the live exception so the fallback branch below can give + # different guidance for local-config vs. network failures. + catalog_error: Optional[IntegrationCatalogError] = exc + else: + catalog_error = None + + if info: + name = info.get("name", integration_id) + version = info.get("version", "?") + console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}") + if info.get("description"): + console.print(f" {info['description']}") + console.print() + + console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}") + if info.get("license"): + console.print(f" [dim]License:[/dim] {info['license']}") + + tags = info.get("tags", []) + if isinstance(tags, list) and tags: + console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}") + + cat_name = info.get("_catalog_name", "") + install_allowed = info.get("_install_allowed", True) + if cat_name: + install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" + console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}") + + if info.get("repository"): + console.print(f" [dim]Repository:[/dim] {info['repository']}") + + if integration_id == installed_key: + console.print("\n [green]✓ Installed[/green] (currently active)") + elif integration_id in INTEGRATION_REGISTRY: + console.print("\n [dim]Built-in integration (not currently active)[/dim]") + return + + if integration_id in INTEGRATION_REGISTRY: + integration = INTEGRATION_REGISTRY[integration_id] + cfg = integration.config or {} + name = cfg.get("name", integration_id) + console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id})") + console.print(" [dim]Built-in integration (not listed in catalog)[/dim]") + if integration_id == installed_key: + console.print("\n [green]✓ Installed[/green] (currently active)") + if catalog_error: + console.print(f"\n[yellow]Catalog unavailable:[/yellow] {catalog_error}") + return + + if catalog_error: + console.print(f"[red]Error:[/red] Could not query integration catalog: {catalog_error}") + if isinstance(catalog_error, IntegrationValidationError): + console.print( + "\nCheck the configuration file path shown above " + "(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml), " + "or use a built-in integration ID directly." + ) + elif os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip(): + console.print( + "\nCheck whether SPECKIT_INTEGRATION_CATALOG_URL is set correctly and reachable, " + "or unset it to use the configured catalog files, or use a built-in integration ID directly." + ) + else: + console.print("\nTry again when online, or use a built-in integration ID directly.") + else: + console.print(f"[red]Error:[/red] Integration '{integration_id}' not found") + console.print("\nTry: specify integration search") + raise typer.Exit(1) + + +@integration_catalog_app.command("list") +def integration_catalog_list(): + """List configured integration catalog sources.""" + from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + env_override = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip() + + try: + if env_override: + project_configs = None + configs = catalog.get_catalog_configs() + else: + project_configs = catalog.get_project_catalog_configs() + configs = project_configs if project_configs is not None else catalog.get_catalog_configs() + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Integration Catalog Sources:[/bold cyan]\n") + if env_override: + console.print( + " SPECKIT_INTEGRATION_CATALOG_URL is set; it supersedes configured catalog files." + ) + console.print( + " Project/user catalog sources are not active while the env override is set.\n" + ) + console.print("[bold]Active catalog source from environment (non-removable here):[/bold]\n") + elif project_configs is None: + console.print(" No project-level catalog sources configured.\n") + console.print("[bold]Active catalog sources (non-removable here):[/bold]\n") + else: + console.print("[bold]Project catalog sources (removable):[/bold]\n") + + for i, cfg in enumerate(configs): + install_status = ( + "[green]install allowed[/green]" + if cfg.get("install_allowed") + else "[yellow]discovery only[/yellow]" + ) + raw_name = cfg.get("name") + display_name = str(raw_name).strip() if raw_name is not None else "" + if not display_name: + display_name = f"catalog-{i + 1}" + if env_override or project_configs is None: + console.print(f" - [bold]{display_name}[/bold] — {install_status}") + else: + console.print(f" [{i}] [bold]{display_name}[/bold] — {install_status}") + console.print(f" {cfg.get('url', '')}") + if cfg.get("description"): + console.print(f" [dim]{cfg['description']}[/dim]") + console.print() + + +@integration_catalog_app.command("add") +def integration_catalog_add( + url: str = typer.Argument( + ..., + help=( + "Catalog URL to add (HTTPS required, except http://localhost, " + "http://127.0.0.1, or http://[::1] for local testing)" + ), + ), + name: Optional[str] = typer.Option(None, "--name", help="Catalog name"), +): + """Add an integration catalog source to the project config.""" + from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + + # Normalize once here so the success message reflects what was actually + # stored. ``IntegrationCatalog.add_catalog`` strips again defensively. + normalized_url = url.strip() + + try: + catalog.add_catalog(normalized_url, name) + except IntegrationCatalogError as exc: + # Covers both URL validation (base class) and config-file validation + # (IntegrationValidationError subclass). + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Catalog source added: {normalized_url}") + + +@integration_catalog_app.command("remove") +def integration_catalog_remove( + index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), +): + """Remove an integration catalog source by 0-based index.""" + from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + + try: + removed_name = catalog.remove_catalog(index) + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") + # ===== Preset Commands ===== 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 97df84ccbad6695b2ee74e0bcbeed723c71319f9 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Thu, 14 May 2026 23:15:10 +0000 Subject: [PATCH 03/38] 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 98813efeb1..52242a639f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1444,13 +1444,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 d7acf297a12fa63b80036752ccd1b1b4a4ef475c Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 13:29:54 +0000 Subject: [PATCH 04/38] 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 3fa13e753f43bfffc0fc48afc6841a4799ea70f1 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 13:50:18 +0000 Subject: [PATCH 05/38] 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 778377fc35..2f66f1b715 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 94e91864934f6a52e4fd00bfb5e99e8ed5e6a78b Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 13:56:58 +0000 Subject: [PATCH 06/38] 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 52242a639f..120ea6545f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1447,7 +1447,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( @@ -1457,7 +1457,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 f6431024d6d4e930b4965072dbfc7a819126a609 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 14:53:03 +0000 Subject: [PATCH 07/38] 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 120ea6545f..c12f9f520b 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1450,15 +1450,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 3ca670ad966e1146ebfd0781e29e679f58dc0232 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 15:01:38 +0000 Subject: [PATCH 08/38] 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 982102f26a68f910ff5e4004890805d299c124d7 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 18:15:32 +0000 Subject: [PATCH 09/38] 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 76cd163b5040e1cf0f988c337ea45a918de76ce7 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 18:26:27 +0000 Subject: [PATCH 10/38] 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 41c5c1cbb1d5cb71652fd6a071e28daa013df22c Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 19:52:00 +0000 Subject: [PATCH 11/38] 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 f539e818d9a3fbdd9bd88a82b92bd65e86041d0c Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 20:11:17 +0000 Subject: [PATCH 12/38] 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 3278fe641b8df8d1f7c2cdc32a72646dee5425f5 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 20:21:45 +0000 Subject: [PATCH 13/38] 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 c12f9f520b..6546966506 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1458,7 +1458,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 641a531135343057d4e4f02de4f60383d5c8dfcf Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 20:34:48 +0000 Subject: [PATCH 14/38] 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 6546966506..d5387211b9 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1459,8 +1459,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 6fa65ea9ca8c2407712bd9751c428ec62c015837 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 15 May 2026 20:44:50 +0000 Subject: [PATCH 15/38] 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 8004ad4c0df4ea7c798f90680f2b1358a8d79c9e Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Sat, 16 May 2026 01:56:59 +0000 Subject: [PATCH 16/38] 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 c43996d494f278acab3641e5c141bdc61e17260c Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Sat, 16 May 2026 06:44:42 +0000 Subject: [PATCH 17/38] 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 d5387211b9..cd50c3bbdb 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1460,7 +1460,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 f445342fd7093146f95904c400836c2739831cd0 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 13:36:43 +0000 Subject: [PATCH 18/38] 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 cd50c3bbdb..e91523c5dd 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1444,7 +1444,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 d6380a301e32e682e17f121fc89ee965b755eb0e Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 14:20:21 +0000 Subject: [PATCH 19/38] 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 82c625bb19101cd7d9f6dedb7d12a26ff86b0652 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 14:29:19 +0000 Subject: [PATCH 20/38] 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 68b54f2c06c5fed922e4dc86244b959ef3f4c1e4 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 14:51:10 +0000 Subject: [PATCH 21/38] 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 53530ea917e28a64ed4ade48f4c940cb3237151e Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 15:04:32 +0000 Subject: [PATCH 22/38] 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 dbcf78a9007a0be3de0fc8d7bfdca35303e99c73 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 15:16:43 +0000 Subject: [PATCH 23/38] 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 137c5a88af67ba915de00b2335976313d88a389c Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 15:18:56 +0000 Subject: [PATCH 24/38] 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 d2b45a73bd3731016d792520931e4fe780328d6f Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 15:23:13 +0000 Subject: [PATCH 25/38] 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 da2f42ac4b4b18fc7f6b2e9dbef3f5a6fd7cd47c Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 18 May 2026 15:30:25 +0000 Subject: [PATCH 26/38] 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 e91523c5dd..d93de1755b 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1462,7 +1462,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: From 8b3e9e067390c67ccb9fa86dd9cc7e46b77c60b4 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 19 May 2026 04:04:55 +0000 Subject: [PATCH 27/38] Update error message in test_missing_catalog_file for clarity --- tests/test_catalog_docs.py | 87 ++++++++++++++++------------ tests/test_community_catalog_docs.py | 5 +- 2 files changed, 54 insertions(+), 38 deletions(-) diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index 395cb1b5d4..5da38dbca6 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -8,7 +8,7 @@ from typer.testing import CliRunner from specify_cli.catalog_docs import ( - _escape_url_for_markdown_link, + escape_url_for_markdown_link, render_cell, list_integrations_for_docs, render_integrations_table, @@ -33,7 +33,7 @@ def _get_catalog_docs_patches(): } fake_label_overrides = {} fake_notes = {"copilot": "Test note"} - + with ExitStack() as stack: stack.enter_context( patch( @@ -74,17 +74,17 @@ def test_render_cell_escapes_pipes_and_normalizes_newlines(): 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)") == ( + 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") == ( + 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") == ( + 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") == ( + assert escape_url_for_markdown_link("https://example.com/path") == ( "https://example.com/path" ) @@ -210,6 +210,29 @@ def parse_first_markdown_table(text: str) -> set[tuple[str, str, str]]: in_target_section = False in_table = False rows = [] + + def split_markdown_table_row(line: str) -> list[str]: + parts = [] + current = "" + backslash_run = 0 + for char in line: + if char == "\\": + backslash_run += 1 + current += char + continue + if char == "|" and backslash_run % 2 == 0: + parts.append(current.strip()) + current = "" + else: + current += char + backslash_run = 0 + parts.append(current.strip()) + if parts and parts[0] == "": + parts = parts[1:] + if parts and parts[-1] == "": + parts = parts[:-1] + return parts + for line in lines: if line.startswith("## Supported AI Coding Agents"): in_target_section = True @@ -219,24 +242,8 @@ def parse_first_markdown_table(text: str) -> set[tuple[str, str, str]]: break if line.strip().startswith("|"): in_table = True - # 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] - + parts = split_markdown_table_row(line) + if ( all(p.startswith("---") or p == "" for p in parts) or parts == ["Agent", "Key", "Notes"] @@ -256,29 +263,35 @@ def parse_first_markdown_table(text: str) -> set[tuple[str, str, str]]: 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 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 + + def split_markdown_table_row(line: str) -> list[str]: parts = [] current = "" - for i, char in enumerate(line): - if char == "|" and (i == 0 or line[i-1] != "\\"): + backslash_run = 0 + for char in line: + if char == "\\": + backslash_run += 1 + current += char + continue + if char == "|" and backslash_run % 2 == 0: parts.append(current.strip()) current = "" else: current += char - if current: - parts.append(current.strip()) - - # Remove empty leading/trailing parts from outer pipes + backslash_run = 0 + parts.append(current.strip()) if parts and parts[0] == "": parts = parts[1:] if parts and parts[-1] == "": parts = parts[:-1] - + return parts + + for line in text.splitlines(): + if not line.strip().startswith("|"): + continue + + parts = split_markdown_table_row(line) + # Skip header and separator rows if ( all(p.startswith("---") or p == "" for p in parts) diff --git a/tests/test_community_catalog_docs.py b/tests/test_community_catalog_docs.py index 9252fe2cca..a5beca6bcf 100644 --- a/tests/test_community_catalog_docs.py +++ b/tests/test_community_catalog_docs.py @@ -50,7 +50,10 @@ def test_community_extensions_are_sorted_by_name() -> None: # --------------------------------------------------------------------------- def test_missing_catalog_file(tmp_path: Path) -> None: - with pytest.raises(FileNotFoundError, match="spec-kit source checkout"): + with pytest.raises( + FileNotFoundError, + match="Ensure the repository checkout includes the extensions/ directory", + ): list_community_extensions(path=tmp_path / "missing.json") From 1e9e3fa49f65120ec42ac381ba387fa96e5886c1 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Wed, 20 May 2026 09:29:10 +0000 Subject: [PATCH 28/38] Remove obsolete integrations sync test --- tests/test_catalog_docs.py | 146 ------------------------------------- 1 file changed, 146 deletions(-) diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index 5da38dbca6..e0ee7ff2ff 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -175,149 +175,3 @@ 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() - - -def test_docs_reference_integrations_md_stays_in_sync(): - """Regression test: committed docs/reference/integrations.md stays in sync. - - 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 - - # Find the committed integrations.md file - repo_root = Path(__file__).parent.parent - docs_file = repo_root / "docs" / "reference" / "integrations.md" - - 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 explicit UTF-8 encoding - with open(docs_file, encoding="utf-8") as f: - committed_content = f.read() - - # 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 - rows = [] - - def split_markdown_table_row(line: str) -> list[str]: - parts = [] - current = "" - backslash_run = 0 - for char in line: - if char == "\\": - backslash_run += 1 - current += char - continue - if char == "|" and backslash_run % 2 == 0: - parts.append(current.strip()) - current = "" - else: - current += char - backslash_run = 0 - parts.append(current.strip()) - if parts and parts[0] == "": - parts = parts[1:] - if parts and parts[-1] == "": - parts = parts[:-1] - return parts - - 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 = split_markdown_table_row(line) - - if ( - all(p.startswith("---") or p == "" for p in parts) - or parts == ["Agent", "Key", "Notes"] - ): - continue - - # Validate we have 3 columns - 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: - break - return set(rows) - - def parse_markdown_table_rows(text: str) -> set[tuple[str, str, str]]: - """Parse markdown table rows, respecting escaped pipes.""" - rows = [] - - def split_markdown_table_row(line: str) -> list[str]: - parts = [] - current = "" - backslash_run = 0 - for char in line: - if char == "\\": - backslash_run += 1 - current += char - continue - if char == "|" and backslash_run % 2 == 0: - parts.append(current.strip()) - current = "" - else: - current += char - backslash_run = 0 - parts.append(current.strip()) - if parts and parts[0] == "": - parts = parts[1:] - if parts and parts[-1] == "": - parts = parts[:-1] - return parts - - for line in text.splitlines(): - if not line.strip().startswith("|"): - continue - - parts = split_markdown_table_row(line) - - # 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) - generated_table = render_integrations_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 bf98504cc498f0e2b5f21bcc0a3f3906e6382bf3 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Thu, 21 May 2026 22:25:13 +0000 Subject: [PATCH 29/38] keep integrations docs in sync --- src/specify_cli/catalog_docs.py | 5 +++++ tests/test_catalog_docs.py | 30 ++++++++++++++++++++++++++++ tests/test_community_catalog_docs.py | 15 ++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index c2ec5fb7bb..dd04745e3a 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -2,9 +2,14 @@ from __future__ import annotations +from pathlib import Path from typing import Any +ROOT_DIR = Path(__file__).resolve().parents[2] +INTEGRATIONS_REFERENCE_PATH = ROOT_DIR / "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 e0ee7ff2ff..fd38bb1ef0 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -9,6 +9,7 @@ from specify_cli.catalog_docs import ( escape_url_for_markdown_link, + INTEGRATIONS_REFERENCE_PATH, render_cell, list_integrations_for_docs, render_integrations_table, @@ -63,6 +64,35 @@ def test_integrations_table_renders(): assert lines[1] == "| --- | --- | --- |" +def test_integrations_reference_doc_matches_renderer(): + doc_text = INTEGRATIONS_REFERENCE_PATH.read_text(encoding="utf-8") + start_marker = "## Supported AI Coding Agents\n\n" + end_marker = "\n## List Available Integrations\n" + start = doc_text.index(start_marker) + len(start_marker) + end = doc_text.index(end_marker) + committed_table = doc_text[start:end].rstrip("\n") + rendered_table = render_integrations_table().rstrip("\n") + + def parse_table(table: str) -> list[list[str]]: + rows: list[list[str]] = [] + for line in table.splitlines(): + if not line.startswith("| "): + continue + parts = [part.strip() for part in line.strip("|").split("|")] + if parts and set(parts[0]) == {"-"}: + continue + if len(parts) == 3: + rows.append(parts) + return rows + + committed_rows = parse_table(committed_table) + rendered_rows = parse_table(rendered_table) + committed_rows.sort(key=lambda row: row[1]) + rendered_rows.sort(key=lambda row: row[1]) + + assert committed_rows == rendered_rows + + def test_render_cell_escapes_pipes_and_normalizes_newlines(): assert render_cell("a|b") == "a\\|b" assert render_cell("a\nb") == "a b" diff --git a/tests/test_community_catalog_docs.py b/tests/test_community_catalog_docs.py index a5beca6bcf..fcb806700a 100644 --- a/tests/test_community_catalog_docs.py +++ b/tests/test_community_catalog_docs.py @@ -136,3 +136,18 @@ def test_url_escaping_in_repository_links(tmp_path: Path) -> None: table = render_community_extensions_table(path=f) # The URL should be escaped: ) → \) and | → \| assert "[Foo](https://example.com/repo?x=1\\)&y=2\\|bad)" in table + + +def test_extension_id_is_sanitized(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo|bar": { + "name": "Foo", + "id": "foo|bar\n", + "description": "", + "tags": [], + "verified": False, + "repository": "", + }, + }) + table = render_community_extensions_table(path=f) + assert "`foo\\|bar `" in table From a180eeb3adf21ec7fc35f9a04a4a0ebe0452189f Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 22 May 2026 14:05:27 +0700 Subject: [PATCH 30/38] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/community_catalog_docs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py index a5ca769e7b..7c47eeaf8e 100644 --- a/src/specify_cli/community_catalog_docs.py +++ b/src/specify_cli/community_catalog_docs.py @@ -49,7 +49,7 @@ def list_community_extensions( "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 ""), + "repository": str(ext.get("repository") or "").strip(), } ) From c4c7fab8dee2856a9f1af029d336fe3b37c90236 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 22 May 2026 14:16:54 +0700 Subject: [PATCH 31/38] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d93de1755b..66b8f31196 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1465,7 +1465,7 @@ def integration_search( 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) + raise typer.Exit(code=1) from exc return from .integrations import INTEGRATION_REGISTRY From 4f06e0625dd9427679519300b970d4991ce8eea1 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 22 May 2026 14:26:35 +0700 Subject: [PATCH 32/38] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/catalog_docs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index dd04745e3a..e743439a6c 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -102,7 +102,7 @@ def render_cell(value: 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. """ return url.replace(")", "\\)").replace("|", "\\|") From bedb02b7aa65bda248d0839a01b0b965b5fb4bfe Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 22 May 2026 14:35:37 +0700 Subject: [PATCH 33/38] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/community_catalog_docs.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py index 7c47eeaf8e..8e7c39da35 100644 --- a/src/specify_cli/community_catalog_docs.py +++ b/src/specify_cli/community_catalog_docs.py @@ -27,10 +27,17 @@ def list_community_extensions( ) -> list[dict[str, Any]]: """Return community extensions sorted alphabetically by name then ID.""" if not path.exists(): - raise FileNotFoundError( - f"Community catalog not found at {path}. " - "Ensure the repository checkout includes the extensions/ directory." - ) + if path == COMMUNITY_CATALOG_PATH: + message = ( + f"Community catalog not found at {path}. " + "Ensure the repository checkout includes the extensions/ directory." + ) + else: + message = ( + f"Community catalog not found at {path}. " + "Provide path= to a valid community catalog JSON file." + ) + raise FileNotFoundError(message) data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict): raise ValueError(f"Expected {path} to contain a JSON object") From f223704df5d2a4df98ca2d4667d0d6eee1020655 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Sun, 24 May 2026 23:08:42 +0000 Subject: [PATCH 34/38] fix: address remaining integrations docs feedback Tighten the integrations docs rendering helpers and tests to cover the remaining Copilot feedback from PR #2563: shared repo-root lookup, safer markdown link text, whitespace repository handling, narrower error handling, and missing-file/test parsing guards. Tests: pytest tests/test_catalog_docs.py tests/test_community_catalog_docs.py -q; python3 -m compileall -q src/specify_cli/__init__.py src/specify_cli/catalog_docs.py src/specify_cli/community_catalog_docs.py tests/test_catalog_docs.py tests/test_community_catalog_docs.py Reference: PR #2563; reply comment https://github.com/github/spec-kit/pull/2563#issuecomment-4530453741 --- src/specify_cli/__init__.py | 9 ++- src/specify_cli/catalog_docs.py | 16 ++-- src/specify_cli/community_catalog_docs.py | 23 +++--- tests/test_catalog_docs.py | 98 +++++++++++++---------- tests/test_community_catalog_docs.py | 23 ++++++ 5 files changed, 110 insertions(+), 59 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 66b8f31196..92fa51b2b9 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1448,11 +1448,14 @@ def integration_search( "--markdown", help=( "Output the full built-in integrations table as markdown " - "(ignores filters)" + "(ignores query and --tag/--author filters)" ), ), ): - """Search for integrations in the active catalog stack, or output the built-in reference table with --markdown.""" + """Search for integrations in the active catalog stack. + + Or output the built-in reference table with --markdown. + """ if markdown: if query or tag or author: typer.echo( @@ -1463,7 +1466,7 @@ def integration_search( from .catalog_docs import render_integrations_table try: typer.echo(render_integrations_table(), nl=False) - except Exception as exc: + except (FileNotFoundError, ValueError) as exc: typer.echo(f"Error rendering integrations table: {exc}", err=True) raise typer.Exit(code=1) from exc return diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index e743439a6c..5020ae173c 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -2,11 +2,12 @@ from __future__ import annotations -from pathlib import Path from typing import Any +from ._assets import _repo_root -ROOT_DIR = Path(__file__).resolve().parents[2] + +ROOT_DIR = _repo_root() INTEGRATIONS_REFERENCE_PATH = ROOT_DIR / "docs" / "reference" / "integrations.md" @@ -108,6 +109,11 @@ def escape_url_for_markdown_link(url: str) -> str: return url.replace(")", "\\)").replace("|", "\\|") +def escape_markdown_link_text(text: str) -> str: + """Escape characters that can break Markdown link text.""" + return text.replace("[", "\\[").replace("]", "\\]") + + def _get_integration_registry() -> dict[str, Any]: from specify_cli.integrations import INTEGRATION_REGISTRY @@ -155,7 +161,7 @@ def list_integrations_for_docs( ) warnings.warn( f"Stale key(s) found in doc maps (no longer in registry): " - f"{stale_keys}. Consider removing them from " + f"{', '.join(stale_keys)}. Consider removing them from " "INTEGRATION_DOC_URLS, INTEGRATION_LABEL_OVERRIDES, and " "INTEGRATION_NOTES.", stacklevel=2 @@ -182,7 +188,7 @@ def render_integrations_table() -> str: for key, label, url, notes in list_integrations_for_docs(): # Escape raw field values *before* composing Markdown syntax so that # a pipe inside a label or notes doesn't break a link target. - safe_label = render_cell(label) + safe_label = escape_markdown_link_text(render_cell(label)) safe_notes = render_cell(notes) safe_url = escape_url_for_markdown_link(url) if url else None agent = ( @@ -190,7 +196,7 @@ def render_integrations_table() -> str: if safe_url else safe_label ) - table_rows.append([agent, f"`{key}`", safe_notes]) + table_rows.append([agent, f"`{render_cell(key)}`", safe_notes]) headers = ("Agent", "Key", "Notes") diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py index 8e7c39da35..e90b56e2f4 100644 --- a/src/specify_cli/community_catalog_docs.py +++ b/src/specify_cli/community_catalog_docs.py @@ -6,10 +6,15 @@ from pathlib import Path from typing import Any -from .catalog_docs import escape_url_for_markdown_link, render_cell +from ._assets import _repo_root +from .catalog_docs import ( + escape_markdown_link_text, + escape_url_for_markdown_link, + render_cell, +) -ROOT_DIR = Path(__file__).resolve().parents[2] +ROOT_DIR = _repo_root() COMMUNITY_CATALOG_PATH = ROOT_DIR / "extensions" / "catalog.community.json" @@ -76,13 +81,13 @@ 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_repo = escape_url_for_markdown_link(row["repository"]) - link = ( - f"[{safe_name}]({safe_repo})" - if row["repository"] - else safe_name - ) + safe_name = escape_markdown_link_text(render_cell(row["name"])) + repository = row["repository"] + if repository: + safe_repo = escape_url_for_markdown_link(repository) + link = f"[{safe_name}]({safe_repo})" + else: + link = safe_name table_rows.append( [ link, diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index fd38bb1ef0..74ee0f3a2e 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -3,12 +3,15 @@ from __future__ import annotations from contextlib import ExitStack, contextmanager +import re from unittest.mock import MagicMock, patch +import pytest from typer.testing import CliRunner from specify_cli.catalog_docs import ( escape_url_for_markdown_link, + escape_markdown_link_text, INTEGRATIONS_REFERENCE_PATH, render_cell, list_integrations_for_docs, @@ -21,19 +24,29 @@ @contextmanager -def _get_catalog_docs_patches(): +def _get_catalog_docs_patches( + *, + fake_registry=None, + fake_doc_urls=None, + fake_label_overrides=None, + fake_notes=None, +): """Context manager that applies mocked registry and doc maps for tests.""" - 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"} + if fake_registry is None: + fake_registry = { + "copilot": MagicMock(config={"name": "GitHub Copilot"}), + "codex": MagicMock(config={"name": "Codex CLI"}), + } + if fake_doc_urls is None: + fake_doc_urls = { + "copilot": "https://code.visualstudio.com/", + "codex": "https://github.com/openai/codex", + } + if fake_label_overrides is None: + fake_label_overrides = {} + if fake_notes is None: + fake_notes = {"copilot": "Test note"} with ExitStack() as stack: stack.enter_context( @@ -65,6 +78,11 @@ def test_integrations_table_renders(): def test_integrations_reference_doc_matches_renderer(): + if not INTEGRATIONS_REFERENCE_PATH.exists(): + pytest.skip( + f"Integrations reference not found at {INTEGRATIONS_REFERENCE_PATH}. " + "Skipping (expected when running from sdist/wheel)." + ) doc_text = INTEGRATIONS_REFERENCE_PATH.read_text(encoding="utf-8") start_marker = "## Supported AI Coding Agents\n\n" end_marker = "\n## List Available Integrations\n" @@ -78,7 +96,10 @@ def parse_table(table: str) -> list[list[str]]: for line in table.splitlines(): if not line.startswith("| "): continue - parts = [part.strip() for part in line.strip("|").split("|")] + parts = [ + part.strip() + for part in re.split(r"(? None: assert "[Foo](" not in table # plain name, no link +def test_whitespace_repository_is_treated_as_missing(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": {"name": "Foo", "id": "foo", "description": "", "tags": [], "verified": False, "repository": " "}, + }) + table = render_community_extensions_table(path=f) + assert "Foo" in table + assert "[Foo](" not in table + + def test_tags_containing_pipe_do_not_break_table(tmp_path: Path) -> None: f = _write_catalog(tmp_path, { # No "id" field — exercises ext_id fallback; tag has pipe — exercises stripping @@ -138,6 +147,20 @@ def test_url_escaping_in_repository_links(tmp_path: Path) -> None: assert "[Foo](https://example.com/repo?x=1\\)&y=2\\|bad)" in table +def test_link_text_is_escaped(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": { + "name": "Code [Buddy]", + "description": "", + "tags": [], + "verified": False, + "repository": "https://example.com/repo", + }, + }) + table = render_community_extensions_table(path=f) + assert "[Code \\[Buddy\\]](https://example.com/repo)" in table + + def test_extension_id_is_sanitized(tmp_path: Path) -> None: f = _write_catalog(tmp_path, { "foo|bar": { From 7f0ce3099ef7417042101e11d9ceb71fb3497992 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 25 May 2026 01:28:15 +0000 Subject: [PATCH 35/38] fix: address remaining PR 2563 feedback Normalize community tag rendering for multiline values and simplify the docs warnings import. Tests: pytest tests/test_catalog_docs.py tests/test_community_catalog_docs.py -q Reference: PR 2563 feedback --- src/specify_cli/catalog_docs.py | 4 ++-- src/specify_cli/community_catalog_docs.py | 6 +++++- tests/test_community_catalog_docs.py | 20 +++++++++++++++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index 5020ae173c..e80cd2a30e 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -131,13 +131,14 @@ def list_integrations_for_docs( 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. """ + import warnings + registry = _get_integration_registry() registry_keys = set(registry) # Warn if there are integrations missing from INTEGRATION_DOC_URLS (when enabled) missing = sorted(registry_keys - set(INTEGRATION_DOC_URLS)) if missing and warn_on_missing: - import warnings warnings.warn( f"Integration(s) missing from INTEGRATION_DOC_URLS: " f"{', '.join(missing)}. They will be included in the docs table " @@ -155,7 +156,6 @@ def list_integrations_for_docs( 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) ) diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py index e90b56e2f4..fea9d3314f 100644 --- a/src/specify_cli/community_catalog_docs.py +++ b/src/specify_cli/community_catalog_docs.py @@ -23,7 +23,11 @@ def _format_tags(tags: Any) -> str: 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())] + cleaned = [ + f"`{c}`" + for tag in tags + if (c := str(tag).replace("|", "").replace("\r\n", " ").replace("\r", " ").replace("\n", " ").strip()) + ] return ", ".join(cleaned) if cleaned else "—" diff --git a/tests/test_community_catalog_docs.py b/tests/test_community_catalog_docs.py index 7abb14d1d1..5da1c728b5 100644 --- a/tests/test_community_catalog_docs.py +++ b/tests/test_community_catalog_docs.py @@ -52,7 +52,7 @@ def test_community_extensions_are_sorted_by_name() -> None: def test_missing_catalog_file(tmp_path: Path) -> None: with pytest.raises( FileNotFoundError, - match="Ensure the repository checkout includes the extensions/ directory", + match="Provide path= to a valid community catalog JSON file", ): list_community_extensions(path=tmp_path / "missing.json") @@ -123,6 +123,24 @@ def test_tags_containing_pipe_do_not_break_table(tmp_path: Path) -> None: assert foo_row.count("|") == 6 +def test_tags_with_newlines_are_normalized(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": { + "name": "Foo", + "description": "", + "tags": ["foo\nbar", "baz\r\nqux", "quux\rquuz"], + "verified": False, + "repository": "", + }, + }) + table = render_community_extensions_table(path=f) + assert "`foo bar`" in table + assert "`baz qux`" in table + assert "`quux quuz`" in table + foo_row = next(line for line in table.split("\n") if line.startswith("| ") and "Foo" in line) + assert foo_row.count("|") == 6 + + def test_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": ""}, From 54c0e410069cdd8c971f7a4524efedd6ef8951a9 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Thu, 28 May 2026 15:23:17 +0000 Subject: [PATCH 36/38] test: tighten community docs regression --- tests/test_community_catalog_docs.py | 53 ++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_community_catalog_docs.py b/tests/test_community_catalog_docs.py index 5da1c728b5..e7fac6e88a 100644 --- a/tests/test_community_catalog_docs.py +++ b/tests/test_community_catalog_docs.py @@ -45,6 +45,59 @@ def test_community_extensions_are_sorted_by_name() -> None: assert names == sorted(names, key=str.casefold) +def test_community_extensions_table_rows_are_rendered_in_sorted_order(tmp_path: Path) -> None: + catalog = _write_catalog( + tmp_path, + { + "gamma": { + "name": "Gamma", + "id": "gamma", + "description": "Third entry", + "tags": [], + "verified": False, + "repository": "", + }, + "alpha": { + "name": "Alpha", + "id": "alpha", + "description": "First entry", + "tags": [], + "verified": True, + "repository": "", + }, + "beta": { + "name": "Beta", + "id": "beta", + "description": "Second entry", + "tags": [], + "verified": False, + "repository": "", + }, + }, + ) + table = render_community_extensions_table(path=catalog) + + rendered_rows: list[tuple[str, str]] = [] + for line in table.splitlines(): + if not line.startswith("| "): + continue + if line == "| Extension | ID | Description | Tags | Verified |": + continue + if line == "| --- | --- | --- | --- | --- |": + continue + cells = [part.strip() for part in line.strip("|").split("|")] + assert len(cells) == 5, f"Malformed table row: {line}" + extension_cell = cells[0] + if extension_cell.startswith("[") and "](" in extension_cell: + extension_name = extension_cell[1:extension_cell.index("](")] + else: + extension_name = extension_cell + rendered_rows.append((extension_name, cells[1].strip("`"))) + + expected_rows = [("Alpha", "alpha"), ("Beta", "beta"), ("Gamma", "gamma")] + assert rendered_rows == expected_rows + + # --------------------------------------------------------------------------- # Edge-case tests using synthetic catalogs # --------------------------------------------------------------------------- From 224f86504cf1f071d582e0431e83f524e8f56b5b Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 7 Jul 2026 22:16:21 +0000 Subject: [PATCH 37/38] fix: add trailing commas to catalog docs warnings Assisted-by: GitHub Copilot (model: GPT-5, autonomous) --- src/specify_cli/catalog_docs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py index e80cd2a30e..daf0efad54 100644 --- a/src/specify_cli/catalog_docs.py +++ b/src/specify_cli/catalog_docs.py @@ -144,7 +144,7 @@ def list_integrations_for_docs( 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 + stacklevel=2, ) # Warn if there are stale keys in doc maps not in the registry (when enabled) @@ -164,7 +164,7 @@ def list_integrations_for_docs( f"{', '.join(stale_keys)}. Consider removing them from " "INTEGRATION_DOC_URLS, INTEGRATION_LABEL_OVERRIDES, and " "INTEGRATION_NOTES.", - stacklevel=2 + stacklevel=2, ) rows: list[tuple[str, str, str | None, str]] = [] From 47dcfb3e1c45b5a420ceba97a1d4adf5d6dd3d0f Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Wed, 8 Jul 2026 01:06:37 +0000 Subject: [PATCH 38/38] Fix integration docs and lint cleanup Assisted-by: GitHub Copilot (model: GPT-5, autonomous) --- docs/reference/integrations.md | 68 ++-- src/specify_cli/__init__.py | 351 ++---------------- .../integrations/_query_commands.py | 28 +- .../test_update_agent_context_feature_json.py | 1 - tests/test_catalog_docs.py | 32 +- 5 files changed, 121 insertions(+), 359 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 2f66f1b715..d0445598e9 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -4,38 +4,42 @@ 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 | +| 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` | +| Cline | `cline` | | +| [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-` | +| Firebender | `firebender` | | +| [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/` | +| Hermes Agent | `hermes` | | +| [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 | +| Oh My Pi | `omp` | | +| [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` | | +| RovoDev ACLI | `rovodev` | | +| [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` | | +| ZCode | `zcode` | | +| Zed | `zed` | | ## List Available Integrations diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 92fa51b2b9..c045becf23 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -30,6 +30,7 @@ import sys import json from pathlib import Path +from typing import Optional import typer from rich.panel import Panel @@ -511,9 +512,48 @@ def version( # Re-export selected helpers to preserve the public import surface. from .integrations._helpers import ( # noqa: E402 _clear_init_options_for_integration as _clear_init_options_for_integration, + _cli_error_detail as _cli_error_detail, + _cli_phase_label as _cli_phase_label, + _get_speckit_version as _get_speckit_version, + _MANIFEST_READ_ERRORS as _MANIFEST_READ_ERRORS, + _parse_integration_options as _parse_integration_options, + _read_integration_json as _read_integration_json, + _refresh_init_options_speckit_version as _refresh_init_options_speckit_version, + _register_extensions_for_agent as _register_extensions_for_agent, + _remove_integration_json as _remove_integration_json, + _resolve_integration_options as _resolve_integration_options, + _resolve_integration_script_type as _resolve_integration_script_type, + _resolve_script_type as _resolve_script_type, + _set_default_integration as _set_default_integration, + _set_default_integration_or_exit as _set_default_integration_or_exit, + _SharedTemplateRefreshError as _SharedTemplateRefreshError, + _unregister_extensions_for_agent as _unregister_extensions_for_agent, _update_init_options_for_integration as _update_init_options_for_integration, + _write_integration_json as _write_integration_json, ) from ._project import _resolve_init_dir_override as _resolve_init_dir_override # noqa: E402 +from .integration_runtime import ( # noqa: E402 + invoke_separator_for_integration as _invoke_separator_for_integration, + with_integration_setting as _with_integration_setting, +) +from .integration_state import ( # noqa: E402 + dedupe_integration_keys as _dedupe_integration_keys, + default_integration_key as _default_integration_key, + installed_integration_keys as _installed_integration_keys, + integration_settings as _integration_settings, +) + + +integration_app = typer.Typer( + name="integration", + help="Manage coding agent integrations", + add_completion=False, +) +integration_catalog_app = typer.Typer( + name="catalog", + help="Manage integration catalog sources", + add_completion=False, +) def _require_specify_project() -> Path: @@ -539,317 +579,6 @@ def _require_specify_project() -> Path: ) raise typer.Exit(1) -@integration_app.command("list") -def integration_list( - catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), -): - """List available integrations and installed status.""" - from .integrations import INTEGRATION_REGISTRY - - project_root = _require_specify_project() - current = _read_integration_json(project_root) - default_key = _default_integration_key(current) - installed_keys = set(_installed_integration_keys(current)) - - if catalog: - from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError - - ic = IntegrationCatalog(project_root) - try: - entries = ic.search() - except IntegrationCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - if not entries: - console.print("[yellow]No integrations found in catalog.[/yellow]") - return - - table = Table(title="Integration Catalog") - table.add_column("ID", style="cyan") - table.add_column("Name") - table.add_column("Version") - table.add_column("Source") - table.add_column("Status") - table.add_column("Multi-install Safe") - - for entry in sorted(entries, key=lambda e: e["id"]): - eid = entry["id"] - cat_name = entry.get("_catalog_name", "") - install_allowed = entry.get("_install_allowed", True) - if eid == default_key: - status = "[green]installed (default)[/green]" - elif eid in installed_keys: - status = "[green]installed[/green]" - elif eid in INTEGRATION_REGISTRY: - status = "built-in" - elif install_allowed is False: - status = "discovery-only" - else: - status = "" - safe = "" - if eid in INTEGRATION_REGISTRY: - safe = "yes" if getattr(INTEGRATION_REGISTRY[eid], "multi_install_safe", False) else "no" - table.add_row( - eid, - entry.get("name", eid), - entry.get("version", ""), - cat_name, - status, - safe, - ) - - console.print(table) - return - - table = Table(title="Coding Agent Integrations") - table.add_column("Key", style="cyan") - table.add_column("Name") - table.add_column("Status") - table.add_column("CLI Required") - table.add_column("Multi-install Safe") - - for key in sorted(INTEGRATION_REGISTRY.keys()): - integration = INTEGRATION_REGISTRY[key] - cfg = integration.config or {} - name = cfg.get("name", key) - requires_cli = cfg.get("requires_cli", False) - - if key == default_key: - status = "[green]installed (default)[/green]" - elif key in installed_keys: - status = "[green]installed[/green]" - else: - status = "" - - cli_req = "yes" if requires_cli else "no (IDE)" - safe = "yes" if getattr(integration, "multi_install_safe", False) else "no" - table.add_row(key, name, status, cli_req, safe) - - console.print(table) - - if installed_keys: - console.print(f"\n[dim]Default integration:[/dim] [cyan]{default_key or 'none'}[/cyan]") - console.print(f"[dim]Installed integrations:[/dim] [cyan]{', '.join(sorted(installed_keys))}[/cyan]") - else: - console.print("\n[yellow]No integration currently installed.[/yellow]") - console.print("Install one with: [cyan]specify integration install [/cyan]") - - -@integration_app.command("install") -def integration_install( - key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"), - script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), - force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"), - integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), -): - """Install an integration into an existing project.""" - from .integrations import INTEGRATION_REGISTRY, get_integration - from .integrations.manifest import IntegrationManifest - - project_root = _require_specify_project() - integration = get_integration(key) - if integration is None: - console.print(f"[red]Error:[/red] Unknown integration '{key}'") - available = ", ".join(sorted(INTEGRATION_REGISTRY.keys())) - console.print(f"Available integrations: {available}") - raise typer.Exit(1) - - current = _read_integration_json(project_root) - default_key = _default_integration_key(current) - installed_keys = _installed_integration_keys(current) - - if key in installed_keys: - console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]") - if default_key == key: - console.print("It is already the default integration.") - else: - console.print( - f"To make it the default integration, run " - f"[cyan]specify integration use {key}[/cyan]." - ) - console.print( - f"To refresh its managed files or options, run " - f"[cyan]specify integration upgrade {key}[/cyan]." - ) - console.print("No files were changed.") - raise typer.Exit(0) - - if installed_keys and not force: - unsafe_keys = [] - for installed_key in installed_keys: - installed_integration = get_integration(installed_key) - if not installed_integration or not getattr(installed_integration, "multi_install_safe", False): - unsafe_keys.append(installed_key) - if unsafe_keys or not getattr(integration, "multi_install_safe", False): - console.print( - f"[red]Error:[/red] Installed integrations: {', '.join(installed_keys)}." - ) - if default_key: - console.print(f"Default integration: [cyan]{default_key}[/cyan].") - console.print( - "Installing multiple integrations is only automatic when all involved " - "integrations are declared multi-install safe." - ) - console.print( - f"To replace the default integration, run " - f"[cyan]specify integration switch {key}[/cyan]." - ) - console.print( - f"To install '{key}' alongside the existing integrations anyway, " - "retry the same install command with [cyan]--force[/cyan]." - ) - raise typer.Exit(1) - - selected_script = _resolve_script_type(project_root, script) - - # Build parsed options from --integration-options so the integration - # can determine its effective invoke separator before shared infra - # is installed. - raw_options, parsed_options = _resolve_integration_options( - integration, current, key, integration_options - ) - - # Ensure shared infrastructure is present (safe to run unconditionally; - # _install_shared_infra merges missing files without overwriting). - infra_integration = integration - infra_key = key - infra_parsed = parsed_options - if default_key: - default_integration = get_integration(default_key) - if default_integration is not None: - infra_integration = default_integration - infra_key = default_key - _, infra_parsed = _resolve_integration_options( - default_integration, current, default_key, None - ) - _install_shared_infra_or_exit( - project_root, - selected_script, - invoke_separator=_invoke_separator_for_integration( - infra_integration, current, infra_key, infra_parsed - ), - ) - if os.name != "nt": - ensure_executable_scripts(project_root) - - manifest = IntegrationManifest( - integration.key, project_root, version=get_speckit_version() - ) - - try: - integration.setup( - project_root, manifest, - parsed_options=parsed_options, - script_type=selected_script, - raw_options=raw_options, - ) - manifest.save() - new_installed = _dedupe_integration_keys([*installed_keys, integration.key]) - new_default = default_key or integration.key - settings = _with_integration_setting( - current, - integration.key, - integration, - script_type=selected_script, - raw_options=raw_options, - parsed_options=parsed_options, - ) - _write_integration_json(project_root, new_default, new_installed, settings) - if new_default == integration.key: - _update_init_options_for_integration(project_root, integration, script_type=selected_script) - - except Exception as e: - # Attempt rollback of any files written by setup - try: - integration.teardown(project_root, manifest, force=True) - except Exception as rollback_err: - # Suppress so the original setup error remains the primary failure - console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration changes: {rollback_err}") - if installed_keys: - _write_integration_json( - project_root, default_key, installed_keys, _integration_settings(current) - ) - else: - _remove_integration_json(project_root) - console.print(f"[red]Error:[/red] Failed to install integration: {e}") - raise typer.Exit(1) - - name = (integration.config or {}).get("name", key) - console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully") - if default_key: - console.print(f"[dim]Default integration remains:[/dim] [cyan]{default_key}[/cyan]") - - -def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None: - """Parse --integration-options string into a dict matching the integration's declared options. - - Returns ``None`` when no options are provided. - """ - import shlex - parsed: dict[str, Any] = {} - tokens = shlex.split(raw_options) - declared_options = list(integration.options()) - declared = {opt.name.lstrip("-"): opt for opt in declared_options} - allowed = ", ".join(sorted(opt.name for opt in declared_options)) - i = 0 - while i < len(tokens): - token = tokens[i] - if not token.startswith("-"): - console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.") - if allowed: - console.print(f"Allowed options: {allowed}") - raise typer.Exit(1) - name = token.lstrip("-") - value: str | None = None - # Handle --name=value syntax - if "=" in name: - name, value = name.split("=", 1) - opt = declared.get(name) - if not opt: - console.print(f"[red]Error:[/red] Unknown integration option '{token}'.") - if allowed: - console.print(f"Allowed options: {allowed}") - raise typer.Exit(1) - key = name.replace("-", "_") - if opt.is_flag: - if value is not None: - console.print(f"[red]Error:[/red] Option '{opt.name}' is a flag and does not accept a value.") - raise typer.Exit(1) - parsed[key] = True - i += 1 - elif value is not None: - parsed[key] = value - i += 1 - elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"): - parsed[key] = tokens[i + 1] - i += 2 - else: - console.print(f"[red]Error:[/red] Option '{opt.name}' requires a value.") - raise typer.Exit(1) - return parsed or None - - -def _update_init_options_for_integration( - project_root: Path, - integration: Any, - script_type: str | None = None, -) -> None: - """Update ``init-options.json`` to reflect *integration* as the active one.""" - from .integrations.base import SkillsIntegration - opts = load_init_options(project_root) - opts["integration"] = integration.key - opts["ai"] = integration.key - opts["context_file"] = integration.context_file - if script_type: - opts["script"] = script_type - if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): - opts["ai_skills"] = True - else: - opts.pop("ai_skills", None) - save_init_options(project_root, opts) - - @integration_app.command("use") def integration_use( key: str = typer.Argument(help="Installed integration key to make the default"), diff --git a/src/specify_cli/integrations/_query_commands.py b/src/specify_cli/integrations/_query_commands.py index bb47e6142e..270ca786c3 100644 --- a/src/specify_cli/integrations/_query_commands.py +++ b/src/specify_cli/integrations/_query_commands.py @@ -265,8 +265,34 @@ def integration_search( query: Optional[str] = typer.Argument(None, help="Search query (optional)"), tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), + markdown: bool = typer.Option( + False, + "--markdown", + help=( + "Output the full built-in integrations table as markdown " + "(ignores query and --tag/--author filters)" + ), + ), ): - """Search for integrations in the active catalog stack.""" + """Search for integrations in the active catalog stack. + + Or output the built-in reference table with --markdown. + """ + if markdown: + if query or tag or author: + typer.echo( + "Warning: --markdown outputs the full built-in integrations table " + "and ignores query/--tag/--author filters.", + err=True, + ) + from ..catalog_docs import render_integrations_table + try: + typer.echo(render_integrations_table(), nl=False) + except (FileNotFoundError, ValueError) as exc: + typer.echo(f"Error rendering integrations table: {exc}", err=True) + raise typer.Exit(code=1) from exc + return + from . import INTEGRATION_REGISTRY from .catalog import ( IntegrationCatalog, diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py index 957415708c..25b5ff9457 100644 --- a/tests/extensions/test_update_agent_context_feature_json.py +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -11,7 +11,6 @@ from tests.conftest import requires_bash from tests.extensions.test_extension_agent_context import ( - BASH, POWERSHELL, _bash_posix_path, _run_bash_agent_context_script, diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py index 74ee0f3a2e..00b90117a2 100644 --- a/tests/test_catalog_docs.py +++ b/tests/test_catalog_docs.py @@ -3,7 +3,6 @@ from __future__ import annotations from contextlib import ExitStack, contextmanager -import re from unittest.mock import MagicMock, patch import pytest @@ -91,27 +90,18 @@ def test_integrations_reference_doc_matches_renderer(): committed_table = doc_text[start:end].rstrip("\n") rendered_table = render_integrations_table().rstrip("\n") - def parse_table(table: str) -> list[list[str]]: + def normalize_table(table: str) -> list[list[str]]: rows: list[list[str]] = [] for line in table.splitlines(): if not line.startswith("| "): continue - parts = [ - part.strip() - for part in re.split(r"(?