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..d0e5f6b083 100644 --- a/src/specify_cli/integrations/_lifecycle.py +++ b/src/specify_cli/integrations/_lifecycle.py @@ -536,7 +536,10 @@ def invoke(*args, **kwargs): installer.unload_installed_integrations() installer.persist_package(root, key, *candidate) for removed in records.keys() - set(remaining): - if operation == "uninstall": + # The uninstall command cleans up its own key. A stale + # record's contributions must go here, while its adapter + # is still loaded. + if operation == "uninstall" and removed != key: from ._helpers import ( _unregister_extensions_for_agent, _unregister_presets_for_agent, @@ -551,22 +554,6 @@ def invoke(*args, **kwargs): 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..e16099e8c8 100644 --- a/tests/specify_cli/integrations/test_command_uninstall.py +++ b/tests/specify_cli/integrations/test_command_uninstall.py @@ -8,6 +8,7 @@ from pathlib import Path # noqa: F401 import pytest # noqa: F401 +import yaml from specify_cli import app # noqa: F401 from tests.conftest import strip_ansi # noqa: F401 @@ -21,6 +22,30 @@ runner, # noqa: F401 ) + +def _add_sample_preset(project, tmp_path): + """Install a dev preset that overrides ``speckit.specify``.""" + preset = tmp_path / "sample-preset" + (preset / "commands").mkdir(parents=True) + (preset / "commands" / "speckit.specify.md").write_text( + "---\ndescription: Sample preset command\n---\nSample preset guidance\n", + encoding="utf-8", + ) + (preset / "preset.yml").write_text(yaml.safe_dump({ + "schema_version": "1.0", + "preset": {"id": "sample-preset", "name": "Sample Preset", "version": "1.0.0", "description": "Sample guidance"}, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"templates": [{"type": "command", "name": "speckit.specify", "file": "commands/speckit.specify.md"}]}, + }), encoding="utf-8") + result = _run_in_project(project, ["preset", "add", "--dev", str(preset)]) + assert result.exit_code == 0, f"preset add failed: {result.output}" + + +def _registered_agents(project, kind, package_id): + """Agents whose artifacts the extension or preset registry tracks for ``package_id``.""" + registry = json.loads((project / ".specify" / kind / ".registry").read_text(encoding="utf-8")) + return set(registry[kind][package_id]["registered_commands"]) + class TestIntegrationUninstall: def test_uninstall_requires_speckit_project(self, tmp_path): old_cwd = os.getcwd() @@ -167,6 +192,78 @@ 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_and_preset_artifacts(self, tmp_path): + """Uninstall must not leave extension or preset files 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}" + _add_sample_preset(project, tmp_path) + claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" + claude_specify = project / ".claude" / "skills" / "speckit-specify" / "SKILL.md" + assert claude_git_feature.exists() + assert "Sample preset guidance" in claude_specify.read_text(encoding="utf-8") + + 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" + assert not claude_specify.exists(), "Preset skill should be removed with its agent" + assert "claude" not in _registered_agents(project, "extensions", "git") + assert "claude" not in _registered_agents(project, "presets", "sample-preset") + + @pytest.mark.parametrize("missing_manifest", [False, True]) + def test_uninstall_default_registers_contributions_for_fallback(self, tmp_path, missing_manifest): + """The integration promoted to default receives enabled extensions and presets, 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}" + _add_sample_preset(project, tmp_path) + 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" + codex_specify = project / ".agents" / "skills" / "speckit-specify" / "SKILL.md" + assert not codex_git_feature.exists(), "precondition: secondary install has no extension artifacts" + assert "Sample preset guidance" not in codex_specify.read_text(encoding="utf-8"), ( + "precondition: secondary install has no preset 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 "Sample preset guidance" in codex_specify.read_text(encoding="utf-8"), ( + "Promoted default should receive enabled presets" + ) + assert not (project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md").exists() + assert not (project / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists() + + def test_uninstall_non_default_preserves_default_contributions(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}" + _add_sample_preset(project, tmp_path) + 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() + claude_specify = project / ".claude" / "skills" / "speckit-specify" / "SKILL.md" + assert "Sample preset guidance" in claude_specify.read_text(encoding="utf-8") + assert "claude" in _registered_agents(project, "extensions", "git") + assert "claude" in _registered_agents(project, "presets", "sample-preset") + 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") diff --git a/tests/specify_cli/integrations/test_installed_adapters.py b/tests/specify_cli/integrations/test_installed_adapters.py index 43ff50634b..7e73fab45a 100644 --- a/tests/specify_cli/integrations/test_installed_adapters.py +++ b/tests/specify_cli/integrations/test_installed_adapters.py @@ -1623,6 +1623,48 @@ def fail_commit(*args, **kwargs): assert json.loads((project / ".specify/integration.json").read_text())["integration"] == "claude" +def test_uninstall_cleans_up_contributions_of_stale_adapter_record(tmp_path, server): + publish(server) + project = catalog_project(tmp_path, server) + assert run(project, ["integration", "install", "claude"]).exit_code == 0 + install(project) + extension = run(project, ["extension", "add", "git"]) + assert extension.exit_code == 0, extension.output + preset = tmp_path / "sample-preset" + (preset / "commands").mkdir(parents=True) + (preset / "commands/speckit.specify.md").write_text( + "---\ndescription: Sample preset command\n---\nSample preset guidance\n" + ) + (preset / "preset.yml").write_text(yaml.safe_dump({ + "schema_version": "1.0", + "preset": {"id": "sample-preset", "name": "Sample Preset", "version": "1.0.0", "description": "Sample guidance"}, + "requires": {"speckit_version": ">=0.1"}, + "provides": {"templates": [{"type": "command", "name": "speckit.specify", "file": "commands/speckit.specify.md"}]}, + })) + added = run(project, ["preset", "add", "--dev", str(preset)]) + assert added.exit_code == 0, added.output + activated = run(project, ["integration", "use", KEY]) + assert activated.exit_code == 0, activated.output + git_commit = project / ".sample-agent/skills/speckit-git-commit/SKILL.md" + specify = project / ".sample-agent/skills/speckit-specify/SKILL.md" + assert git_commit.is_file() + assert "Sample preset guidance" in specify.read_text(encoding="utf-8") + state_path = project / ".specify/integration.json" + state = json.loads(state_path.read_text()) + state.update(integration="claude", default_integration="claude", installed_integrations=["claude"]) + state_path.write_text(json.dumps(state)) + + removed = run(project, ["integration", "uninstall", "claude"]) + assert removed.exit_code == 0, removed.output + assert KEY not in read_records(project) + assert not git_commit.exists() + assert not specify.exists() + extensions = json.loads((project / ".specify/extensions/.registry").read_text())["extensions"] + assert KEY not in extensions["git"]["registered_commands"] + presets = json.loads((project / ".specify/presets/.registry").read_text())["presets"] + assert KEY not in presets["sample-preset"]["registered_commands"] + + def test_builtin_generic_can_coexist_with_external_adapter(tmp_path, server): publish(server) project = catalog_project(tmp_path, server)