Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 0 additions & 30 deletions src/specify_cli/integrations/_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
45 changes: 44 additions & 1 deletion src/specify_cli/integrations/command_uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions tests/specify_cli/integrations/test_command_uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down