diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index df113a74be..9e25a1ee8a 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -230,6 +230,8 @@ Uninstalls the current integration (or the specified one). Spec Kit tracks every - **Modified files** (where you've made manual edits) are preserved so your customizations are not lost. - Use `--force` to remove all integration files regardless of modifications. +Uninstall also removes the extension and preset artifacts registered for the integration. The extensions and presets themselves stay installed. If you uninstall the default integration while other integrations remain, one of them becomes the default, and Spec Kit registers the enabled extensions and presets for it, as `use` does. + ## Switch to a Different Integration ```bash diff --git a/src/specify_cli/integrations/_lifecycle.py b/src/specify_cli/integrations/_lifecycle.py index c7d7d78b5f..cb3243ba71 100644 --- a/src/specify_cli/integrations/_lifecycle.py +++ b/src/specify_cli/integrations/_lifecycle.py @@ -536,37 +536,7 @@ def invoke(*args, **kwargs): installer.unload_installed_integrations() installer.persist_package(root, key, *candidate) for removed in records.keys() - set(remaining): - if operation == "uninstall": - from ._helpers import ( - _unregister_extensions_for_agent, - _unregister_presets_for_agent, - ) - - _unregister_extensions_for_agent( - root, removed, - continuing="The adapter was removed, but extension artifacts may need manual cleanup.", - ) - _unregister_presets_for_agent( - root, removed, - continuing="The adapter was removed, but preset artifacts may need manual cleanup.", - ) installer.remove_package(root, removed) - if operation == "uninstall" and key in records and key == default_integration_key(state or {}): - fallback = default_integration_key(new_state or {}) - if fallback: - from ._helpers import ( - _register_extensions_for_agent, - _register_presets_for_agent, - ) - - _register_extensions_for_agent( - root, fallback, - continuing="The fallback integration was selected, but extensions may need re-registration.", - ) - _register_presets_for_agent( - root, fallback, - continuing="The fallback integration was selected, but presets may need re-registration.", - ) if not candidate: installer.load_installed_integrations(root) for message in messages: diff --git a/src/specify_cli/integrations/command_uninstall.py b/src/specify_cli/integrations/command_uninstall.py index c217f0e94a..ffe45c8ae1 100644 --- a/src/specify_cli/integrations/command_uninstall.py +++ b/src/specify_cli/integrations/command_uninstall.py @@ -10,7 +10,7 @@ from ..integration_state import 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 ._lifecycle import external_lifecycle, lifecycle_success -from ._helpers import _MANIFEST_READ_ERRORS, _clear_init_options_for_integration, _read_integration_json, _remove_integration_json, _resolve_integration_options, _set_default_integration_or_exit, _write_integration_json +from ._helpers import _MANIFEST_READ_ERRORS, _clear_init_options_for_integration, _read_integration_json, _register_extensions_for_agent, _register_presets_for_agent, _remove_integration_json, _resolve_integration_options, _set_default_integration_or_exit, _unregister_extensions_for_agent, _unregister_presets_for_agent, _write_integration_json @integration_app.command("uninstall") @@ -44,6 +44,16 @@ def integration_uninstall( manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" if not manifest_path.exists(): console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]") + _unregister_extensions_for_agent( + project_root, + key, + continuing="Continuing with integration uninstall; extension artifacts may need manual cleanup.", + ) + _unregister_presets_for_agent( + project_root, + key, + continuing="Continuing with integration uninstall; preset artifacts may need manual cleanup.", + ) remaining = [installed for installed in installed_keys if installed != key] new_default = default_key if default_key != key else (remaining[0] if remaining else None) if remaining: @@ -60,6 +70,16 @@ def integration_uninstall( raw_options=raw_options, parsed_options=parsed_options, ) + _register_extensions_for_agent( + project_root, + new_default, + continuing="The fallback integration was selected, but extensions may need re-registration.", + ) + _register_presets_for_agent( + project_root, + new_default, + continuing="The fallback integration was selected, but presets may need re-registration.", + ) else: _write_integration_json( project_root, new_default, remaining, _integration_settings(current) @@ -92,6 +112,19 @@ def integration_uninstall( else: removed, skipped = integration.teardown(project_root, manifest, force=force) + # Extension/preset artifacts are tracked in their own registries, not the + # integration manifest, so teardown leaves them behind. + _unregister_extensions_for_agent( + project_root, + key, + continuing="Continuing with integration uninstall; extension artifacts may need manual cleanup.", + ) + _unregister_presets_for_agent( + project_root, + key, + continuing="Continuing with integration uninstall; preset artifacts may need manual cleanup.", + ) + remaining = [installed for installed in installed_keys if installed != key] new_default = default_key if default_key != key else (remaining[0] if remaining else None) if remaining: @@ -108,6 +141,16 @@ def integration_uninstall( raw_options=raw_options, parsed_options=parsed_options, ) + _register_extensions_for_agent( + project_root, + new_default, + continuing="The fallback integration was selected, but extensions may need re-registration.", + ) + _register_presets_for_agent( + project_root, + new_default, + continuing="The fallback integration was selected, but presets may need re-registration.", + ) else: _write_integration_json( project_root, new_default, remaining, _integration_settings(current) diff --git a/tests/specify_cli/integrations/test_command_uninstall.py b/tests/specify_cli/integrations/test_command_uninstall.py index c32d4c7d52..b3ea373a60 100644 --- a/tests/specify_cli/integrations/test_command_uninstall.py +++ b/tests/specify_cli/integrations/test_command_uninstall.py @@ -167,6 +167,66 @@ def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path): assert "/speckit-plan" in template.read_text(encoding="utf-8") assert "/speckit-plan" in script.read_text(encoding="utf-8") + def test_uninstall_unregisters_extension_artifacts(self, tmp_path): + """Uninstall must not leave extension skills orphaned in the agent's directory.""" + project = _init_project(tmp_path, "claude") + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" + assert claude_git_feature.exists() + + result = _run_in_project(project, ["integration", "uninstall", "claude"]) + assert result.exit_code == 0, result.output + + assert not claude_git_feature.exists(), "Extension skill should be removed with its agent" + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + assert "claude" not in registry["extensions"]["git"]["registered_commands"] + + @pytest.mark.parametrize("missing_manifest", [False, True]) + def test_uninstall_default_registers_extensions_for_fallback(self, tmp_path, missing_manifest): + """The integration promoted to default receives enabled extensions, like ``use``.""" + project = _init_project(tmp_path, "claude") + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + codex_git_feature = project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + assert not codex_git_feature.exists(), "precondition: secondary install has no extension artifacts" + if missing_manifest: + (project / ".specify" / "integrations" / "claude.manifest.json").unlink() + + result = _run_in_project(project, ["integration", "uninstall", "claude"]) + assert result.exit_code == 0, result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "codex" + assert codex_git_feature.exists(), "Promoted default should receive enabled extensions" + assert not (project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md").exists() + + def test_uninstall_non_default_preserves_default_extension_artifacts(self, tmp_path): + project = _init_project(tmp_path, "claude") + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + result = _run_in_project(project, ["integration", "uninstall", "codex"]) + assert result.exit_code == 0, result.output + + assert (project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md").exists() + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + assert "claude" in registry["extensions"]["git"]["registered_commands"] + def test_uninstall_preserves_shared_infra(self, tmp_path): """Shared scripts and templates are not removed by integration uninstall.""" project = _init_project(tmp_path, "claude")