Files
spec-kit/src/specify_cli/integrations/_migrate_commands.py
T
655a3cb8ca fix(integrations): preserve native skill invocation prefixes (#3663)
* fix(integrations): use native dollar skill invocations

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): preserve skill post-process idempotence

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): preserve literal skill invocations

Resolve generated command references with the active agent prefix instead of rewriting all slash-form text during post-processing.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): preserve shared invocation prefix

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): preserve install invocation prefix

Pass dollar-style skill prefixes through bare-project integration installation and cover the shared template output.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): preserve dollar refs everywhere

Use agent-native invocation prefixes in extension command registration and dynamic shared-script command hints.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(shared-infra): preserve dollar command hints

Escape dollar-prefixed commands embedded in Bash strings and propagate the native prefix into installed Python command helpers.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(shared-infra): render native helper prefixes

Rewrite installed Bash and PowerShell formatter return expressions so direct callers receive the selected integration's native prefix.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(skills): use invocation-neutral hook guidance

Describe hook-derived references as command invocations so dollar-prefixed skills do not receive contradictory slash-command terminology.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* test(integrations): expect native fallback invocation

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* refactor(integrations): centralize invocation prefix selection

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): add Kimi /skill: prefix and fix docstrings

- Add SKILL_COLON_AGENTS frozenset and get_invocation_prefix() to
  _invocation_style.py so Kimi resolves to '/skill:' in skills mode
- Switch invoke_prefix_for_integration() to use get_invocation_prefix()
  instead of the binary dollar/slash check
- Update post_process_skill_content docstring (base.py) to cover both
  slash and dollar native invocation forms
- Update _resolve_command_refs_in_skill docstring (presets/__init__.py)
  to document the dollar-prefixed result alongside slash forms

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* fix(agents): use get_invocation_prefix for Kimi in register_commands

Replace the binary is_dollar_skills_agent ternary with get_invocation_prefix
so that __SPECKIT_COMMAND_*__ tokens in Kimi skill files resolve to
/skill:speckit-<name> rather than /speckit-<name>.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* fix(agents): remove unused is_dollar_skills_agent import

Leftover from replacing the inline ternary with get_invocation_prefix.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* fix(integrations): use get_invocation_prefix in post_process_skill_content

Replaces the binary is_dollar_skills_agent ternary with get_invocation_prefix
so that Kimi's hook-command note is injected as /skill:speckit-git-commit from
the start. This keeps _inject_hook_command_note idempotent for Kimi: the
previous note with its native prefix now matches on repeated passes, preventing
duplicate note injection.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* fix(presets): use get_invocation_prefix in _resolve_skill_command_refs

Replace the binary is_dollar_skills_agent ternary with get_invocation_prefix
so Kimi tokens resolve to /skill:speckit-* directly rather than /speckit-*
(which previously relied on the broad post-process body replacement).

Also fix test_restore_skill_preserves_dollar_command_refs to write raw_core
with the unresolved __SPECKIT_COMMAND_PLAN__ token, exercising the resolver
rather than bypassing it with a pre-resolved string.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* docs(presets): document /skill: form in _resolve_skill_command_refs

Add /skill:speckit-<cmd> to the docstring so the contract covers all
three native prefix forms returned by get_invocation_prefix.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* test(integrations): add Kimi /skill: prefix coverage

- test_skill_colon_prefix_core_command: resolve_command_refs with /skill: prefix
- test_get_invocation_prefix_skill_colon: get_invocation_prefix returns /skill:
  for kimi (skills), / for kimi (non-skills), $ for codex, / for claude
- test_kimi_skill_post_processing_is_idempotent: verifies Kimi's hook-command
  note is injected with /skill: prefix and does not duplicate on re-runs
- test_installed_bash_formatter_uses_skill_colon_prefix: shared-infra bash
  formatter outputs /skill:speckit-plan when installed with /skill: prefix

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* fix(kimi): use get_invocation_prefix in process_template, remove broad replacement

process_template() was still using a binary is_dollar_skills_agent ternary
to select between dollar and slash prefix, so Kimi tokens were emitted as
/speckit-* and then corrected by a broad .replace('/speckit-', '/skill:speckit-')
in KimiIntegration.post_process_skill_content(). That broad replacement would
also rewrite any literal /speckit-* text in generated skill content, contrary
to the PR's token-only behavior.

- Use get_invocation_prefix(agent_name, invoke_separator == '-') in
  process_template() so Kimi tokens are emitted as /skill:speckit-* directly.
- Remove the broad .replace() from KimiIntegration.post_process_skill_content();
  it is now a no-op (tokens are already correctly prefixed at source).
- Add test_process_template_kimi_uses_skill_colon_prefix to guard the fix.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
2026-07-28 15:48:40 +01:00

883 lines
38 KiB
Python

"""specify integration switch / upgrade command handlers."""
from __future__ import annotations
import json
import os
from pathlib import Path, PurePath
import typer
from .._console import console
from ..integration_runtime import (
invoke_prefix_for_integration as _invoke_prefix_for_integration,
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,
_register_extensions_for_agent,
_register_presets_for_agent,
_remove_integration_json,
_resolve_integration_options,
_resolve_integration_script_type,
_resolve_script_type,
_set_default_integration,
_set_default_integration_or_exit,
_unregister_enabled_extension_commands_for_agent,
_unregister_extensions_for_agent,
_unregister_presets_for_agent,
_update_init_options_for_integration,
_write_integration_json,
)
def _manifest_tracks_skill_layout(manifest) -> bool:
"""Return True when *manifest* tracks any skills-layout artifact.
A skill scaffold is written as ``.../speckit-<name>/SKILL.md``, so a
manifest whose tracked files include a ``/SKILL.md`` key is in the skills
layout; otherwise it is in the command layout. Used by ``upgrade`` to
detect a dual-mode agent (e.g. Bob) flipping between the legacy commands
layout and the skills layout so orphaned extension artifacts from the old
layout can be reconciled.
"""
return any(str(rel).endswith("/SKILL.md") for rel in manifest.files)
def _manifest_path_under(rel_path: str, root: str) -> bool:
"""Return True when manifest key *rel_path* is inside project-relative *root*."""
normalized_root = PurePath(root).as_posix().strip("/")
normalized_rel = PurePath(rel_path).as_posix().strip("/")
if not normalized_root:
return False
return normalized_rel == normalized_root or normalized_rel.startswith(
f"{normalized_root}/"
)
def _legacy_command_root_changed(
integration,
project_root: Path,
old_manifest,
new_manifest,
) -> bool:
"""Return True when command artifacts moved from legacy_dir to canonical dir."""
config = integration.registrar_config or {}
canonical = config.get("dir")
legacy = config.get("legacy_dir")
if (
not isinstance(canonical, str)
or not canonical.strip()
or not isinstance(legacy, str)
or not legacy.strip()
or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
):
return False
canonical_dir = project_root / canonical
legacy_dir = project_root / legacy
if not canonical_dir.is_dir() or not legacy_dir.is_dir():
return False
old_had_legacy = any(
_manifest_path_under(rel, legacy) for rel in old_manifest.files
)
new_has_canonical = any(
_manifest_path_under(rel, canonical) for rel in new_manifest.files
)
return old_had_legacy and new_has_canonical
def _legacy_command_root_upgrade_pending(integration, old_manifest) -> bool:
"""Return True when the old manifest tracks command files under legacy_dir."""
config = integration.registrar_config or {}
canonical = config.get("dir")
legacy = config.get("legacy_dir")
if (
not isinstance(canonical, str)
or not canonical.strip()
or not isinstance(legacy, str)
or not legacy.strip()
or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
):
return False
return any(_manifest_path_under(rel, legacy) for rel in old_manifest.files)
class _PresetRegistryUnreadableError(Exception):
"""Raised when an existing preset registry cannot be read or parsed.
Distinct from a *genuinely absent* registry (no presets installed): an
unreadable registry means we cannot verify whether preset overrides would
be orphaned by a layout change, so the migration must be rejected rather
than proceeding on a false "no presets" assumption.
"""
def _installed_presets_affecting_agent(
project_root,
agent_key: str,
*,
include_skills: bool = True,
) -> list[str]:
"""Return IDs of installed presets with artifacts registered for *agent_key*.
Preset registration is active-agent-only (#2948): command overrides are
written for the active non-skills agent and skills for the active skills
agent, tracked per preset in ``registered_commands`` /
``registered_skills``. Entries for *other* agents may still exist from
when those agents were active. Callers use this to reject command-root or
command↔skills layout migrations before mutation: preset rescaffolding is
best-effort and cannot guarantee every tracked artifact has a replacement.
Fails **closed**: a genuinely absent registry (no presets ever installed)
returns an empty list, but if the registry file exists and cannot be read
or parsed (e.g. a permission error or corruption) this raises
:class:`_PresetRegistryUnreadableError`. Reporting "no presets" in that
case would let a ``--force`` layout-changing upgrade delete
preset-overridden files while their registry state can't be reconciled —
the exact inconsistency the guard exists to prevent.
"""
from ..presets import PresetRegistry
registry_path = (
Path(project_root) / ".specify" / "presets" / PresetRegistry.REGISTRY_FILE
)
# Genuinely absent registry → no presets installed → safe to proceed.
if not registry_path.exists():
return []
# The registry exists: any failure to read or parse it must surface as an
# error, not be swallowed into an empty ("no presets") result.
try:
data = json.loads(registry_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise _PresetRegistryUnreadableError(str(exc)) from exc
if not isinstance(data, dict) or not isinstance(data.get("presets", {}), dict):
raise _PresetRegistryUnreadableError(
"preset registry structure is malformed"
)
affected: list[str] = []
for preset_id, meta in data.get("presets", {}).items():
# A malformed entry means we cannot verify whether this preset owns
# artifacts for the agent, so fail closed rather than skip it.
if not isinstance(meta, dict):
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' entry is malformed"
)
registered_commands = meta.get("registered_commands", {})
if not isinstance(registered_commands, dict) or not all(
isinstance(names, list) for names in registered_commands.values()
):
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' registered_commands is malformed"
)
registered_skills = meta.get("registered_skills", [])
if isinstance(registered_skills, dict):
# Per-agent provenance ({agent: [skill names]}): only entries for
# *this* agent make the preset affect it. Values must be lists —
# anything else (e.g. null) leaves ownership undecidable, so fail
# closed rather than read it as "no artifacts".
if not all(
isinstance(names, list) for names in registered_skills.values()
):
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' registered_skills is malformed"
)
has_skills = include_skills and bool(
registered_skills.get(agent_key)
)
elif isinstance(registered_skills, (list, tuple)):
# Legacy flat list: not agent-scoped, so any recorded skill may
# belong to this agent — fail closed and count it as affecting.
has_skills = include_skills and bool(registered_skills)
else:
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' registered_skills is malformed"
)
has_commands = bool(registered_commands.get(agent_key))
if has_commands or has_skills:
affected.append(preset_id)
return affected
def _installed_command_presets_affecting_agent(
project_root,
agent_key: str,
) -> list[str]:
"""Return installed presets with command artifacts registered for *agent_key*."""
return _installed_presets_affecting_agent(
project_root,
agent_key,
include_skills=False,
)
@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, ps, or py (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,
)
_register_extensions_for_agent(
project_root,
target,
continuing=(
"The integration switch succeeded, but installed extensions may "
"need re-registration."
),
)
_register_presets_for_agent(
project_root,
target,
continuing=(
"The integration switch succeeded, but installed presets may "
"need re-registration."
),
)
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.
_unregister_extensions_for_agent(
project_root,
installed_key,
continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.",
)
# Unregister preset commands/skills for the old agent for the same
# reason: without this, a preset's command overrides (including
# custom preset commands) and skill mirrors rendered for
# installed_key would remain orphaned in its directory once a
# different, possibly not-yet-installed integration becomes active
# (#2948). Scoped strictly to installed_key; other agents' files,
# tracking, and the preset packs themselves are untouched.
_unregister_presets_for_agent(
project_root,
installed_key,
continuing="Continuing with integration switch; old preset 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,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
target_integration, target, parsed_options, project_root
),
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,
)
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:
# Under active-only registration the fallback may never
# have received any extension/preset artifacts (it was
# installed while another integration was active), and
# Phase 1 already unregistered the outgoing agent's
# artifacts. Rescaffold so the restored default is
# actually usable. Both helpers are best-effort and
# cannot raise past this point.
_register_extensions_for_agent(
project_root,
fallback_key,
continuing="The switch was rolled back; installed extensions may need re-registration.",
)
_register_presets_for_agent(
project_root,
fallback_key,
continuing="The switch was rolled back; installed presets may need re-registration.",
)
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)
# Re-register extension commands for the new agent so previously-installed
# extensions are available in it. Done after the try/except (the switch has
# committed) so this best-effort step can never trigger the rollback above.
_register_extensions_for_agent(
project_root,
target,
continuing="The integration switch succeeded, but installed extensions may need re-registration.",
)
_register_presets_for_agent(
project_root,
target,
continuing="The integration switch succeeded, but installed presets may need re-registration.",
)
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, ps, or py (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
)
legacy_command_root_upgrade_pending = _legacy_command_root_upgrade_pending(
integration,
old_manifest,
)
# Guard: Kilo's legacy command root moves from .kilocode/workflows to
# .kilo/commands. Preset command artifacts are tracked outside the
# integration manifest, and their agent-scoped rescaffold is best-effort,
# not transactional with command-root cleanup. Refuse before setup writes
# .kilo/commands rather than risking orphaned legacy files or missing
# registry-tracked overrides in the canonical directory.
if key == "kilocode" and legacy_command_root_upgrade_pending:
config = integration.registrar_config or {}
legacy = config.get("legacy_dir", "legacy command directory")
canonical = config.get("dir", "canonical command directory")
try:
affected_presets = _installed_command_presets_affecting_agent(
project_root,
key,
)
except _PresetRegistryUnreadableError as exc:
console.print(
f"[red]Error:[/red] Cannot migrate '{key}' command directory "
f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan]: "
"the preset registry could not be read to verify installed presets."
)
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
console.print(
"A command directory migration cannot reconcile preset command "
"artifacts while the preset registry state is unknown. Fix or "
"restore [cyan].specify/presets/.registry[/cyan] and retry."
)
raise typer.Exit(1)
if affected_presets:
preset_list = ", ".join(sorted(affected_presets))
console.print(
f"[red]Error:[/red] Cannot migrate '{key}' command directory "
f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan] while "
f"preset override(s) are installed: [bold]{preset_list}[/bold]."
)
console.print(
"Preset command artifacts cannot yet be reconciled across this "
"command directory migration, so the upgrade is refused before "
"changing files."
)
console.print(
"Remove the preset(s), run the upgrade, then reinstall them:\n"
f" [cyan]specify preset remove <id>[/cyan]\n"
f" [cyan]specify integration upgrade {key} --script {selected_script} --force[/cyan]\n"
f" [cyan]specify preset add <id>[/cyan]"
)
raise typer.Exit(1)
# Reject command↔skills layout changes while preset artifacts are tracked
# for the integration (review #3415). Preset rescaffolding is best-effort:
# an enabled preset can still have a missing/corrupt manifest or command
# source, or fail during a write. Phase 2 would otherwise delete the
# old-layout file before a replacement is known to exist. Refuse before
# any mutation; same-layout upgrades still rescaffold the active agent.
if _manifest_tracks_skill_layout(old_manifest) != integration.is_skills_mode(
parsed_options, project_root
):
try:
affected_presets = _installed_presets_affecting_agent(project_root, key)
except _PresetRegistryUnreadableError as exc:
console.print(
f"[red]Error:[/red] Cannot change '{key}' command layout: the "
f"preset registry could not be read to verify installed presets."
)
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
console.print(
"A layout change cannot reconcile preset artifacts, so the "
"migration is refused while the preset registry state is "
"unknown. Fix or restore "
"[cyan].specify/presets/.registry[/cyan] and retry."
)
raise typer.Exit(1)
if affected_presets:
preset_list = ", ".join(sorted(affected_presets))
console.print(
f"[red]Error:[/red] Cannot change '{key}' command layout while "
f"preset override(s) are installed: [bold]{preset_list}[/bold]."
)
console.print(
"Preset artifacts cannot be safely reconciled across a "
"command↔skills layout change, so the migration is refused "
"before changing files."
)
console.print(
"Remove the preset(s), run the upgrade, then reinstall them:\n"
f" [cyan]specify preset remove <id>[/cyan]\n"
f" [cyan]specify integration upgrade {key} "
f"--integration-options \"...\"[/cyan]\n"
f" [cyan]specify preset add <id>[/cyan]"
)
raise typer.Exit(1)
# 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,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
infra_integration, infra_key, infra_parsed, project_root
),
)
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,
project_root=project_root,
)
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,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
integration, key, parsed_options, project_root
),
force=force,
refresh_managed=True,
)
except (ValueError, OSError) as exc:
raise _SharedTemplateRefreshError(
f"Failed to refresh shared infrastructure for '{key}': {exc}"
) from exc
if os.name != "nt":
from .. import ensure_executable_scripts
ensure_executable_scripts(project_root)
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,
parsed_options=parsed_options,
)
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
# Exclude integration-declared paths that use conditional manifest tracking
# (e.g. merge targets like .vscode/settings.json) so they are never deleted
# as "stale" while still being actively managed. Manifest keys are stored
# in POSIX form, so normalize the exclusions the same way before subtracting
# (an integration may build paths with os.path.join / backslashes).
exclusions = {PurePath(p).as_posix() for p in integration.stale_cleanup_exclusions()}
stale_keys = (set(old_files) - set(new_files)) - exclusions
if stale_keys:
stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup")
stale_manifest._files = {k: old_files[k] for k in stale_keys}
# remove_manifest=False: this throwaway manifest shares ``key`` with the
# real one just saved above (new_manifest.save()). Letting uninstall()
# delete ``{key}.manifest.json`` would wipe the freshly-written manifest
# whenever an upgrade shrinks the tracked file set (e.g. Bob migrating
# from the legacy commands layout to skills), leaving the integration
# untracked and un-upgradeable.
stale_removed, _ = stale_manifest.uninstall(
project_root, force=True, remove_manifest=False
)
if stale_removed:
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
legacy_command_root_changed = _legacy_command_root_changed(
integration,
project_root,
old_manifest,
new_manifest,
)
if legacy_command_root_changed:
_unregister_enabled_extension_commands_for_agent(
project_root,
key,
continuing=(
"The integration command directory changed, but legacy enabled "
"extension artifacts may need manual cleanup."
),
)
# Re-register enabled extensions and presets only when upgrading the
# active integration. Inactive integrations remain untouched until
# `use` or `switch` activates and rescaffolds them (#2948). This runs
# after the core upgrade transaction, so failures remain best-effort.
if key == installed_key:
_register_extensions_for_agent(
project_root,
key,
continuing="The integration was upgraded, but installed extensions may need re-registration.",
)
_register_presets_for_agent(
project_root,
key,
continuing="The integration was upgraded, but installed presets may need re-registration.",
)
name = (integration.config or {}).get("name", key)
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")