Skip to content
3 changes: 3 additions & 0 deletions src/specify_cli/_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ def _repo_root() -> Path:
return Path(__file__).parent.parent.parent


ROOT_DIR = _repo_root()


def _locate_bundled_extension(extension_id: str) -> Path | None:
"""Return the path to a bundled extension, or None.

Expand Down
243 changes: 243 additions & 0 deletions src/specify_cli/catalog_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
"""Helpers for rendering the built-in integrations reference table."""

from __future__ import annotations

import re
from typing import Any

from ._assets import ROOT_DIR

INTEGRATIONS_REFERENCE_PATH = ROOT_DIR / "docs" / "reference" / "integrations.md"


INTEGRATION_DOC_URLS: dict[str, str | None] = {
"amp": "https://ampcode.com/",
"agy": "https://antigravity.google/",
"auggie": "https://docs.augmentcode.com/cli/overview",
"bob": "https://www.ibm.com/products/bob",
"claude": "https://www.anthropic.com/claude-code",
"codebuddy": "https://www.codebuddy.ai/cli",
"codex": "https://github.com/openai/codex",
"copilot": "https://code.visualstudio.com/",
"cursor-agent": "https://cursor.sh/",
"devin": "https://cli.devin.ai/docs",
"forge": "https://forgecode.dev/",
"gemini": "https://github.com/google-gemini/gemini-cli",
"generic": None,
"goose": "https://block.github.io/goose/",
"iflow": "https://docs.iflow.cn/en/cli/quickstart",
"junie": "https://junie.jetbrains.com/",
"kilocode": "https://github.com/Kilo-Org/kilocode",
"kimi": "https://code.kimi.com/",
"kiro-cli": "https://kiro.dev/docs/cli/",
"lingma": "https://lingma.aliyun.com/",
"opencode": "https://opencode.ai/",
"pi": "https://pi.dev",
"qodercli": "https://qoder.com/cli",
"qwen": "https://github.com/QwenLM/qwen-code",
"roo": "https://roocode.com/",
"shai": "https://github.com/ovh/shai",
"tabnine": "https://docs.tabnine.com/main/getting-started/tabnine-cli",
"trae": "https://www.trae.ai/",
"vibe": "https://github.com/mistralai/mistral-vibe",
"windsurf": "https://windsurf.com/",
"zed": "https://zed.dev/",
}

INTEGRATION_LABEL_OVERRIDES: dict[str, str] = {
"agy": "Antigravity (agy)",
"codebuddy": "CodeBuddy CLI",
"hermes": "Hermes",
"rovodev": "RovoDev",
"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-<command>`"
),
"bob": "IDE-based agent",
"devin": (
"Skills-based integration; installs skills into `.devin/skills/` "
"and invokes them as `/speckit-<command>`"
),
"firebender": "IDE-based agent for Android Studio / IntelliJ",
"goose": "Uses YAML recipe format in `.goose/recipes/`",
"hermes": (
"Skills-based integration; installs skills globally into "
"`~/.hermes/skills/`"
),
"kimi": (
"Skills-based integration; installs into `.kimi-code/skills/`. "
"`--migrate-legacy` moves old `.kimi/skills/` installs to the new "
"paths, and (when the `agent-context` extension is enabled) migrates "
"`KIMI.md` context into `AGENTS.md`"
),
"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`",
"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 <path>\"` "
"for AI coding agents not listed above"
),
"trae": "Skills-based integration; skills are installed automatically",
"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-<command>`"
),
"zed": (
"Skills-based integration; installs skills into `.agents/skills` "
"and invokes them as `/speckit-<command>`"
),
}


def render_cell(value: str) -> str:
r"""Escape markdown special characters (pipes) and normalize newlines to spaces.

This ensures table cells remain valid markdown even if they contain
pipes (escaped as \|) or carriage returns (normalized to spaces).
"""
value = value.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")
return value.replace("|", "\\|")


def escape_url_for_markdown_link(url: str) -> str:
"""Escape characters that can break Markdown link syntax.

Escapes `)` and `|` which can terminate or corrupt the link destination.
"""
return url.replace(")", "\\)").replace("|", "\\|")


def escape_markdown_link_text(text: str) -> str:
"""Escape characters that can break Markdown link text."""
return text.replace("[", "\\[").replace("]", "\\]")


def render_code_span(value: str) -> str:
"""Safely render a value as an inline markdown code span."""
delimiter_width = max((len(match) for match in re.findall(r"`+", value)), default=0) + 1
delimiter = "`" * delimiter_width
return f"{delimiter}{value}{delimiter}"


def _get_integration_registry() -> dict[str, Any]:
from specify_cli.integrations import INTEGRATION_REGISTRY

return INTEGRATION_REGISTRY


def list_integrations_for_docs(
warn_on_missing: bool = False,
warn_on_extra: bool = False,
) -> list[tuple[str, str, str | None, str]]:
"""List all integrations with their documentation URLs and notes.

Returns all integrations in the registry. Missing entries in INTEGRATION_DOC_URLS
default to None; if `warn_on_missing` is True, emits a warning for these.
If `warn_on_extra` is True, emits a warning for stale keys in the doc maps that
are no longer in the registry. Missing notes entries default to empty string.
"""
registry = _get_integration_registry()
registry_keys = set(registry)

# Warn if there are integrations missing from INTEGRATION_DOC_URLS (when enabled)
missing = sorted(registry_keys - set(INTEGRATION_DOC_URLS))
if missing and warn_on_missing:
import warnings
warnings.warn(
f"Integration(s) missing from INTEGRATION_DOC_URLS: "
f"{', '.join(missing)}. They will be included in the docs table "
"without documentation links. Add them to INTEGRATION_DOC_URLS in "
"catalog_docs.py if a link should be available.",
stacklevel=2,
)

# Warn if there are stale keys in doc maps not in the registry (when enabled)
if warn_on_extra:
extra_in_urls = sorted(set(INTEGRATION_DOC_URLS) - registry_keys)
extra_in_labels = sorted(
set(INTEGRATION_LABEL_OVERRIDES) - registry_keys
)
extra_in_notes = sorted(set(INTEGRATION_NOTES) - registry_keys)
extra_keys = extra_in_urls or extra_in_labels or extra_in_notes
if extra_keys:
import warnings
stale_keys = sorted(
set(extra_in_urls + extra_in_labels + extra_in_notes)
)
warnings.warn(
f"Stale key(s) found in doc maps (no longer in registry): "
f"{', '.join(stale_keys)}. Consider removing them from "
"INTEGRATION_DOC_URLS, INTEGRATION_LABEL_OVERRIDES, and "
"INTEGRATION_NOTES.",
stacklevel=2,
)

rows: list[tuple[str, str, str | None, str]] = []

for key, integration in registry.items():
config = getattr(integration, "config", {})
if not isinstance(config, dict):
config = {}
label = INTEGRATION_LABEL_OVERRIDES.get(key, str(config.get("name") or key))
if key in INTEGRATION_DOC_URLS:
url = INTEGRATION_DOC_URLS[key]
else:
url = config.get("install_url")
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, render_code_span(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"
107 changes: 107 additions & 0 deletions src/specify_cli/community_catalog_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Helpers for rendering the community extensions reference table."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

from ._assets import ROOT_DIR
from .catalog_docs import (
escape_markdown_link_text,
escape_url_for_markdown_link,
render_cell,
render_code_span,
)


COMMUNITY_CATALOG_PATH = ROOT_DIR / "extensions" / "catalog.community.json"


def _format_tags(tags: Any) -> str:
if not isinstance(tags, list) or not tags:
return "—"
# Clean first, then filter: a tag of " | " would pass str(tag).strip() but produce
# an empty backtick span after pipe removal, so filter on the cleaned value.
cleaned = [
render_code_span(render_cell(c))
for tag in tags
if (c := str(tag).replace("|", "").strip())
]
return ", ".join(cleaned) if cleaned else "—"
Comment thread
DyanGalih marked this conversation as resolved.


def list_community_extensions(
path: Path = COMMUNITY_CATALOG_PATH,
) -> list[dict[str, Any]]:
"""Return community extensions sorted alphabetically by name then ID."""
if not path.exists():
raise FileNotFoundError(
f"Community catalog not found at {path}. "
"Ensure the repository checkout includes the extensions/ directory."
)
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"Expected {path} to contain a JSON object")
extensions = data.get("extensions")
if not isinstance(extensions, dict):
raise ValueError(f"Expected {path} to contain an 'extensions' object")

rows: list[dict[str, Any]] = []
for ext_id, ext in extensions.items():
if not isinstance(ext, dict):
raise ValueError(f"Community extension {ext_id!r} must be a mapping")
rows.append(
{
"name": str(ext.get("name") or ext_id),
"id": str(ext.get("id") or ext_id),
"description": str(ext.get("description") or ""),
"tags": ext.get("tags") or [],
"verified": "Yes" if bool(ext.get("verified")) else "No",
"repository": str(ext.get("repository") or "").strip(),
}
)

return sorted(
rows,
key=lambda row: (row["name"].casefold(), row["id"].casefold()),
)


def render_community_extensions_table(path: Path = COMMUNITY_CATALOG_PATH) -> str:
"""Render the community extensions table from catalog.community.json."""
rows = list_community_extensions(path=path)
if not rows:
raise ValueError("Community catalog has no extensions")

table_rows: list[list[str]] = []
for row in rows:
# Escape raw field values *before* composing Markdown syntax so that
# a pipe inside a name or description doesn't break a link target.
safe_name = escape_markdown_link_text(render_cell(row["name"]))
repository = row["repository"]
if repository:
safe_repo = escape_url_for_markdown_link(repository)
link = f"[{safe_name}]({safe_repo})"
else:
link = safe_name
table_rows.append(
[
link,
render_code_span(render_cell(row["id"])),
render_cell(row["description"]),
_format_tags(row["tags"]),
row["verified"],
]
)
Comment thread
DyanGalih marked this conversation as resolved.

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"
27 changes: 26 additions & 1 deletion src/specify_cli/integrations/_query_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,33 @@ 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(1)
return
from . import INTEGRATION_REGISTRY
from .catalog import (
IntegrationCatalog,
Expand Down
Loading