Skip to content
Merged
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
21 changes: 4 additions & 17 deletions src/specify_cli/integrations/_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Comment thread
Chaoqi31 marked this conversation as resolved.
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
97 changes: 97 additions & 0 deletions tests/specify_cli/integrations/test_command_uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
42 changes: 42 additions & 0 deletions tests/specify_cli/integrations/test_installed_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading