refactor(integrations): co-locate integration commands in integrations/ domain dir (PR-5/8) (#2720)

* refactor(integrations): co-locate integration commands in integrations/ domain dir

- Remove commands/ stubs (handlers will live in domain dirs)
- Move all integration CLI handlers out of __init__.py into integrations/
- Split into focused modules under integrations/:
    _helpers.py           (340 lines) — domain helpers
    _install_commands.py  (306 lines) — install / uninstall
    _migrate_commands.py  (487 lines) — switch / upgrade
    _query_commands.py    (442 lines) — list / use / search / info / catalog
    _commands.py           (34 lines) — app objects + register()
- __init__.py reduced by ~1400 lines; integration block replaced with register() call
- Fix patch paths in tests to new module locations

* fix(integrations): restore original integration list output in refactor

Preserve the CLI Required column, post-table default/installed summary,
and no-installed guidance that were dropped during the no-behavior-change
refactor of integration list into _query_commands.py.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(integrations): restore _clear/_update_init_options public imports

The refactor that split integration commands moved
_clear_init_options_for_integration and _update_init_options_for_integration
into integrations/_helpers.py, but tests still import them from the top-level
specify_cli package, causing ImportError. Re-export them with explicit aliases
at the end of __init__.py to preserve the public import surface.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
darion-yaphet
2026-06-03 01:21:19 +08:00
committed by GitHub
parent a1b8de68bc
commit ee17b04784
13 changed files with 1718 additions and 1518 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
"""specify extension * commands — placeholder for future extraction."""
from __future__ import annotations

View File

@@ -151,13 +151,15 @@ def register(app: typer.Typer) -> None:
# Lazy imports to avoid circular dependency — __init__.py imports this module
from .. import (
_install_shared_infra_or_exit,
_parse_integration_options,
_print_cli_warning,
_update_agent_context_config_file,
_write_integration_json,
ensure_executable_scripts,
save_init_options,
)
from ..integrations._commands import (
_parse_integration_options,
_write_integration_json,
)
from ..integration_runtime import with_integration_setting as _with_integration_setting
show_banner()

View File

@@ -1,2 +0,0 @@
"""specify integration * commands — placeholder for future extraction."""
from __future__ import annotations

View File

@@ -1,2 +0,0 @@
"""specify preset * commands — placeholder for future extraction."""
from __future__ import annotations

View File

@@ -1,2 +0,0 @@
"""specify workflow * commands — placeholder for future extraction."""
from __future__ import annotations

View File

@@ -0,0 +1,34 @@
"""specify integration * commands — app objects and register() entry point."""
from __future__ import annotations
import typer
from .._assets import get_speckit_version # noqa: F401 — re-exported for monkeypatching in tests
# Re-export helpers used by commands/init.py and tests
from ._helpers import ( # noqa: F401
_cli_error_detail,
_cli_phase_label,
_parse_integration_options,
_write_integration_json,
)
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,
)
integration_app.add_typer(integration_catalog_app, name="catalog")
def register(app: typer.Typer) -> None:
from . import _install_commands # noqa: F401 — registers handlers via decorators
from . import _migrate_commands # noqa: F401
from . import _query_commands # noqa: F401
app.add_typer(integration_app, name="integration")

View File

@@ -0,0 +1,402 @@
"""specify integration helpers — internal utilities shared across command modules."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import typer
from .._agent_config import SCRIPT_TYPE_CHOICES
from .._console import console
from ..integration_runtime import (
invoke_separator_for_integration as _invoke_separator_for_integration,
resolve_integration_options as _resolve_integration_options_impl,
with_integration_setting as _with_integration_setting,
)
from ..integration_state import (
INTEGRATION_JSON,
INTEGRATION_STATE_SCHEMA,
integration_setting as _integration_setting,
try_read_integration_json as _try_read_integration_json,
write_integration_json as _write_integration_json_file,
)
def _get_speckit_version() -> str:
"""Return the current Spec Kit version.
Resolved lazily through ``_commands.get_speckit_version`` so that tests
that monkeypatch ``specify_cli.integrations._commands.get_speckit_version``
still affect helpers called from the command handlers.
"""
from . import _commands # noqa: PLC0415 — intentional late import to avoid circular + enable patching
return _commands.get_speckit_version()
# ---------------------------------------------------------------------------
# JSON read / write helpers
# ---------------------------------------------------------------------------
def _read_integration_json(project_root: Path) -> dict[str, Any]:
"""Load ``.specify/integration.json``. Returns normalized state when present.
Delegates the parse / schema-guard logic to the shared
:func:`_try_read_integration_json` helper so the CLI and workflow engine
cannot drift on validation rules. Each error variant is translated into
the existing loud-fail UX (console message + ``typer.Exit(1)``).
"""
path = project_root / INTEGRATION_JSON
state, error = _try_read_integration_json(project_root)
if error is None:
return state or {}
if error.kind == "decode":
console.print(f"[red]Error:[/red] {path} contains invalid JSON or is not valid UTF-8.")
console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
console.print(f"[dim]Details:[/dim] {error.detail}")
elif error.kind == "os":
console.print(f"[red]Error:[/red] Could not read {path}.")
console.print(f"Please fix file permissions or delete {INTEGRATION_JSON} and retry.")
console.print(f"[dim]Details:[/dim] {error.detail}")
elif error.kind == "not_object":
console.print(
f"[red]Error:[/red] {path} must contain a JSON object, got {error.detail}."
)
console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
elif error.kind == "schema_too_new":
console.print(
f"[red]Error:[/red] {path} uses integration state schema {error.schema}, "
f"but this CLI only supports schema {INTEGRATION_STATE_SCHEMA}."
)
console.print("Please upgrade Spec Kit before modifying integrations.")
raise typer.Exit(1)
def _write_integration_json(
project_root: Path,
integration_key: str | None,
installed_integrations: list[str] | None = None,
integration_settings: dict[str, dict[str, Any]] | None = None,
) -> None:
"""Write ``.specify/integration.json`` with legacy-compatible state."""
_write_integration_json_file(
project_root,
version=_get_speckit_version(),
integration_key=integration_key,
installed_integrations=installed_integrations,
settings=integration_settings,
)
# ---------------------------------------------------------------------------
# init-options.json helpers
# ---------------------------------------------------------------------------
def _refresh_init_options_speckit_version(project_root: Path) -> None:
"""Refresh only the Spec Kit version recorded in init-options.json."""
from .. import load_init_options, save_init_options
opts = load_init_options(project_root)
if not isinstance(opts, dict) or not opts:
return
opts["speckit_version"] = _get_speckit_version()
save_init_options(project_root, opts)
def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None:
"""Clear active integration keys from init-options.json when they match.
Also clears ``context_file`` from the agent-context extension config so
no stale path is left behind when the integration is uninstalled.
"""
from .. import (
_AGENT_CTX_EXT_CONFIG,
_update_agent_context_config_file,
load_init_options,
save_init_options,
)
opts = load_init_options(project_root)
has_legacy_context_keys = ("context_file" in opts) or ("context_markers" in opts)
# Remove legacy fields that older versions may have written.
opts.pop("context_file", None)
opts.pop("context_markers", None)
if opts.get("integration") == integration_key or opts.get("ai") == integration_key:
opts.pop("integration", None)
opts.pop("ai", None)
opts.pop("ai_skills", None)
save_init_options(project_root, opts)
# Clear context_file in the extension config if it already exists.
# Avoid creating the config (and parent dirs) in projects where the
# agent-context extension was never installed.
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
if ext_cfg_path.exists():
_update_agent_context_config_file(
project_root, "", preserve_markers=True
)
elif has_legacy_context_keys:
save_init_options(project_root, opts)
def _remove_integration_json(project_root: Path) -> None:
"""Remove ``.specify/integration.json`` if it exists."""
path = project_root / INTEGRATION_JSON
if path.exists():
path.unlink()
# ---------------------------------------------------------------------------
# Error sentinels
# ---------------------------------------------------------------------------
_MANIFEST_READ_ERRORS = (ValueError, FileNotFoundError, OSError, UnicodeDecodeError)
class _SharedTemplateRefreshError(RuntimeError):
"""Raised when default integration metadata should not be persisted."""
# ---------------------------------------------------------------------------
# Script type resolution
# ---------------------------------------------------------------------------
def _normalize_script_type(script_type: str, source: str) -> str:
"""Normalize and validate a script type from CLI/config sources."""
normalized = script_type.strip().lower()
if normalized in SCRIPT_TYPE_CHOICES:
return normalized
console.print(
f"[red]Error:[/red] Invalid script type {script_type!r} from {source}. "
f"Expected one of: {', '.join(sorted(SCRIPT_TYPE_CHOICES.keys()))}."
)
raise typer.Exit(1)
def _resolve_script_type(project_root: Path, script_type: str | None) -> str:
"""Resolve the script type from the CLI flag or init-options.json."""
from .. import load_init_options
if script_type:
return _normalize_script_type(script_type, "--script")
opts = load_init_options(project_root)
saved = opts.get("script")
if isinstance(saved, str) and saved.strip():
return _normalize_script_type(saved, ".specify/init-options.json")
return "ps" if os.name == "nt" else "sh"
def _resolve_integration_script_type(
project_root: Path,
state: dict[str, Any],
key: str,
script_type: str | None = None,
) -> str:
"""Resolve script type for an integration, preferring stored settings."""
if script_type:
return _normalize_script_type(script_type, "--script")
stored = _integration_setting(state, key).get("script")
if isinstance(stored, str) and stored.strip():
return _normalize_script_type(stored, f"{INTEGRATION_JSON} integration_settings.{key}.script")
return _resolve_script_type(project_root, None)
# ---------------------------------------------------------------------------
# Integration options
# ---------------------------------------------------------------------------
def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None:
"""Parse --integration-options string into a dict matching the integration's declared options.
Returns ``None`` when no options are provided.
"""
import shlex
parsed: dict[str, Any] = {}
tokens = shlex.split(raw_options)
declared_options = list(integration.options())
declared = {opt.name.lstrip("-"): opt for opt in declared_options}
allowed = ", ".join(sorted(opt.name for opt in declared_options))
i = 0
while i < len(tokens):
token = tokens[i]
if not token.startswith("-"):
console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.")
if allowed:
console.print(f"Allowed options: {allowed}")
raise typer.Exit(1)
name = token.lstrip("-")
value: str | None = None
# Handle --name=value syntax
if "=" in name:
name, value = name.split("=", 1)
opt = declared.get(name)
if not opt:
console.print(f"[red]Error:[/red] Unknown integration option '{token}'.")
if allowed:
console.print(f"Allowed options: {allowed}")
raise typer.Exit(1)
key = name.replace("-", "_")
if opt.is_flag:
if value is not None:
console.print(f"[red]Error:[/red] Option '{opt.name}' is a flag and does not accept a value.")
raise typer.Exit(1)
parsed[key] = True
i += 1
elif value is not None:
parsed[key] = value
i += 1
elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"):
parsed[key] = tokens[i + 1]
i += 2
else:
console.print(f"[red]Error:[/red] Option '{opt.name}' requires a value.")
raise typer.Exit(1)
return parsed or None
def _resolve_integration_options(
integration: Any,
state: dict[str, Any],
key: str,
raw_options: str | None,
) -> tuple[str | None, dict[str, Any] | None]:
"""Resolve raw and parsed options for an integration operation."""
return _resolve_integration_options_impl(
integration,
state,
key,
raw_options,
parse_options=_parse_integration_options,
)
def _update_init_options_for_integration(
project_root: Path,
integration: Any,
script_type: str | None = None,
) -> None:
"""Update init-options.json and the agent-context extension config to
reflect *integration* as the active one.
``context_file`` and ``context_markers`` are stored in the agent-context
extension config (``.specify/extensions/agent-context/agent-context-config.yml``),
not in ``init-options.json``. Existing user-customised markers are
always preserved when the config already exists; invalid marker values
are silently ignored at runtime by ``_resolve_context_markers()`` which
falls back to the class-level defaults.
"""
from .. import (
_AGENT_CTX_EXT_CONFIG,
_update_agent_context_config_file,
load_init_options,
save_init_options,
)
from .base import SkillsIntegration
opts = load_init_options(project_root)
opts["integration"] = integration.key
opts["ai"] = integration.key
# Remove legacy fields if they were written by an older version.
opts.pop("context_file", None)
opts.pop("context_markers", None)
opts["speckit_version"] = _get_speckit_version()
if script_type:
opts["script"] = script_type
if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False):
opts["ai_skills"] = True
else:
opts.pop("ai_skills", None)
# Update the agent-context extension config BEFORE init-options.json
# so a failure here doesn't leave init-options partially updated.
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
if ext_cfg_path.exists():
_update_agent_context_config_file(
project_root,
integration.context_file,
preserve_markers=True,
)
elif integration.context_file:
# Extension config doesn't exist yet (extension not installed).
# Write defaults so scripts have something to read.
_update_agent_context_config_file(
project_root,
integration.context_file,
preserve_markers=False,
)
save_init_options(project_root, opts)
# ---------------------------------------------------------------------------
# Default integration persistence
# ---------------------------------------------------------------------------
def _set_default_integration(
project_root: Path,
state: dict[str, Any],
key: str,
integration: Any,
installed_keys: list[str],
*,
script_type: str | None = None,
raw_options: str | None = None,
parsed_options: dict[str, Any] | None = None,
refresh_templates: bool = True,
refresh_templates_force: bool = False,
refresh_hint: str | None = None,
) -> None:
"""Persist *key* as default and align active runtime metadata."""
from .. import _install_shared_infra
resolved_script = _resolve_integration_script_type(project_root, state, key, script_type)
settings = _with_integration_setting(
state,
key,
integration,
script_type=resolved_script,
raw_options=raw_options,
parsed_options=parsed_options,
)
if refresh_templates:
try:
_install_shared_infra(
project_root,
resolved_script,
invoke_separator=_invoke_separator_for_integration(
integration, {"integration_settings": settings}, key, parsed_options
),
force=refresh_templates_force,
refresh_managed=True,
refresh_hint=refresh_hint,
)
except (ValueError, OSError) as exc:
raise _SharedTemplateRefreshError(
f"Failed to refresh shared infrastructure for '{key}': {exc}"
) from exc
_write_integration_json(project_root, key, installed_keys, settings)
_update_init_options_for_integration(project_root, integration, script_type=resolved_script)
def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None:
try:
_set_default_integration(*args, **kwargs)
except _SharedTemplateRefreshError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
# ---------------------------------------------------------------------------
# CLI formatting helpers (re-exported from _commands.py)
# ---------------------------------------------------------------------------
def _cli_error_detail(exc: BaseException) -> str:
"""Return a compact one-line exception detail for CLI output."""
return str(exc).replace("\n", " ").strip() or exc.__class__.__name__
def _cli_phase_label(phase: str, target_kind: str, target: str | None = None) -> str:
"""Format a stable operation label for user-visible diagnostics."""
label = f"{phase} {target_kind}".strip()
if target:
label = f"{label} '{target}'"
return label

View File

@@ -0,0 +1,309 @@
"""specify integration install / uninstall command handlers."""
from __future__ import annotations
import os
import typer
from .._console import console
from .._utils import _display_project_path
from ..integration_runtime import (
invoke_separator_for_integration as _invoke_separator_for_integration,
with_integration_setting as _with_integration_setting,
)
from ..integration_state import (
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,
)
from ._commands import integration_app
from ._helpers import (
_MANIFEST_READ_ERRORS,
_clear_init_options_for_integration,
_cli_error_detail,
_cli_phase_label,
_get_speckit_version,
_read_integration_json,
_refresh_init_options_speckit_version,
_remove_integration_json,
_resolve_integration_options,
_resolve_script_type,
_set_default_integration_or_exit,
_update_init_options_for_integration,
_write_integration_json,
)
@integration_app.command("install")
def integration_install(
key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"),
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="Allow multi-install when integrations are not declared safe"),
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
):
"""Install an integration into an existing project."""
from . import INTEGRATION_REGISTRY, get_integration
from .manifest import IntegrationManifest
from .. import _require_specify_project, _install_shared_infra_or_exit
project_root = _require_specify_project()
integration = get_integration(key)
if integration is None:
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
available = ", ".join(sorted(INTEGRATION_REGISTRY.keys()))
console.print(f"Available integrations: {available}")
raise typer.Exit(1)
current = _read_integration_json(project_root)
default_key = _default_integration_key(current)
installed_keys = _installed_integration_keys(current)
if key in installed_keys:
console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]")
if default_key == key:
console.print("It is already the default integration.")
else:
console.print(
f"To make it the default integration, run "
f"[cyan]specify integration use {key}[/cyan]."
)
console.print(
f"To refresh its managed files or options, run "
f"[cyan]specify integration upgrade {key}[/cyan]."
)
console.print("No files were changed.")
raise typer.Exit(0)
if installed_keys and not force:
unsafe_keys = []
for installed_key in installed_keys:
installed_integration = get_integration(installed_key)
if not installed_integration or not getattr(installed_integration, "multi_install_safe", False):
unsafe_keys.append(installed_key)
if unsafe_keys or not getattr(integration, "multi_install_safe", False):
console.print(
f"[red]Error:[/red] Installed integrations: {', '.join(installed_keys)}."
)
if default_key:
console.print(f"Default integration: [cyan]{default_key}[/cyan].")
console.print(
"Installing multiple integrations is only automatic when all involved "
"integrations are declared multi-install safe."
)
console.print(
f"To replace the default integration, run "
f"[cyan]specify integration switch {key}[/cyan]."
)
console.print(
f"To install '{key}' alongside the existing integrations anyway, "
"retry the same install command with [cyan]--force[/cyan]."
)
raise typer.Exit(1)
selected_script = _resolve_script_type(project_root, 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 present (safe to run unconditionally;
# _install_shared_infra merges missing files without overwriting).
infra_integration = integration
infra_key = key
infra_parsed = parsed_options
if default_key:
default_integration = get_integration(default_key)
if default_integration is not None:
infra_integration = default_integration
infra_key = default_key
_, infra_parsed = _resolve_integration_options(
default_integration, current, default_key, None
)
_install_shared_infra_or_exit(
project_root,
selected_script,
invoke_separator=_invoke_separator_for_integration(
infra_integration, current, infra_key, infra_parsed
),
)
if os.name != "nt":
from .. import ensure_executable_scripts
ensure_executable_scripts(project_root)
manifest = IntegrationManifest(
integration.key, project_root, version=_get_speckit_version()
)
try:
integration.setup(
project_root, manifest,
parsed_options=parsed_options,
script_type=selected_script,
raw_options=raw_options,
)
manifest.save()
new_installed = _dedupe_integration_keys([*installed_keys, integration.key])
new_default = default_key or integration.key
settings = _with_integration_setting(
current,
integration.key,
integration,
script_type=selected_script,
raw_options=raw_options,
parsed_options=parsed_options,
)
_write_integration_json(project_root, new_default, new_installed, settings)
if new_default == integration.key:
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
else:
_refresh_init_options_speckit_version(project_root)
except Exception as exc:
# Attempt rollback of any files written by setup
try:
integration.teardown(project_root, manifest, force=True)
except Exception as rollback_err:
# Suppress so the original setup error remains the primary failure
from .. import _print_cli_warning
_print_cli_warning(
"rollback",
"integration",
key,
rollback_err,
continuing="The original install failure is still the primary error.",
)
if installed_keys:
_write_integration_json(
project_root, default_key, installed_keys, _integration_settings(current)
)
else:
_remove_integration_json(project_root)
console.print(
f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', key)}: "
f"{_cli_error_detail(exc)}"
)
raise typer.Exit(1)
name = (integration.config or {}).get("name", key)
console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully")
if default_key:
console.print(f"[dim]Default integration remains:[/dim] [cyan]{default_key}[/cyan]")
@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 . import get_integration
from .manifest import IntegrationManifest
from .. import _require_specify_project
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)
if not integration:
console.print(
f"[yellow]Warning:[/yellow] Integration '{key}' not found "
"in registry. Falling back to manifest-based cleanup."
)
removed, skipped = manifest.uninstall(project_root, force=force)
else:
removed, skipped = integration.teardown(project_root, manifest, force=force)
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}")

View File

@@ -0,0 +1,490 @@
"""specify integration switch / upgrade command handlers."""
from __future__ import annotations
import os
import typer
from .._console import console
from ..integration_runtime import (
invoke_separator_for_integration as _invoke_separator_for_integration,
with_integration_setting as _with_integration_setting,
)
from ..integration_state import (
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,
)
from ._commands import integration_app
from ._helpers import (
_MANIFEST_READ_ERRORS,
_SharedTemplateRefreshError,
_clear_init_options_for_integration,
_cli_error_detail,
_cli_phase_label,
_get_speckit_version,
_read_integration_json,
_refresh_init_options_speckit_version,
_remove_integration_json,
_resolve_integration_options,
_resolve_integration_script_type,
_resolve_script_type,
_set_default_integration,
_set_default_integration_or_exit,
_update_init_options_for_integration,
_write_integration_json,
)
@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 . import INTEGRATION_REGISTRY, get_integration
from .manifest import IntegrationManifest
from .. import _print_cli_warning, _require_specify_project, _install_shared_infra_or_exit
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]; "
"shared infrastructure 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 = current_integration.teardown(
project_root, old_manifest, 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")
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:
_print_cli_warning(
"clean up extension artifacts for",
"integration",
installed_key,
ext_err,
continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.",
)
# 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":
from .. import ensure_executable_scripts
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:
_print_cli_warning(
"register extension artifacts for",
"integration",
target,
ext_err,
continuing="The integration switch succeeded, but installed extensions may need re-registration.",
)
except Exception as exc:
# 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
_print_cli_warning(
"rollback",
"integration",
target,
rollback_err,
continuing="The original switch failure is still the primary error.",
)
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 {_cli_phase_label('install', 'integration', target)} "
f"during switch: {_cli_error_detail(exc)}"
)
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 . import get_integration
from .manifest import IntegrationManifest
from .. import _require_specify_project, _install_shared_infra_or_exit, _install_shared_infra
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":
from .. import ensure_executable_scripts
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:
_install_shared_infra(
project_root,
selected_script,
invoke_separator=_invoke_separator_for_integration(
integration, {"integration_settings": settings}, key, parsed_options
),
force=force,
refresh_managed=True,
)
except (ValueError, OSError) as exc:
raise _SharedTemplateRefreshError(
f"Failed to refresh shared infrastructure 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)
else:
_refresh_init_options_speckit_version(project_root)
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 {_cli_phase_label('upgrade', 'integration', key)}.")
console.print(f"[dim]Details:[/dim] {_cli_error_detail(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")

View File

@@ -0,0 +1,464 @@
"""specify integration list/use/search/info + catalog list/add/remove command handlers."""
from __future__ import annotations
import os
from typing import Optional
import typer
from rich.table import Table
from .._console import console
from ..integration_state import (
default_integration_key as _default_integration_key,
installed_integration_keys as _installed_integration_keys,
)
from ._commands import integration_app, integration_catalog_app
from ._helpers import (
_read_integration_json,
_resolve_integration_options,
_set_default_integration_or_exit,
)
@integration_app.command("list")
def integration_list(
catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"),
):
"""List available integrations and installed status."""
from . import INTEGRATION_REGISTRY
from .. import _require_specify_project
project_root = _require_specify_project()
current = _read_integration_json(project_root)
default_key = _default_integration_key(current)
installed_keys = set(_installed_integration_keys(current))
if catalog:
from .catalog import IntegrationCatalog, IntegrationCatalogError
ic = IntegrationCatalog(project_root)
try:
entries = ic.search()
except IntegrationCatalogError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
if not entries:
console.print("[yellow]No integrations found in catalog.[/yellow]")
return
table = Table(title="Integration Catalog")
table.add_column("ID", style="cyan")
table.add_column("Name")
table.add_column("Version")
table.add_column("Source")
table.add_column("Status")
table.add_column("Multi-install Safe")
for entry in sorted(entries, key=lambda e: e["id"]):
eid = entry["id"]
cat_name = entry.get("_catalog_name", "")
install_allowed = entry.get("_install_allowed", True)
if eid == default_key:
status = "[green]installed (default)[/green]"
elif eid in installed_keys:
status = "[green]installed[/green]"
elif eid in INTEGRATION_REGISTRY:
status = "built-in"
elif install_allowed is False:
status = "discovery-only"
else:
status = ""
safe = ""
if eid in INTEGRATION_REGISTRY:
reg_integ = INTEGRATION_REGISTRY[eid]
safe = "yes" if getattr(reg_integ, "multi_install_safe", False) else "no"
table.add_row(
eid,
entry.get("name", eid),
entry.get("version", ""),
cat_name,
status,
safe,
)
console.print(table)
return
if not INTEGRATION_REGISTRY:
console.print("[yellow]No integrations available.[/yellow]")
return
table = Table(title="Coding Agent Integrations")
table.add_column("Key", style="cyan")
table.add_column("Name")
table.add_column("Status")
table.add_column("CLI Required")
table.add_column("Multi-install Safe")
for key in sorted(INTEGRATION_REGISTRY.keys()):
integration = INTEGRATION_REGISTRY[key]
cfg = integration.config or {}
name = cfg.get("name", key)
requires_cli = cfg.get("requires_cli", False)
if key == default_key:
status = "[green]installed (default)[/green]"
elif key in installed_keys:
status = "[green]installed[/green]"
else:
status = ""
cli_req = "yes" if requires_cli else "no (IDE)"
safe = "yes" if getattr(integration, "multi_install_safe", False) else "no"
table.add_row(key, name, status, cli_req, safe)
console.print(table)
if installed_keys:
console.print(f"\n[dim]Default integration:[/dim] [cyan]{default_key or 'none'}[/cyan]")
console.print(f"[dim]Installed integrations:[/dim] [cyan]{', '.join(sorted(installed_keys))}[/cyan]")
else:
console.print("\n[yellow]No integration currently installed.[/yellow]")
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
@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 existing shared infrastructure files, including customizations, while changing the default"),
):
"""Set the default integration without uninstalling other integrations."""
from . import get_integration
from .. import _require_specify_project
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 <key>[/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,
refresh_hint=(
"To overwrite customizations, re-run with "
f"[cyan]specify integration use {key} --force[/cyan]."
),
)
console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].")
# ===== 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"),
):
"""Search for integrations in the active catalog stack."""
from . import INTEGRATION_REGISTRY
from .catalog import (
IntegrationCatalog,
IntegrationCatalogError,
IntegrationValidationError,
)
from .. import _require_specify_project
project_root = _require_specify_project()
integration_config = _read_integration_json(project_root)
installed_key = _default_integration_key(integration_config)
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 . import INTEGRATION_REGISTRY
from .catalog import (
IntegrationCatalog,
IntegrationCatalogError,
IntegrationValidationError,
)
from .. import _require_specify_project
project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)
installed_key = _default_integration_key(_read_integration_json(project_root))
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 .catalog import IntegrationCatalog, IntegrationCatalogError
from .. import _require_specify_project
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 .catalog import IntegrationCatalog, IntegrationCatalogError
from .. import _require_specify_project
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 .catalog import IntegrationCatalog, IntegrationCatalogError
from .. import _require_specify_project
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")

View File

@@ -242,9 +242,9 @@ class TestIntegrationInstall:
opts["speckit_version"] = "0.6.1"
init_options.write_text(json.dumps(opts), encoding="utf-8")
import specify_cli
import specify_cli.integrations._commands as _int_cmds
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
result = _run_in_project(project, [
"integration", "install", "codex",
@@ -1210,9 +1210,9 @@ class TestIntegrationUpgrade:
opts["speckit_version"] = "0.6.1"
init_options.write_text(json.dumps(opts), encoding="utf-8")
import specify_cli
import specify_cli.integrations._commands as _int_cmds
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
result = _run_in_project(project, [
"integration", "upgrade", "claude",
@@ -1236,9 +1236,9 @@ class TestIntegrationUpgrade:
opts["speckit_version"] = "0.6.1"
init_options.write_text(json.dumps(opts), encoding="utf-8")
import specify_cli
import specify_cli.integrations._commands as _int_cmds
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
result = _run_in_project(project, [
"integration", "upgrade", "claude",
@@ -1472,7 +1472,7 @@ class TestScriptTypeValidation:
class TestParseIntegrationOptionsEqualsForm:
def test_equals_form_parsed(self):
"""--commands-dir=./x should be parsed the same as --commands-dir ./x."""
from specify_cli import _parse_integration_options
from specify_cli.integrations._commands import _parse_integration_options
from specify_cli.integrations import get_integration
integration = get_integration("generic")

View File

@@ -13,12 +13,6 @@ def test_commands_init_importable():
assert callable(mod.register)
def test_commands_stubs_importable():
for name in ("integration", "preset", "extension", "workflow"):
mod = importlib.import_module(f"specify_cli.commands.{name}")
assert mod is not None
def test_agent_config_importable():
from specify_cli._agent_config import (
AGENT_CONFIG,