-
Notifications
You must be signed in to change notification settings - Fork 10.4k
docs: stabilize integrations reference rendering #2694
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
Open
DyanGalih
wants to merge
8
commits into
github:main
Choose a base branch
from
DyanGalih:pr/2563-docs-fixes
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
072d5a7
fix: keep integrations docs changes scoped to PR 2563
DyanGalih a3071c7
fix: restore markdown integrations table command on PR branch
DyanGalih e82e431
fix: address Copilot feedback on inline code spans
DyanGalih 7cfac35
fix: address remaining Copilot feedback (CliRunner mix_stderr and cac…
DyanGalih 5bcbb5a
fix: address new Copilot feedback on URL resolution
DyanGalih df0b7b6
Fix newline and pipe rendering in community catalog tags
DyanGalih 24a1217
Fix trailing whitespace in render_code_span docstring
DyanGalih 662f4ae
Fix integration docs rendering
DyanGalih File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 "—" | ||
|
|
||
|
|
||
| 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"], | ||
| ] | ||
| ) | ||
|
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" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.