Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
848cb96
docs: generate integrations reference from catalog
DyanGalih May 14, 2026
2a72b53
refactor: integrate table rendering into specify integration search -…
DyanGalih May 14, 2026
df40ef8
fix: address Copilot review feedback on catalog_docs and integration_…
DyanGalih May 14, 2026
73602ca
fix: add sync test, INTEGRATIONS_REFERENCE_PATH constant, and fix naming
DyanGalih May 15, 2026
c621732
revert: restore docs/reference/integrations.md to upstream/main; remo…
DyanGalih May 15, 2026
b4bd56f
fix: remove dead INTEGRATIONS_REFERENCE_PATH, drop URL-length padding…
DyanGalih May 15, 2026
7caace8
fix: send --markdown warnings/errors to stderr, rename test for clarity
DyanGalih May 15, 2026
01be38f
fix: detect stale doc-map keys, test _render_cell escaping, strengthe…
DyanGalih May 15, 2026
70afa5c
refactor: promote _render_cell to public render_cell function
DyanGalih May 15, 2026
1c5af18
test: mock registry and doc maps to avoid brittle live registry coupling
DyanGalih May 15, 2026
b64cc3b
refactor: flatten patches, remove unused imports, fix trailing whites…
DyanGalih May 15, 2026
c351766
refactor: make validation non-fatal, fix context manager syntax, add …
DyanGalih May 15, 2026
e9c4bc4
fix: improve docstring clarity, test robustness, and exception handling
DyanGalih May 15, 2026
7d1a401
fix: improve test assertions, disable warnings by default, enhance ex…
DyanGalih May 15, 2026
dd32eb1
fix: make CLI tests deterministic and improve config access resilience
DyanGalih May 15, 2026
d9bda8a
fix: remove extra blank line, add stale keys validation, add regressi…
DyanGalih May 16, 2026
f11bca9
Fix 5 remaining feedback items:
DyanGalih May 16, 2026
4dab389
address all outstanding copilot review feedback on PR 2563
DyanGalih May 18, 2026
be4b7a6
Address Copilot feedback: escape URLs in markdown links, deduplicate …
DyanGalih May 18, 2026
28e68d6
Address 3 new Copilot feedback: add URL escaping test, fix parse_firs…
DyanGalih May 18, 2026
2b27eed
Address 3 new Copilot feedback: escape id field, remove unused alias,…
DyanGalih May 18, 2026
295dcb6
Address 3 new Copilot feedback: fix comment name, include all integra…
DyanGalih May 18, 2026
826fbf5
Fix architectural issue: escape raw fields before composing Markdown …
DyanGalih May 18, 2026
77c7327
Deduplicate _escape_url_for_markdown_link and add URL escaping test
DyanGalih May 18, 2026
34fff7c
Address 4 new Copilot feedback: add trailing newline, fix test helper…
DyanGalih May 18, 2026
60fc926
Address 4 new Copilot feedback: make escape function public, fix erro…
DyanGalih May 18, 2026
e275568
Update error message in test_missing_catalog_file for clarity
DyanGalih May 19, 2026
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
25 changes: 24 additions & 1 deletion src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2377,8 +2377,31 @@ 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 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)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Updated the typer.echo() call to use nl=False, which prevents adding an extra newline since render_integrations_table() already returns a string with a trailing newline. This keeps the CLI output clean and consistent.

except Exception as exc:
typer.echo(f"Error rendering integrations table: {exc}", err=True)
raise typer.Exit(code=1)
return
Comment thread
mnriem marked this conversation as resolved.

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

from __future__ import annotations

from typing import Any


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 _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()
Comment thread
mnriem marked this conversation as resolved.
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"{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))

Comment thread
mnriem marked this conversation as resolved.
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 = 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"
98 changes: 98 additions & 0 deletions src/specify_cli/community_catalog_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Helpers for rendering the community extensions reference table."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

from .catalog_docs import escape_url_for_markdown_link, render_cell
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Renamed _escape_url_for_markdown_link() to escape_url_for_markdown_link() (now public) in catalog_docs.py, and updated the import in community_catalog_docs.py to use the public name. This removes the "private API" coupling concern.



ROOT_DIR = Path(__file__).resolve().parents[2]
COMMUNITY_CATALOG_PATH = ROOT_DIR / "extensions" / "catalog.community.json"

Comment on lines +1 to +14
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid observation! The community extensions table generation is intentional and part of the same feature set. The PR adds a unified reference table generation system that covers:

  1. Built-in integrations reference (via integration_search --markdown)
  2. Community extensions reference (related capability for completeness)

Both use the same markdown table architecture, so they're inherently coupled. If you'd prefer this split into separate PRs, I can do that, but keeping them together ensures consistency in the table rendering, escaping, and testing approach across both doc generators.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Noted - the community_catalog_docs.py module is newly introduced and intentionally creates a complete reference implementation for rendering community extensions. This is an intentional part of the PR to support the community extension registration feature.


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 at {path}. "
"Ensure the repository checkout includes the extensions/ directory."
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Updated the error message to describe the actual requirement instead of referencing the CLI flag: "Community catalog not found at {path}. Ensure the repository checkout includes the extensions/ directory." This is now accurate for both CLI and programmatic usage.

)
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"])
safe_repo = escape_url_for_markdown_link(row["repository"])
link = (
f"[{safe_name}]({safe_repo})"
if row["repository"]
else safe_name
)
table_rows.append(
[
link,
f"`{render_cell(row['id'])}`",
render_cell(row["description"]),
_format_tags(row["tags"]),
row["verified"],
]
)
Comment on lines +79 to +87
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Addressed - community_catalog_docs.py now correctly applies render_cell() to escape field values. The ID field is escaped at line 82: f\"{render_cell(row['id'])}\" to handle any pipes in IDs.

Comment on lines +79 to +87
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

Updated line 100 to escape the id field using render_cell() just like name and description. This ensures that if an extension id contains newlines or pipes, the markdown table remains valid and the cell content is safely escaped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Addressed in commit 60fc926 - render_cell() is now applied to the ID field. See line 82 in community_catalog_docs.py where ID is escaped via render_cell().


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