diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index f538fac86f..d0445598e9 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -4,42 +4,42 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify ## Supported AI Coding Agents -| Agent | Key | Notes | -| ------------------------------------------------------------------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| [Amp](https://ampcode.com/) | `amp` | | -| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically | -| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | | -| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` | -| [Cline](https://github.com/cline/cline) | `cline` | IDE-based agent | -| [CodeBuddy CLI](https://www.codebuddy.cn/docs/cli/installation) | `codebuddy` | | -| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-` | -| [Cursor](https://cursor.sh/) | `cursor-agent` | | -| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-` | -| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ | -| [Forge](https://forgecode.dev/) | `forge` | | -| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | | -| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | | -| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | -| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` | -| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent | -| [Junie](https://junie.jetbrains.com/) | `junie` | | -| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | | -| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths | -| [Kiro CLI](https://kiro.dev/docs/cli/) | `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](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | -| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | -| [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) | `omp` | Installs slash commands into `.omp/commands` | -| [opencode](https://opencode.ai/) | `opencode` | | -| [Pi Coding Agent](https://pi.dev) | `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) | -| [Qoder CLI](https://qoder.com/cli) | `qodercli` | | -| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | | -| [RovoDev](https://www.atlassian.com/software/rovo-dev) | `rovodev` | Generates `.rovodev/skills/`, prompt wrappers, and `prompts.yml`; runtime dispatch uses `acli rovodev` | -| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | | -| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | | -| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically | -| [ZCode](https://zcode.z.ai/) | `zcode` | Skills-based integration; installs skills into `.zcode/skills/` and invokes them as `$speckit-` | -| [Zed](https://zed.dev/) | `zed` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `/speckit-` | -| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir "` for AI coding agents not listed above | +| Agent | Key | Notes | +| --- | --- | --- | +| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically | +| [Amp](https://ampcode.com/) | `amp` | | +| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | | +| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent | +| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` | +| Cline | `cline` | | +| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | | +| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-` | +| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | | +| [Cursor](https://cursor.sh/) | `cursor-agent` | | +| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-` | +| Firebender | `firebender` | | +| [Forge](https://forgecode.dev/) | `forge` | | +| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | | +| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir "` for AI coding agents not listed above | +| [Goose](https://block.github.io/goose/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | +| Hermes Agent | `hermes` | | +| [Junie](https://junie.jetbrains.com/) | `junie` | | +| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | | +| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration | +| [Kiro CLI](https://kiro.dev/docs/cli/) | `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](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | +| Oh My Pi | `omp` | | +| [opencode](https://opencode.ai/) | `opencode` | | +| [Pi Coding Agent](https://pi.dev) | `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) | +| [Qoder CLI](https://qoder.com/cli) | `qodercli` | | +| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | | +| RovoDev ACLI | `rovodev` | | +| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | | +| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | | +| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically | +| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | +| ZCode | `zcode` | | +| Zed | `zed` | | ## List Available Integrations diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f2ed932c46..c045becf23 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -30,6 +30,7 @@ import sys import json from pathlib import Path +from typing import Optional import typer from rich.panel import Panel @@ -511,9 +512,48 @@ def version( # Re-export selected helpers to preserve the public import surface. from .integrations._helpers import ( # noqa: E402 _clear_init_options_for_integration as _clear_init_options_for_integration, + _cli_error_detail as _cli_error_detail, + _cli_phase_label as _cli_phase_label, + _get_speckit_version as _get_speckit_version, + _MANIFEST_READ_ERRORS as _MANIFEST_READ_ERRORS, + _parse_integration_options as _parse_integration_options, + _read_integration_json as _read_integration_json, + _refresh_init_options_speckit_version as _refresh_init_options_speckit_version, + _register_extensions_for_agent as _register_extensions_for_agent, + _remove_integration_json as _remove_integration_json, + _resolve_integration_options as _resolve_integration_options, + _resolve_integration_script_type as _resolve_integration_script_type, + _resolve_script_type as _resolve_script_type, + _set_default_integration as _set_default_integration, + _set_default_integration_or_exit as _set_default_integration_or_exit, + _SharedTemplateRefreshError as _SharedTemplateRefreshError, + _unregister_extensions_for_agent as _unregister_extensions_for_agent, _update_init_options_for_integration as _update_init_options_for_integration, + _write_integration_json as _write_integration_json, ) from ._project import _resolve_init_dir_override as _resolve_init_dir_override # noqa: E402 +from .integration_runtime import ( # noqa: E402 + invoke_separator_for_integration as _invoke_separator_for_integration, + with_integration_setting as _with_integration_setting, +) +from .integration_state import ( # noqa: E402 + dedupe_integration_keys as _dedupe_integration_keys, + default_integration_key as _default_integration_key, + installed_integration_keys as _installed_integration_keys, + integration_settings as _integration_settings, +) + + +integration_app = typer.Typer( + name="integration", + help="Manage coding agent integrations", + add_completion=False, +) +integration_catalog_app = typer.Typer( + name="catalog", + help="Manage integration catalog sources", + add_completion=False, +) def _require_specify_project() -> Path: @@ -539,6 +579,906 @@ def _require_specify_project() -> Path: ) raise typer.Exit(1) +@integration_app.command("use") +def integration_use( + key: str = typer.Argument(help="Installed integration key to make the default"), + force: bool = typer.Option(False, "--force", help="Overwrite managed shared templates while changing the default"), +): + """Set the default integration without uninstalling other integrations.""" + from .integrations import get_integration + + project_root = _require_specify_project() + current = _read_integration_json(project_root) + installed_keys = _installed_integration_keys(current) + if key not in installed_keys: + console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") + if installed_keys: + console.print(f"[yellow]Installed integrations:[/yellow] {', '.join(installed_keys)}") + else: + console.print("Install one with: [cyan]specify integration install [/cyan]") + raise typer.Exit(1) + + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + raise typer.Exit(1) + + raw_options, parsed_options = _resolve_integration_options(integration, current, key, None) + _set_default_integration_or_exit( + project_root, + current, + key, + integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + refresh_templates_force=force, + ) + console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].") + + +@integration_app.command("uninstall") +def integration_uninstall( + key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"), + force: bool = typer.Option(False, "--force", help="Remove files even if modified"), +): + """Uninstall an integration, safely preserving modified files.""" + from .integrations import get_integration + from .integrations.manifest import IntegrationManifest + + project_root = _require_specify_project() + current = _read_integration_json(project_root) + default_key = _default_integration_key(current) + installed_keys = _installed_integration_keys(current) + + if key is None: + if not default_key: + console.print("[yellow]No integration is currently installed.[/yellow]") + raise typer.Exit(0) + key = default_key + + if key not in installed_keys: + console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") + raise typer.Exit(1) + + integration = get_integration(key) + + manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" + if not manifest_path.exists(): + console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]") + remaining = [installed for installed in installed_keys if installed != key] + new_default = default_key if default_key != key else (remaining[0] if remaining else None) + if remaining: + if default_key == key and new_default and (new_integration := get_integration(new_default)): + raw_options, parsed_options = _resolve_integration_options( + new_integration, current, new_default, None + ) + _set_default_integration_or_exit( + project_root, + current, + new_default, + new_integration, + remaining, + raw_options=raw_options, + parsed_options=parsed_options, + ) + else: + _write_integration_json( + project_root, new_default, remaining, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + if default_key == key: + _clear_init_options_for_integration(project_root, key) + raise typer.Exit(0) + + try: + manifest = IntegrationManifest.load(key, project_root) + except _MANIFEST_READ_ERRORS as exc: + console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable.") + console.print(f"Manifest: {manifest_path}") + console.print( + f"To recover, delete the unreadable manifest, run " + f"[cyan]specify integration uninstall {key}[/cyan] to clear stale metadata, " + f"then run [cyan]specify integration install {key}[/cyan] to regenerate." + ) + console.print(f"[dim]Details:[/dim] {exc}") + raise typer.Exit(1) + + removed, skipped = manifest.uninstall(project_root, force=force) + + # Remove managed context section from the agent context file + if integration: + integration.remove_context_section(project_root) + + remaining = [installed for installed in installed_keys if installed != key] + new_default = default_key if default_key != key else (remaining[0] if remaining else None) + if remaining: + if default_key == key and new_default and (new_integration := get_integration(new_default)): + raw_options, parsed_options = _resolve_integration_options( + new_integration, current, new_default, None + ) + _set_default_integration_or_exit( + project_root, + current, + new_default, + new_integration, + remaining, + raw_options=raw_options, + parsed_options=parsed_options, + ) + else: + _write_integration_json( + project_root, new_default, remaining, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + + if default_key == key: + _clear_init_options_for_integration(project_root, key) + + name = (integration.config or {}).get("name", key) if integration else key + console.print(f"\n[green]✓[/green] Integration '{name}' uninstalled") + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f"\n[yellow]⚠[/yellow] {len(skipped)} modified file(s) were preserved:") + for path in skipped: + rel = _display_project_path(project_root, path) + console.print(f" {rel}") + + +@integration_app.command("switch") +def integration_switch( + target: str = typer.Argument(help="Integration key to switch to"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"), + refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"), + integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'), +): + """Switch from the current integration to a different one.""" + from .integrations import INTEGRATION_REGISTRY, get_integration + from .integrations.manifest import IntegrationManifest + + project_root = _require_specify_project() + target_integration = get_integration(target) + if target_integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{target}'") + available = ", ".join(sorted(INTEGRATION_REGISTRY.keys())) + console.print(f"Available integrations: {available}") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_keys = _installed_integration_keys(current) + installed_key = _default_integration_key(current) + + if installed_key == target: + if integration_options is not None: + console.print( + "[red]Error:[/red] --integration-options cannot be used when switching " + "to an already installed integration." + ) + console.print( + f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] " + "to update managed files/options." + ) + raise typer.Exit(1) + if force: + raw_options, parsed_options = _resolve_integration_options( + target_integration, current, target, None + ) + _set_default_integration_or_exit( + project_root, + current, + target, + target_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + refresh_templates_force=True, + ) + console.print( + f"\n[green]✓[/green] Default integration remains [bold]{target}[/bold]; " + "managed shared templates refreshed." + ) + raise typer.Exit(0) + console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]") + raise typer.Exit(0) + + if target in installed_keys: + if integration_options is not None: + console.print( + "[red]Error:[/red] --integration-options cannot be used when switching " + "to an already installed integration." + ) + console.print( + f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] " + f"to update managed files/options, then [cyan]specify integration use {target}[/cyan]." + ) + raise typer.Exit(1) + raw_options, parsed_options = _resolve_integration_options( + target_integration, current, target, None + ) + _set_default_integration_or_exit( + project_root, + current, + target, + target_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + refresh_templates_force=force, + ) + console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].") + raise typer.Exit(0) + + selected_script = _resolve_script_type(project_root, script) + + # Phase 1: Uninstall current integration (if any) + if installed_key: + current_integration = get_integration(installed_key) + manifest_path = project_root / ".specify" / "integrations" / f"{installed_key}.manifest.json" + + if current_integration and manifest_path.exists(): + console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]") + try: + old_manifest = IntegrationManifest.load(installed_key, project_root) + except _MANIFEST_READ_ERRORS as exc: + console.print(f"[red]Error:[/red] Could not read integration manifest for '{installed_key}': {manifest_path}") + console.print(f"[dim]{exc}[/dim]") + console.print( + f"To recover, delete the unreadable manifest at {manifest_path}, " + f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry." + ) + raise typer.Exit(1) + removed, skipped = old_manifest.uninstall(project_root, force=force) + current_integration.remove_context_section(project_root) + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") + elif not current_integration and manifest_path.exists(): + # Integration removed from registry but manifest exists — use manifest-only uninstall + console.print(f"Uninstalling unknown integration '{installed_key}' via manifest") + try: + old_manifest = IntegrationManifest.load(installed_key, project_root) + removed, skipped = old_manifest.uninstall(project_root, force=force) + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") + except _MANIFEST_READ_ERRORS as exc: + console.print(f"[yellow]Warning:[/yellow] Could not read manifest for '{installed_key}': {exc}") + else: + console.print(f"[red]Error:[/red] Integration '{installed_key}' is installed but has no manifest.") + console.print( + f"Run [cyan]specify integration uninstall {installed_key}[/cyan] to clear metadata, " + f"then retry [cyan]specify integration switch {target}[/cyan]." + ) + raise typer.Exit(1) + + # Unregister extension commands for the old agent so they don't + # remain as orphans in the old agent's directory. + try: + from .extensions import ExtensionManager + + ext_mgr = ExtensionManager(project_root) + ext_mgr.unregister_agent_artifacts(installed_key) + except Exception as ext_err: + console.print( + f"[yellow]Warning:[/yellow] Could not clean up extension artifacts " + f"(commands, skills, registry entries) for '{installed_key}': {ext_err}" + ) + + # Clear metadata so a failed Phase 2 doesn't leave stale references + installed_keys = [installed for installed in installed_keys if installed != installed_key] + _clear_init_options_for_integration(project_root, installed_key) + if installed_keys: + fallback_key = installed_keys[0] + fallback_integration = get_integration(fallback_key) + if fallback_integration is not None: + raw_options, parsed_options = _resolve_integration_options( + fallback_integration, current, fallback_key, None + ) + _set_default_integration_or_exit( + project_root, + current, + fallback_key, + fallback_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + ) + else: + _write_integration_json( + project_root, fallback_key, installed_keys, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + current = _read_integration_json(project_root) + + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + raw_options, parsed_options = _resolve_integration_options( + target_integration, current, target, integration_options + ) + + # Refresh shared infrastructure to the current CLI version. Switching + # integrations is exactly when stale vendored shared scripts (e.g. + # update-agent-context.sh that pre-dates the target integration's + # supported-agent list) would silently break the new integration. + # + # Use refresh_managed=True so only files that match their previously + # recorded hash are overwritten — user customizations are detected via + # hash divergence and preserved with a warning. Pass + # --refresh-shared-infra to overwrite customizations as well. See #2293. + _install_shared_infra_or_exit( + project_root, + selected_script, + force=refresh_shared_infra, + refresh_managed=True, + invoke_separator=_invoke_separator_for_integration( + target_integration, current, target, parsed_options + ), + refresh_hint=( + "To overwrite customizations, re-run with " + "[cyan]specify integration switch ... --refresh-shared-infra[/cyan]." + ), + ) + if os.name != "nt": + ensure_executable_scripts(project_root) + + # Phase 2: Install target integration + console.print(f"Installing integration: [cyan]{target}[/cyan]") + manifest = IntegrationManifest( + target_integration.key, project_root, version=get_speckit_version() + ) + + try: + target_integration.setup( + project_root, manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=raw_options, + ) + manifest.save() + _set_default_integration( + project_root, + current, + target_integration.key, + target_integration, + _dedupe_integration_keys([*installed_keys, target_integration.key]), + script_type=selected_script, + raw_options=raw_options, + parsed_options=parsed_options, + ) + + # Re-register extension commands for the new agent so that + # previously-installed extensions are available in the new integration. + try: + from .extensions import ExtensionManager + + ext_mgr = ExtensionManager(project_root) + ext_mgr.register_enabled_extensions_for_agent(target) + except Exception as ext_err: + console.print( + f"[yellow]Warning:[/yellow] Could not register extension commands, skills, " + f"or related artifacts for '{target}': {ext_err}" + ) + + except Exception as e: + # Attempt rollback of any files written by setup + try: + target_integration.teardown(project_root, manifest, force=True) + except Exception as rollback_err: + # Suppress so the original setup error remains the primary failure + console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration '{target}': {rollback_err}") + if installed_keys: + fallback_key = installed_keys[0] + fallback_integration = get_integration(fallback_key) + if fallback_integration is not None: + raw_options, parsed_options = _resolve_integration_options( + fallback_integration, current, fallback_key, None + ) + try: + _set_default_integration( + project_root, + current, + fallback_key, + fallback_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + ) + except _SharedTemplateRefreshError as restore_err: + console.print( + f"[yellow]Warning:[/yellow] Failed to restore default " + f"integration '{fallback_key}': {restore_err}" + ) + else: + _write_integration_json( + project_root, fallback_key, installed_keys, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + console.print(f"[red]Error:[/red] Failed to install integration '{target}': {e}") + raise typer.Exit(1) + + name = (target_integration.config or {}).get("name", target) + console.print(f"\n[green]✓[/green] Switched to integration '{name}'") + + +@integration_app.command("upgrade") +def integration_upgrade( + key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"), + force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"), +): + """Upgrade an integration by reinstalling with diff-aware file handling. + + Compares manifest hashes to detect locally modified files and + blocks the upgrade unless --force is used. + """ + from .integrations import get_integration + from .integrations.manifest import IntegrationManifest + + project_root = _require_specify_project() + current = _read_integration_json(project_root) + installed_key = _default_integration_key(current) + installed_keys = _installed_integration_keys(current) + + if key is None: + if not installed_key: + console.print("[yellow]No integration is currently installed.[/yellow]") + raise typer.Exit(0) + key = installed_key + + if key not in installed_keys: + console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") + raise typer.Exit(1) + + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + raise typer.Exit(1) + + manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" + if not manifest_path.exists(): + console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to upgrade.[/yellow]") + console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.") + raise typer.Exit(0) + + try: + old_manifest = IntegrationManifest.load(key, project_root) + except _MANIFEST_READ_ERRORS as exc: + console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}") + raise typer.Exit(1) + + # Detect modified files via manifest hashes + modified = old_manifest.check_modified() + if modified and not force: + console.print(f"[yellow]⚠[/yellow] {len(modified)} file(s) have been modified since installation:") + for rel in modified: + console.print(f" {rel}") + console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.") + raise typer.Exit(1) + + selected_script = _resolve_integration_script_type(project_root, current, key, script) + + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + raw_options, parsed_options = _resolve_integration_options( + integration, current, key, integration_options + ) + + # Ensure shared infrastructure is up to date; --force overwrites existing files. + infra_integration = integration + infra_key = key + infra_parsed = parsed_options + if installed_key and installed_key != key: + default_integration = get_integration(installed_key) + if default_integration is not None: + infra_integration = default_integration + infra_key = installed_key + _, infra_parsed = _resolve_integration_options( + default_integration, current, installed_key, None + ) + _install_shared_infra_or_exit( + project_root, + selected_script, + force=force, + invoke_separator=_invoke_separator_for_integration( + infra_integration, current, infra_key, infra_parsed + ), + ) + if os.name != "nt": + ensure_executable_scripts(project_root) + + # Phase 1: Install new files (overwrites existing; old-only files remain) + console.print(f"Upgrading integration: [cyan]{key}[/cyan]") + new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version()) + + try: + integration.setup( + project_root, + new_manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=raw_options, + ) + settings = _with_integration_setting( + current, + key, + integration, + script_type=selected_script, + raw_options=raw_options, + parsed_options=parsed_options, + ) + if installed_key == key: + try: + _refresh_shared_templates( + project_root, + invoke_separator=_invoke_separator_for_integration( + integration, {"integration_settings": settings}, key, parsed_options + ), + force=force, + ) + except (ValueError, OSError) as exc: + raise _SharedTemplateRefreshError( + f"Failed to refresh shared templates for '{key}': {exc}" + ) from exc + new_manifest.save() + _write_integration_json(project_root, installed_key, installed_keys, settings) + if installed_key == key: + _update_init_options_for_integration(project_root, integration, script_type=selected_script) + except Exception as exc: + # Don't teardown — setup overwrites in-place, so teardown would + # delete files that were working before the upgrade. Just report. + console.print(f"[red]Error:[/red] Failed to upgrade integration: {exc}") + console.print("[yellow]The previous integration files may still be in place.[/yellow]") + raise typer.Exit(1) + + # Phase 2: Remove stale files from old manifest that are not in the new one + old_files = old_manifest.files + new_files = new_manifest.files + stale_keys = set(old_files) - set(new_files) + if stale_keys: + stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup") + stale_manifest._files = {k: old_files[k] for k in stale_keys} + stale_removed, _ = stale_manifest.uninstall(project_root, force=True) + if stale_removed: + console.print(f" Removed {len(stale_removed)} stale file(s) from previous install") + + name = (integration.config or {}).get("name", key) + console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") + + +# ===== Integration catalog discovery commands ===== +# +# These commands mirror the workflow catalog CLI shape: +# - `search` / `info` for discovery over the active catalog stack +# - `catalog list/add/remove` for managing catalog sources +# +# They deliberately do NOT add `integration add/remove/enable/disable/ +# set-priority`: integrations are single-active (install / uninstall / switch), +# not additive like extensions and presets. + + +@integration_app.command("search") +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. + + 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) from exc + return + + from .integrations import INTEGRATION_REGISTRY + from .integrations.catalog import ( + IntegrationCatalog, + IntegrationCatalogError, + IntegrationValidationError, + ) + + project_root = _require_specify_project() + integration_config = _read_integration_json(project_root) + installed_key = integration_config.get("integration") + catalog = IntegrationCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag, author=author) + except IntegrationValidationError as exc: + console.print(f"[red]Error:[/red] {exc}") + console.print( + "\nTip: Check the configuration file path shown above for invalid catalog configuration " + "(for example, .specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)." + ) + raise typer.Exit(1) + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + if os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip(): + console.print( + "\nTip: Check the SPECKIT_INTEGRATION_CATALOG_URL environment variable for an invalid " + "catalog URL, or unset it to use the configured catalog files " + "(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)." + ) + else: + console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") + raise typer.Exit(1) + + if not results: + console.print("\n[yellow]No integrations found matching criteria[/yellow]") + if query or tag or author: + console.print("\nTry:") + console.print(" • Broader search terms") + console.print(" • Remove filters") + console.print(" • specify integration search (show all)") + return + + console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n") + for integ in sorted(results, key=lambda e: e.get("id", "")): + iid = integ.get("id", "?") + name = integ.get("name", iid) + version = integ.get("version", "?") + console.print(f"[bold]{name}[/bold] ({iid}) v{version}") + desc = integ.get("description", "") + if desc: + console.print(f" {desc}") + + console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}") + tags = integ.get("tags", []) + if isinstance(tags, list) and tags: + console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}") + + cat_name = integ.get("_catalog_name", "") + install_allowed = integ.get("_install_allowed", True) + if cat_name: + if install_allowed: + console.print(f" [dim]Catalog:[/dim] {cat_name}") + else: + console.print( + f" [dim]Catalog:[/dim] {cat_name} " + "[yellow](discovery only — not installable)[/yellow]" + ) + + if iid == installed_key: + console.print("\n [green]✓ Installed[/green] (currently active)") + elif iid in INTEGRATION_REGISTRY: + console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}") + elif install_allowed: + console.print( + "\n [yellow]Found in catalog.[/yellow] Only built-in integration IDs " + "can be installed with 'specify integration install'." + ) + else: + console.print( + f"\n [yellow]⚠[/yellow] Not directly installable from '{cat_name}'." + ) + console.print() + + +@integration_app.command("info") +def integration_info( + integration_id: str = typer.Argument(..., help="Integration ID"), +): + """Show catalog details for a single integration.""" + from .integrations import INTEGRATION_REGISTRY + from .integrations.catalog import ( + IntegrationCatalog, + IntegrationCatalogError, + IntegrationValidationError, + ) + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + installed_key = _read_integration_json(project_root).get("integration") + + try: + info = catalog.get_integration_info(integration_id) + except IntegrationCatalogError as exc: + info = None + # Keep the live exception so the fallback branch below can give + # different guidance for local-config vs. network failures. + catalog_error: Optional[IntegrationCatalogError] = exc + else: + catalog_error = None + + if info: + name = info.get("name", integration_id) + version = info.get("version", "?") + console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}") + if info.get("description"): + console.print(f" {info['description']}") + console.print() + + console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}") + if info.get("license"): + console.print(f" [dim]License:[/dim] {info['license']}") + + tags = info.get("tags", []) + if isinstance(tags, list) and tags: + console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}") + + cat_name = info.get("_catalog_name", "") + install_allowed = info.get("_install_allowed", True) + if cat_name: + install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" + console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}") + + if info.get("repository"): + console.print(f" [dim]Repository:[/dim] {info['repository']}") + + if integration_id == installed_key: + console.print("\n [green]✓ Installed[/green] (currently active)") + elif integration_id in INTEGRATION_REGISTRY: + console.print("\n [dim]Built-in integration (not currently active)[/dim]") + return + + if integration_id in INTEGRATION_REGISTRY: + integration = INTEGRATION_REGISTRY[integration_id] + cfg = integration.config or {} + name = cfg.get("name", integration_id) + console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id})") + console.print(" [dim]Built-in integration (not listed in catalog)[/dim]") + if integration_id == installed_key: + console.print("\n [green]✓ Installed[/green] (currently active)") + if catalog_error: + console.print(f"\n[yellow]Catalog unavailable:[/yellow] {catalog_error}") + return + + if catalog_error: + console.print(f"[red]Error:[/red] Could not query integration catalog: {catalog_error}") + if isinstance(catalog_error, IntegrationValidationError): + console.print( + "\nCheck the configuration file path shown above " + "(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml), " + "or use a built-in integration ID directly." + ) + elif os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip(): + console.print( + "\nCheck whether SPECKIT_INTEGRATION_CATALOG_URL is set correctly and reachable, " + "or unset it to use the configured catalog files, or use a built-in integration ID directly." + ) + else: + console.print("\nTry again when online, or use a built-in integration ID directly.") + else: + console.print(f"[red]Error:[/red] Integration '{integration_id}' not found") + console.print("\nTry: specify integration search") + raise typer.Exit(1) + + +@integration_catalog_app.command("list") +def integration_catalog_list(): + """List configured integration catalog sources.""" + from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + env_override = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip() + + try: + if env_override: + project_configs = None + configs = catalog.get_catalog_configs() + else: + project_configs = catalog.get_project_catalog_configs() + configs = project_configs if project_configs is not None else catalog.get_catalog_configs() + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Integration Catalog Sources:[/bold cyan]\n") + if env_override: + console.print( + " SPECKIT_INTEGRATION_CATALOG_URL is set; it supersedes configured catalog files." + ) + console.print( + " Project/user catalog sources are not active while the env override is set.\n" + ) + console.print("[bold]Active catalog source from environment (non-removable here):[/bold]\n") + elif project_configs is None: + console.print(" No project-level catalog sources configured.\n") + console.print("[bold]Active catalog sources (non-removable here):[/bold]\n") + else: + console.print("[bold]Project catalog sources (removable):[/bold]\n") + + for i, cfg in enumerate(configs): + install_status = ( + "[green]install allowed[/green]" + if cfg.get("install_allowed") + else "[yellow]discovery only[/yellow]" + ) + raw_name = cfg.get("name") + display_name = str(raw_name).strip() if raw_name is not None else "" + if not display_name: + display_name = f"catalog-{i + 1}" + if env_override or project_configs is None: + console.print(f" - [bold]{display_name}[/bold] — {install_status}") + else: + console.print(f" [{i}] [bold]{display_name}[/bold] — {install_status}") + console.print(f" {cfg.get('url', '')}") + if cfg.get("description"): + console.print(f" [dim]{cfg['description']}[/dim]") + console.print() + + +@integration_catalog_app.command("add") +def integration_catalog_add( + url: str = typer.Argument( + ..., + help=( + "Catalog URL to add (HTTPS required, except http://localhost, " + "http://127.0.0.1, or http://[::1] for local testing)" + ), + ), + name: Optional[str] = typer.Option(None, "--name", help="Catalog name"), +): + """Add an integration catalog source to the project config.""" + from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + + # Normalize once here so the success message reflects what was actually + # stored. ``IntegrationCatalog.add_catalog`` strips again defensively. + normalized_url = url.strip() + + try: + catalog.add_catalog(normalized_url, name) + except IntegrationCatalogError as exc: + # Covers both URL validation (base class) and config-file validation + # (IntegrationValidationError subclass). + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Catalog source added: {normalized_url}") + + +@integration_catalog_app.command("remove") +def integration_catalog_remove( + index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), +): + """Remove an integration catalog source by 0-based index.""" + from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + + try: + removed_name = catalog.remove_catalog(index) + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") + # ===== Preset Commands ===== diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py new file mode 100644 index 0000000000..daf0efad54 --- /dev/null +++ b/src/specify_cli/catalog_docs.py @@ -0,0 +1,210 @@ +"""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-`" + ), + "bob": "IDE-based agent", + "devin": ( + "Skills-based integration; installs skills into `.devin/skills/` " + "and invokes them as `/speckit-`" + ), + "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 \"` " + "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. + """ + import warnings + + 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: + 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: + 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"`{render_cell(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" diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py new file mode 100644 index 0000000000..fea9d3314f --- /dev/null +++ b/src/specify_cli/community_catalog_docs.py @@ -0,0 +1,114 @@ +"""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" + + +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("|", "").replace("\r\n", " ").replace("\r", " ").replace("\n", " ").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(): + if path == COMMUNITY_CATALOG_PATH: + message = ( + f"Community catalog not found at {path}. " + "Ensure the repository checkout includes the extensions/ directory." + ) + else: + message = ( + f"Community catalog not found at {path}. " + "Provide path= to a valid community catalog JSON file." + ) + raise FileNotFoundError(message) + 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"], + ] + ) + + 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" diff --git a/src/specify_cli/integrations/_query_commands.py b/src/specify_cli/integrations/_query_commands.py index bb47e6142e..270ca786c3 100644 --- a/src/specify_cli/integrations/_query_commands.py +++ b/src/specify_cli/integrations/_query_commands.py @@ -265,8 +265,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) from exc + return + from . import INTEGRATION_REGISTRY from .catalog import ( IntegrationCatalog, diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py index 957415708c..25b5ff9457 100644 --- a/tests/extensions/test_update_agent_context_feature_json.py +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -11,7 +11,6 @@ from tests.conftest import requires_bash from tests.extensions.test_extension_agent_context import ( - BASH, POWERSHELL, _bash_posix_path, _run_bash_agent_context_script, diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py new file mode 100644 index 0000000000..00b90117a2 --- /dev/null +++ b/tests/test_catalog_docs.py @@ -0,0 +1,225 @@ +"""Tests for the integration registry documentation generation.""" + +from __future__ import annotations + +from contextlib import ExitStack, contextmanager +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from specify_cli.catalog_docs import ( + escape_url_for_markdown_link, + escape_markdown_link_text, + INTEGRATIONS_REFERENCE_PATH, + render_cell, + list_integrations_for_docs, + render_integrations_table, +) +from specify_cli import app + + +runner = CliRunner() + + +@contextmanager +def _get_catalog_docs_patches( + *, + fake_registry=None, + fake_doc_urls=None, + fake_label_overrides=None, + fake_notes=None, +): + """Context manager that applies mocked registry and doc maps for tests.""" + + if fake_registry is None: + fake_registry = { + "copilot": MagicMock(config={"name": "GitHub Copilot"}), + "codex": MagicMock(config={"name": "Codex CLI"}), + } + if fake_doc_urls is None: + fake_doc_urls = { + "copilot": "https://code.visualstudio.com/", + "codex": "https://github.com/openai/codex", + } + if fake_label_overrides is None: + fake_label_overrides = {} + if fake_notes is None: + fake_notes = {"copilot": "Test note"} + + with ExitStack() as stack: + stack.enter_context( + patch( + "specify_cli.catalog_docs._get_integration_registry", + return_value=fake_registry, + ) + ) + stack.enter_context( + patch("specify_cli.catalog_docs.INTEGRATION_DOC_URLS", fake_doc_urls) + ) + stack.enter_context( + patch( + "specify_cli.catalog_docs.INTEGRATION_LABEL_OVERRIDES", + fake_label_overrides, + ) + ) + stack.enter_context( + patch("specify_cli.catalog_docs.INTEGRATION_NOTES", fake_notes) + ) + yield + + +def test_integrations_table_renders(): + table = render_integrations_table() + lines = table.splitlines() + assert lines[0] == "| Agent | Key | Notes |" + assert lines[1] == "| --- | --- | --- |" + + +def test_integrations_reference_doc_matches_renderer(): + if not INTEGRATIONS_REFERENCE_PATH.exists(): + pytest.skip( + f"Integrations reference not found at {INTEGRATIONS_REFERENCE_PATH}. " + "Skipping (expected when running from sdist/wheel)." + ) + doc_text = INTEGRATIONS_REFERENCE_PATH.read_text(encoding="utf-8") + start_marker = "## Supported AI Coding Agents\n\n" + end_marker = "\n## List Available Integrations\n" + start = doc_text.index(start_marker) + len(start_marker) + end = doc_text.index(end_marker) + committed_table = doc_text[start:end].rstrip("\n") + rendered_table = render_integrations_table().rstrip("\n") + + def normalize_table(table: str) -> list[list[str]]: + rows: list[list[str]] = [] + for line in table.splitlines(): + if not line.startswith("| "): + continue + parts = [part.strip() for part in line.strip().strip("|").split("|")] + if parts and set(parts[0]) == {"-"}: + continue + rows.append(parts) + return rows + + assert normalize_table(committed_table) == normalize_table(rendered_table) + + +def test_render_cell_escapes_pipes_and_normalizes_newlines(): + assert render_cell("a|b") == "a\\|b" + assert render_cell("a\nb") == "a b" + assert render_cell("a\r\nb") == "a b" + assert render_cell("a\rb") == "a b" + assert render_cell("a|b\nc") == "a\\|b c" + + +def test_escape_url_for_markdown_link(): + """Test that URLs with special characters are properly escaped for Markdown links.""" + # URLs containing ) and | should be escaped + assert escape_url_for_markdown_link("https://example.com/path)") == ( + "https://example.com/path\\)" + ) + assert escape_url_for_markdown_link("https://example.com/path|query") == ( + "https://example.com/path\\|query" + ) + assert escape_url_for_markdown_link("https://example.com/path)|query") == ( + "https://example.com/path\\)\\|query" + ) + # URLs without special characters should be unchanged + assert escape_url_for_markdown_link("https://example.com/path") == ( + "https://example.com/path" + ) + + +def test_escape_markdown_link_text(): + assert escape_markdown_link_text("Code [Buddy]") == "Code \\[Buddy\\]" + + +def test_integrations_docs_label_and_url_sources(): + """Test using mocked registry/doc maps to avoid test brittleness.""" + with _get_catalog_docs_patches(fake_notes={}): + rows = { + key: (label, url) + for key, label, url, _notes in list_integrations_for_docs() + } + assert rows["copilot"][0] == "GitHub Copilot" + assert rows["copilot"][1] == "https://code.visualstudio.com/" + assert rows["codex"][0] == "Codex CLI" + assert rows["codex"][1] == "https://github.com/openai/codex" + + +def test_cli_integration_search_markdown_success(): + """Test that `integration search --markdown` outputs the markdown table.""" + with _get_catalog_docs_patches(): + result = runner.invoke(app, ["integration", "search", "--markdown"]) + assert result.exit_code == 0 + lines = result.stdout.splitlines() + assert len(lines) > 2 # At least header, separator, and one data row + assert lines[0] == "| Agent | Key | Notes |" + assert lines[1] == "| --- | --- | --- |" + + +def test_render_integrations_table_escapes_link_text(): + fake_registry = { + "bracket": MagicMock(config={"name": "Code [Buddy]"}), + } + fake_doc_urls = { + "bracket": "https://example.com/docs", + } + + with _get_catalog_docs_patches( + fake_registry=fake_registry, + fake_doc_urls=fake_doc_urls, + fake_notes={}, + ): + table = render_integrations_table() + + assert "[Code \\[Buddy\\]](https://example.com/docs)" in table + + +def test_cli_integration_search_markdown_with_filters_warns(): + """Test that `integration search --markdown` with filters warns.""" + with _get_catalog_docs_patches(): + result = runner.invoke( + app, + [ + "integration", + "search", + "test-query", + "--markdown", + "--tag", + "some-tag", + ], + ) + assert result.exit_code == 0 + # Check for the specific Typer warning message + assert "ignores query/--tag/--author filters" in result.stderr + lines = result.stdout.splitlines() + assert lines[0] == "| Agent | Key | Notes |" + + +def test_cli_integration_search_markdown_stdout_is_clean(): + """Test that stdout contains only the markdown table with proper format.""" + with _get_catalog_docs_patches(): + result = runner.invoke(app, ["integration", "search", "--markdown"]) + assert result.exit_code == 0 + stdout = result.stdout + lines = stdout.splitlines() + # Verify markdown table header is present + assert len(lines) > 1 + assert lines[0] == "| Agent | Key | Notes |" + # Ensure stderr has no error messages + assert "error" not in result.stderr.lower() + + +def test_cli_integration_search_markdown_failure_exits_nonzero(): + """Test that render failures return a clean non-zero exit.""" + with _get_catalog_docs_patches(): + with patch( + "specify_cli.catalog_docs.render_integrations_table", + side_effect=ValueError("boom"), + ): + result = runner.invoke(app, ["integration", "search", "--markdown"]) + + assert result.exit_code == 1 + assert "Error rendering integrations table: boom" in result.stderr + assert result.stdout == "" diff --git a/tests/test_community_catalog_docs.py b/tests/test_community_catalog_docs.py new file mode 100644 index 0000000000..e7fac6e88a --- /dev/null +++ b/tests/test_community_catalog_docs.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from specify_cli.community_catalog_docs import list_community_extensions, render_community_extensions_table + + +def _write_catalog(tmp_path: Path, extensions: dict) -> Path: + p = tmp_path / "catalog.community.json" + p.write_text(json.dumps({"extensions": extensions}), encoding="utf-8") + return p + + +# --------------------------------------------------------------------------- +# Happy-path tests against the real catalog +# --------------------------------------------------------------------------- + +def test_community_extensions_table_renders() -> None: + from specify_cli.community_catalog_docs import COMMUNITY_CATALOG_PATH + if not COMMUNITY_CATALOG_PATH.exists(): + pytest.skip( + f"Community catalog not found at {COMMUNITY_CATALOG_PATH}. " + "Skipping (expected when running from sdist/wheel)." + ) + table = render_community_extensions_table() + assert "| Extension" in table + assert "| ID" in table + assert "| Description" in table + assert "| Tags" in table + assert "| Verified" in table + + +def test_community_extensions_are_sorted_by_name() -> None: + from specify_cli.community_catalog_docs import COMMUNITY_CATALOG_PATH + if not COMMUNITY_CATALOG_PATH.exists(): + pytest.skip( + f"Community catalog not found at {COMMUNITY_CATALOG_PATH}. " + "Skipping (expected when running from sdist/wheel)." + ) + rows = list_community_extensions() + names = [row["name"] for row in rows] + assert names == sorted(names, key=str.casefold) + + +def test_community_extensions_table_rows_are_rendered_in_sorted_order(tmp_path: Path) -> None: + catalog = _write_catalog( + tmp_path, + { + "gamma": { + "name": "Gamma", + "id": "gamma", + "description": "Third entry", + "tags": [], + "verified": False, + "repository": "", + }, + "alpha": { + "name": "Alpha", + "id": "alpha", + "description": "First entry", + "tags": [], + "verified": True, + "repository": "", + }, + "beta": { + "name": "Beta", + "id": "beta", + "description": "Second entry", + "tags": [], + "verified": False, + "repository": "", + }, + }, + ) + table = render_community_extensions_table(path=catalog) + + rendered_rows: list[tuple[str, str]] = [] + for line in table.splitlines(): + if not line.startswith("| "): + continue + if line == "| Extension | ID | Description | Tags | Verified |": + continue + if line == "| --- | --- | --- | --- | --- |": + continue + cells = [part.strip() for part in line.strip("|").split("|")] + assert len(cells) == 5, f"Malformed table row: {line}" + extension_cell = cells[0] + if extension_cell.startswith("[") and "](" in extension_cell: + extension_name = extension_cell[1:extension_cell.index("](")] + else: + extension_name = extension_cell + rendered_rows.append((extension_name, cells[1].strip("`"))) + + expected_rows = [("Alpha", "alpha"), ("Beta", "beta"), ("Gamma", "gamma")] + assert rendered_rows == expected_rows + + +# --------------------------------------------------------------------------- +# Edge-case tests using synthetic catalogs +# --------------------------------------------------------------------------- + +def test_missing_catalog_file(tmp_path: Path) -> None: + with pytest.raises( + FileNotFoundError, + match="Provide path= to a valid community catalog JSON file", + ): + list_community_extensions(path=tmp_path / "missing.json") + + +def test_malformed_json(tmp_path: Path) -> None: + bad = tmp_path / "bad.json" + bad.write_text("not valid json", encoding="utf-8") + with pytest.raises(json.JSONDecodeError): + list_community_extensions(path=bad) + + +def test_non_dict_root(tmp_path: Path) -> None: + f = tmp_path / "catalog.json" + f.write_text(json.dumps([{"id": "foo"}]), encoding="utf-8") + with pytest.raises(ValueError, match="JSON object"): + list_community_extensions(path=f) + + +def test_missing_extensions_key(tmp_path: Path) -> None: + f = tmp_path / "catalog.json" + f.write_text(json.dumps({"other": {}}), encoding="utf-8") + with pytest.raises(ValueError, match="'extensions' object"): + list_community_extensions(path=f) + + +def test_non_dict_extension_value(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, {"foo": "not-a-dict"}) + with pytest.raises(ValueError, match="must be a mapping"): + list_community_extensions(path=f) + + +def test_empty_catalog_raises(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, {}) + with pytest.raises(ValueError, match="no extensions"): + render_community_extensions_table(path=f) + + +def test_extension_without_repository(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": {"name": "Foo", "id": "foo", "description": "A foo tool", "tags": [], "verified": False, "repository": ""}, + }) + table = render_community_extensions_table(path=f) + assert "Foo" in table + assert "[Foo](" not in table # plain name, no link + + +def test_whitespace_repository_is_treated_as_missing(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": {"name": "Foo", "id": "foo", "description": "", "tags": [], "verified": False, "repository": " "}, + }) + table = render_community_extensions_table(path=f) + assert "Foo" in table + assert "[Foo](" not in table + + +def test_tags_containing_pipe_do_not_break_table(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + # No "id" field — exercises ext_id fallback; tag has pipe — exercises stripping + "foo": {"name": "Foo", "description": "", "tags": ["foo|bar"], "verified": False, "repository": ""}, + }) + table = render_community_extensions_table(path=f) + # pipe stripped from tag value + assert "`foobar`" in table + # id falls back to the dict key when "id" field is absent + assert "`foo`" in table + # row is well-formed: 5-column table has exactly 6 pipe separators per row + foo_row = next(line for line in table.split("\n") if line.startswith("| ") and "Foo" in line) + assert foo_row.count("|") == 6 + + +def test_tags_with_newlines_are_normalized(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": { + "name": "Foo", + "description": "", + "tags": ["foo\nbar", "baz\r\nqux", "quux\rquuz"], + "verified": False, + "repository": "", + }, + }) + table = render_community_extensions_table(path=f) + assert "`foo bar`" in table + assert "`baz qux`" in table + assert "`quux quuz`" in table + foo_row = next(line for line in table.split("\n") if line.startswith("| ") and "Foo" in line) + assert foo_row.count("|") == 6 + + +def test_non_list_tags_renders_em_dash(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": {"name": "Foo", "description": "", "tags": "not-a-list", "verified": False, "repository": ""}, + }) + table = render_community_extensions_table(path=f) + assert "—" in table + + +def test_url_escaping_in_repository_links(tmp_path: Path) -> None: + """Test that URLs with `)` and `|` are properly escaped in markdown links.""" + f = _write_catalog(tmp_path, { + "foo": { + "name": "Foo", + "description": "", + "tags": [], + "verified": False, + "repository": "https://example.com/repo?x=1)&y=2|bad", # Contains ) and | + }, + }) + table = render_community_extensions_table(path=f) + # The URL should be escaped: ) → \) and | → \| + assert "[Foo](https://example.com/repo?x=1\\)&y=2\\|bad)" in table + + +def test_link_text_is_escaped(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": { + "name": "Code [Buddy]", + "description": "", + "tags": [], + "verified": False, + "repository": "https://example.com/repo", + }, + }) + table = render_community_extensions_table(path=f) + assert "[Code \\[Buddy\\]](https://example.com/repo)" in table + + +def test_extension_id_is_sanitized(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo|bar": { + "name": "Foo", + "id": "foo|bar\n", + "description": "", + "tags": [], + "verified": False, + "repository": "", + }, + }) + table = render_community_extensions_table(path=f) + assert "`foo\\|bar `" in table