diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index c22445ebe0..1e284e47ce 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3215,6 +3215,7 @@ def install_from_archive( priority: int = 10, force: bool = False, *, + register_commands: bool = True, archive_file: BinaryIO | None = None, source_name: str | None = None, content_type: str | None = None, @@ -3230,6 +3231,8 @@ def install_from_archive( priority: Resolution priority (lower = higher precedence, default 10) force: If True and extension is already installed, remove it first before proceeding with installation + register_commands: Whether to register command files for the active + integration; skills are registered independently when enabled archive_file: Already-open archive stream to consume instead of reopening ``zip_path`` @@ -3294,6 +3297,7 @@ def install_from_archive( return self.install_from_directory( extension_dir, speckit_version, + register_commands=register_commands, priority=priority, force=force, catalog_name=catalog_name, @@ -3449,6 +3453,7 @@ def install_from_zip( priority: int = 10, force: bool = False, *, + register_commands: bool = True, archive_file: BinaryIO | None = None, source_name: str | None = None, content_type: str | None = None, @@ -3460,6 +3465,7 @@ def install_from_zip( return self.install_from_archive( zip_path, speckit_version, + register_commands=register_commands, priority=priority, force=force, archive_file=archive_file, diff --git a/src/specify_cli/extensions/_command_update_transaction.py b/src/specify_cli/extensions/_command_update_transaction.py index 585b3cc2bf..1a571eea71 100644 --- a/src/specify_cli/extensions/_command_update_transaction.py +++ b/src/specify_cli/extensions/_command_update_transaction.py @@ -554,6 +554,9 @@ def backup_extension_skills(skill_names, *, skills_dir=None): _ = manager.install_from_zip( archive_path, speckit_version, + register_commands=not is_ai_skills_enabled( + _commands.load_init_options(project_root) + ), catalog_name=update.catalog_name, ) diff --git a/tests/specify_cli/extensions/test_command_update.py b/tests/specify_cli/extensions/test_command_update.py index c7428677a8..d6c91cae8a 100644 --- a/tests/specify_cli/extensions/test_command_update.py +++ b/tests/specify_cli/extensions/test_command_update.py @@ -7,6 +7,7 @@ import json import shutil +import zipfile from pathlib import Path from unittest.mock import patch @@ -349,10 +350,18 @@ def test_update_success_preserves_installed_at( v2_dir = self._create_extension_source(tmp_path, "2.0.0") def fake_install_from_zip( - self_obj, _zip_path, speckit_version, *, catalog_name=None + self_obj, + _zip_path, + speckit_version, + *, + register_commands=True, + catalog_name=None, ): return self_obj.install_from_directory( - v2_dir, speckit_version, catalog_name=catalog_name + v2_dir, + speckit_version, + register_commands=register_commands, + catalog_name=catalog_name, ) with patch.object(Path, "cwd", return_value=project_dir), \ @@ -667,6 +676,68 @@ def test_update_bundled_blocked_when_no_local_copy_exists(self, tmp_path): assert "upgrade spec-kit" in flat assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + def test_update_preserves_skills_mode_registration(self, tmp_path, monkeypatch): + """Updating an extension must keep Claude skills in skills mode.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: fake_home) + + project_dir = tmp_path / "project" + (project_dir / ".specify").mkdir(parents=True) + (project_dir / ".claude" / "skills").mkdir(parents=True) + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True, "script": "sh"}), + encoding="utf-8", + ) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0", register_commands=False) + + skill_name = "speckit-test-ext-hello" + skill_file = project_dir / ".claude" / "skills" / skill_name / "SKILL.md" + original_skill = skill_file.read_text(encoding="utf-8") + old_entry = manager.registry.get("test-ext") + assert old_entry["registered_skills"] == [skill_name] + assert old_entry.get("registered_commands", {}) == {} + + v2_dir = self._create_extension_source(tmp_path, "2.0.0") + zip_path = tmp_path / "test-ext-2.0.0.zip" + with zipfile.ZipFile(zip_path, "w") as archive: + for path in v2_dir.rglob("*"): + if path.is_file(): + archive.write(path, path.relative_to(v2_dir)) + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + ExtensionCatalog, + "get_extension_info", + return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "_install_allowed": True, + }, + ), + patch.object( + ExtensionCatalog, "download_extension", return_value=zip_path + ), + ): + result = runner.invoke( + app, + ["extension", "update", "test-ext"], + input="y\n", + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + updated = ExtensionManager(project_dir).registry.get("test-ext") + assert updated["registered_skills"] == [skill_name] + assert updated.get("registered_commands", {}) == {} + assert skill_file.read_text(encoding="utf-8") == original_skill + def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path, monkeypatch): """Failed update should restore original registry, hooks, and command files.""" from specify_cli import app