mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 22:32:13 +08:00
* 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>
310 lines
12 KiB
Python
310 lines
12 KiB
Python
"""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}")
|