From daaa70f81f37545b48fd1fa6908d6f07f08ba3b5 Mon Sep 17 00:00:00 2001 From: Chaoqi Date: Sat, 10 Oct 2026 12:03:54 +0800 Subject: [PATCH 1/4] test(integrations): cover extension artifacts on integration uninstall Assisted-by: Claude Code (model: Claude Opus 5, autonomous) --- .../integrations/test_command_uninstall.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) 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") From 535a397430e866678aefaf30ed322eaab2a84e86 Mon Sep 17 00:00:00 2001 From: Chaoqi Date: Sat, 10 Oct 2026 12:04:06 +0800 Subject: [PATCH 2/4] fix(integrations): move extension artifacts on integration uninstall `specify integration uninstall` tore down the integration files but left the extension and preset commands and skills registered for that agent, and when it promoted another integration to default it did not register the enabled extensions and presets for it, unlike `use` and `switch`. #4862 added both steps for catalog-installed adapters in the `external_lifecycle` wrapper only. Do them in the uninstall command for every integration, including the no-manifest path, and drop the wrapper copy so external adapters do not run them twice. Assisted-by: Claude Code (model: Claude Opus 5, autonomous) --- docs/reference/integrations.md | 2 + src/specify_cli/integrations/_lifecycle.py | 30 ------------- .../integrations/command_uninstall.py | 45 ++++++++++++++++++- 3 files changed, 46 insertions(+), 31 deletions(-) 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) From 28df6e1e23d073750d17340f01f61135cc776286 Mon Sep 17 00:00:00 2001 From: Chaoqi Date: Wed, 14 Oct 2026 02:35:06 +0800 Subject: [PATCH 3/4] fix(integrations): keep stale adapter cleanup on uninstall The uninstall reconciliation loop also removes packages whose records are no longer in the integration state. The previous commit dropped the extension and preset cleanup for those packages, so their files and registry entries stayed behind once the adapter was gone. Restore it for every removed record other than the uninstalled key, which the command already cleans up. Assisted-by: Claude Code (model: Claude Opus 5, autonomous) --- src/specify_cli/integrations/_lifecycle.py | 17 ++++++++ .../integrations/test_installed_adapters.py | 42 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/specify_cli/integrations/_lifecycle.py b/src/specify_cli/integrations/_lifecycle.py index cb3243ba71..d0e5f6b083 100644 --- a/src/specify_cli/integrations/_lifecycle.py +++ b/src/specify_cli/integrations/_lifecycle.py @@ -536,6 +536,23 @@ def invoke(*args, **kwargs): installer.unload_installed_integrations() installer.persist_package(root, key, *candidate) for removed in records.keys() - set(remaining): + # 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, + ) + + _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 not candidate: installer.load_installed_integrations(root) 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) From bd65b28493ae0933c6dcf6ac56ac887d89fdcfaa Mon Sep 17 00:00:00 2001 From: Chaoqi Date: Wed, 14 Oct 2026 02:35:06 +0800 Subject: [PATCH 4/4] test(integrations): cover preset artifacts on integration uninstall Assisted-by: Claude Code (model: Claude Opus 5, autonomous) --- .../integrations/test_command_uninstall.py | 63 +++++++++++++++---- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/tests/specify_cli/integrations/test_command_uninstall.py b/tests/specify_cli/integrations/test_command_uninstall.py index b3ea373a60..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,36 +192,43 @@ 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.""" + 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" - registry = json.loads( - (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") - ) - assert "claude" not in registry["extensions"]["git"]["registered_commands"] + 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_extensions_for_fallback(self, tmp_path, missing_manifest): - """The integration promoted to default receives enabled extensions, like ``use``.""" + 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() @@ -206,12 +238,17 @@ def test_uninstall_default_registers_extensions_for_fallback(self, tmp_path, mis 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_extension_artifacts(self, tmp_path): + 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", @@ -222,10 +259,10 @@ def test_uninstall_non_default_preserves_default_extension_artifacts(self, tmp_p 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"] + 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."""