"""specify integration helpers — internal utilities shared across command modules.""" from __future__ import annotations import os from pathlib import Path from typing import Any, Callable 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, preserve_context_files=False ) 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``, ``context_files``, 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. Existing ``context_files`` lists are also preserved so projects can keep multi-agent context anchors during integration switches. 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) # --------------------------------------------------------------------------- # Extension (un)registration helpers (shared by use / switch / upgrade) # --------------------------------------------------------------------------- def _best_effort_extension_op( project_root: Path, agent_key: str, op: Callable[[Any, str], None], *, phase: str, continuing: str, ) -> None: """Run a best-effort ``ExtensionManager`` operation for ``agent_key``. ``op`` receives the ``ExtensionManager`` and ``agent_key``. Any failure is surfaced as a warning via ``_print_cli_warning`` and never aborts the surrounding integration operation. ``continuing`` describes what already succeeded so the warning makes the partial outcome clear. """ try: from ..extensions import ExtensionManager ext_mgr = ExtensionManager(project_root) op(ext_mgr, agent_key) except Exception as ext_err: from .. import _print_cli_warning _print_cli_warning(phase, "integration", agent_key, ext_err, continuing=continuing) def _register_extensions_for_agent( project_root: Path, agent_key: str, *, continuing: str, ) -> None: """Register all enabled extensions' commands/skills for ``agent_key``. ``use`` / ``switch`` re-register enabled extensions for the agent they activate; ``upgrade`` backfills them for the refreshed agent. Plain ``install`` deliberately does not call this helper so adding a secondary integration has no extension side effects until it is selected or upgraded. See issue #2886. Known limitation: extension *skill* rendering is scoped to the active agent (init-options track a single ``ai`` / ``ai_skills`` pair). A skills-mode agent registered while it is *not* the active agent (e.g. Copilot ``--skills`` registered while non-active) therefore receives command files rather than skills here — matching ``extension add``'s multi-agent behavior. ``use`` / ``switch`` avoid this because they make the target the active agent first. Per-agent skills parity is tracked in #2948. Best-effort: never aborts the surrounding integration operation. Callers invoke it *after* the use/upgrade/switch transaction has committed so a failure here cannot trigger a rollback. """ _best_effort_extension_op( project_root, agent_key, lambda mgr, key: mgr.register_enabled_extensions_for_agent(key), phase="register extension artifacts for", continuing=continuing, ) def _unregister_extensions_for_agent( project_root: Path, agent_key: str, *, continuing: str, ) -> None: """Best-effort removal of ``agent_key``'s extension artifacts. Used by ``switch`` when uninstalling the previous integration so its extension command/skill files don't linger as orphans in the old agent's directory. """ _best_effort_extension_op( project_root, agent_key, lambda mgr, key: mgr.unregister_agent_artifacts(key), phase="clean up extension artifacts for", continuing=continuing, ) # --------------------------------------------------------------------------- # 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