From 015ee2339540703c2531d0ea17e3d0a29bc6c38a Mon Sep 17 00:00:00 2001 From: chelsealong Date: Sat, 17 Oct 2026 10:36:31 +0000 Subject: [PATCH] fix: preserve registered integration skills during extension update --- .../extensions/_command_update_transaction.py | 46 ++++++++++++-- .../extensions/test_command_update.py | 62 +++++++++++++++++++ 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/extensions/_command_update_transaction.py b/src/specify_cli/extensions/_command_update_transaction.py index 81c7199d55..f4b9be60e3 100644 --- a/src/specify_cli/extensions/_command_update_transaction.py +++ b/src/specify_cli/extensions/_command_update_transaction.py @@ -407,10 +407,17 @@ def backup_extension_skills(skill_names, *, skills_dir=None): # already exists, and remember paths that are absent now so # rollback can remove files created before registry state is # available. Include aliases and Copilot companion prompts. - for ( - agent_name, - commands_dir, - ) in manager._command_registration_targets().items(): + command_targets = manager._command_registration_targets() + for agent_name, cmd_names in registered_commands.items(): + if cmd_names and agent_name in registrar.AGENT_CONFIGS: + command_targets.setdefault( + agent_name, + _AgentReg._resolve_agent_dir( + agent_name, registrar.AGENT_CONFIGS[agent_name], + project_root, + ), + ) + for agent_name, commands_dir in command_targets.items(): agent_config = registrar.AGENT_CONFIGS[agent_name] for command_name in new_command_names: output_name = _AgentReg._compute_output_name( @@ -557,6 +564,37 @@ def backup_extension_skills(skill_names, *, skills_dir=None): catalog_name=update.catalog_name, ) + # An update replaces an existing installation. Keep the + # integrations that held commands before removal, even + # when they are not the current default integration. + if registered_commands: + new_metadata = manager.registry.get(extension_id) + if not isinstance(new_metadata, dict): + raise RuntimeError( + f"Registry entry for '{extension_id}' missing after install" + ) + refreshed_commands = dict(new_metadata.get("registered_commands", {})) + missing_agents = [ + agent_name for agent_name, cmd_names in registered_commands.items() + if cmd_names and agent_name not in refreshed_commands + and agent_name in registrar.AGENT_CONFIGS + ] + if missing_agents: + manifest = manager.get_extension(extension_id) + if manifest is None: + raise RuntimeError(f"Extension '{extension_id}' missing after install") + for agent_name in missing_agents: + rendered = registrar.register_commands_for_agent( + agent_name, manifest, + manager.extensions_dir / extension_id, project_root, + ) + if rendered: + refreshed_commands[agent_name] = rendered + if refreshed_commands != new_metadata.get("registered_commands", {}): + manager.registry.update( + extension_id, {"registered_commands": refreshed_commands} + ) + # Restore user config files from backup after successful install. new_extension_dir = manager.extensions_dir / extension_id if backup_config_dir.exists() and new_extension_dir.exists(): diff --git a/tests/specify_cli/extensions/test_command_update.py b/tests/specify_cli/extensions/test_command_update.py index c1d5f1743c..f6cb27753d 100644 --- a/tests/specify_cli/extensions/test_command_update.py +++ b/tests/specify_cli/extensions/test_command_update.py @@ -16,6 +16,7 @@ from specify_cli import app from specify_cli.extensions import ( + CommandRegistrar, ExtensionCatalog, ExtensionManager, HookExecutor, @@ -381,6 +382,67 @@ def fake_install_from_zip( ).read_text() assert restored_config_content == original_config_content + @pytest.mark.parametrize("previously_registered", [True, False]) + def test_update_preserves_other_registered_integration( + self, tmp_path, previously_registered + ): + project_dir = tmp_path / "project" + (project_dir / ".specify").mkdir(parents=True) + (project_dir / ".claude" / "skills").mkdir(parents=True) + (project_dir / ".agents" / "skills").mkdir(parents=True) + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "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") + codex_skill = project_dir / ".agents" / "skills" / "speckit-test-ext-hello" / "SKILL.md" + if previously_registered: + commands = CommandRegistrar(project_dir).register_commands_for_agent( + "codex", manager.get_extension("test-ext"), + manager.extensions_dir / "test-ext", project_dir, + ) + manager.registry.update("test-ext", { + "registered_commands": { + **manager.registry.get("test-ext")["registered_commands"], + "codex": commands, + } + }) + assert codex_skill.is_file() + + v2_dir = self._create_extension_source(tmp_path, "2.0.0") + (v2_dir / "commands" / "hello.md").write_text( + "---\ndescription: Updated\n---\n\nUpdated content\n", encoding="utf-8" + ) + zip_path = tmp_path / "test-ext-update.zip" + self._create_catalog_zip(zip_path, "2.0.0") + + def fake_install_from_zip(self_obj, _zip_path, speckit_version, *, catalog_name=None): + return self_obj.install_from_directory( + v2_dir, speckit_version, catalog_name=catalog_name + ) + + 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), \ + patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip): + result = CliRunner().invoke( + app, ["extension", "update", "test-ext"], input="y\n" + ) + + assert result.exit_code == 0, result.output + registered = ExtensionManager(project_dir).registry.get("test-ext")["registered_commands"] + if previously_registered: + assert "Updated content" in codex_skill.read_text(encoding="utf-8") + assert registered["codex"] == ["speckit.test-ext.hello"] + else: + assert not codex_skill.exists() + assert "codex" not in registered + def test_update_installs_bundled_extension_from_local_copy(self, tmp_path): """A bundled extension (no download URL) updates from the copy shipped with the running spec-kit release instead of failing at download (#4345)."""