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
6 changes: 6 additions & 0 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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``

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/specify_cli/extensions/_command_update_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
),
Comment on lines +557 to +559
Comment on lines +557 to +559
Comment on lines +557 to +559
catalog_name=update.catalog_name,
)
Comment on lines 554 to 561

Expand Down
75 changes: 73 additions & 2 deletions tests/specify_cli/extensions/test_command_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import json
import shutil
import zipfile
from pathlib import Path
from unittest.mock import patch

Expand Down Expand Up @@ -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), \
Expand Down Expand Up @@ -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
Expand Down
Loading