Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2447,8 +2447,34 @@ def integration_search(
query: Optional[str] = typer.Argument(None, help="Search query (optional)"),
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"),
author: Optional[str] = typer.Option(None, "--author", help="Filter by author"),
markdown: bool = typer.Option(
False,
"--markdown",
help=(
"Output the full built-in integrations table as markdown "
"(ignores query and --tag/--author filters)"
),
),
):
"""Search for integrations in the active catalog stack."""
"""Search for integrations in the active catalog stack.
Or output the built-in reference table with --markdown.
"""
if markdown:
if query or tag or author:
typer.echo(
"Warning: --markdown outputs the full built-in integrations table "
"and ignores query/--tag/--author filters.",
err=True,
)
from .catalog_docs import render_integrations_table
try:
typer.echo(render_integrations_table(), nl=False)
except (FileNotFoundError, ValueError) as exc:
typer.echo(f"Error rendering integrations table: {exc}", err=True)
raise typer.Exit(code=1)
return

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

from __future__ import annotations

from typing import Any

from ._assets import _repo_root

ROOT_DIR = _repo_root()
INTEGRATIONS_REFERENCE_PATH = ROOT_DIR / "docs" / "reference" / "integrations.md"


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

INTEGRATION_LABEL_OVERRIDES: dict[str, str] = {
"agy": "Antigravity (agy)",
"codebuddy": "CodeBuddy CLI",
"generic": "Generic",
"shai": "SHAI (OVHcloud)",
}

INTEGRATION_NOTES: dict[str, str] = {
"agy": "Skills-based integration; skills are installed automatically",
"claude": "Skills-based integration; installs skills in `.claude/skills`",
"codex": (
"Skills-based integration; installs skills into `.agents/skills` "
"and invokes them as `$speckit-<command>`"
),
"bob": "IDE-based agent",
"devin": (
"Skills-based integration; installs skills into `.devin/skills/` "
"and invokes them as `/speckit-<command>`"
),
"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 <path>\"` "
"for AI coding agents not listed above"
),
"trae": "Skills-based integration; skills are installed automatically",
}


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

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


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

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


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


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

return INTEGRATION_REGISTRY


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

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

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

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

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

for key, integration in registry.items():
config = getattr(integration, "config", {})
if not isinstance(config, dict):
config = {}
label = INTEGRATION_LABEL_OVERRIDES.get(key, str(config.get("name") or key))
url = INTEGRATION_DOC_URLS.get(key) # None if not in map
notes = INTEGRATION_NOTES.get(key, "")
rows.append((key, label, url, notes))

return sorted(rows, key=lambda r: r[0])


def render_integrations_table() -> str:
"""Render the built-in integrations reference table as markdown."""
table_rows: list[list[str]] = []

for key, label, url, notes in list_integrations_for_docs():
# Escape raw field values *before* composing Markdown syntax so that
# a pipe inside a label or notes doesn't break a link target.
safe_label = escape_markdown_link_text(render_cell(label))
safe_notes = render_cell(notes)
safe_url = escape_url_for_markdown_link(url) if url else None
agent = (
f"[{safe_label}]({safe_url})"
if safe_url
else safe_label
)
table_rows.append([agent, f"`{key}`", safe_notes])

headers = ("Agent", "Key", "Notes")

def render_row(values: list[str]) -> str:
# Values are already escaped; do not re-apply render_cell here.
return "| " + " | ".join(values) + " |"

separator = "| " + " | ".join("---" for _ in headers) + " |"
lines = [render_row(list(headers)), separator]
lines.extend(render_row(row) for row in table_rows)
return "\n".join(lines) + "\n"
103 changes: 103 additions & 0 deletions src/specify_cli/community_catalog_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Helpers for rendering the community extensions reference table."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

from ._assets import _repo_root
from .catalog_docs import (
escape_markdown_link_text,
escape_url_for_markdown_link,
render_cell,
)


ROOT_DIR = _repo_root()
COMMUNITY_CATALOG_PATH = ROOT_DIR / "extensions" / "catalog.community.json"
Comment on lines +17 to +18


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 "—"
Comment on lines +21 to +27


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

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

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


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

table_rows: list[list[str]] = []
for row in rows:
# Escape raw field values *before* composing Markdown syntax so that
# a pipe inside a name or description doesn't break a link target.
safe_name = escape_markdown_link_text(render_cell(row["name"]))
repository = row["repository"]
if repository:
safe_repo = escape_url_for_markdown_link(repository)
link = f"[{safe_name}]({safe_repo})"
else:
link = safe_name
table_rows.append(
[
link,
f"`{render_cell(row['id'])}`",
render_cell(row["description"]),
_format_tags(row["tags"]),
row["verified"],
]
)
Comment on lines +84 to +92

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"
Loading