diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 1280dd6083..e31330361d 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -4,43 +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` | Defaults to legacy markdown mode: `.agent.md` command files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. Pass `--integration-options="--skills"` to scaffold skills as `speckit-/SKILL.md` under `.github/skills/` instead. Legacy markdown mode is deprecated and will stop being the default in a future release. | -| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | -| [Grok Build](https://docs.x.ai/build/overview) | `grok` | Skills-based integration; installs skills into `.grok/skills` and invokes them as `/speckit-` | -| [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 | +| 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](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-` | +| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Defaults to legacy markdown mode: `.agent.md` command files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. Pass `--integration-options="--skills"` to scaffold skills as `speckit-/SKILL.md` under `.github/skills/` instead. Legacy markdown mode is deprecated and will stop being the default in a future release. | +| [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` | | +| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir "` for AI coding agents not listed above | +| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | +| [Grok Build](https://docs.x.ai/build/overview) | `grok` | Skills-based integration; installs skills into `.grok/skills` and invokes them as `/speckit-` | +| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` | +| [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](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 ACLI](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 | +| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | +| [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-` | ## List Available Integrations diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py new file mode 100644 index 0000000000..4930337ab9 --- /dev/null +++ b/src/specify_cli/catalog_docs.py @@ -0,0 +1,254 @@ +"""Helpers for rendering the built-in integrations reference table.""" + +from __future__ import annotations + +from typing import Any + +from ._assets import _repo_root + + +ROOT_DIR = _repo_root() +INTEGRATIONS_REFERENCE_PATH = ROOT_DIR / "docs" / "reference" / "integrations.md" + + +INTEGRATION_DOC_URLS: dict[str, str | None] = { + "amp": "https://ampcode.com/", + "agy": "https://antigravity.google/", + "auggie": "https://docs.augmentcode.com/cli/overview", + "bob": "https://www.ibm.com/products/bob", + "claude": "https://www.anthropic.com/claude-code", + "cline": "https://github.com/cline/cline", + "codebuddy": "https://www.codebuddy.cn/docs/cli/installation", + "codex": "https://github.com/openai/codex", + "copilot": "https://code.visualstudio.com/", + "cursor-agent": "https://cursor.sh/", + "devin": "https://cli.devin.ai/docs", + "firebender": "https://firebender.com/", + "forge": "https://forgecode.dev/", + "gemini": "https://github.com/google-gemini/gemini-cli", + "generic": None, + "goose": "https://goose-docs.ai/", + "grok": "https://docs.x.ai/build/overview", + "hermes": "https://github.com/NousResearch/hermes-agent", + "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/", + "omp": "https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent", + "opencode": "https://opencode.ai/", + "pi": "https://pi.dev", + "qodercli": "https://qoder.com/cli", + "qwen": "https://github.com/QwenLM/qwen-code", + "rovodev": "https://www.atlassian.com/software/rovo-dev", + "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", + "zcode": "https://zcode.z.ai/", + "zed": "https://zed.dev/", +} + +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`", + "cline": "IDE-based agent", + "codex": ( + "Skills-based integration; installs skills into `.agents/skills` " + "and invokes them as `$speckit-`" + ), + "copilot": ( + "Defaults to legacy markdown mode: `.agent.md` command files under " + "`.github/agents/`, companion `.prompt.md` files under " + "`.github/prompts/`, and a `.vscode/settings.json` merge. Pass " + "`--integration-options=\"--skills\"` to scaffold skills as " + "`speckit-/SKILL.md` under `.github/skills/` instead. " + "Legacy markdown mode is deprecated and will stop being the default " + "in a future release." + ), + "bob": "IDE-based agent", + "devin": ( + "Skills-based integration; installs skills into `.devin/skills/` " + "and invokes them as `/speckit-`" + ), + "firebender": "IDE-based agent for Android Studio / IntelliJ", + "goose": "Uses YAML recipe format in `.goose/recipes/`", + "grok": ( + "Skills-based integration; installs skills into `.grok/skills` " + "and invokes them as `/speckit-`" + ), + "hermes": ( + "Skills-based integration; installs skills globally into " + "`~/.hermes/skills/`" + ), + "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", + "omp": "Installs slash commands into `.omp/commands`", + "rovodev": ( + "Generates `.rovodev/skills/`, prompt wrappers, and `prompts.yml`; " + "runtime dispatch uses `acli rovodev`" + ), + "zcode": ( + "Skills-based integration; installs skills into `.zcode/skills` " + "and invokes them as `$speckit-`" + ), + "zed": ( + "Skills-based integration; installs skills into `.agents/skills` " + "and invokes them as `/speckit-`" + ), + "pi": ( + "Pi doesn't have MCP support out of the box, so `taskstoissues` " + "won't work as intended. MCP support can be added via " + "[extensions](https://github.com/badlogic/pi-mono/tree/main/" + "packages/coding-agent#extensions)" + ), + "generic": ( + "Bring your own agent — use `--integration generic " + "--integration-options=\"--commands-dir \"` " + "for AI coding agents not listed above" + ), + "trae": "Skills-based integration; skills are installed automatically", +} + + +def render_cell(value: str) -> str: + r"""Escape markdown special characters (pipes) and normalize newlines to spaces. + + This ensures table cells remain valid markdown even if they contain + pipes (escaped as \|) or carriage returns (normalized to spaces). + """ + value = value.replace("\r\n", " ").replace("\r", " ").replace("\n", " ") + return value.replace("|", "\\|") + + +def escape_url_for_markdown_link(url: str) -> str: + """Escape characters that can break Markdown link syntax. + + Removes line breaks and escapes `)` and `|` which can terminate or corrupt + the link destination. + """ + normalized = url.replace("\r\n", "").replace("\r", "").replace("\n", "") + return normalized.replace(")", "\\)").replace("|", "\\|") + + +def escape_markdown_link_text(text: str) -> str: + """Escape characters that can break Markdown link text.""" + return text.replace("[", "\\[").replace("]", "\\]") + + +def _get_integration_registry() -> dict[str, Any]: + from specify_cli.integrations import INTEGRATION_REGISTRY + + return INTEGRATION_REGISTRY + + +def list_integrations_for_docs( + warn_on_missing: bool = False, + warn_on_extra: bool = False, +) -> list[tuple[str, str, str | None, str]]: + """List all integrations with their documentation URLs and notes. + + Returns all integrations in the registry. Missing entries in + INTEGRATION_DOC_URLS fall back to the integration's install_url; 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. + """ + 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: + warnings.warn( + f"Integration(s) missing from INTEGRATION_DOC_URLS: " + f"{', '.join(missing)}. Their install_url values will be used when " + "available. Add them to INTEGRATION_DOC_URLS in catalog_docs.py to " + "override or suppress those links.", + 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: + stale_keys = sorted( + set(extra_in_urls + extra_in_labels + extra_in_notes) + ) + warnings.warn( + f"Stale key(s) found in doc maps (no longer in registry): " + f"{', '.join(stale_keys)}. Consider removing them from " + "INTEGRATION_DOC_URLS, INTEGRATION_LABEL_OVERRIDES, and " + "INTEGRATION_NOTES.", + stacklevel=2, + ) + + rows: list[tuple[str, str, str | None, str]] = [] + + for key, integration in registry.items(): + config = getattr(integration, "config", {}) + if not isinstance(config, dict): + config = {} + label = INTEGRATION_LABEL_OVERRIDES.get(key, str(config.get("name") or key)) + if key in INTEGRATION_DOC_URLS: + url = INTEGRATION_DOC_URLS[key] + else: + install_url = config.get("install_url") + url = str(install_url) if install_url else None + notes = INTEGRATION_NOTES.get(key, "") + rows.append((key, label, url, notes)) + + return sorted(rows, key=lambda r: r[0]) + + +def render_integrations_table() -> str: + """Render the built-in integrations reference table as markdown.""" + table_rows: list[list[str]] = [] + + for key, label, url, notes in list_integrations_for_docs(): + # Escape raw field values *before* composing Markdown syntax so that + # a pipe inside a label or notes doesn't break a link target. + safe_label = escape_markdown_link_text(render_cell(label)) + safe_notes = render_cell(notes) + safe_url = escape_url_for_markdown_link(url) if url else None + agent = ( + f"[{safe_label}]({safe_url})" + if safe_url + else safe_label + ) + table_rows.append([agent, f"`{render_cell(key)}`", safe_notes]) + + headers = ("Agent", "Key", "Notes") + + def render_row(values: list[str]) -> str: + # Values are already escaped; do not re-apply render_cell here. + return "| " + " | ".join(values) + " |" + + separator = "| " + " | ".join("---" for _ in headers) + " |" + lines = [render_row(list(headers)), separator] + lines.extend(render_row(row) for row in table_rows) + return "\n".join(lines) + "\n" diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py new file mode 100644 index 0000000000..eb2dec602b --- /dev/null +++ b/src/specify_cli/community_catalog_docs.py @@ -0,0 +1,113 @@ +"""Helpers for rendering the community extensions reference table.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from ._assets import _repo_root +from .catalog_docs import ( + escape_markdown_link_text, + escape_url_for_markdown_link, + render_cell, +) + + +ROOT_DIR = _repo_root() +COMMUNITY_CATALOG_PATH = ROOT_DIR / "extensions" / "catalog.community.json" + + + +def 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(): + 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") + 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 ""), + "category": str(ext.get("category") or "").strip(), + "effect": str(ext.get("effect") or "").strip(), + "repository": str(ext.get("repository") or "").strip(), + } + ) + + return sorted( + rows, + key=lambda row: ( + row["name"].lstrip(". ").casefold(), + row["id"].casefold(), + ), + ) + + +def render_community_extensions_table(path: Path = COMMUNITY_CATALOG_PATH) -> str: + """Render the community extensions table from catalog.community.json.""" + rows = list_community_extensions(path=path) + if not rows: + raise ValueError("Community catalog has no extensions") + + table_rows: list[list[str]] = [] + for row in rows: + # Escape raw field values *before* composing Markdown syntax so that + # a pipe inside a name or description doesn't break a link target. + safe_name = render_cell(row["name"]) + safe_description = render_cell(row["description"]) + category = render_cell(row["category"]) + category_cell = f"`{category}`" if category else "—" + effect = { + "read-only": "Read-only", + "read-write": "Read+Write", + }.get(row["effect"], render_cell(row["effect"]) or "—") + repository = row["repository"] + if repository: + safe_repo = escape_url_for_markdown_link(repository) + safe_id = escape_markdown_link_text(render_cell(row["id"])) + link = f"[{safe_id}]({safe_repo})" + else: + link = render_cell(row["id"]) or "—" + table_rows.append( + [ + safe_name, + safe_description, + category_cell, + effect, + link, + ] + ) + + headers = ("Extension", "Purpose", "Category", "Effect", "URL") + + def render_row(values: list[str]) -> str: + # Values are already escaped; do not re-apply render_cell here. + return "| " + " | ".join(values) + " |" + + separator = "| " + " | ".join("---" for _ in headers) + " |" + lines = [render_row(list(headers)), separator] + lines.extend(render_row(row) for row in table_rows) + return "\n".join(lines) + "\n" diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 4494e15114..6e580b3dc0 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -206,8 +206,29 @@ def _resolve_catalog_extension( def extension_list( available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"), all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"), + markdown: bool = typer.Option( + False, + "--markdown", + help="Output the full community extensions reference table as Markdown", + ), ): - """List installed extensions.""" + """List installed extensions or render the community reference table.""" + if markdown: + if available or all_extensions: + typer.echo( + "Warning: --markdown outputs the full community extensions table " + "and ignores --available/--all.", + err=True, + ) + from ..community_catalog_docs import render_community_extensions_table + + try: + typer.echo(render_community_extensions_table(), nl=False) + except (FileNotFoundError, ValueError) as exc: + typer.echo(f"Error rendering community extensions table: {exc}", err=True) + raise typer.Exit(code=1) from exc + return + from . import ExtensionManager project_root = _require_specify_project() 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 new file mode 100644 index 0000000000..8b9ed304d1 --- /dev/null +++ b/tests/test_catalog_docs.py @@ -0,0 +1,295 @@ +"""Tests for the integration registry documentation generation.""" + +from __future__ import annotations + +from contextlib import ExitStack, contextmanager +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, + INTEGRATION_DOC_URLS, + INTEGRATION_NOTES, + render_cell, + list_integrations_for_docs, + render_integrations_table, +) +from specify_cli import app + + +runner = CliRunner() + + +@contextmanager +def _get_catalog_docs_patches( + *, + fake_registry=None, + fake_doc_urls=None, + fake_label_overrides=None, + fake_notes=None, +): + """Context manager that applies mocked registry and doc maps for tests.""" + + if fake_registry is None: + fake_registry = { + "copilot": MagicMock(config={"name": "GitHub Copilot"}), + "codex": MagicMock(config={"name": "Codex CLI"}), + } + if fake_doc_urls is None: + fake_doc_urls = { + "copilot": "https://code.visualstudio.com/", + "codex": "https://github.com/openai/codex", + } + if fake_label_overrides is None: + fake_label_overrides = {} + if fake_notes is None: + fake_notes = {"copilot": "Test note"} + + with ExitStack() as stack: + stack.enter_context( + patch( + "specify_cli.catalog_docs._get_integration_registry", + return_value=fake_registry, + ) + ) + stack.enter_context( + patch("specify_cli.catalog_docs.INTEGRATION_DOC_URLS", fake_doc_urls) + ) + stack.enter_context( + patch( + "specify_cli.catalog_docs.INTEGRATION_LABEL_OVERRIDES", + fake_label_overrides, + ) + ) + stack.enter_context( + patch("specify_cli.catalog_docs.INTEGRATION_NOTES", fake_notes) + ) + yield + + +def test_integrations_table_renders(): + table = render_integrations_table() + lines = table.splitlines() + assert lines[0] == "| Agent | Key | Notes |" + assert lines[1] == "| --- | --- | --- |" + + +def test_integrations_reference_doc_matches_renderer(): + if not INTEGRATIONS_REFERENCE_PATH.exists(): + pytest.skip( + f"Integrations reference not found at {INTEGRATIONS_REFERENCE_PATH}. " + "Skipping (expected when running from sdist/wheel)." + ) + doc_text = INTEGRATIONS_REFERENCE_PATH.read_text(encoding="utf-8") + start_marker = "## Supported AI Coding Agents\n\n" + end_marker = "\n## List Available Integrations\n" + start = doc_text.index(start_marker) + len(start_marker) + end = doc_text.index(end_marker) + committed_table = doc_text[start:end].rstrip("\n") + rendered_table = render_integrations_table().rstrip("\n") + + def 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 line.strip().strip("|").split("|")] + if parts and set(parts[0]) == {"-"}: + continue + rows.append(parts) + return rows + + assert normalize_table(committed_table) == normalize_table(rendered_table) + + +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_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_escape_url_for_markdown_link_removes_line_breaks(): + assert escape_url_for_markdown_link("https://example.com/pa\r\nth\n") == ( + "https://example.com/path" + ) + + +def test_missing_doc_url_falls_back_to_install_url(): + fake_registry = { + "example": MagicMock( + config={ + "name": "Example", + "install_url": "https://example.com/install", + } + ), + } + with _get_catalog_docs_patches( + fake_registry=fake_registry, + fake_doc_urls={}, + fake_notes={}, + ): + rows = list_integrations_for_docs() + + assert rows == [ + ("example", "Example", "https://example.com/install", ""), + ] + + +def test_explicit_none_doc_url_suppresses_install_url(): + fake_registry = { + "example": MagicMock( + config={ + "name": "Example", + "install_url": "https://example.com/install", + } + ), + } + with _get_catalog_docs_patches( + fake_registry=fake_registry, + fake_doc_urls={"example": None}, + fake_notes={}, + ): + rows = list_integrations_for_docs() + + assert rows == [("example", "Example", None, "")] + + +def test_escape_markdown_link_text(): + assert escape_markdown_link_text("Code [Buddy]") == "Code \\[Buddy\\]" + + +def test_doc_url_map_matches_registry_keys(): + from specify_cli.integrations import INTEGRATION_REGISTRY + + assert set(INTEGRATION_DOC_URLS) == set(INTEGRATION_REGISTRY) + + +def test_notes_preserve_hand_maintained_integration_guidance(): + expected_keys = { + "cline", + "copilot", + "firebender", + "grok", + "hermes", + "omp", + "rovodev", + "zcode", + "zed", + } + + assert expected_keys <= set(INTEGRATION_NOTES) + + +def test_integrations_docs_label_and_url_sources(): + """Test using mocked registry/doc maps to avoid test brittleness.""" + with _get_catalog_docs_patches(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" + + +def test_cli_integration_search_markdown_success(): + """Test that `integration search --markdown` outputs the markdown table.""" + 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] == "| --- | --- | --- |" + + +def test_render_integrations_table_escapes_link_text(): + fake_registry = { + "bracket": MagicMock(config={"name": "Code [Buddy]"}), + } + fake_doc_urls = { + "bracket": "https://example.com/docs", + } + + with _get_catalog_docs_patches( + fake_registry=fake_registry, + fake_doc_urls=fake_doc_urls, + fake_notes={}, + ): + table = render_integrations_table() + + assert "[Code \\[Buddy\\]](https://example.com/docs)" in table + + +def test_cli_integration_search_markdown_with_filters_warns(): + """Test that `integration search --markdown` with filters warns.""" + with _get_catalog_docs_patches(): + result = runner.invoke( + app, + [ + "integration", + "search", + "test-query", + "--markdown", + "--tag", + "some-tag", + ], + ) + assert result.exit_code == 0 + # Check for the specific Typer warning message + assert "ignores query/--tag/--author filters" in result.stderr + lines = result.stdout.splitlines() + assert lines[0] == "| Agent | Key | Notes |" + + +def test_cli_integration_search_markdown_stdout_is_clean(): + """Test that stdout contains only the markdown table with proper format.""" + with _get_catalog_docs_patches(): + result = runner.invoke(app, ["integration", "search", "--markdown"]) + assert result.exit_code == 0 + stdout = result.stdout + lines = stdout.splitlines() + # Verify markdown table header is present + assert len(lines) > 1 + assert lines[0] == "| Agent | Key | Notes |" + # Ensure stderr has no error messages + assert "error" not in result.stderr.lower() + + +def test_cli_integration_search_markdown_failure_exits_nonzero(): + """Test that render failures return a clean non-zero exit.""" + with _get_catalog_docs_patches(): + with patch( + "specify_cli.catalog_docs.render_integrations_table", + side_effect=ValueError("boom"), + ): + result = runner.invoke(app, ["integration", "search", "--markdown"]) + + assert result.exit_code == 1 + assert "Error rendering integrations table: boom" in result.stderr + assert result.stdout == "" diff --git a/tests/test_community_catalog_docs.py b/tests/test_community_catalog_docs.py new file mode 100644 index 0000000000..70ffd5a510 --- /dev/null +++ b/tests/test_community_catalog_docs.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from specify_cli import app + +from specify_cli.community_catalog_docs import list_community_extensions, render_community_extensions_table + + +runner = CliRunner() + + +def _write_catalog(tmp_path: Path, extensions: dict) -> Path: + p = tmp_path / "catalog.community.json" + p.write_text(json.dumps({"extensions": extensions}), encoding="utf-8") + return p + + +# --------------------------------------------------------------------------- +# Happy-path tests against the real catalog +# --------------------------------------------------------------------------- + +def test_community_extensions_table_renders() -> None: + from specify_cli.community_catalog_docs import COMMUNITY_CATALOG_PATH + if not COMMUNITY_CATALOG_PATH.exists(): + pytest.skip( + f"Community catalog not found at {COMMUNITY_CATALOG_PATH}. " + "Skipping (expected when running from sdist/wheel)." + ) + table = render_community_extensions_table() + assert "| Extension" in table + assert "| Purpose" in table + assert "| Category" in table + assert "| Effect" in table + assert "| URL" in table + + +def test_cli_extension_list_markdown_success() -> None: + result = runner.invoke(app, ["extension", "list", "--markdown"]) + + assert result.exit_code == 0 + lines = result.stdout.splitlines() + assert lines[0] == "| Extension | Purpose | Category | Effect | URL |" + assert lines[1] == "| --- | --- | --- | --- | --- |" + assert result.stderr == "" + + +def test_cli_extension_list_markdown_warns_when_list_filters_are_present() -> None: + result = runner.invoke( + app, + ["extension", "list", "--markdown", "--available"], + ) + + assert result.exit_code == 0 + assert "ignores --available/--all" in result.stderr + assert result.stdout.startswith("| Extension | Purpose | Category | Effect | URL |") + + +def test_cli_extension_list_markdown_failure_exits_nonzero() -> None: + with patch( + "specify_cli.community_catalog_docs.render_community_extensions_table", + side_effect=ValueError("boom"), + ): + result = runner.invoke(app, ["extension", "list", "--markdown"]) + + assert result.exit_code == 1 + assert "Error rendering community extensions table: boom" in result.stderr + assert result.stdout == "" + + +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=lambda name: name.lstrip(". ").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 | Purpose | Category | Effect | URL |": + 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[4])) + + expected_rows = [("Alpha", "alpha"), ("Beta", "beta"), ("Gamma", "gamma")] + assert rendered_rows == expected_rows + + +# --------------------------------------------------------------------------- +# Edge-case tests using synthetic catalogs +# --------------------------------------------------------------------------- + +def test_missing_catalog_file(tmp_path: Path) -> None: + with pytest.raises( + FileNotFoundError, + match="Provide path= to a valid community catalog JSON file", + ): + list_community_extensions(path=tmp_path / "missing.json") + + +def test_malformed_json(tmp_path: Path) -> None: + bad = tmp_path / "bad.json" + bad.write_text("not valid json", encoding="utf-8") + with pytest.raises(json.JSONDecodeError): + list_community_extensions(path=bad) + + +def test_non_dict_root(tmp_path: Path) -> None: + f = tmp_path / "catalog.json" + f.write_text(json.dumps([{"id": "foo"}]), encoding="utf-8") + with pytest.raises(ValueError, match="JSON object"): + list_community_extensions(path=f) + + +def test_missing_extensions_key(tmp_path: Path) -> None: + f = tmp_path / "catalog.json" + f.write_text(json.dumps({"other": {}}), encoding="utf-8") + with pytest.raises(ValueError, match="'extensions' object"): + list_community_extensions(path=f) + + +def test_non_dict_extension_value(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, {"foo": "not-a-dict"}) + with pytest.raises(ValueError, match="must be a mapping"): + list_community_extensions(path=f) + + +def test_empty_catalog_raises(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, {}) + with pytest.raises(ValueError, match="no extensions"): + render_community_extensions_table(path=f) + + +def test_extension_without_repository(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": {"name": "Foo", "id": "foo", "description": "A foo tool", "tags": [], "verified": False, "repository": ""}, + }) + table = render_community_extensions_table(path=f) + assert "Foo" in table + assert "[Foo](" not in table # plain name, no link + + +def test_whitespace_repository_is_treated_as_missing(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": {"name": "Foo", "id": "foo", "description": "", "tags": [], "verified": False, "repository": " "}, + }) + table = render_community_extensions_table(path=f) + assert "Foo" in table + assert "[Foo](" not in table + + +def test_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 r"[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", + "id": "code[buddy]", + "description": "", + "tags": [], + "verified": False, + "repository": "https://example.com/repo", + }, + }) + table = render_community_extensions_table(path=f) + assert r"[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": { + "name": "Foo", + "id": "foo|bar\n", + "description": "", + "tags": [], + "verified": False, + "repository": "", + }, + }) + table = render_community_extensions_table(path=f) + assert "foo\\|bar " in table