-
Notifications
You must be signed in to change notification settings - Fork 9k
generate integrations reference from catalog #2563
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
848cb96
2a72b53
df40ef8
73602ca
c621732
b4bd56f
7caace8
01be38f
70afa5c
1c5af18
b64cc3b
c351766
e9c4bc4
7d1a401
dd32eb1
d9bda8a
f11bca9
4dab389
be4b7a6
28e68d6
2b27eed
295dcb6
826fbf5
77c7327
34fff7c
60fc926
e275568
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() | ||
|
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)) | ||
|
|
||
|
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" | ||
| 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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. Renamed |
||
|
|
||
|
|
||
| ROOT_DIR = Path(__file__).resolve().parents[2] | ||
| COMMUNITY_CATALOG_PATH = ROOT_DIR / "extensions" / "catalog.community.json" | ||
|
|
||
|
Comment on lines
+1
to
+14
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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." | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅ Addressed - community_catalog_docs.py now correctly applies
Comment on lines
+79
to
+87
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed! Updated line 100 to escape the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅ Addressed in commit 60fc926 - |
||
|
|
||
| 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" | ||
There was a problem hiding this comment.
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 usenl=False, which prevents adding an extra newline sincerender_integrations_table()already returns a string with a trailing newline. This keeps the CLI output clean and consistent.