mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
* feat(scripts): port create-new-feature, setup-plan and setup-tasks to Python Ports the three core workflow scripts to Python as part of #3280, following the check-prerequisites PoC pattern from #3302. Adds resolve_template() to the shared common.py module and parity tests that run bash and Python side by side. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): treat only None env as unset in parity run helper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): fall back to directory scan on any registry error, skip hidden preset dirs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(templates): add py: lines for setup_plan and setup_tasks Ships with the scripts they reference; the remaining templates got their py: lines in #3403. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: support py variant in skills placeholder resolver resolve_skill_placeholders only accepted sh/ps, so a py init option fell into the fallback path and {SCRIPT} rendered without an interpreter prefix. Accept py and prefix the resolved interpreter, matching process_template. Also guard ps_cmd against a missing PowerShell with a clear assert. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: pin clean-error behavior for invalid --number Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(scripts): reword unused-arg comment to match implementation The loop accepts and silently ignores extra positional args (it doesn't build a collected list); match the wording to what the code and setup-plan.sh actually do. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: fall back when configured script variant is missing from frontmatter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): reject signed/whitespace --number values to match bash 10# parity The bash twin uses $((10#$BRANCH_NUMBER)), which rejects signed and whitespace-padded values. Python's int() accepted them (e.g. -1), producing a malformed -01-... prefix that sequential scans ignore. Restrict --number to unsigned decimal digits before conversion, and pin the parity with a bash-comparison test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): complete Python port installation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(integrations): fall back for missing script variants Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: make Python script checks platform-aware Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix Windows Python command invocation parity Use PowerShell's call operator for spaced Python interpreter paths and align setup-tasks missing-template errors across script variants. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): preserve cross-platform Python parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: reject signed PowerShell feature numbers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align feature number range Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): reject exhausted feature numbers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): complete create feature parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align create feature outputs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): harden cross-platform parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): keep truncation JSON clean Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align setup failure parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): close parity edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): propagate PowerShell setup errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): harden fallback resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): stabilize PowerShell fallbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): complete setup-plan parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(cli): require runnable script fallbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(cli): preserve shell fallback without preference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): restore help and symlink parity - setup-tasks.ps1: check -Help before unknown-argument validation so '-Help --bogus' exits 0 like the Bash/Python variants - common.py: strip the repo root prefix lexically in persist_feature_json instead of resolve(), so a symlinked specs/ still persists the relative 'specs/NNN-name' path the Bash/PowerShell helpers store Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align persist-hint quoting with shlex.quote - create-new-feature.sh: replace printf %q with a shell_quote helper that emits shlex.quote-identical output, so the persistence hints stay byte-identical between the Bash and Python variants (printf %q output also varies between bash versions) - promote the negative --number test to an all-variants parity test now that Bash and PowerShell reject signed values consistently - add a spaced-repo-path parity test for the persistence hints Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
712 lines
32 KiB
Python
712 lines
32 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_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,
|
|
_remove_integration_json,
|
|
_resolve_integration_options,
|
|
_resolve_integration_script_type,
|
|
_resolve_script_type,
|
|
_set_default_integration,
|
|
_set_default_integration_or_exit,
|
|
_unregister_extensions_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)
|
|
|
|
|
|
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) -> list[str]:
|
|
"""Return IDs of installed presets with artifacts registered for *agent_key*.
|
|
|
|
Presets register command overrides for every detected agent and mirror
|
|
skills for the active skills agent, tracking the result in each preset's
|
|
``registered_commands`` / ``registered_skills`` metadata. There is no
|
|
agent-scoped preset re-registration mechanism, so a command↔skills *layout
|
|
change* cannot reconcile those artifacts (see ``integration_upgrade``).
|
|
Callers use this to detect the unsafe case and reject the migration rather
|
|
than silently orphaning preset files / leaving stale registry entries.
|
|
|
|
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):
|
|
raise _PresetRegistryUnreadableError(
|
|
f"preset '{preset_id}' registered_commands is malformed"
|
|
)
|
|
registered_skills = meta.get("registered_skills", [])
|
|
if not isinstance(registered_skills, (list, tuple)):
|
|
raise _PresetRegistryUnreadableError(
|
|
f"preset '{preset_id}' registered_skills is malformed"
|
|
)
|
|
has_commands = bool(registered_commands.get(agent_key))
|
|
has_skills = bool(registered_skills)
|
|
if has_commands or has_skills:
|
|
affected.append(preset_id)
|
|
return affected
|
|
|
|
|
|
@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."
|
|
),
|
|
)
|
|
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.",
|
|
)
|
|
|
|
# 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,
|
|
),
|
|
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:
|
|
_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.",
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
# Guard: reject a command↔skills layout change while preset overrides are
|
|
# installed for this agent (review #3415). A dual-mode agent (e.g. Bob)
|
|
# can flip layout across an upgrade (``--skills`` / ``--legacy-commands``).
|
|
# Extension artifacts are reconciled after the flip (see below), but preset
|
|
# artifacts cannot be: there is no agent-scoped preset re-registration
|
|
# anywhere in the CLI, so migrating would delete a preset's old-layout
|
|
# files without recreating them in the new layout and leave the preset
|
|
# registry claiming artifacts that no longer exist. Detect the intended
|
|
# layout (``is_skills_mode`` reflects the resolved flags/disk state, so a
|
|
# plain same-layout upgrade is unaffected) and bail out *before* any
|
|
# mutation with an actionable error so the project is never left in a
|
|
# half-migrated, inconsistent state.
|
|
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 yet be reconciled across a command↔skills "
|
|
"layout change, so the migration would orphan their files and leave "
|
|
"the preset registry inconsistent."
|
|
)
|
|
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,
|
|
),
|
|
)
|
|
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,
|
|
),
|
|
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")
|
|
|
|
# Re-register enabled extensions for the upgraded agent so its extension
|
|
# commands are (re)created — including agents installed before this
|
|
# back-fill existed. Mirrors switch for command registration; see #2886.
|
|
# Done after the upgrade has fully settled (Phase 2 included) and outside
|
|
# the try/except above so this best-effort step cannot affect upgrade
|
|
# success.
|
|
#
|
|
# Layout-change reconciliation: a dual-mode agent (e.g. Bob) can flip
|
|
# between the legacy commands layout and the skills layout across an
|
|
# upgrade (``upgrade bob --integration-options "--skills"`` / reverse
|
|
# ``--legacy-commands``). Phase 2 above only removes stale files tracked by
|
|
# the *integration* manifest (core commands); extension artifacts are
|
|
# tracked separately in the extension registry, so the old layout's
|
|
# extension command/skill files would otherwise linger as orphans. When the
|
|
# layout actually changed, first unregister the agent's extension artifacts
|
|
# (removing old-layout files and clearing per-agent registry entries) so the
|
|
# re-registration below recreates them in the new layout. ``upgrade``s that
|
|
# don't change layout skip this to avoid needless remove/re-add churn.
|
|
#
|
|
# Only the *active* integration is reconciled this way (``installed_key ==
|
|
# key``). ``ExtensionManager.unregister_agent_artifacts`` treats the
|
|
# per-extension ``registered_skills`` list as belonging to the passed agent
|
|
# and, when that agent's skills directory is absent, falls back to scanning
|
|
# every agent's skills directory — so running it for a *secondary*
|
|
# (non-active) agent could delete or untrack the *active* agent's extension
|
|
# skills. The subsequent re-registration cannot repair that because
|
|
# extension skill rendering is intentionally scoped to the active agent
|
|
# (#2948). Extension skills only ever exist for the active agent, so
|
|
# skipping the unregister for a secondary agent orphans nothing new: a
|
|
# secondary agent only has extension *command* files, which the
|
|
# re-registration below rewrites in place regardless of layout.
|
|
#
|
|
# Known limitation: preset command/skill artifacts are NOT reconciled on a
|
|
# layout change. There is no agent-scoped preset re-registration mechanism
|
|
# anywhere in the CLI — ``use`` / ``switch`` / ``upgrade`` never reconcile
|
|
# presets for any agent (presets are only (un)registered at preset
|
|
# install/remove time). Rather than silently orphan them, the guard near
|
|
# the top of this function rejects a layout-changing upgrade while preset
|
|
# overrides are installed, so control only reaches here (with a changed
|
|
# layout) when no preset artifacts are at stake. Full preset reconciliation
|
|
# would require a new cross-cutting PresetManager subsystem affecting every
|
|
# dual-layout agent, which is out of scope for this Bob migration.
|
|
if (
|
|
installed_key == key
|
|
and _manifest_tracks_skill_layout(old_manifest)
|
|
!= _manifest_tracks_skill_layout(new_manifest)
|
|
):
|
|
_unregister_extensions_for_agent(
|
|
project_root,
|
|
key,
|
|
continuing=(
|
|
"The integration layout changed, but old-layout extension "
|
|
"artifacts may need manual cleanup."
|
|
),
|
|
)
|
|
_register_extensions_for_agent(
|
|
project_root,
|
|
key,
|
|
continuing="The integration was upgraded, but installed extensions may need re-registration.",
|
|
)
|
|
|
|
name = (integration.config or {}).get("name", key)
|
|
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")
|