Skip to content

Commit 2cf127a

Browse files
authored
fix(integrations): move extension artifacts on integration uninstall (#4911)
* test(integrations): cover extension artifacts on integration uninstall Assisted-by: Claude Code (model: Claude Opus 5, autonomous) * 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) * 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) * test(integrations): cover preset artifacts on integration uninstall Assisted-by: Claude Code (model: Claude Opus 5, autonomous)
1 parent 9801d2b commit 2cf127a

5 files changed

Lines changed: 189 additions & 18 deletions

File tree

‎docs/reference/integrations.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,8 @@ Uninstalls the current integration (or the specified one). Spec Kit tracks every
230230
- **Modified files** (where you've made manual edits) are preserved so your customizations are not lost.
231231
- Use `--force` to remove all integration files regardless of modifications.
232232

233+
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.
234+
233235
## Switch to a Different Integration
234236

235237
```bash

‎src/specify_cli/integrations/_lifecycle.py‎

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,10 @@ def invoke(*args, **kwargs):
552552
installer.unload_installed_integrations()
553553
installer.persist_package(root, key, *candidate)
554554
for removed in records.keys() - set(remaining):
555-
if operation == "uninstall":
555+
# The uninstall command cleans up its own key. A stale
556+
# record's contributions must go here, while its adapter
557+
# is still loaded.
558+
if operation == "uninstall" and removed != key:
556559
from ._helpers import (
557560
_unregister_extensions_for_agent,
558561
_unregister_presets_for_agent,
@@ -567,22 +570,6 @@ def invoke(*args, **kwargs):
567570
continuing="The adapter was removed, but preset artifacts may need manual cleanup.",
568571
)
569572
installer.remove_package(root, removed)
570-
if operation == "uninstall" and key in records and key == default_integration_key(state or {}):
571-
fallback = default_integration_key(new_state or {})
572-
if fallback:
573-
from ._helpers import (
574-
_register_extensions_for_agent,
575-
_register_presets_for_agent,
576-
)
577-
578-
_register_extensions_for_agent(
579-
root, fallback,
580-
continuing="The fallback integration was selected, but extensions may need re-registration.",
581-
)
582-
_register_presets_for_agent(
583-
root, fallback,
584-
continuing="The fallback integration was selected, but presets may need re-registration.",
585-
)
586573
if not candidate:
587574
installer.load_installed_integrations(root)
588575
for message in messages:

‎src/specify_cli/integrations/command_uninstall.py‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from ..integration_state import default_integration_key as _default_integration_key, installed_integration_keys as _installed_integration_keys, integration_settings as _integration_settings
1111
from ._commands import integration_app
1212
from ._lifecycle import external_lifecycle, lifecycle_success
13-
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
13+
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
1414

1515

1616
@integration_app.command("uninstall")
@@ -44,6 +44,16 @@ def integration_uninstall(
4444
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
4545
if not manifest_path.exists():
4646
console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]")
47+
_unregister_extensions_for_agent(
48+
project_root,
49+
key,
50+
continuing="Continuing with integration uninstall; extension artifacts may need manual cleanup.",
51+
)
52+
_unregister_presets_for_agent(
53+
project_root,
54+
key,
55+
continuing="Continuing with integration uninstall; preset artifacts may need manual cleanup.",
56+
)
4757
remaining = [installed for installed in installed_keys if installed != key]
4858
new_default = default_key if default_key != key else (remaining[0] if remaining else None)
4959
if remaining:
@@ -60,6 +70,16 @@ def integration_uninstall(
6070
raw_options=raw_options,
6171
parsed_options=parsed_options,
6272
)
73+
_register_extensions_for_agent(
74+
project_root,
75+
new_default,
76+
continuing="The fallback integration was selected, but extensions may need re-registration.",
77+
)
78+
_register_presets_for_agent(
79+
project_root,
80+
new_default,
81+
continuing="The fallback integration was selected, but presets may need re-registration.",
82+
)
6383
else:
6484
_write_integration_json(
6585
project_root, new_default, remaining, _integration_settings(current)
@@ -92,6 +112,19 @@ def integration_uninstall(
92112
else:
93113
removed, skipped = integration.teardown(project_root, manifest, force=force)
94114

115+
# Extension/preset artifacts are tracked in their own registries, not the
116+
# integration manifest, so teardown leaves them behind.
117+
_unregister_extensions_for_agent(
118+
project_root,
119+
key,
120+
continuing="Continuing with integration uninstall; extension artifacts may need manual cleanup.",
121+
)
122+
_unregister_presets_for_agent(
123+
project_root,
124+
key,
125+
continuing="Continuing with integration uninstall; preset artifacts may need manual cleanup.",
126+
)
127+
95128
remaining = [installed for installed in installed_keys if installed != key]
96129
new_default = default_key if default_key != key else (remaining[0] if remaining else None)
97130
if remaining:
@@ -108,6 +141,16 @@ def integration_uninstall(
108141
raw_options=raw_options,
109142
parsed_options=parsed_options,
110143
)
144+
_register_extensions_for_agent(
145+
project_root,
146+
new_default,
147+
continuing="The fallback integration was selected, but extensions may need re-registration.",
148+
)
149+
_register_presets_for_agent(
150+
project_root,
151+
new_default,
152+
continuing="The fallback integration was selected, but presets may need re-registration.",
153+
)
111154
else:
112155
_write_integration_json(
113156
project_root, new_default, remaining, _integration_settings(current)

‎tests/specify_cli/integrations/test_command_uninstall.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from pathlib import Path # noqa: F401
99

1010
import pytest # noqa: F401
11+
import yaml
1112

1213
from specify_cli import app # noqa: F401
1314
from tests.conftest import strip_ansi # noqa: F401
@@ -21,6 +22,30 @@
2122
runner, # noqa: F401
2223
)
2324

25+
26+
def _add_sample_preset(project, tmp_path):
27+
"""Install a dev preset that overrides ``speckit.specify``."""
28+
preset = tmp_path / "sample-preset"
29+
(preset / "commands").mkdir(parents=True)
30+
(preset / "commands" / "speckit.specify.md").write_text(
31+
"---\ndescription: Sample preset command\n---\nSample preset guidance\n",
32+
encoding="utf-8",
33+
)
34+
(preset / "preset.yml").write_text(yaml.safe_dump({
35+
"schema_version": "1.0",
36+
"preset": {"id": "sample-preset", "name": "Sample Preset", "version": "1.0.0", "description": "Sample guidance"},
37+
"requires": {"speckit_version": ">=0.1.0"},
38+
"provides": {"templates": [{"type": "command", "name": "speckit.specify", "file": "commands/speckit.specify.md"}]},
39+
}), encoding="utf-8")
40+
result = _run_in_project(project, ["preset", "add", "--dev", str(preset)])
41+
assert result.exit_code == 0, f"preset add failed: {result.output}"
42+
43+
44+
def _registered_agents(project, kind, package_id):
45+
"""Agents whose artifacts the extension or preset registry tracks for ``package_id``."""
46+
registry = json.loads((project / ".specify" / kind / ".registry").read_text(encoding="utf-8"))
47+
return set(registry[kind][package_id]["registered_commands"])
48+
2449
class TestIntegrationUninstall:
2550
def test_uninstall_requires_speckit_project(self, tmp_path):
2651
old_cwd = os.getcwd()
@@ -167,6 +192,78 @@ def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path):
167192
assert "/speckit-plan" in template.read_text(encoding="utf-8")
168193
assert "/speckit-plan" in script.read_text(encoding="utf-8")
169194

195+
def test_uninstall_unregisters_extension_and_preset_artifacts(self, tmp_path):
196+
"""Uninstall must not leave extension or preset files orphaned in the agent's directory."""
197+
project = _init_project(tmp_path, "claude")
198+
result = _run_in_project(project, ["extension", "add", "git"])
199+
assert result.exit_code == 0, f"extension add failed: {result.output}"
200+
_add_sample_preset(project, tmp_path)
201+
claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
202+
claude_specify = project / ".claude" / "skills" / "speckit-specify" / "SKILL.md"
203+
assert claude_git_feature.exists()
204+
assert "Sample preset guidance" in claude_specify.read_text(encoding="utf-8")
205+
206+
result = _run_in_project(project, ["integration", "uninstall", "claude"])
207+
assert result.exit_code == 0, result.output
208+
209+
assert not claude_git_feature.exists(), "Extension skill should be removed with its agent"
210+
assert not claude_specify.exists(), "Preset skill should be removed with its agent"
211+
assert "claude" not in _registered_agents(project, "extensions", "git")
212+
assert "claude" not in _registered_agents(project, "presets", "sample-preset")
213+
214+
@pytest.mark.parametrize("missing_manifest", [False, True])
215+
def test_uninstall_default_registers_contributions_for_fallback(self, tmp_path, missing_manifest):
216+
"""The integration promoted to default receives enabled extensions and presets, like ``use``."""
217+
project = _init_project(tmp_path, "claude")
218+
result = _run_in_project(project, ["extension", "add", "git"])
219+
assert result.exit_code == 0, f"extension add failed: {result.output}"
220+
_add_sample_preset(project, tmp_path)
221+
result = _run_in_project(project, [
222+
"integration", "install", "codex",
223+
"--script", "sh",
224+
])
225+
assert result.exit_code == 0, result.output
226+
codex_git_feature = project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
227+
codex_specify = project / ".agents" / "skills" / "speckit-specify" / "SKILL.md"
228+
assert not codex_git_feature.exists(), "precondition: secondary install has no extension artifacts"
229+
assert "Sample preset guidance" not in codex_specify.read_text(encoding="utf-8"), (
230+
"precondition: secondary install has no preset artifacts"
231+
)
232+
if missing_manifest:
233+
(project / ".specify" / "integrations" / "claude.manifest.json").unlink()
234+
235+
result = _run_in_project(project, ["integration", "uninstall", "claude"])
236+
assert result.exit_code == 0, result.output
237+
238+
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
239+
assert data["integration"] == "codex"
240+
assert codex_git_feature.exists(), "Promoted default should receive enabled extensions"
241+
assert "Sample preset guidance" in codex_specify.read_text(encoding="utf-8"), (
242+
"Promoted default should receive enabled presets"
243+
)
244+
assert not (project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md").exists()
245+
assert not (project / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists()
246+
247+
def test_uninstall_non_default_preserves_default_contributions(self, tmp_path):
248+
project = _init_project(tmp_path, "claude")
249+
result = _run_in_project(project, ["extension", "add", "git"])
250+
assert result.exit_code == 0, f"extension add failed: {result.output}"
251+
_add_sample_preset(project, tmp_path)
252+
result = _run_in_project(project, [
253+
"integration", "install", "codex",
254+
"--script", "sh",
255+
])
256+
assert result.exit_code == 0, result.output
257+
258+
result = _run_in_project(project, ["integration", "uninstall", "codex"])
259+
assert result.exit_code == 0, result.output
260+
261+
assert (project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md").exists()
262+
claude_specify = project / ".claude" / "skills" / "speckit-specify" / "SKILL.md"
263+
assert "Sample preset guidance" in claude_specify.read_text(encoding="utf-8")
264+
assert "claude" in _registered_agents(project, "extensions", "git")
265+
assert "claude" in _registered_agents(project, "presets", "sample-preset")
266+
170267
def test_uninstall_preserves_shared_infra(self, tmp_path):
171268
"""Shared scripts and templates are not removed by integration uninstall."""
172269
project = _init_project(tmp_path, "claude")

‎tests/specify_cli/integrations/test_installed_adapters.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1623,6 +1623,48 @@ def fail_commit(*args, **kwargs):
16231623
assert json.loads((project / ".specify/integration.json").read_text())["integration"] == "claude"
16241624

16251625

1626+
def test_uninstall_cleans_up_contributions_of_stale_adapter_record(tmp_path, server):
1627+
publish(server)
1628+
project = catalog_project(tmp_path, server)
1629+
assert run(project, ["integration", "install", "claude"]).exit_code == 0
1630+
install(project)
1631+
extension = run(project, ["extension", "add", "git"])
1632+
assert extension.exit_code == 0, extension.output
1633+
preset = tmp_path / "sample-preset"
1634+
(preset / "commands").mkdir(parents=True)
1635+
(preset / "commands/speckit.specify.md").write_text(
1636+
"---\ndescription: Sample preset command\n---\nSample preset guidance\n"
1637+
)
1638+
(preset / "preset.yml").write_text(yaml.safe_dump({
1639+
"schema_version": "1.0",
1640+
"preset": {"id": "sample-preset", "name": "Sample Preset", "version": "1.0.0", "description": "Sample guidance"},
1641+
"requires": {"speckit_version": ">=0.1"},
1642+
"provides": {"templates": [{"type": "command", "name": "speckit.specify", "file": "commands/speckit.specify.md"}]},
1643+
}))
1644+
added = run(project, ["preset", "add", "--dev", str(preset)])
1645+
assert added.exit_code == 0, added.output
1646+
activated = run(project, ["integration", "use", KEY])
1647+
assert activated.exit_code == 0, activated.output
1648+
git_commit = project / ".sample-agent/skills/speckit-git-commit/SKILL.md"
1649+
specify = project / ".sample-agent/skills/speckit-specify/SKILL.md"
1650+
assert git_commit.is_file()
1651+
assert "Sample preset guidance" in specify.read_text(encoding="utf-8")
1652+
state_path = project / ".specify/integration.json"
1653+
state = json.loads(state_path.read_text())
1654+
state.update(integration="claude", default_integration="claude", installed_integrations=["claude"])
1655+
state_path.write_text(json.dumps(state))
1656+
1657+
removed = run(project, ["integration", "uninstall", "claude"])
1658+
assert removed.exit_code == 0, removed.output
1659+
assert KEY not in read_records(project)
1660+
assert not git_commit.exists()
1661+
assert not specify.exists()
1662+
extensions = json.loads((project / ".specify/extensions/.registry").read_text())["extensions"]
1663+
assert KEY not in extensions["git"]["registered_commands"]
1664+
presets = json.loads((project / ".specify/presets/.registry").read_text())["presets"]
1665+
assert KEY not in presets["sample-preset"]["registered_commands"]
1666+
1667+
16261668
def test_builtin_generic_can_coexist_with_external_adapter(tmp_path, server):
16271669
publish(server)
16281670
project = catalog_project(tmp_path, server)

0 commit comments

Comments
 (0)