From 4175f451dc492aa73e94ba249f86b169563f55a3 Mon Sep 17 00:00:00 2001 From: octavi Date: Tue, 24 Mar 2026 00:01:16 +0800 Subject: [PATCH] harden: unify agent config and add transactional install rollback --- src/specify_cli/__init__.py | 196 +------------------------ src/specify_cli/agent_config.py | 190 ++++++++++++++++++++++++ src/specify_cli/agents.py | 143 +----------------- src/specify_cli/extensions.py | 72 ++++++--- src/specify_cli/presets.py | 57 +++++-- tests/test_agent_config_consistency.py | 23 +++ tests/test_extensions.py | 31 ++++ tests/test_presets.py | 46 ++++++ 8 files changed, 389 insertions(+), 369 deletions(-) create mode 100644 src/specify_cli/agent_config.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 08af65107..b0ec0d416 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -56,6 +56,8 @@ import truststore from datetime import datetime, timezone +from .agent_config import AGENT_CONFIG, AI_ASSISTANT_ALIASES + ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) client = httpx.Client(verify=ssl_context) @@ -125,197 +127,11 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) return "\n".join(lines) -# Agent configuration with name, folder, install URL, CLI tool requirement, and commands subdirectory -AGENT_CONFIG = { - "copilot": { - "name": "GitHub Copilot", - "folder": ".github/", - "commands_subdir": "agents", # Special: uses agents/ not commands/ - "install_url": None, # IDE-based, no CLI check needed - "requires_cli": False, - }, - "claude": { - "name": "Claude Code", - "folder": ".claude/", - "commands_subdir": "commands", - "install_url": "https://docs.anthropic.com/en/docs/claude-code/setup", - "requires_cli": True, - }, - "gemini": { - "name": "Gemini CLI", - "folder": ".gemini/", - "commands_subdir": "commands", - "install_url": "https://github.com/google-gemini/gemini-cli", - "requires_cli": True, - }, - "cursor-agent": { - "name": "Cursor", - "folder": ".cursor/", - "commands_subdir": "commands", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "qwen": { - "name": "Qwen Code", - "folder": ".qwen/", - "commands_subdir": "commands", - "install_url": "https://github.com/QwenLM/qwen-code", - "requires_cli": True, - }, - "opencode": { - "name": "opencode", - "folder": ".opencode/", - "commands_subdir": "command", # Special: singular 'command' not 'commands' - "install_url": "https://opencode.ai", - "requires_cli": True, - }, - "codex": { - "name": "Codex CLI", - "folder": ".agents/", - "commands_subdir": "skills", # Codex now uses project skills directly - "install_url": "https://github.com/openai/codex", - "requires_cli": True, - }, - "windsurf": { - "name": "Windsurf", - "folder": ".windsurf/", - "commands_subdir": "workflows", # Special: uses workflows/ not commands/ - "install_url": None, # IDE-based - "requires_cli": False, - }, - "junie": { - "name": "Junie", - "folder": ".junie/", - "commands_subdir": "commands", - "install_url": "https://junie.jetbrains.com/", - "requires_cli": True, - }, - "kilocode": { - "name": "Kilo Code", - "folder": ".kilocode/", - "commands_subdir": "workflows", # Special: uses workflows/ not commands/ - "install_url": None, # IDE-based - "requires_cli": False, - }, - "auggie": { - "name": "Auggie CLI", - "folder": ".augment/", - "commands_subdir": "commands", - "install_url": "https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli", - "requires_cli": True, - }, - "codebuddy": { - "name": "CodeBuddy", - "folder": ".codebuddy/", - "commands_subdir": "commands", - "install_url": "https://www.codebuddy.ai/cli", - "requires_cli": True, - }, - "qodercli": { - "name": "Qoder CLI", - "folder": ".qoder/", - "commands_subdir": "commands", - "install_url": "https://qoder.com/cli", - "requires_cli": True, - }, - "roo": { - "name": "Roo Code", - "folder": ".roo/", - "commands_subdir": "commands", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "kiro-cli": { - "name": "Kiro CLI", - "folder": ".kiro/", - "commands_subdir": "prompts", # Special: uses prompts/ not commands/ - "install_url": "https://kiro.dev/docs/cli/", - "requires_cli": True, - }, - "amp": { - "name": "Amp", - "folder": ".agents/", - "commands_subdir": "commands", - "install_url": "https://ampcode.com/manual#install", - "requires_cli": True, - }, - "shai": { - "name": "SHAI", - "folder": ".shai/", - "commands_subdir": "commands", - "install_url": "https://github.com/ovh/shai", - "requires_cli": True, - }, - "tabnine": { - "name": "Tabnine CLI", - "folder": ".tabnine/agent/", - "commands_subdir": "commands", - "install_url": "https://docs.tabnine.com/main/getting-started/tabnine-cli", - "requires_cli": True, - }, - "agy": { - "name": "Antigravity", - "folder": ".agent/", - "commands_subdir": "commands", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "bob": { - "name": "IBM Bob", - "folder": ".bob/", - "commands_subdir": "commands", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "vibe": { - "name": "Mistral Vibe", - "folder": ".vibe/", - "commands_subdir": "prompts", - "install_url": "https://github.com/mistralai/mistral-vibe", - "requires_cli": True, - }, - "kimi": { - "name": "Kimi Code", - "folder": ".kimi/", - "commands_subdir": "skills", # Kimi uses /skill: with .kimi/skills//SKILL.md - "install_url": "https://code.kimi.com/", - "requires_cli": True, - }, - "trae": { - "name": "Trae", - "folder": ".trae/", - "commands_subdir": "rules", # Trae uses .trae/rules/ for project rules - "install_url": None, # IDE-based - "requires_cli": False, - }, - "pi": { - "name": "Pi Coding Agent", - "folder": ".pi/", - "commands_subdir": "prompts", - "install_url": "https://www.npmjs.com/package/@mariozechner/pi-coding-agent", - "requires_cli": True, - }, - "iflow": { - "name": "iFlow CLI", - "folder": ".iflow/", - "commands_subdir": "commands", - "install_url": "https://docs.iflow.cn/en/cli/quickstart", - "requires_cli": True, - }, - "generic": { - "name": "Generic (bring your own agent)", - "folder": None, # Set dynamically via --ai-commands-dir - "commands_subdir": "commands", - "install_url": None, - "requires_cli": False, - }, -} - -AI_ASSISTANT_ALIASES = { - "kiro": "kiro-cli", -} +# Agent configuration and aliases are shared with command registration logic. +# Keep this module-level import assignment so existing imports/tests remain stable. -# Agents that use TOML command format (others use Markdown) +# Agents that use TOML command format (others use Markdown). +# Kept for compatibility with tests and scaffolding helpers. _TOML_AGENTS = frozenset({"gemini", "tabnine"}) def _build_ai_assistant_help() -> str: diff --git a/src/specify_cli/agent_config.py b/src/specify_cli/agent_config.py new file mode 100644 index 000000000..09bdcd82d --- /dev/null +++ b/src/specify_cli/agent_config.py @@ -0,0 +1,190 @@ +"""Shared AI-agent configuration for runtime and command registration.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +AGENT_CONFIG: dict[str, dict[str, Any]] = {'copilot': {'name': 'GitHub Copilot', + 'folder': '.github/', + 'commands_subdir': 'agents', + 'install_url': None, + 'requires_cli': False}, + 'claude': {'name': 'Claude Code', + 'folder': '.claude/', + 'commands_subdir': 'commands', + 'install_url': 'https://docs.anthropic.com/en/docs/claude-code/setup', + 'requires_cli': True}, + 'gemini': {'name': 'Gemini CLI', + 'folder': '.gemini/', + 'commands_subdir': 'commands', + 'install_url': 'https://github.com/google-gemini/gemini-cli', + 'requires_cli': True}, + 'cursor-agent': {'name': 'Cursor', + 'folder': '.cursor/', + 'commands_subdir': 'commands', + 'install_url': None, + 'requires_cli': False}, + 'qwen': {'name': 'Qwen Code', + 'folder': '.qwen/', + 'commands_subdir': 'commands', + 'install_url': 'https://github.com/QwenLM/qwen-code', + 'requires_cli': True}, + 'opencode': {'name': 'opencode', + 'folder': '.opencode/', + 'commands_subdir': 'command', + 'install_url': 'https://opencode.ai', + 'requires_cli': True}, + 'codex': {'name': 'Codex CLI', + 'folder': '.agents/', + 'commands_subdir': 'skills', + 'install_url': 'https://github.com/openai/codex', + 'requires_cli': True}, + 'windsurf': {'name': 'Windsurf', + 'folder': '.windsurf/', + 'commands_subdir': 'workflows', + 'install_url': None, + 'requires_cli': False}, + 'junie': {'name': 'Junie', + 'folder': '.junie/', + 'commands_subdir': 'commands', + 'install_url': 'https://junie.jetbrains.com/', + 'requires_cli': True}, + 'kilocode': {'name': 'Kilo Code', + 'folder': '.kilocode/', + 'commands_subdir': 'workflows', + 'install_url': None, + 'requires_cli': False}, + 'auggie': {'name': 'Auggie CLI', + 'folder': '.augment/', + 'commands_subdir': 'commands', + 'install_url': 'https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli', + 'requires_cli': True}, + 'codebuddy': {'name': 'CodeBuddy', + 'folder': '.codebuddy/', + 'commands_subdir': 'commands', + 'install_url': 'https://www.codebuddy.ai/cli', + 'requires_cli': True}, + 'qodercli': {'name': 'Qoder CLI', + 'folder': '.qoder/', + 'commands_subdir': 'commands', + 'install_url': 'https://qoder.com/cli', + 'requires_cli': True}, + 'roo': {'name': 'Roo Code', + 'folder': '.roo/', + 'commands_subdir': 'commands', + 'install_url': None, + 'requires_cli': False}, + 'kiro-cli': {'name': 'Kiro CLI', + 'folder': '.kiro/', + 'commands_subdir': 'prompts', + 'install_url': 'https://kiro.dev/docs/cli/', + 'requires_cli': True}, + 'amp': {'name': 'Amp', + 'folder': '.agents/', + 'commands_subdir': 'commands', + 'install_url': 'https://ampcode.com/manual#install', + 'requires_cli': True}, + 'shai': {'name': 'SHAI', + 'folder': '.shai/', + 'commands_subdir': 'commands', + 'install_url': 'https://github.com/ovh/shai', + 'requires_cli': True}, + 'tabnine': {'name': 'Tabnine CLI', + 'folder': '.tabnine/agent/', + 'commands_subdir': 'commands', + 'install_url': 'https://docs.tabnine.com/main/getting-started/tabnine-cli', + 'requires_cli': True}, + 'agy': {'name': 'Antigravity', + 'folder': '.agent/', + 'commands_subdir': 'commands', + 'install_url': None, + 'requires_cli': False}, + 'bob': {'name': 'IBM Bob', + 'folder': '.bob/', + 'commands_subdir': 'commands', + 'install_url': None, + 'requires_cli': False}, + 'vibe': {'name': 'Mistral Vibe', + 'folder': '.vibe/', + 'commands_subdir': 'prompts', + 'install_url': 'https://github.com/mistralai/mistral-vibe', + 'requires_cli': True}, + 'kimi': {'name': 'Kimi Code', + 'folder': '.kimi/', + 'commands_subdir': 'skills', + 'install_url': 'https://code.kimi.com/', + 'requires_cli': True}, + 'trae': {'name': 'Trae', + 'folder': '.trae/', + 'commands_subdir': 'rules', + 'install_url': None, + 'requires_cli': False}, + 'pi': {'name': 'Pi Coding Agent', + 'folder': '.pi/', + 'commands_subdir': 'prompts', + 'install_url': 'https://www.npmjs.com/package/@mariozechner/pi-coding-agent', + 'requires_cli': True}, + 'iflow': {'name': 'iFlow CLI', + 'folder': '.iflow/', + 'commands_subdir': 'commands', + 'install_url': 'https://docs.iflow.cn/en/cli/quickstart', + 'requires_cli': True}, + 'generic': {'name': 'Generic (bring your own agent)', + 'folder': None, + 'commands_subdir': 'commands', + 'install_url': None, + 'requires_cli': False}} + +AI_ASSISTANT_ALIASES: dict[str, str] = {'kiro': 'kiro-cli', 'cursor': 'cursor-agent'} + +# Runtime agent keys that map to a different registrar key. +RUNTIME_TO_REGISTRAR_AGENT_KEY: dict[str, str] = { + "cursor-agent": "cursor", +} + + +def build_command_registrar_configs() -> dict[str, dict[str, str]]: + """Derive command-registrar config from runtime AGENT_CONFIG. + + Keeps command registration targets synchronized with runtime agent support + while preserving backward-compatible key aliases (for example: runtime + ``cursor-agent`` maps to registrar key ``cursor``). + """ + configs: dict[str, dict[str, str]] = {} + + for runtime_key, cfg in AGENT_CONFIG.items(): + if runtime_key == "generic": + continue + + folder = cfg.get("folder") + subdir = cfg.get("commands_subdir") + if not folder or not subdir: + continue + + registrar_key = RUNTIME_TO_REGISTRAR_AGENT_KEY.get(runtime_key, runtime_key) + + command_format = "toml" if registrar_key in {"gemini", "tabnine"} else "markdown" + extension = ".toml" if command_format == "toml" else ".md" + + if registrar_key == "copilot": + extension = ".agent.md" + if registrar_key in {"codex", "kimi"}: + extension = "/SKILL.md" + + configs[registrar_key] = { + "dir": f"{folder.rstrip('/')}/{subdir}", + "format": command_format, + "args": "{{args}}" if command_format == "toml" else "$ARGUMENTS", + "extension": extension, + } + + return configs + + +COMMAND_REGISTRAR_CONFIGS: dict[str, dict[str, str]] = build_command_registrar_configs() + + +def get_command_registrar_configs() -> dict[str, dict[str, str]]: + """Return a deep copy of command registrar config for safe mutation in tests.""" + return deepcopy(COMMAND_REGISTRAR_CONFIGS) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 7fe531606..b7e249142 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -12,6 +12,8 @@ import platform import yaml +from .agent_config import get_command_registrar_configs + class CommandRegistrar: """Handles registration of commands with AI agents. @@ -22,146 +24,7 @@ class CommandRegistrar: """ # Agent configurations with directory, format, and argument placeholder - AGENT_CONFIGS = { - "claude": { - "dir": ".claude/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "gemini": { - "dir": ".gemini/commands", - "format": "toml", - "args": "{{args}}", - "extension": ".toml" - }, - "copilot": { - "dir": ".github/agents", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".agent.md" - }, - "cursor": { - "dir": ".cursor/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "qwen": { - "dir": ".qwen/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "opencode": { - "dir": ".opencode/command", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "codex": { - "dir": ".agents/skills", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": "/SKILL.md", - }, - "windsurf": { - "dir": ".windsurf/workflows", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "junie": { - "dir": ".junie/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "kilocode": { - "dir": ".kilocode/workflows", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "auggie": { - "dir": ".augment/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "roo": { - "dir": ".roo/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "codebuddy": { - "dir": ".codebuddy/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "qodercli": { - "dir": ".qoder/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "kiro-cli": { - "dir": ".kiro/prompts", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "pi": { - "dir": ".pi/prompts", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "amp": { - "dir": ".agents/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "shai": { - "dir": ".shai/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "tabnine": { - "dir": ".tabnine/agent/commands", - "format": "toml", - "args": "{{args}}", - "extension": ".toml" - }, - "bob": { - "dir": ".bob/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "kimi": { - "dir": ".kimi/skills", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": "/SKILL.md", - }, - "trae": { - "dir": ".trae/rules", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "iflow": { - "dir": ".iflow/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - } - } + AGENT_CONFIGS = get_command_registrar_configs() @staticmethod def parse_frontmatter(content: str) -> tuple[dict, str]: diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index b26b1e931..a873af8d7 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -584,38 +584,62 @@ def install_from_directory( f"Use 'specify extension remove {manifest.id}' first." ) - # Install extension + # Install extension transactionally so partial failures are rolled back. dest_dir = self.extensions_dir / manifest.id if dest_dir.exists(): shutil.rmtree(dest_dir) ignore_fn = self._load_extensionignore(source_dir) - shutil.copytree(source_dir, dest_dir, ignore=ignore_fn) - - # Register commands with AI agents + hook_executor = HookExecutor(self.project_root) + registrar = CommandRegistrar() if register_commands else None registered_commands = {} - if register_commands: - registrar = CommandRegistrar() - # Register for all detected agents - registered_commands = registrar.register_commands_for_all_agents( - manifest, dest_dir, self.project_root - ) - # Register hooks - hook_executor = HookExecutor(self.project_root) - hook_executor.register_hooks(manifest) + try: + shutil.copytree(source_dir, dest_dir, ignore=ignore_fn) - # Update registry - self.registry.add(manifest.id, { - "version": manifest.version, - "source": "local", - "manifest_hash": manifest.get_hash(), - "enabled": True, - "priority": priority, - "registered_commands": registered_commands - }) - - return manifest + # Register commands with AI agents + if registrar: + # Register for all detected agents + registered_commands = registrar.register_commands_for_all_agents( + manifest, dest_dir, self.project_root + ) + + # Register hooks + hook_executor.register_hooks(manifest) + + # Update registry + self.registry.add(manifest.id, { + "version": manifest.version, + "source": "local", + "manifest_hash": manifest.get_hash(), + "enabled": True, + "priority": priority, + "registered_commands": registered_commands + }) + return manifest + except Exception: + # Best-effort rollback for partial installs. + if registered_commands and registrar: + try: + registrar.unregister_commands(registered_commands, self.project_root) + except Exception: + pass + + try: + hook_executor.unregister_hooks(manifest.id) + except Exception: + pass + + if self.registry.is_installed(manifest.id): + try: + self.registry.remove(manifest.id) + except Exception: + pass + + if dest_dir.exists(): + shutil.rmtree(dest_dir, ignore_errors=True) + + raise def install_from_zip( self, diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 24d523aa8..7c5b2003b 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -827,25 +827,52 @@ def install_from_directory( if dest_dir.exists(): shutil.rmtree(dest_dir) - shutil.copytree(source_dir, dest_dir) + registered_commands: Dict[str, List[str]] = {} + registered_skills: List[str] = [] - # Register command overrides with AI agents - registered_commands = self._register_commands(manifest, dest_dir) + try: + shutil.copytree(source_dir, dest_dir) + + # Register command overrides with AI agents + registered_commands = self._register_commands(manifest, dest_dir) + + # Update corresponding skills when --ai-skills was previously used + registered_skills = self._register_skills(manifest, dest_dir) + + self.registry.add(manifest.id, { + "version": manifest.version, + "source": "local", + "manifest_hash": manifest.get_hash(), + "enabled": True, + "priority": priority, + "registered_commands": registered_commands, + "registered_skills": registered_skills, + }) + return manifest + except Exception: + # Best-effort rollback for partial installs. + if registered_skills: + try: + self._unregister_skills(registered_skills, dest_dir) + except Exception: + pass - # Update corresponding skills when --ai-skills was previously used - registered_skills = self._register_skills(manifest, dest_dir) + if registered_commands: + try: + self._unregister_commands(registered_commands) + except Exception: + pass + + if self.registry.is_installed(manifest.id): + try: + self.registry.remove(manifest.id) + except Exception: + pass - self.registry.add(manifest.id, { - "version": manifest.version, - "source": "local", - "manifest_hash": manifest.get_hash(), - "enabled": True, - "priority": priority, - "registered_commands": registered_commands, - "registered_skills": registered_skills, - }) + if dest_dir.exists(): + shutil.rmtree(dest_dir, ignore_errors=True) - return manifest + raise def install_from_zip( self, diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index fe5c01cf7..b8a4f1bfe 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -4,6 +4,7 @@ from pathlib import Path from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP +from specify_cli.agent_config import RUNTIME_TO_REGISTRAR_AGENT_KEY from specify_cli.extensions import CommandRegistrar @@ -41,6 +42,28 @@ def test_runtime_codex_uses_native_skills(self): assert AGENT_CONFIG["codex"]["folder"] == ".agents/" assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills" + def test_runtime_agents_map_to_registrar_config(self): + """Every runtime agent (except generic) should resolve to a registrar target.""" + registrar_cfg = CommandRegistrar.AGENT_CONFIGS + + for runtime_agent in AGENT_CONFIG: + if runtime_agent == "generic": + continue + registrar_agent = RUNTIME_TO_REGISTRAR_AGENT_KEY.get(runtime_agent, runtime_agent) + assert registrar_agent in registrar_cfg + + def test_registrar_includes_agy_and_vibe(self): + """Agy and Vibe should support extension/preset command registration.""" + registrar_cfg = CommandRegistrar.AGENT_CONFIGS + assert "agy" in registrar_cfg + assert registrar_cfg["agy"]["dir"] == ".agent/commands" + assert "vibe" in registrar_cfg + assert registrar_cfg["vibe"]["dir"] == ".vibe/prompts" + + def test_cursor_alias_points_to_cursor_agent(self): + """CLI should accept `cursor` alias while keeping cursor-agent as canonical key.""" + assert AI_ASSISTANT_ALIASES["cursor"] == "cursor-agent" + def test_release_agent_lists_include_kiro_cli_and_exclude_q(self): """Bash and PowerShell release scripts should agree on agent key set for Kiro.""" sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index cd0f9ba44..014875e2a 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -16,6 +16,8 @@ from pathlib import Path from datetime import datetime, timezone +import yaml + from specify_cli.extensions import ( CatalogEntry, ExtensionManifest, @@ -588,6 +590,35 @@ def test_install_duplicate(self, extension_dir, project_dir): with pytest.raises(ExtensionError, match="already installed"): manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + def test_install_failure_rolls_back_files_hooks_commands_and_registry(self, extension_dir, project_dir, monkeypatch): + """A failed install should leave no partial extension state behind.""" + from specify_cli.extensions import HookExecutor + + (project_dir / ".claude" / "commands").mkdir(parents=True) + manager = ExtensionManager(project_dir) + + def fail_hook_registration(_self, _manifest): + raise RuntimeError("boom") + + monkeypatch.setattr(HookExecutor, "register_hooks", fail_hook_registration) + + with pytest.raises(RuntimeError, match="boom"): + manager.install_from_directory(extension_dir, "0.1.0", register_commands=True) + + assert not manager.registry.is_installed("test-ext") + assert not (project_dir / ".specify" / "extensions" / "test-ext").exists() + assert not (project_dir / ".claude" / "commands" / "speckit.test.hello.md").exists() + + hooks_file = project_dir / ".specify" / "extensions.yml" + if hooks_file.exists(): + hooks_content = yaml.safe_load(hooks_file.read_text()) or {} + all_hooks = hooks_content.get("hooks", {}) + assert not any( + h.get("extension") == "test-ext" + for hooks in all_hooks.values() + for h in hooks + ) + def test_remove_extension(self, extension_dir, project_dir): """Test removing an installed extension.""" manager = ExtensionManager(project_dir) diff --git a/tests/test_presets.py b/tests/test_presets.py index 2716b73dc..0d4cf50e6 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -564,6 +564,52 @@ def test_install_already_installed(self, project_dir, pack_dir): with pytest.raises(PresetError, match="already installed"): manager.install_from_directory(pack_dir, "0.1.5") + def test_install_failure_rolls_back_files_commands_and_registry(self, project_dir, temp_dir, monkeypatch): + """A failed preset install should not leave partial preset or command state.""" + preset_dir = temp_dir / "cmd-pack" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "cmd-pack", + "name": "Command Pack", + "version": "1.0.0", + "description": "Preset with command override", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/specify.md", + } + ] + }, + } + (preset_dir / "preset.yml").write_text(yaml.safe_dump(manifest_data)) + (preset_dir / "commands" / "specify.md").write_text( + "---\ndescription: test\n---\n\n# test\n\n$ARGUMENTS\n" + ) + + (project_dir / ".claude" / "commands").mkdir(parents=True) + + manager = PresetManager(project_dir) + + def fail_register_skills(_manifest, _dest_dir): + raise RuntimeError("boom") + + monkeypatch.setattr(manager, "_register_skills", fail_register_skills) + + with pytest.raises(RuntimeError, match="boom"): + manager.install_from_directory(preset_dir, "0.1.5") + + assert not manager.registry.is_installed("cmd-pack") + assert not (project_dir / ".specify" / "presets" / "cmd-pack").exists() + assert not (project_dir / ".claude" / "commands" / "speckit.specify.md").exists() + def test_install_incompatible(self, project_dir, temp_dir, valid_pack_data): """Test installing an incompatible pack raises error.""" valid_pack_data["requires"]["speckit_version"] = ">=99.0.0"