From c5f1e96e8197d949c2c0d506343264ddc0b65825 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Fri, 24 Jul 2026 01:13:28 +0300 Subject: [PATCH 01/20] feat: first-class agent-native runtime hooks for integrations --- src/specify_cli/integrations/_hooks.py | 975 +++++++++++++++++++++++++ src/specify_cli/integrations/base.py | 26 + tests/integrations/test_hooks.py | 678 +++++++++++++++++ 3 files changed, 1679 insertions(+) create mode 100644 src/specify_cli/integrations/_hooks.py create mode 100644 tests/integrations/test_hooks.py diff --git a/src/specify_cli/integrations/_hooks.py b/src/specify_cli/integrations/_hooks.py new file mode 100644 index 0000000000..7f24395b34 --- /dev/null +++ b/src/specify_cli/integrations/_hooks.py @@ -0,0 +1,975 @@ +"""Agent runtime hooks for integrations. + +Provides: +- ``resolve_hooks`` — layered hook resolution (CLI flag → YAML override → extension-declared → built-in). +- ``collect_extension_runtime_hooks`` — scan installed extension.yml files for ``runtime_hooks:``. +- ``HookAdapter`` / adapters — generate and merge native hook config per agent CLI. +- ``install_integration_hooks`` / ``remove_integration_hooks`` — entry points called from ``IntegrationBase.setup()`` / ``teardown()``. + +""" + +from __future__ import annotations + +import json +import logging +import re +from abc import ABC, abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import yaml + +if TYPE_CHECKING: + from .base import IntegrationBase + from .manifest import IntegrationManifest + +logger = logging.getLogger(__name__) + +# -- Constants ------------------------------------------------------------- + +HOOK_BRIDGE_DIR = Path(".specify") / "hooks" +HOOK_BRIDGE_FILENAME = "bridge.py" +HOOK_BRIDGE_REL = str(HOOK_BRIDGE_DIR / HOOK_BRIDGE_FILENAME) + +YAML_OVERRIDE_FILENAME = Path(".specify") / "integration-hooks.yml" + +# Sentinel marker embedded in generated native config entries so we can +# identify and remove only our entries on uninstall/upgrade. +_SPECKIT_MARKER = "__speckit_hook__" + +# Canonical runtime hook event names that extensions use in ``runtime_hooks:``. +# Adapters translate these to each agent's native event names via +# ``CANONICAL_TO_NATIVE``. Extensions never need to know agent-specific names. +CANONICAL_RUNTIME_EVENTS = frozenset({ + "PreToolUse", + "PostToolUse", + "Stop", + "SessionStart", + "SessionEnd", + "UserPromptSubmit", +}) + +# -- Bridge script template ------------------------------------------------ + +_BRIDGE_TEMPLATE = '''#!/usr/bin/env python3 +"""Specify CLI Hook Bridge — dispatches agent runtime hooks to slash commands. + +Generated by: specify integration install/upgrade +Do not edit manually. Regenerate with: specify integration upgrade +""" +import json, sys, subprocess, re, os, platform +from pathlib import Path + + +def _project_root(): + for env_var in ("CLAUDE_PROJECT_DIR", "CURSOR_PROJECT_ROOT", "PROJECT_ROOT"): + val = os.environ.get(env_var) + if val and Path(val).is_dir(): + return Path(val) + return Path.cwd() + + +def _find_command_template(command_name, project_root): + # 1. Check extension .registry + registry = project_root / ".specify" / "extensions" / ".registry" + if registry.exists(): + try: + data = json.loads(registry.read_text(encoding="utf-8")) + except Exception: + data = {} + for ext_id, meta in data.items(): + if not isinstance(meta, dict): + continue + for cmd in meta.get("commands", []): + if cmd.get("name") == command_name: + ext_dir = project_root / ".specify" / "extensions" / ext_id + return ext_dir / cmd["file"], ext_id + # 2. Scan extension directories + exts_dir = project_root / ".specify" / "extensions" + if exts_dir.is_dir(): + for ext_dir in sorted(exts_dir.iterdir()): + cmds_dir = ext_dir / "commands" + if cmds_dir.is_dir(): + for f in cmds_dir.glob("*.md"): + if f.stem == command_name: + return f, ext_dir.name + # 3. Check core templates + core = project_root / ".specify" / "templates" / "commands" + if core.is_dir(): + stem = command_name.replace("speckit.", "").replace("spec.", "") + candidate = core / f"{stem}.md" + if candidate.exists(): + return candidate, None + return None, None + + +def _extract_script_path(template_path, project_root, ext_id): + content = template_path.read_text(encoding="utf-8") + m = re.match(r'^---\\n(.*?)\\n---', content, re.DOTALL) + if not m: + return None + fm = m.group(1) + scripts_m = re.search(r'scripts:\\s*\\n((?:\\s+\\w+:.*\\n)*)', fm) + if not scripts_m: + return None + variants = {} + for line in scripts_m.group(1).strip().splitlines(): + kv = re.match(r'\\s+(\\w+):\\s*(.+)', line) + if kv: + variants[kv.group(1)] = kv.group(2).strip().strip('"\\'') + default = "ps" if platform.system().lower().startswith("win") else "sh" + script_rel = variants.get(default) or variants.get("sh") or variants.get("py") + if not script_rel: + return None + if ext_id: + script_abs = project_root / ".specify" / "extensions" / ext_id / script_rel + else: + script_abs = project_root / ".specify" / script_rel + return str(script_abs) if script_abs.exists() else None + + +def main(): + if len(sys.argv) < 3: + sys.exit(0) + command_name = sys.argv[1] + event_name = sys.argv[2] + project_root = _project_root() + payload = sys.stdin.read() if not sys.stdin.isatty() else "{}" + template, ext_id = _find_command_template(command_name, project_root) + if not template: + sys.exit(0) + script_path = _extract_script_path(template, project_root, ext_id) + if not script_path: + sys.exit(0) + try: + result = subprocess.run( + [script_path], input=payload, capture_output=True, text=True, timeout=120, + ) + if result.stdout: + print(result.stdout, end="") + if result.returncode != 0: + if result.stderr: + print(result.stderr, file=sys.stderr, end="") + sys.exit(2) + sys.exit(0) + except subprocess.TimeoutExpired: + print(f"Hook {command_name} timed out", file=sys.stderr) + sys.exit(2) + except Exception as e: + print(f"Hook {command_name} error: {e}", file=sys.stderr) + sys.exit(2) + + +if __name__ == "__main__": + main() +''' + +# -- TS plugin template (opencode) ---------------------------------------- + +_TS_PLUGIN_TEMPLATE = '''import {{ execSync }} from 'child_process'; +import * as path from 'path'; + +const BRIDGE = path.join(process.cwd(), '.specify', 'hooks', 'bridge.py'); + +function runHook(command: string, event: string, input: any): void {{ + try {{ + execSync(`python3 ${{BRIDGE}} ${{command}} ${{event}}`, {{ + input: JSON.stringify(input), + stdio: ['pipe', 'inherit', 'inherit'], + timeout: 60000, + }}); + }} catch (e) {{ + process.exit(2); + }} +}} + +{hook_entries} + +export default (async ({{ client, project, directory, $ }}) => {{ + return {{ +{plugin_returns} + }}; +}}); +''' + + +# -- Hook resolution ------------------------------------------------------- + +def resolve_hooks( + integration_key: str, + integration_config: dict[str, Any] | None, + project_root: Path, + parsed_options: dict[str, Any] | None, +) -> dict[str, dict[str, Any]]: + """Resolve the final hook set for an integration. + + Layer 1: ``--hooks false`` → return ``{}`` + Layer 2: ``.specify/integration-hooks.yml`` → replace entirely if key present + Layer 3: extension-declared ``runtime_hooks:`` → appended + Layer 4: ``config["hooks"]`` → built-in baseline + """ + # Layer 1: CLI flag gate + if parsed_options: + hooks_flag = str(parsed_options.get("hooks", "true")).lower() + if hooks_flag in ("false", "0", "no", "off"): + return {} + + # Layer 4: built-in defaults from integration config + hooks: dict[str, dict[str, Any]] = {} + if integration_config and isinstance(integration_config.get("hooks"), dict): + hooks = dict(integration_config["hooks"]) + + # Layer 3: extension-declared runtime hooks + ext_hooks = collect_extension_runtime_hooks(project_root) + hooks.update(ext_hooks) + + # Layer 2: user YAML override (replaces entirely if key present) + override_file = project_root / YAML_OVERRIDE_FILENAME + if override_file.exists(): + try: + override = yaml.safe_load(override_file.read_text(encoding="utf-8")) or {} + except yaml.YAMLError: + logger.warning("Could not parse %s; ignoring override", override_file) + override = {} + integrations = override.get("integrations", {}) if isinstance(override, dict) else {} + if integration_key in integrations: + key_data = integrations[integration_key] + if isinstance(key_data, dict): + hooks = key_data.get("hooks", {}) or {} + else: + hooks = {} + + return hooks + + +def collect_extension_runtime_hooks(project_root: Path) -> dict[str, dict[str, Any]]: + """Scan all installed extensions for ``runtime_hooks:`` declarations.""" + hooks: dict[str, dict[str, Any]] = {} + exts_dir = project_root / ".specify" / "extensions" + if not exts_dir.is_dir(): + return hooks + + for ext_dir in sorted(exts_dir.iterdir()): + if not ext_dir.is_dir(): + continue + ext_yml = ext_dir / "extension.yml" + if not ext_yml.exists(): + continue + try: + data = yaml.safe_load(ext_yml.read_text(encoding="utf-8")) or {} + except yaml.YAMLError: + continue + if not isinstance(data, dict): + continue + runtime = data.get("runtime_hooks", {}) or {} + if not isinstance(runtime, dict): + continue + for event, config in runtime.items(): + if isinstance(config, dict): + hooks[event] = config + return hooks + + +# -- Adapter infrastructure ------------------------------------------------ + +class HookAdapter(ABC): + """Abstract base for per-agent native hook config generation/merge. + + Subclasses set ``CANONICAL_TO_NATIVE`` to map canonical event names + (e.g. ``PreToolUse``) to the agent's native event names. Events not + in the mapping are silently skipped with a warning at install time. + """ + + # Maps canonical event name → agent-native event name. + # Empty dict means no events are supported (adapter is a no-op). + CANONICAL_TO_NATIVE: dict[str, str] = {} + + @property + @abstractmethod + def config_file_rel(self) -> str: + """Project-relative path to the native settings file.""" + + @abstractmethod + def generate_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> tuple[str, Any]: + """Return (format, fragment) where format is 'json' or 'toml'.""" + + @abstractmethod + def merge_fragment(self, dst: Path, fragment: Any, *, format: str) -> None: + """Merge fragment into the native config file at *dst*.""" + + @abstractmethod + def remove_entries(self, dst: Path) -> None: + """Remove all Specify-authored hook entries from *dst*.""" + + def _native_event(self, canonical: str) -> str | None: + """Return the native event name for a canonical name, or None.""" + return self.CANONICAL_TO_NATIVE.get(canonical) + + def _filter_hooks(self, hooks: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + """Filter hooks to only those supported by this adapter. + + Prints a warning for each unsupported event and returns a dict + containing only the supported ones. + """ + import sys + filtered: dict[str, dict[str, Any]] = {} + for event, config in hooks.items(): + if self._native_event(event) is not None: + filtered[event] = config + else: + adapter_name = type(self).__name__ + print( + f"\u26a0\ufe0f {adapter_name} does not support '{event}' events; skipping", + file=sys.stderr, + ) + return filtered + + def install( + self, + project_root: Path, + manifest: "IntegrationManifest", + hooks: dict[str, dict[str, Any]], + ) -> list[Path]: + """Generate bridge, merge native config, return created files.""" + # Filter to only events this adapter supports + hooks = self._filter_hooks(hooks) + if not hooks: + return [] + + created: list[Path] = [] + + # Generate bridge script + bridge_dir = project_root / HOOK_BRIDGE_DIR + bridge_dir.mkdir(parents=True, exist_ok=True) + bridge_path = bridge_dir / HOOK_BRIDGE_FILENAME + bridge_path.write_text(_BRIDGE_TEMPLATE, encoding="utf-8") + bridge_path.chmod(0o755) + manifest.record_file( + str(bridge_path.relative_to(project_root)), + bridge_path.read_bytes(), + ) + created.append(bridge_path) + + # Generate / merge native config + config_path = project_root / self.config_file_rel + fmt, fragment = self.generate_fragment(hooks, project_root) + self.merge_fragment(config_path, fragment, format=fmt) + + # Record or mark as existing + if config_path.exists(): + rel = str(config_path.relative_to(project_root)) + if rel not in manifest.files: + manifest.record_existing(rel) + + created.append(config_path) + return created + + def remove(self, project_root: Path, manifest: "IntegrationManifest") -> None: + """Remove Specify-authored entries from native config.""" + config_path = project_root / self.config_file_rel + if config_path.exists(): + self.remove_entries(config_path) + + +class JSONHookAdapter(HookAdapter): + """Base for agents using JSON settings files. + + Subclasses set ``config_file_rel``, ``CANONICAL_TO_NATIVE``, and + optionally ``bridge_path_prefix`` (e.g. ``${CLAUDE_PROJECT_DIR}/``). + The default ``_build_fragment()`` produces Claude-style nested + matcher-groups. Override for different JSON structures (e.g. Cursor's + flat format). + """ + + config_file_rel: str = "" + bridge_path_prefix: str = "" + + def generate_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> tuple[str, Any]: + fragment = self._build_fragment(hooks, project_root) + return "json", fragment + + @abstractmethod + def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: + ... + + def _build_nested_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: + """Build Claude-style nested matcher-groups fragment. + + Shared by Claude, Qwen, Devin, Gemini, Tabnine — all use the same + nested ``hooks.{Event}[].{matcher, hooks[]}`` structure. + """ + result: dict[str, list] = {"hooks": {}} + for event, config in hooks.items(): + native = self._native_event(event) + if native is None: + continue + matcher = config.get("matcher", "*") + timeout = config.get("timeout", 60) + command = config.get("command", "") + entry = { + "type": "command", + "command": "python3", + "args": [ + self.bridge_path_prefix + HOOK_BRIDGE_REL, + command, + event, + ], + "timeout": timeout, + _SPECKIT_MARKER: True, + } + result["hooks"][native] = [{ + "matcher": matcher, + "hooks": [entry], + }] + return result + + def merge_fragment(self, dst: Path, fragment: Any, *, format: str) -> None: + if format != "json": + return + existing: dict = {} + if dst.exists(): + try: + existing = json.loads(dst.read_text(encoding="utf-8")) + if not isinstance(existing, dict): + existing = {} + except (json.JSONDecodeError, OSError): + logger.warning("Could not parse %s; skipping merge", dst) + return + + # Merge: only touch the "hooks" key + existing_hooks = existing.get("hooks", {}) + our_hooks = fragment.get("hooks", {}) + + for event, entries in our_hooks.items(): + existing_list = existing_hooks.get(event, []) + if not isinstance(existing_list, list): + existing_list = [] + # Remove our old entries for this event (by marker), then append new + existing_list = [e for e in existing_list if not _has_marker(e)] + existing_list.extend(entries) + existing_hooks[event] = existing_list + + existing["hooks"] = existing_hooks + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") + + def remove_entries(self, dst: Path) -> None: + try: + existing = json.loads(dst.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return + if not isinstance(existing, dict): + return + hooks = existing.get("hooks", {}) + if not isinstance(hooks, dict): + return + cleaned: dict[str, list] = {} + for event, entries in hooks.items(): + if not isinstance(entries, list): + continue + kept_entries = [] + for entry in entries: + if not isinstance(entry, dict): + kept_entries.append(entry) + continue + # Claude nested format: entry has "hooks" list + inner = entry.get("hooks") + if isinstance(inner, list): + kept_inner = [h for h in inner if not _has_marker(h)] + if kept_inner: + entry["hooks"] = kept_inner + kept_entries.append(entry) + # else: drop the entire matcher-group (all our hooks) + elif _has_marker(entry): + pass # drop this flat entry (Cursor format) + else: + kept_entries.append(entry) + if kept_entries: + cleaned[event] = kept_entries + if cleaned: + existing["hooks"] = cleaned + else: + existing.pop("hooks", None) + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") + + +class ClaudeHookAdapter(JSONHookAdapter): + """Claude Code: nested matcher-groups in .claude/settings.json.""" + + config_file_rel = ".claude/settings.json" + bridge_path_prefix = "${CLAUDE_PROJECT_DIR}/" + CANONICAL_TO_NATIVE = { + "PreToolUse": "PreToolUse", + "PostToolUse": "PostToolUse", + "Stop": "Stop", + "SessionStart": "SessionStart", + "SessionEnd": "SessionEnd", + "UserPromptSubmit": "UserPromptSubmit", + } + + def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: + return self._build_nested_fragment(hooks, project_root) + + +class CursorHookAdapter(JSONHookAdapter): + """Cursor: flat handler arrays in .cursor/hooks.json.""" + + config_file_rel = ".cursor/hooks.json" + CANONICAL_TO_NATIVE = { + "PreToolUse": "preToolUse", + "PostToolUse": "postToolUse", + "Stop": "stop", + "SessionStart": "sessionStart", + "SessionEnd": "sessionEnd", + "UserPromptSubmit": "beforeSubmitPrompt", + } + + def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: + result: dict[str, list] = {"hooks": {}} + for event, config in hooks.items(): + native = self._native_event(event) + if native is None: + continue + matcher = config.get("matcher", "*") + timeout = config.get("timeout", 60) + command = config.get("command", "") + entry = { + "command": f"python3 {HOOK_BRIDGE_REL} {command} {event}", + "type": "command", + "timeout": timeout, + "matcher": matcher, + _SPECKIT_MARKER: True, + } + result["hooks"][native] = [entry] + return result + + +class CodexHookAdapter(HookAdapter): + """Codex CLI: TOML [[hooks.*]] in config.toml.""" + + config_file_rel = ".codex/config.toml" + CANONICAL_TO_NATIVE = { + "PreToolUse": "PreToolUse", + "PostToolUse": "PostToolUse", + "Stop": "Stop", + "SessionStart": "SessionStart", + "SessionEnd": "SessionEnd", + "UserPromptSubmit": "UserPromptSubmit", + } + + def generate_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> tuple[str, Any]: + lines: list[str] = [] + for event, config in hooks.items(): + native = self._native_event(event) + if native is None: + continue + matcher = config.get("matcher", "*") + timeout = config.get("timeout", 60) + command = config.get("command", "") + bridge_abs = f"$(git rev-parse --show-toplevel)/{HOOK_BRIDGE_REL}" + lines.append(f'[[hooks.{native}]]') + lines.append(f'matcher = "{matcher}"') + lines.append('') + lines.append(f'[[hooks.{native}.hooks]]') + lines.append('type = "command"') + lines.append(f'command = \'python3 {bridge_abs} {command} {event}\'') + lines.append(f'timeout = {timeout}') + lines.append('speckit_marker = true') + lines.append('') + return "toml", "\n".join(lines) + + def merge_fragment(self, dst: Path, fragment: Any, *, format: str) -> None: + if format != "toml": + return + existing = "" + if dst.exists(): + existing = dst.read_text(encoding="utf-8") + # Remove old speckit-marked blocks + existing = re.sub( + r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*', + "", + existing, + flags=re.DOTALL, + ) + # Append new + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8") + + def remove_entries(self, dst: Path) -> None: + if not dst.exists(): + return + existing = dst.read_text(encoding="utf-8") + cleaned = re.sub( + r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*', + "", + existing, + flags=re.DOTALL, + ) + dst.write_text(cleaned, encoding="utf-8") + + +class OpencodeHookAdapter(HookAdapter): + """opencode: TypeScript plugin in .opencode/plugin/ + opencode.json merge.""" + + config_file_rel = "opencode.json" + _PLUGIN_REL = ".opencode/plugin/speckit-hooks.ts" + CANONICAL_TO_NATIVE = { + "PreToolUse": "tool.execute.before", + "PostToolUse": "tool.execute.after", + } + + def install( + self, + project_root: Path, + manifest: "IntegrationManifest", + hooks: dict[str, dict[str, Any]], + ) -> list[Path]: + # Filter to only events this adapter supports + hooks = self._filter_hooks(hooks) + if not hooks: + return [] + + created: list[Path] = [] + + # Generate bridge script + bridge_dir = project_root / HOOK_BRIDGE_DIR + bridge_dir.mkdir(parents=True, exist_ok=True) + bridge_path = bridge_dir / HOOK_BRIDGE_FILENAME + bridge_path.write_text(_BRIDGE_TEMPLATE, encoding="utf-8") + bridge_path.chmod(0o755) + manifest.record_file( + str(bridge_path.relative_to(project_root)), + bridge_path.read_bytes(), + ) + created.append(bridge_path) + + # Generate TS plugin + plugin_path = project_root / self._PLUGIN_REL + plugin_path.parent.mkdir(parents=True, exist_ok=True) + plugin_path.write_text(self._build_plugin(hooks), encoding="utf-8") + manifest.record_file( + str(plugin_path.relative_to(project_root)), + plugin_path.read_bytes(), + ) + created.append(plugin_path) + + # Merge plugin path into opencode.json + config_path = project_root / self.config_file_rel + self._merge_plugin_ref(config_path) + rel = str(config_path.relative_to(project_root)) + if rel not in manifest.files: + manifest.record_existing(rel) + + created.append(config_path) + return created + + def generate_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> tuple[str, Any]: + return "skip", None + + def merge_fragment(self, dst: Path, fragment: Any, *, format: str) -> None: + pass + + def remove_entries(self, dst: Path) -> None: + if not dst.exists(): + return + try: + existing = json.loads(dst.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return + if not isinstance(existing, dict): + return + plugins = existing.get("plugin", []) + if isinstance(plugins, list): + plugins = [p for p in plugins if p != f"./{self._PLUGIN_REL}"] + if plugins: + existing["plugin"] = plugins + else: + existing.pop("plugin", None) + dst.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") + + def _build_plugin(self, hooks: dict[str, dict[str, Any]]) -> str: + hook_entries: list[str] = [] + plugin_returns: list[str] = [] + for event, config in hooks.items(): + native = self._native_event(event) + if native is None: + continue + command = config.get("command", "") + matcher = config.get("matcher", "*") + ts_hook = native # already "tool.execute.before" etc. + match_cond = "" + if matcher and matcher != "*": + tools = [t.strip().strip('"') for t in matcher.split("|")] + checks = " || ".join(f"input.tool === '{t.lower()}'" for t in tools) + match_cond = f"if ({checks}) {{" + else: + match_cond = "{" + hook_entries.append( + f"function _{event}(input: any) {match_cond}\n" + f" runHook('{command}', '{event}', input);\n" + f" }}" + ) + plugin_returns.append( + f" \"{ts_hook}\": async (input: any, output: any) => {{\n" + f" _{event}(input);\n" + f" }}," + ) + return _TS_PLUGIN_TEMPLATE.format( + hook_entries="\n\n".join(hook_entries), + plugin_returns="\n".join(plugin_returns), + ) + + def _merge_plugin_ref(self, config_path: Path) -> None: + existing: dict = {} + if config_path.exists(): + try: + existing = json.loads(config_path.read_text(encoding="utf-8")) + if not isinstance(existing, dict): + existing = {} + except (json.JSONDecodeError, OSError): + return + plugins = existing.get("plugin", []) + if not isinstance(plugins, list): + plugins = [] + ref = f"./{self._PLUGIN_REL}" + if ref not in plugins: + plugins.append(ref) + existing["plugin"] = plugins + config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") + + +# -- Phase 1: JSON config-file adapters (nested matcher-groups) ------------ + +class QwenHookAdapter(JSONHookAdapter): + """Qwen Code: nested matcher-groups in .qwen/settings.json. + + Qwen uses the same event names and JSON structure as Claude Code. + """ + + config_file_rel = ".qwen/settings.json" + CANONICAL_TO_NATIVE = { + "PreToolUse": "PreToolUse", + "PostToolUse": "PostToolUse", + "Stop": "Stop", + "SessionStart": "SessionStart", + "SessionEnd": "SessionEnd", + "UserPromptSubmit": "UserPromptSubmit", + } + + def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: + return self._build_nested_fragment(hooks, project_root) + + +class GeminiHookAdapter(JSONHookAdapter): + """Gemini CLI: nested matcher-groups in .gemini/settings.json. + + Gemini uses different event names (BeforeTool, AfterTool, AfterAgent). + """ + + config_file_rel = ".gemini/settings.json" + CANONICAL_TO_NATIVE = { + "PreToolUse": "BeforeTool", + "PostToolUse": "AfterTool", + "Stop": "AfterAgent", + "SessionStart": "SessionStart", + "SessionEnd": "SessionEnd", + } + + def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: + return self._build_nested_fragment(hooks, project_root) + + +class DevinHookAdapter(JSONHookAdapter): + """Devin for Terminal: hooks in .devin/hooks.v1.json. + + Devin uses the same event names and JSON structure as Claude Code. + Also reads .claude/settings.json for compatibility, but we write to + the native format for independence. + """ + + config_file_rel = ".devin/hooks.v1.json" + CANONICAL_TO_NATIVE = { + "PreToolUse": "PreToolUse", + "PostToolUse": "PostToolUse", + "Stop": "Stop", + "SessionStart": "SessionStart", + "SessionEnd": "SessionEnd", + "UserPromptSubmit": "UserPromptSubmit", + } + + def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: + return self._build_nested_fragment(hooks, project_root) + + +class TabnineHookAdapter(JSONHookAdapter): + """Tabnine CLI: nested matcher-groups in .tabnine/agent/settings.json. + + Tabnine uses different event names (BeforeTool, AfterTool), like Gemini. + """ + + config_file_rel = ".tabnine/agent/settings.json" + CANONICAL_TO_NATIVE = { + "PreToolUse": "BeforeTool", + "PostToolUse": "AfterTool", + "SessionStart": "SessionStart", + "SessionEnd": "SessionEnd", + } + + def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: + return self._build_nested_fragment(hooks, project_root) + + +# -- Adapter registry ----------------------------------------------------- + +_ADAPTERS: dict[str, type[HookAdapter]] = { + "claude": ClaudeHookAdapter, + "cursor-agent": CursorHookAdapter, + "codex": CodexHookAdapter, + "opencode": OpencodeHookAdapter, + "qwen": QwenHookAdapter, + "gemini": GeminiHookAdapter, + "devin": DevinHookAdapter, + "tabnine": TabnineHookAdapter, +} + + +def resolve_adapter(integration_key: str) -> HookAdapter | None: + """Return a hook adapter for the integration key, or None if unsupported.""" + cls = _ADAPTERS.get(integration_key) + return cls() if cls else None + + +# -- Public entry points (called from IntegrationBase) -------------------- + +def install_integration_hooks( + integration: "IntegrationBase", + project_root: Path, + manifest: "IntegrationManifest", + parsed_options: dict[str, Any] | None = None, +) -> list[Path]: + """Install runtime hooks for an integration. + + Called from ``IntegrationBase.setup()`` after commands are written. + Returns list of created/managed files. No-ops gracefully when hooks + are disabled or the agent has no adapter. + """ + adapter = resolve_adapter(integration.key) + if adapter is None: + return [] + + hooks = resolve_hooks( + integration.key, + integration.config, + project_root, + parsed_options, + ) + if not hooks: + # Hooks disabled or empty — clean up any previous hooks + remove_integration_hooks(integration, project_root, manifest) + return [] + + # Remove old hooks before installing new ones so stale events + # from a previous install/upgrade are cleaned up. + remove_integration_hooks(integration, project_root, manifest) + + return adapter.install(project_root, manifest, hooks) + + +def remove_integration_hooks( + integration: "IntegrationBase", + project_root: Path, + manifest: "IntegrationManifest", +) -> None: + """Remove runtime hooks for an integration. + + Called from ``IntegrationBase.teardown()`` before manifest uninstall. + Removes only Specify-authored entries from native config files. + """ + adapter = resolve_adapter(integration.key) + if adapter is None: + return + + adapter.remove(project_root, manifest) + + # Remove bridge script if tracked + bridge_rel = HOOK_BRIDGE_REL + if bridge_rel in manifest.files: + bridge_path = project_root / bridge_rel + if bridge_path.exists(): + bridge_path.unlink(missing_ok=True) + manifest.remove(bridge_rel) + + # Remove opencode TS plugin if tracked + if integration.key == "opencode": + plugin_rel = str(OpencodeHookAdapter._PLUGIN_REL) + if plugin_rel in manifest.files: + plugin_path = project_root / plugin_rel + if plugin_path.exists(): + plugin_path.unlink(missing_ok=True) + manifest.remove(plugin_rel) + + +def hooks_stale_exclusions(integration_key: str) -> set[str]: + """Return project-relative paths to protect from stale cleanup.""" + adapter = resolve_adapter(integration_key) + if adapter is None: + return set() + exclusions = {adapter.config_file_rel} + if integration_key == "opencode": + exclusions.add(OpencodeHookAdapter._PLUGIN_REL) + return exclusions + + +# -- Helpers --------------------------------------------------------------- + +def _has_marker(entry: Any) -> bool: + """Check if a JSON hook entry has the Specify marker.""" + if isinstance(entry, dict): + return entry.get(_SPECKIT_MARKER, False) is True + return False + + +def _to_camel_case(snake_or_pascal: str) -> str: + """Convert PascalCase or snake_case to camelCase.""" + if not snake_or_pascal: + return snake_or_pascal + first = snake_or_pascal[0].lower() + rest = snake_or_pascal[1:] + # Handle PascalCase: PreToolUse -> preToolUse + return first + rest + + +# -- Extension manifest validation callout --------------------------------- + +def validate_runtime_hooks(data: dict[str, Any]) -> None: + """Validate ``runtime_hooks`` field in extension manifest data. + + Called from ``ExtensionManifest._validate()`` via a callout. + Raises ``ValidationError`` if ``runtime_hooks`` is present but malformed. + """ + from ..extensions import ValidationError + + runtime_hooks = data.get("runtime_hooks") + if "runtime_hooks" in data and not isinstance(runtime_hooks, dict): + raise ValidationError("Invalid runtime_hooks: expected a mapping") + if runtime_hooks: + for hook_name, hook_config in runtime_hooks.items(): + if not isinstance(hook_config, dict): + raise ValidationError( + f"Invalid runtime_hook '{hook_name}': expected a mapping" + ) + if not hook_config.get("command"): + raise ValidationError( + f"Runtime hook '{hook_name}' missing required 'command' field" + ) + if hook_name not in CANONICAL_RUNTIME_EVENTS: + raise ValidationError( + f"Unknown runtime_hook '{hook_name}': " + f"must be one of {sorted(CANONICAL_RUNTIME_EVENTS)}" + ) + + +def has_runtime_hooks(data: dict[str, Any]) -> bool: + """Return True if ``runtime_hooks`` is present and non-empty.""" + return bool(data.get("runtime_hooks")) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index ccc8587709..477c69834e 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -29,6 +29,7 @@ from .._toml_string import escape_toml_basic as _escape_toml_basic from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control +from ._hooks import install_integration_hooks, remove_integration_hooks if TYPE_CHECKING: from .manifest import IntegrationManifest @@ -902,6 +903,7 @@ def teardown( Returns ``(removed, skipped)`` file lists. """ + remove_integration_hooks(self, project_root, manifest) return manifest.uninstall(project_root, force=force) # -- Convenience helpers for subclasses ------------------------------- @@ -1008,6 +1010,12 @@ def setup( created.append(dst_file) + # Install agent runtime hooks + hook_files = install_integration_hooks( + self, project_root, manifest, parsed_options + ) + created.extend(hook_files) + return created @@ -1215,6 +1223,12 @@ def setup( created.append(dst_file) + # Install agent runtime hooks + hook_files = install_integration_hooks( + self, project_root, manifest, parsed_options + ) + created.extend(hook_files) + return created @@ -1451,6 +1465,12 @@ def setup( created.append(dst_file) + # Install agent runtime hooks + hook_files = install_integration_hooks( + self, project_root, manifest, parsed_options + ) + created.extend(hook_files) + return created @@ -1686,4 +1706,10 @@ def setup( created.append(dst) + # Install agent runtime hooks + hook_files = install_integration_hooks( + self, project_root, manifest, parsed_options + ) + created.extend(hook_files) + return created diff --git a/tests/integrations/test_hooks.py b/tests/integrations/test_hooks.py new file mode 100644 index 0000000000..cb1dd1c6e2 --- /dev/null +++ b/tests/integrations/test_hooks.py @@ -0,0 +1,678 @@ +"""Tests for _hooks module: integration runtime hooks.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +from specify_cli.integrations._hooks import ( + ClaudeHookAdapter, + CodexHookAdapter, + CursorHookAdapter, + DevinHookAdapter, + GeminiHookAdapter, + HookAdapter, + JSONHookAdapter, + OpencodeHookAdapter, + QwenHookAdapter, + TabnineHookAdapter, + _has_marker, + _to_camel_case, + CANONICAL_RUNTIME_EVENTS, + collect_extension_runtime_hooks, + hooks_stale_exclusions, + install_integration_hooks, + remove_integration_hooks, + resolve_adapter, + resolve_hooks, + validate_runtime_hooks, +) +from specify_cli.integrations.manifest import IntegrationManifest + + +# -- resolve_hooks -------------------------------------------------------- + +class TestResolveHooks: + """Test the 4-layer hook resolution chain.""" + + def test_layer1_disabled_returns_empty(self, tmp_path): + """--hooks false returns empty dict.""" + result = resolve_hooks( + "claude", + {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate"}}}, + tmp_path, + {"hooks": "false"}, + ) + assert result == {} + + def test_layer4_built_in_defaults(self, tmp_path): + """Built-in config hooks are returned when no override.""" + config = {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write"}}} + result = resolve_hooks("claude", config, tmp_path, None) + assert "PostToolUse" in result + assert result["PostToolUse"]["command"] == "speckit.tdd.validate" + + def test_layer3_extension_hooks_appended(self, tmp_path): + """Extension-declared runtime_hooks are appended to built-in.""" + ext_dir = tmp_path / ".specify" / "extensions" / "myext" + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text(yaml.dump({ + "schema_version": "1.0", + "extension": {"id": "myext", "name": "My Ext", "version": "1.0.0", + "description": "test"}, + "requires": {"speckit_version": ">=0.0.80"}, + "provides": {"commands": []}, + "runtime_hooks": { + "Stop": {"command": "speckit.myext.check", "matcher": "*"}, + }, + })) + config = {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate"}}} + result = resolve_hooks("claude", config, tmp_path, None) + assert "PostToolUse" in result + assert "Stop" in result + assert result["Stop"]["command"] == "speckit.myext.check" + + def test_layer2_yaml_override_replaces(self, tmp_path): + """YAML override replaces built-in and extension hooks entirely.""" + override_file = tmp_path / ".specify" / "integration-hooks.yml" + override_file.parent.mkdir(parents=True) + override_file.write_text(yaml.dump({ + "version": 1, + "integrations": { + "claude": { + "hooks": { + "PreToolUse": {"command": "speckit.protected_paths", "matcher": "Edit|Write"}, + }, + }, + }, + })) + config = {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate"}}} + result = resolve_hooks("claude", config, tmp_path, None) + assert "PreToolUse" in result + assert "PostToolUse" not in result # replaced, not merged + + def test_layer2_empty_hooks_disables(self, tmp_path): + """Empty hooks: {} in YAML override disables all hooks.""" + override_file = tmp_path / ".specify" / "integration-hooks.yml" + override_file.parent.mkdir(parents=True) + override_file.write_text(yaml.dump({ + "version": 1, + "integrations": {"claude": {"hooks": {}}}, + })) + config = {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate"}}} + result = resolve_hooks("claude", config, tmp_path, None) + assert result == {} + + def test_no_config_no_hooks(self, tmp_path): + """No config, no extensions, no override → empty.""" + result = resolve_hooks("gemini", None, tmp_path, None) + assert result == {} + + +# -- collect_extension_runtime_hooks -------------------------------------- + +class TestCollectExtensionRuntimeHooks: + """Test scanning installed extensions for runtime_hooks.""" + + def test_no_extensions_dir(self, tmp_path): + assert collect_extension_runtime_hooks(tmp_path) == {} + + def test_no_runtime_hooks_in_extension(self, tmp_path): + ext_dir = tmp_path / ".specify" / "extensions" / "myext" + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text(yaml.dump({ + "schema_version": "1.0", + "extension": {"id": "myext", "name": "My Ext", "version": "1.0.0", + "description": "test"}, + "requires": {"speckit_version": ">=0.0.80"}, + "provides": {"commands": [{"name": "speckit.myext.cmd", "file": "cmd.md"}]}, + "hooks": {"after_plan": {"command": "speckit.myext.cmd"}}, + })) + result = collect_extension_runtime_hooks(tmp_path) + assert result == {} + + def test_runtime_hooks_collected(self, tmp_path): + ext_dir = tmp_path / ".specify" / "extensions" / "tdd" + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text(yaml.dump({ + "schema_version": "1.0", + "extension": {"id": "tdd", "name": "TDD", "version": "1.0.0", + "description": "test"}, + "requires": {"speckit_version": ">=0.0.80"}, + "provides": {"commands": []}, + "runtime_hooks": { + "PostToolUse": {"command": "adlc.tdd.validate", "matcher": "Edit|Write"}, + "Stop": {"command": "adlc.tdd.validate", "matcher": "*"}, + }, + })) + result = collect_extension_runtime_hooks(tmp_path) + assert "PostToolUse" in result + assert "Stop" in result + assert result["PostToolUse"]["command"] == "adlc.tdd.validate" + + def test_invalid_yaml_skipped(self, tmp_path): + ext_dir = tmp_path / ".specify" / "extensions" / "broken" + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text("{{invalid yaml") + result = collect_extension_runtime_hooks(tmp_path) + assert result == {} + + +# -- Adapter resolution --------------------------------------------------- + +class TestResolveAdapter: + """Test adapter registry.""" + + def test_claude_returns_adapter(self): + adapter = resolve_adapter("claude") + assert isinstance(adapter, ClaudeHookAdapter) + + def test_cursor_returns_adapter(self): + adapter = resolve_adapter("cursor-agent") + assert isinstance(adapter, CursorHookAdapter) + + def test_codex_returns_adapter(self): + adapter = resolve_adapter("codex") + assert isinstance(adapter, CodexHookAdapter) + + def test_opencode_returns_adapter(self): + adapter = resolve_adapter("opencode") + assert isinstance(adapter, OpencodeHookAdapter) + + def test_gemini_returns_adapter(self): + adapter = resolve_adapter("gemini") + assert isinstance(adapter, GeminiHookAdapter) + + def test_qwen_returns_adapter(self): + adapter = resolve_adapter("qwen") + assert isinstance(adapter, QwenHookAdapter) + + def test_devin_returns_adapter(self): + adapter = resolve_adapter("devin") + assert isinstance(adapter, DevinHookAdapter) + + def test_tabnine_returns_adapter(self): + adapter = resolve_adapter("tabnine") + assert isinstance(adapter, TabnineHookAdapter) + + def test_copilot_returns_none(self): + assert resolve_adapter("copilot") is None + + def test_unknown_returns_none(self): + assert resolve_adapter("nonexistent") is None + + +# -- Canonical event mapping ----------------------------------------------- + +class TestCanonicalEventMapping: + """Test canonical→native event name translation per adapter.""" + + def test_claude_passthrough(self): + adapter = ClaudeHookAdapter() + assert adapter._native_event("PreToolUse") == "PreToolUse" + assert adapter._native_event("PostToolUse") == "PostToolUse" + assert adapter._native_event("Stop") == "Stop" + + def test_cursor_camelcase(self): + adapter = CursorHookAdapter() + assert adapter._native_event("PreToolUse") == "preToolUse" + assert adapter._native_event("PostToolUse") == "postToolUse" + assert adapter._native_event("UserPromptSubmit") == "beforeSubmitPrompt" + + def test_opencode_limited(self): + adapter = OpencodeHookAdapter() + assert adapter._native_event("PreToolUse") == "tool.execute.before" + assert adapter._native_event("PostToolUse") == "tool.execute.after" + assert adapter._native_event("Stop") is None # unsupported + + def test_gemini_mapping(self): + adapter = GeminiHookAdapter() + assert adapter._native_event("PreToolUse") == "BeforeTool" + assert adapter._native_event("PostToolUse") == "AfterTool" + assert adapter._native_event("Stop") == "AfterAgent" + + def test_qwen_identity(self): + adapter = QwenHookAdapter() + assert adapter._native_event("PreToolUse") == "PreToolUse" + + def test_devin_identity(self): + adapter = DevinHookAdapter() + assert adapter._native_event("PreToolUse") == "PreToolUse" + + def test_tabnine_mapping(self): + adapter = TabnineHookAdapter() + assert adapter._native_event("PreToolUse") == "BeforeTool" + assert adapter._native_event("PostToolUse") == "AfterTool" + assert adapter._native_event("Stop") is None # not supported + + +class TestUnknownEventRejection: + """Test that validate_runtime_hooks rejects unknown event names.""" + + def test_unknown_event_rejected(self): + from specify_cli.extensions import ValidationError + data = { + "runtime_hooks": { + "FooBar": {"command": "speckit.test"}, + }, + } + with pytest.raises(ValidationError, match="Unknown runtime_hook 'FooBar'"): + validate_runtime_hooks(data) + + def test_known_event_accepted(self): + data = { + "runtime_hooks": { + "PostToolUse": {"command": "speckit.test"}, + }, + } + validate_runtime_hooks(data) # should not raise + + def test_all_canonical_events_accepted(self): + for event in CANONICAL_RUNTIME_EVENTS: + data = {"runtime_hooks": {event: {"command": "speckit.test"}}} + validate_runtime_hooks(data) # should not raise + + +class TestUnsupportedEventWarning: + """Test that adapters warn and skip unsupported events.""" + + def test_opencode_skips_stop(self, tmp_path, capsys): + from specify_cli.integrations.manifest import IntegrationManifest + adapter = OpencodeHookAdapter() + manifest = IntegrationManifest("opencode", tmp_path) + hooks = { + "PostToolUse": {"command": "speckit.test", "matcher": "Edit|Write"}, + "Stop": {"command": "speckit.test", "matcher": "*"}, + } + adapter.install(tmp_path, manifest, hooks) + captured = capsys.readouterr() + assert "Stop" in captured.err + assert "skipping" in captured.err + # Plugin should only have PostToolUse, not Stop + plugin = tmp_path / ".opencode" / "plugin" / "speckit-hooks.ts" + content = plugin.read_text() + assert "tool.execute.after" in content + assert "tool.execute.before" not in content + + def test_tabnine_skips_stop(self, tmp_path, capsys): + adapter = TabnineHookAdapter() + from specify_cli.integrations.manifest import IntegrationManifest + manifest = IntegrationManifest("tabnine", tmp_path) + hooks = { + "PostToolUse": {"command": "speckit.test", "matcher": "Edit|Write"}, + "Stop": {"command": "speckit.test", "matcher": "*"}, + } + adapter.install(tmp_path, manifest, hooks) + captured = capsys.readouterr() + assert "Stop" in captured.err + settings = tmp_path / ".tabnine" / "agent" / "settings.json" + data = json.loads(settings.read_text()) + assert "AfterTool" in data.get("hooks", {}) + assert "Stop" not in data.get("hooks", {}) + + +# -- New adapter tests ----------------------------------------------------- + +class TestGeminiHookAdapter: + + def test_generate_fragment_uses_native_names(self): + adapter = GeminiHookAdapter() + hooks = { + "PreToolUse": {"command": "speckit.test", "matcher": "Edit|Write", "timeout": 10}, + } + fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp")) + assert fmt == "json" + assert "BeforeTool" in fragment["hooks"] + assert "PreToolUse" not in fragment["hooks"] + + def test_install_creates_settings(self, tmp_path): + from specify_cli.integrations.manifest import IntegrationManifest + adapter = GeminiHookAdapter() + manifest = IntegrationManifest("gemini", tmp_path) + hooks = {"PostToolUse": {"command": "speckit.test", "matcher": "Edit|Write", "timeout": 60}} + created = adapter.install(tmp_path, manifest, hooks) + settings = tmp_path / ".gemini" / "settings.json" + assert settings.exists() + data = json.loads(settings.read_text()) + assert "AfterTool" in data["hooks"] + + +class TestQwenHookAdapter: + + def test_generate_fragment_identity(self): + adapter = QwenHookAdapter() + hooks = { + "PreToolUse": {"command": "speckit.test", "matcher": "Edit|Write", "timeout": 10}, + } + fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp")) + assert "PreToolUse" in fragment["hooks"] + + +class TestDevinHookAdapter: + + def test_install_creates_hooks_file(self, tmp_path): + from specify_cli.integrations.manifest import IntegrationManifest + adapter = DevinHookAdapter() + manifest = IntegrationManifest("devin", tmp_path) + hooks = {"PostToolUse": {"command": "speckit.test", "matcher": "Edit|Write", "timeout": 60}} + created = adapter.install(tmp_path, manifest, hooks) + hooks_file = tmp_path / ".devin" / "hooks.v1.json" + assert hooks_file.exists() + data = json.loads(hooks_file.read_text()) + assert "PostToolUse" in data["hooks"] + + +class TestTabnineHookAdapter: + + def test_generate_fragment_native_names(self): + adapter = TabnineHookAdapter() + hooks = { + "PreToolUse": {"command": "speckit.test", "matcher": "Edit|Write", "timeout": 10}, + } + fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp")) + assert "BeforeTool" in fragment["hooks"] + + +# -- ClaudeHookAdapter ---------------------------------------------------- + +class TestClaudeHookAdapter: + """Test Claude Code native config generation and merge.""" + + def test_generate_fragment_structure(self): + adapter = ClaudeHookAdapter() + hooks = { + "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, + } + fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp")) + assert fmt == "json" + assert "hooks" in fragment + assert "PostToolUse" in fragment["hooks"] + entry_group = fragment["hooks"]["PostToolUse"][0] + assert entry_group["matcher"] == "Edit|Write" + handler = entry_group["hooks"][0] + assert handler["type"] == "command" + assert handler["command"] == "python3" + assert "speckit.tdd.validate" in handler["args"] + assert "PostToolUse" in handler["args"] + assert handler["timeout"] == 60 + + def test_merge_into_empty_file(self, tmp_path): + adapter = ClaudeHookAdapter() + config_path = tmp_path / ".claude" / "settings.json" + config_path.parent.mkdir(parents=True) + hooks = { + "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, + } + fmt, fragment = adapter.generate_fragment(hooks, tmp_path) + adapter.merge_fragment(config_path, fragment, format=fmt) + data = json.loads(config_path.read_text()) + assert "hooks" in data + assert "PostToolUse" in data["hooks"] + assert len(data["hooks"]["PostToolUse"]) == 1 + + def test_merge_preserves_user_keys(self, tmp_path): + adapter = ClaudeHookAdapter() + config_path = tmp_path / ".claude" / "settings.json" + config_path.parent.mkdir(parents=True) + config_path.write_text(json.dumps({ + "model": "claude-sonnet-4-20250514", + "permissions": {"allow": ["Bash(git *)"]}, + "hooks": { + "PostToolUse": [{ + "matcher": "Bash", + "hooks": [{"type": "command", "command": "/usr/bin/my-hook.sh"}], + }], + }, + })) + hooks = { + "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, + } + fmt, fragment = adapter.generate_fragment(hooks, tmp_path) + adapter.merge_fragment(config_path, fragment, format=fmt) + data = json.loads(config_path.read_text()) + assert data["model"] == "claude-sonnet-4-20250514" + assert data["permissions"]["allow"] == ["Bash(git *)"] + assert len(data["hooks"]["PostToolUse"]) == 2 # user hook + our hook + + def test_remove_entries_preserves_user_hooks(self, tmp_path): + adapter = ClaudeHookAdapter() + config_path = tmp_path / ".claude" / "settings.json" + config_path.parent.mkdir(parents=True) + config_path.write_text(json.dumps({ + "hooks": { + "PostToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", "command": "/usr/bin/my-hook.sh"}]}, + {"matcher": "Edit|Write", "hooks": [{"type": "command", "command": "python3", + "args": [".specify/hooks/bridge.py", "speckit.tdd.validate", "PostToolUse"], + "__speckit_hook__": True}]}, + ], + }, + })) + adapter.remove_entries(config_path) + data = json.loads(config_path.read_text()) + assert len(data["hooks"]["PostToolUse"]) == 1 + assert data["hooks"]["PostToolUse"][0]["matcher"] == "Bash" + + +# -- CursorHookAdapter ---------------------------------------------------- + +class TestCursorHookAdapter: + """Test Cursor native config generation.""" + + def test_generate_fragment_camelcase(self): + adapter = CursorHookAdapter() + hooks = { + "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, + } + fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp")) + assert "postToolUse" in fragment["hooks"] + entry = fragment["hooks"]["postToolUse"][0] + assert "python3" in entry["command"] + assert entry["matcher"] == "Edit|Write" + + +# -- CodexHookAdapter ----------------------------------------------------- + +class TestCodexHookAdapter: + """Test Codex TOML config generation.""" + + def test_generate_fragment_toml(self): + adapter = CodexHookAdapter() + hooks = { + "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, + } + fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp")) + assert fmt == "toml" + assert "[[hooks.PostToolUse]]" in fragment + assert 'matcher = "Edit|Write"' in fragment + assert "speckit.tdd.validate" in fragment + assert "speckit_marker = true" in fragment + + +# -- OpencodeHookAdapter -------------------------------------------------- + +class TestOpencodeHookAdapter: + """Test opencode TS plugin generation.""" + + def test_install_generates_plugin_and_merges_config(self, tmp_path): + adapter = OpencodeHookAdapter() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + hooks = { + "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, + } + created = adapter.install(tmp_path, manifest, hooks) + plugin_path = tmp_path / ".opencode/plugin/speckit-hooks.ts" + assert plugin_path.exists() + content = plugin_path.read_text() + assert "tool.execute.after" in content + assert "speckit.tdd.validate" in content + config_path = tmp_path / "opencode.json" + assert config_path.exists() + config = json.loads(config_path.read_text()) + assert "./.opencode/plugin/speckit-hooks.ts" in config["plugin"] + + def test_remove_removes_plugin_ref(self, tmp_path): + adapter = OpencodeHookAdapter() + config_path = tmp_path / "opencode.json" + config_path.write_text(json.dumps({ + "plugin": ["./.opencode/plugin/speckit-hooks.ts", "./other.ts"], + })) + adapter.remove_entries(config_path) + config = json.loads(config_path.read_text()) + assert "./.opencode/plugin/speckit-hooks.ts" not in config["plugin"] + assert "./other.ts" in config["plugin"] + + +# -- Helpers -------------------------------------------------------------- + +class TestHelpers: + def test_has_marker_true(self): + assert _has_marker({"__speckit_hook__": True, "command": "foo"}) + + def test_has_marker_false(self): + assert not _has_marker({"command": "foo"}) + + def test_to_camel_case(self): + assert _to_camel_case("PostToolUse") == "postToolUse" + assert _to_camel_case("PreToolUse") == "preToolUse" + assert _to_camel_case("Stop") == "stop" + assert _to_camel_case("") == "" + + +# -- hooks_stale_exclusions ----------------------------------------------- + +class TestStaleExclusions: + def test_claude_returns_settings_path(self): + exclusions = hooks_stale_exclusions("claude") + assert ".claude/settings.json" in exclusions + + def test_opencode_returns_config_and_plugin(self): + exclusions = hooks_stale_exclusions("opencode") + assert "opencode.json" in exclusions + assert ".opencode/plugin/speckit-hooks.ts" in exclusions + + def test_gemini_returns_settings_path(self): + exclusions = hooks_stale_exclusions("gemini") + assert ".gemini/settings.json" in exclusions + + def test_copilot_returns_empty(self): + assert hooks_stale_exclusions("copilot") == set() + + +# -- Integration: install + remove round-trip ----------------------------- + +class TestInstallRemoveRoundTrip: + """End-to-end install → remove for Claude adapter.""" + + def test_claude_install_then_remove(self, tmp_path): + from specify_cli.integrations._hooks import HOOK_BRIDGE_REL + + manifest = IntegrationManifest("claude", tmp_path) + adapter = ClaudeHookAdapter() + hooks = { + "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, + } + created = adapter.install(tmp_path, manifest, hooks) + assert len(created) == 2 # bridge + settings.json + bridge = tmp_path / HOOK_BRIDGE_REL + assert bridge.exists() + settings = tmp_path / ".claude/settings.json" + assert settings.exists() + data = json.loads(settings.read_text()) + assert "PostToolUse" in data["hooks"] + + # Remove + adapter.remove(tmp_path, manifest) + data = json.loads(settings.read_text()) + # Our entries removed, but file still exists + assert "hooks" not in data or "PostToolUse" not in data.get("hooks", {}) + + def test_group_b_no_crash(self, tmp_path): + """Installing hooks for a Group B agent (no adapter) is a no-op.""" + mock_integration = MagicMock() + mock_integration.key = "copilot" + mock_integration.config = {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate"}}} + manifest = IntegrationManifest("gemini", tmp_path) + result = install_integration_hooks(mock_integration, tmp_path, manifest, None) + assert result == [] + + def test_upgrade_removes_stale_events(self, tmp_path): + """Upgrading from hook set A to hook set B removes stale events.""" + mock_integration = MagicMock() + mock_integration.key = "claude" + mock_integration.config = None + (tmp_path / ".specify").mkdir(parents=True) + + # v1: install PostToolUse only + mock_integration.config = {"hooks": { + "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, + }} + manifest_v1 = IntegrationManifest("claude", tmp_path) + install_integration_hooks(mock_integration, tmp_path, manifest_v1, None) + settings = tmp_path / ".claude" / "settings.json" + data = json.loads(settings.read_text()) + assert "PostToolUse" in data.get("hooks", {}) + + # v2: upgrade to PreToolUse + Stop (no PostToolUse) + mock_integration.config = {"hooks": { + "PreToolUse": {"command": "speckit.protected_paths", "matcher": "Edit|Write", "timeout": 10}, + "Stop": {"command": "speckit.tdd.validate", "matcher": "*", "timeout": 30}, + }} + manifest_v2 = IntegrationManifest("claude", tmp_path) + install_integration_hooks(mock_integration, tmp_path, manifest_v2, None) + data2 = json.loads(settings.read_text()) + hooks_v2 = data2.get("hooks", {}) + assert "PostToolUse" not in hooks_v2, "Stale PostToolUse should be removed on upgrade" + assert "PreToolUse" in hooks_v2 + assert "Stop" in hooks_v2 + + def test_upgrade_preserves_user_hooks(self, tmp_path): + """User-defined hooks survive upgrade.""" + mock_integration = MagicMock() + mock_integration.key = "claude" + mock_integration.config = None + (tmp_path / ".specify").mkdir(parents=True) + + # Pre-existing user hook + settings_dir = tmp_path / ".claude" + settings_dir.mkdir(parents=True) + (settings_dir / "settings.json").write_text(json.dumps({ + "hooks": { + "PostToolUse": [{ + "matcher": "Bash", + "hooks": [{"type": "command", "command": "/usr/bin/my-linter.sh"}], + }], + }, + })) + + # Install our hook + mock_integration.config = {"hooks": { + "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, + }} + manifest = IntegrationManifest("claude", tmp_path) + install_integration_hooks(mock_integration, tmp_path, manifest, None) + data = json.loads((settings_dir / "settings.json").read_text()) + post_hooks = data["hooks"]["PostToolUse"] + assert len(post_hooks) == 2 # user hook + our hook + + # Upgrade: change our hook to PreToolUse only + mock_integration.config = {"hooks": { + "PreToolUse": {"command": "speckit.protected_paths", "matcher": "Edit|Write", "timeout": 10}, + }} + manifest_v2 = IntegrationManifest("claude", tmp_path) + install_integration_hooks(mock_integration, tmp_path, manifest_v2, None) + data2 = json.loads((settings_dir / "settings.json").read_text()) + hooks2 = data2.get("hooks", {}) + # User's PostToolUse hook should survive + assert "PostToolUse" in hooks2 + user_entry = hooks2["PostToolUse"][0] + assert user_entry["matcher"] == "Bash" + # Our new PreToolUse should be present + assert "PreToolUse" in hooks2 From da6c20d9f0644651af1976197656d6b0e9202a87 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Sat, 25 Jul 2026 17:17:17 +0300 Subject: [PATCH 02/20] refactor: rework integration events per maintainer review - Rename hooks terminology to 'events' (events:, --events flag, events.py). - Use snake_case names for canonical events consistent with spec-kit vocabulary. - Fold event config adapters into integration classes via class attributes (CANONICAL_TO_NATIVE, events_config_file, events_format). - Lift event command-script resolution to core 'specify event run' command. - Split events sourcing from integration config writing. - Support first-class Copilot CLI events JSON generation under '.github/hooks/speckit.json'. - Rewrite and expand full test suite under 'tests/integrations/test_events.py'. Assisted-by: opencode (model: litellm/gemini-3.5-flash, autonomous) --- src/specify_cli/__init__.py | 5 + src/specify_cli/commands/event.py | 36 + src/specify_cli/commands/init.py | 8 + src/specify_cli/events.py | 769 ++++++++++++++ src/specify_cli/extensions/__init__.py | 9 +- src/specify_cli/integrations/_hooks.py | 975 ------------------ .../integrations/_install_commands.py | 9 + .../integrations/_migrate_commands.py | 16 + src/specify_cli/integrations/base.py | 77 +- .../integrations/claude/__init__.py | 11 + .../integrations/codex/__init__.py | 19 +- .../integrations/copilot/__init__.py | 30 +- .../integrations/cursor_agent/__init__.py | 19 +- .../integrations/devin/__init__.py | 11 + .../integrations/gemini/__init__.py | 10 + .../integrations/opencode/__init__.py | 9 + src/specify_cli/integrations/qwen/__init__.py | 11 + .../integrations/tabnine/__init__.py | 9 + tests/integrations/test_events.py | 374 +++++++ tests/integrations/test_hooks.py | 678 ------------ 20 files changed, 1399 insertions(+), 1686 deletions(-) create mode 100644 src/specify_cli/commands/event.py create mode 100644 src/specify_cli/events.py delete mode 100644 src/specify_cli/integrations/_hooks.py create mode 100644 tests/integrations/test_events.py delete mode 100644 tests/integrations/test_hooks.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1d5a34073c..c74db40875 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -508,6 +508,11 @@ def version( from .integrations._commands import register as _register_integration_cmds # noqa: E402 _register_integration_cmds(app) + +# ===== Event Commands ===== +from .commands.event import register as _register_event_cmds +_register_event_cmds(app) + # Re-export selected helpers to preserve the public import surface. from .integrations._helpers import ( # noqa: E402 _clear_init_options_for_integration as _clear_init_options_for_integration, diff --git a/src/specify_cli/commands/event.py b/src/specify_cli/commands/event.py new file mode 100644 index 0000000000..bca6877ddf --- /dev/null +++ b/src/specify_cli/commands/event.py @@ -0,0 +1,36 @@ +"""specify event * command handlers.""" + +from __future__ import annotations + +from pathlib import Path +import sys +import typer + +from .._console import console + +event_app = typer.Typer( + name="event", + help="Manage and execute event-driven commands", + add_completion=False, +) + + +@event_app.command("run") +def event_run( + command_name: str = typer.Argument(..., help="Name of the command to execute"), + event_name: str = typer.Argument(..., help="Canonical event name (e.g., session_start)"), +): + """Resolve and run an event-driven command script with stdin payload.""" + from ..events import resolve_and_run_event_command + + # Read payload from stdin if available + payload = sys.stdin.read() if not sys.stdin.isatty() else "{}" + + # Run the event command + project_root = Path.cwd() # The agent runs events from project root + exit_code = resolve_and_run_event_command(command_name, event_name, payload, project_root) + raise typer.Exit(code=exit_code) + + +def register(app: typer.Typer) -> None: + app.add_typer(event_app, name="event") diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index ccdd7b0a1b..76af5022c4 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -442,12 +442,20 @@ def init( if extra: integration_parsed_options.update(extra) + from ..events import resolve_events + events_map = resolve_events( + resolved_integration.key, + resolved_integration.config, + project_path, + integration_parsed_options or None, + ) resolved_integration.setup( project_path, manifest, parsed_options=integration_parsed_options or None, script_type=selected_script, raw_options=integration_options, + events=events_map, ) manifest.save() diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py new file mode 100644 index 0000000000..a4f126aa42 --- /dev/null +++ b/src/specify_cli/events.py @@ -0,0 +1,769 @@ +"""Agent runtime events for integrations. + +Provides: +- ``resolve_events`` — layered event resolution (CLI flag → YAML override → extension-declared → built-in). +- ``collect_extension_events`` — scan installed extension.yml files for ``events:``. +- ``install_integration_events`` / ``remove_integration_events`` — entry points called from ``IntegrationBase.setup()`` / ``teardown()``. +""" + +from __future__ import annotations + +import json +import logging +import re +import sys +import subprocess +import platform +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import yaml + +if TYPE_CHECKING: + from .integrations.base import IntegrationBase + from .integrations.manifest import IntegrationManifest + +logger = logging.getLogger(__name__) + +# -- Constants ------------------------------------------------------------- + +EVENTS_DISPATCHER_DIR = Path(".specify") +EVENTS_DISPATCHER_FILENAME = "events.py" +EVENTS_DISPATCHER_REL = str(EVENTS_DISPATCHER_DIR / EVENTS_DISPATCHER_FILENAME) + +YAML_OVERRIDE_FILENAME = Path(".specify") / "integration-events.yml" + +_SPECKIT_MARKER = "__speckit_event__" + +# Canonical event names (snake_case) +CANONICAL_EVENTS = frozenset({ + "session_start", + "pre_tool_use", + "post_tool_use", + "session_end", + "user_prompt_submit", + "stop", +}) + +# -- Events Dispatcher template --------------------------------------------- + +_EVENTS_DISPATCHER_TEMPLATE = '''#!/usr/bin/env python3 +"""Specify CLI Event Dispatcher — dispatches agent runtime events. + +Generated by: specify integration install/upgrade +Do not edit manually. +""" +import sys, subprocess, os +from pathlib import Path + + +def _find_specify(): + project_root = Path(__file__).parent.parent.resolve() + # 1. Check local virtualenv + for candidate in ("venv", ".venv"): + py_bin = project_root / candidate / "bin" / "specify" + if py_bin.exists(): + return [str(py_bin)] + # Windows virtualenv + win_bin = project_root / candidate / "Scripts" / "specify.exe" + if win_bin.exists(): + return [str(win_bin)] + + # 2. Check python -m specify_cli + for candidate in ("venv", ".venv"): + py_exe = project_root / candidate / "bin" / "python" + if py_exe.exists(): + return [str(py_exe), "-m", "specify_cli"] + win_py = project_root / candidate / "Scripts" / "python.exe" + if win_py.exists(): + return [str(win_py), "-m", "specify_cli"] + + # 3. Fallback to system path specify + return ["specify"] + + +def main(): + if len(sys.argv) < 3: + sys.exit(0) + command_name = sys.argv[1] + event_name = sys.argv[2] + payload = sys.stdin.read() if not sys.stdin.isatty() else "{}" + + specify_args = _find_specify() + ["event", "run", command_name, event_name] + try: + result = subprocess.run( + specify_args, + input=payload, + capture_output=True, + text=True, + timeout=120, + ) + if result.stdout: + sys.stdout.write(result.stdout) + if result.returncode != 0: + if result.stderr: + sys.stderr.write(result.stderr) + sys.exit(result.returncode) + sys.exit(0) + except subprocess.TimeoutExpired: + print(f"Event {command_name} timed out", file=sys.stderr) + sys.exit(2) + except Exception as e: + print(f"Event {command_name} error: {e}", file=sys.stderr) + sys.exit(2) + + +if __name__ == "__main__": + main() +''' + +# -- TS plugin template (opencode) ---------------------------------------- + +_TS_PLUGIN_TEMPLATE = '''import {{ execSync }} from 'child_process'; +import * as path from 'path'; + +const DISPATCHER = path.join(process.cwd(), '.specify', 'events.py'); + +function runEvent(command: string, event: string, input: any): void {{ + try {{ + execSync(`python3 ${{DISPATCHER}} ${{command}} ${{event}}`, {{ + input: JSON.stringify(input), + stdio: ['pipe', 'inherit', 'inherit'], + timeout: 60000, + }}); + }} catch (e) {{ + process.exit(2); + }} +}} + +{event_entries} + +export default (async ({{ client, project, directory, $ }}) => {{ + return {{ +{plugin_returns} + }}; +}}); +''' + + +# -- Command runner logic (core) -------------------------------------------- + +def _find_command_template(command_name: str, project_root: Path) -> tuple[Path | None, str | None]: + # 1. Check extension .registry + registry = project_root / ".specify" / "extensions" / ".registry" + if registry.exists(): + try: + data = json.loads(registry.read_text(encoding="utf-8")) + except Exception: + data = {} + for ext_id, meta in data.items(): + if not isinstance(meta, dict): + continue + for cmd in meta.get("commands", []): + if cmd.get("name") == command_name: + ext_dir = project_root / ".specify" / "extensions" / ext_id + return ext_dir / cmd["file"], ext_id + + # 2. Scan extension directories + exts_dir = project_root / ".specify" / "extensions" + if exts_dir.is_dir(): + for ext_dir in sorted(exts_dir.iterdir()): + cmds_dir = ext_dir / "commands" + if cmds_dir.is_dir(): + for f in cmds_dir.glob("*.md"): + if f.stem == command_name: + return f, ext_dir.name + + # 3. Check core templates + core = project_root / ".specify" / "templates" / "commands" + if core.is_dir(): + stem = command_name.replace("speckit.", "").replace("spec.", "") + candidate = core / f"{stem}.md" + if candidate.exists(): + return candidate, None + + # Fallback to package bundled templates + try: + import inspect + pkg_dir = Path(inspect.getfile(validate_events)).resolve().parent + core_pack = pkg_dir / "core_pack" / "templates" / "commands" + if core_pack.is_dir(): + stem = command_name.replace("speckit.", "").replace("spec.", "") + candidate = core_pack / f"{stem}.md" + if candidate.exists(): + return candidate, None + except Exception: + pass + + return None, None + + +def _extract_script_path(template_path: Path, project_root: Path, ext_id: str | None) -> str | None: + content = template_path.read_text(encoding="utf-8") + m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) + if not m: + return None + fm = m.group(1) + try: + fm_data = yaml.safe_load(fm) or {} + except Exception: + return None + if not isinstance(fm_data, dict): + return None + scripts = fm_data.get("scripts", {}) + if not isinstance(scripts, dict): + return None + default = "ps" if platform.system().lower().startswith("win") else "sh" + script_rel = scripts.get(default) or scripts.get("sh") or scripts.get("py") + if not script_rel: + return None + if ext_id: + script_abs = project_root / ".specify" / "extensions" / ext_id / script_rel + else: + script_abs = project_root / ".specify" / script_rel + return str(script_abs) if script_abs.exists() else None + + +def resolve_and_run_event_command(command_name: str, event_name: str, payload: str, project_root: Path) -> int: + """Core entry point to resolve and execute an event-driven command.""" + template_path, ext_id = _find_command_template(command_name, project_root) + if not template_path: + logger.warning("Event command '%s' not found", command_name) + return 0 + script_path = _extract_script_path(template_path, project_root, ext_id) + if not script_path: + logger.warning("No script found for event command '%s'", command_name) + return 0 + try: + result = subprocess.run( + [script_path], + input=payload, + capture_output=True, + text=True, + timeout=120, + ) + if result.stdout: + sys.stdout.write(result.stdout) + if result.returncode != 0: + if result.stderr: + sys.stderr.write(result.stderr) + return result.returncode + return 0 + except subprocess.TimeoutExpired: + sys.stderr.write(f"Event command {command_name} timed out\n") + return 2 + except Exception as e: + sys.stderr.write(f"Event command {command_name} error: {e}\n") + return 2 + + +# -- Sourcing events map (CLI/Orchestration domain) ------------------------- + +def resolve_events( + integration_key: str, + integration_config: dict[str, Any] | None, + project_root: Path, + parsed_options: dict[str, Any] | None, +) -> dict[str, dict[str, Any]]: + """Resolve the final event set for an integration.""" + # Layer 1: CLI flag gate + if parsed_options: + events_flag = str(parsed_options.get("events", "true")).lower() + if events_flag in ("false", "0", "no", "off"): + return {} + + # Layer 4: built-in defaults from integration config + events: dict[str, dict[str, Any]] = {} + if integration_config and isinstance(integration_config.get("events"), dict): + events = dict(integration_config["events"]) + + # Layer 3: extension-declared events + ext_events = collect_extension_events(project_root) + events.update(ext_events) + + # Layer 2: user YAML override (replaces entirely if key present) + override_file = project_root / YAML_OVERRIDE_FILENAME + if override_file.exists(): + try: + override = yaml.safe_load(override_file.read_text(encoding="utf-8")) or {} + except yaml.YAMLError: + logger.warning("Could not parse %s; ignoring override", override_file) + override = {} + integrations = override.get("integrations", {}) if isinstance(override, dict) else {} + if integration_key in integrations: + key_data = integrations[integration_key] + if isinstance(key_data, dict): + events = key_data.get("events", {}) or {} + else: + events = {} + + return events + + +def collect_extension_events(project_root: Path) -> dict[str, dict[str, Any]]: + """Scan all installed extensions for ``events:`` declarations.""" + events: dict[str, dict[str, Any]] = {} + exts_dir = project_root / ".specify" / "extensions" + if not exts_dir.is_dir(): + return events + + for ext_dir in sorted(exts_dir.iterdir()): + if not ext_dir.is_dir(): + continue + ext_yml = ext_dir / "extension.yml" + if not ext_yml.exists(): + continue + try: + data = yaml.safe_load(ext_yml.read_text(encoding="utf-8")) or {} + except yaml.YAMLError: + continue + if not isinstance(data, dict): + continue + runtime = data.get("events", {}) or {} + if not isinstance(runtime, dict): + continue + for event, config in runtime.items(): + if isinstance(config, dict): + events[event] = config + return events + + +# -- Writing/Merging Config (Integration domain) --------------------------- + +def install_integration_events( + integration: IntegrationBase, + project_root: Path, + manifest: IntegrationManifest, + events: dict[str, dict[str, Any]], +) -> list[Path]: + """Generate dispatcher, merge native config, return created files.""" + canonical_to_native = getattr(integration, "CANONICAL_TO_NATIVE", {}) + if not canonical_to_native: + return [] + + # Filter to only supported events + filtered: dict[str, dict[str, Any]] = {} + for ev, cfg in events.items(): + if ev in canonical_to_native: + filtered[ev] = cfg + else: + print( + f"\u26a0\ufe0f {integration.key} does not support '{ev}' events; skipping", + file=sys.stderr, + ) + + if not filtered: + return [] + + created: list[Path] = [] + + # 1. Generate events.py dispatcher script + dispatcher_dir = project_root / EVENTS_DISPATCHER_DIR + dispatcher_dir.mkdir(parents=True, exist_ok=True) + dispatcher_path = dispatcher_dir / EVENTS_DISPATCHER_FILENAME + dispatcher_path.write_text(_EVENTS_DISPATCHER_TEMPLATE, encoding="utf-8") + dispatcher_path.chmod(0o755) + manifest.record_file( + str(dispatcher_path.relative_to(project_root)), + dispatcher_path.read_bytes(), + ) + created.append(dispatcher_path) + + # 2. Format-specific merge/write + fmt = getattr(integration, "events_format", "json-nested") + config_file = getattr(integration, "events_config_file", None) + if not config_file: + return created + + config_path = project_root / config_file + + if fmt == "ts-plugin": + # Opencode TS plugin custom merge + plugin_rel = ".opencode/plugin/speckit-events.ts" + plugin_path = project_root / plugin_rel + plugin_path.parent.mkdir(parents=True, exist_ok=True) + plugin_path.write_text(_build_opencode_plugin(filtered, canonical_to_native), encoding="utf-8") + manifest.record_file( + plugin_rel, + plugin_path.read_bytes(), + ) + created.append(plugin_path) + + # Merge plugin path into opencode.json + _merge_opencode_plugin_ref(config_path, f"./{plugin_rel}") + rel = str(config_path.relative_to(project_root)) + if rel not in manifest.files: + manifest.record_existing(rel) + created.append(config_path) + + elif fmt == "copilot-json": + # Copilot hooks JSON write (creates dedicated .github/hooks/speckit.json) + copilot_hooks = {} + for ev, cfg in filtered.items(): + native = canonical_to_native[ev] + command = cfg.get("command", "") + copilot_hooks[native] = [ + { + "type": "command", + "bash": f"python3 {EVENTS_DISPATCHER_REL} {command} {ev}", + "powershell": f"python3 {EVENTS_DISPATCHER_REL} {command} {ev}", + "timeoutSec": cfg.get("timeout", 60), + } + ] + fragment = {"version": 1, "hooks": copilot_hooks} + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps(fragment, indent=2) + "\n", encoding="utf-8") + rel = str(config_path.relative_to(project_root)) + if rel not in manifest.files: + manifest.record_existing(rel) + created.append(config_path) + + elif fmt == "toml": + # Codex config.toml custom merge + lines: list[str] = [] + for ev, cfg in filtered.items(): + native = canonical_to_native[ev] + timeout = cfg.get("timeout", 60) + command = cfg.get("command", "") + lines.append(f'[[hooks.{native}]]') + lines.append(f'matcher = "{cfg.get("matcher", "*")}"') + lines.append('') + lines.append(f'[[hooks.{native}.hooks]]') + lines.append('type = "command"') + lines.append(f'command = \'python3 {EVENTS_DISPATCHER_REL} {command} {ev}\'') + lines.append(f'timeout = {timeout}') + lines.append('speckit_marker = true') + lines.append('') + _merge_toml_fragment(config_path, "\n".join(lines)) + rel = str(config_path.relative_to(project_root)) + if rel not in manifest.files: + manifest.record_existing(rel) + created.append(config_path) + + elif fmt == "json-flat": + # Cursor hooks.json custom merge + cursor_hooks = {} + for ev, cfg in filtered.items(): + native = canonical_to_native[ev] + command = cfg.get("command", "") + cursor_hooks[native] = [ + { + "command": f"python3 {EVENTS_DISPATCHER_REL} {command} {ev}", + "type": "command", + "timeout": cfg.get("timeout", 60), + "matcher": cfg.get("matcher", "*"), + _SPECKIT_MARKER: True, + } + ] + _merge_json_fragment(config_path, cursor_hooks) + rel = str(config_path.relative_to(project_root)) + if rel not in manifest.files: + manifest.record_existing(rel) + created.append(config_path) + + elif fmt == "json-nested": + # Claude/Qwen/Gemini/Devin/Tabnine nested config JSON merge + nested_hooks = {} + bridge_path_prefix = "${CLAUDE_PROJECT_DIR}/" if integration.key == "claude" else "" + for ev, cfg in filtered.items(): + native = canonical_to_native[ev] + command = cfg.get("command", "") + nested_hooks[native] = [ + { + "matcher": cfg.get("matcher", "*"), + "hooks": [ + { + "type": "command", + "command": "python3", + "args": [ + bridge_path_prefix + EVENTS_DISPATCHER_REL, + command, + ev, + ], + "timeout": cfg.get("timeout", 60), + _SPECKIT_MARKER: True, + } + ], + } + ] + _merge_json_fragment(config_path, nested_hooks) + rel = str(config_path.relative_to(project_root)) + if rel not in manifest.files: + manifest.record_existing(rel) + created.append(config_path) + + return created + + +def remove_integration_events(integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest) -> None: + """Remove Specify-authored event entries from native config.""" + fmt = getattr(integration, "events_format", None) + config_file = getattr(integration, "events_config_file", None) + if config_file: + config_path = project_root / config_file + if config_path.exists(): + if fmt == "copilot-json": + # Clean delete of dedicated file + config_path.unlink(missing_ok=True) + manifest.remove(config_file) + elif fmt == "toml": + _remove_toml_entries(config_path) + elif fmt in ("json-nested", "json-flat"): + _remove_json_entries(config_path) + elif fmt == "ts-plugin": + _remove_opencode_entries(config_path) + + # Clean up dispatcher script + dispatcher_rel = EVENTS_DISPATCHER_REL + if dispatcher_rel in manifest.files: + dispatcher_path = project_root / dispatcher_rel + if dispatcher_path.exists(): + dispatcher_path.unlink(missing_ok=True) + manifest.remove(dispatcher_rel) + + # Clean up opencode TS plugin + if integration.key == "opencode": + plugin_rel = ".opencode/plugin/speckit-events.ts" + if plugin_rel in manifest.files: + plugin_path = project_root / plugin_rel + if plugin_path.exists(): + plugin_path.unlink(missing_ok=True) + manifest.remove(plugin_rel) + + +def events_stale_exclusions(integration_key: str) -> set[str]: + """Return project-relative paths to protect from stale cleanup.""" + from .integrations import get_integration + integration = get_integration(integration_key) + if not integration: + return set() + exclusions = set() + config_file = getattr(integration, "events_config_file", None) + if config_file: + exclusions.add(config_file) + if integration_key == "opencode": + exclusions.add(".opencode/plugin/speckit-events.ts") + return exclusions + + +# -- Manifest validation --------------------------------------------------- + +def validate_events(data: dict[str, Any]) -> None: + """Validate ``events`` field in extension manifest data.""" + from .extensions import ValidationError + + events = data.get("events") + if "events" in data and not isinstance(events, dict): + raise ValidationError("Invalid events: expected a mapping") + if events: + for event_name, event_config in events.items(): + if not isinstance(event_config, dict): + raise ValidationError( + f"Invalid event '{event_name}': expected a mapping" + ) + if not event_config.get("command"): + raise ValidationError( + f"Event '{event_name}' missing required 'command' field" + ) + if event_name not in CANONICAL_EVENTS: + raise ValidationError( + f"Unknown event '{event_name}': " + f"must be one of {sorted(CANONICAL_EVENTS)}" + ) + + +def has_events(data: dict[str, Any]) -> bool: + """Return True if ``events`` is present and non-empty.""" + return bool(data.get("events")) + + +# -- Helper merging functions ---------------------------------------------- + +def _build_opencode_plugin(filtered_events: dict[str, dict[str, Any]], canonical_to_native: dict[str, str]) -> str: + event_entries: list[str] = [] + plugin_returns: list[str] = [] + event_handlers: list[str] = [] + + for ev, cfg in filtered_events.items(): + native = canonical_to_native[ev] + command = cfg.get("command", "") + matcher = cfg.get("matcher", "*") + + if native.startswith("tool.execute."): + ts_hook = native + match_cond = "" + if matcher and matcher != "*": + tools = [t.strip().strip('"') for t in matcher.split("|")] + checks = " || ".join(f"input.tool === '{t.lower()}'" for t in tools) + match_cond = f"if ({checks}) {{" + else: + match_cond = "{" + event_entries.append( + f"function _{ev}(input: any) {match_cond}\n" + f" runEvent('{command}', '{ev}', input);\n" + f" }}" + ) + plugin_returns.append( + f" \"{ts_hook}\": async (input: any, output: any) => {{\n" + f" _{ev}(input);\n" + f" }}," + ) + else: + event_entries.append( + f"function _{ev}(input: any) {{\n" + f" runEvent('{command}', '{ev}', input);\n" + f" }}" + ) + event_handlers.append( + f" if (event.type === '{native}') {{ _{ev}(event); }}" + ) + + if event_handlers: + plugin_returns.append( + f" event: async ({{ event }}) => {{\n" + + "\n".join(event_handlers) + "\n" + f" }}," + ) + + return _TS_PLUGIN_TEMPLATE.format( + event_entries="\n\n".join(event_entries), + plugin_returns="\n".join(plugin_returns), + ) + + +def _merge_opencode_plugin_ref(config_path: Path, ref: str) -> None: + existing: dict = {} + if config_path.exists(): + try: + existing = json.loads(config_path.read_text(encoding="utf-8")) + except Exception: + existing = {} + if not isinstance(existing, dict): + existing = {} + plugins = existing.get("plugin", []) + if not isinstance(plugins, list): + plugins = [] + if ref not in plugins: + plugins.append(ref) + existing["plugin"] = plugins + config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") + + +def _remove_opencode_entries(config_path: Path) -> None: + if not config_path.exists(): + return + try: + existing = json.loads(config_path.read_text(encoding="utf-8")) + except Exception: + return + if not isinstance(existing, dict): + return + plugins = existing.get("plugin", []) + if isinstance(plugins, list): + ref = "./.opencode/plugin/speckit-events.ts" + plugins = [p for p in plugins if p != ref] + if plugins: + existing["plugin"] = plugins + else: + existing.pop("plugin", None) + config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") + + +def _merge_toml_fragment(dst: Path, fragment: str) -> None: + existing = "" + if dst.exists(): + existing = dst.read_text(encoding="utf-8") + existing = re.sub( + r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*', + "", + existing, + flags=re.DOTALL, + ) + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8") + + +def _remove_toml_entries(dst: Path) -> None: + if not dst.exists(): + return + existing = dst.read_text(encoding="utf-8") + cleaned = re.sub( + r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*', + "", + existing, + flags=re.DOTALL, + ) + dst.write_text(cleaned, encoding="utf-8") + + +def _merge_json_fragment(dst: Path, new_hooks: dict) -> None: + existing: dict = {} + if dst.exists(): + try: + existing = json.loads(dst.read_text(encoding="utf-8")) + except Exception: + pass + if not isinstance(existing, dict): + existing = {} + + hooks_key = "hooks" + existing_hooks = existing.get(hooks_key, {}) + if not isinstance(existing_hooks, dict): + existing_hooks = {} + + for event, entries in new_hooks.items(): + existing_list = existing_hooks.get(event, []) + if not isinstance(existing_list, list): + existing_list = [] + # Filter out existing entries carrying our marker + existing_list = [e for e in existing_list if not _has_marker(e)] + existing_list.extend(entries) + existing_hooks[event] = existing_list + + existing[hooks_key] = existing_hooks + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") + + +def _remove_json_entries(dst: Path) -> None: + try: + existing = json.loads(dst.read_text(encoding="utf-8")) + except Exception: + return + if not isinstance(existing, dict): + return + hooks = existing.get("hooks", {}) + if not isinstance(hooks, dict): + return + cleaned: dict[str, list] = {} + for event, entries in hooks.items(): + if not isinstance(entries, list): + continue + kept_entries = [] + for entry in entries: + if not isinstance(entry, dict): + kept_entries.append(entry) + continue + inner = entry.get("hooks") + if isinstance(inner, list): + kept_inner = [h for h in inner if not _has_marker(h)] + if kept_inner: + entry["hooks"] = kept_inner + kept_entries.append(entry) + elif _has_marker(entry): + pass + else: + kept_entries.append(entry) + if kept_entries: + cleaned[event] = kept_entries + if cleaned: + existing["hooks"] = cleaned + else: + existing.pop("hooks", None) + dst.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") + + +def _has_marker(entry: Any) -> bool: + if isinstance(entry, dict): + return entry.get(_SPECKIT_MARKER, False) is True + return False diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 00a6d92b41..5e3408ce20 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -300,17 +300,22 @@ def _validate(self): provides = self.data["provides"] commands = provides.get("commands", []) hooks = self.data.get("hooks") + events = self.data.get("events") if "commands" in provides and not isinstance(commands, list): raise ValidationError("Invalid provides.commands: expected a list") if "hooks" in self.data and not isinstance(hooks, dict): raise ValidationError("Invalid hooks: expected a mapping") + if "events" in self.data: + from ..events import validate_events + validate_events(self.data) has_commands = bool(commands) has_hooks = bool(hooks) + has_events = bool(events) - if not has_commands and not has_hooks: - raise ValidationError("Extension must provide at least one command or hook") + if not has_commands and not has_hooks and not has_events: + raise ValidationError("Extension must provide at least one command, hook, or event") # Validate hook values (if present). # Each event is a single mapping or a list of mappings. diff --git a/src/specify_cli/integrations/_hooks.py b/src/specify_cli/integrations/_hooks.py deleted file mode 100644 index 7f24395b34..0000000000 --- a/src/specify_cli/integrations/_hooks.py +++ /dev/null @@ -1,975 +0,0 @@ -"""Agent runtime hooks for integrations. - -Provides: -- ``resolve_hooks`` — layered hook resolution (CLI flag → YAML override → extension-declared → built-in). -- ``collect_extension_runtime_hooks`` — scan installed extension.yml files for ``runtime_hooks:``. -- ``HookAdapter`` / adapters — generate and merge native hook config per agent CLI. -- ``install_integration_hooks`` / ``remove_integration_hooks`` — entry points called from ``IntegrationBase.setup()`` / ``teardown()``. - -""" - -from __future__ import annotations - -import json -import logging -import re -from abc import ABC, abstractmethod -from pathlib import Path -from typing import TYPE_CHECKING, Any - -import yaml - -if TYPE_CHECKING: - from .base import IntegrationBase - from .manifest import IntegrationManifest - -logger = logging.getLogger(__name__) - -# -- Constants ------------------------------------------------------------- - -HOOK_BRIDGE_DIR = Path(".specify") / "hooks" -HOOK_BRIDGE_FILENAME = "bridge.py" -HOOK_BRIDGE_REL = str(HOOK_BRIDGE_DIR / HOOK_BRIDGE_FILENAME) - -YAML_OVERRIDE_FILENAME = Path(".specify") / "integration-hooks.yml" - -# Sentinel marker embedded in generated native config entries so we can -# identify and remove only our entries on uninstall/upgrade. -_SPECKIT_MARKER = "__speckit_hook__" - -# Canonical runtime hook event names that extensions use in ``runtime_hooks:``. -# Adapters translate these to each agent's native event names via -# ``CANONICAL_TO_NATIVE``. Extensions never need to know agent-specific names. -CANONICAL_RUNTIME_EVENTS = frozenset({ - "PreToolUse", - "PostToolUse", - "Stop", - "SessionStart", - "SessionEnd", - "UserPromptSubmit", -}) - -# -- Bridge script template ------------------------------------------------ - -_BRIDGE_TEMPLATE = '''#!/usr/bin/env python3 -"""Specify CLI Hook Bridge — dispatches agent runtime hooks to slash commands. - -Generated by: specify integration install/upgrade -Do not edit manually. Regenerate with: specify integration upgrade -""" -import json, sys, subprocess, re, os, platform -from pathlib import Path - - -def _project_root(): - for env_var in ("CLAUDE_PROJECT_DIR", "CURSOR_PROJECT_ROOT", "PROJECT_ROOT"): - val = os.environ.get(env_var) - if val and Path(val).is_dir(): - return Path(val) - return Path.cwd() - - -def _find_command_template(command_name, project_root): - # 1. Check extension .registry - registry = project_root / ".specify" / "extensions" / ".registry" - if registry.exists(): - try: - data = json.loads(registry.read_text(encoding="utf-8")) - except Exception: - data = {} - for ext_id, meta in data.items(): - if not isinstance(meta, dict): - continue - for cmd in meta.get("commands", []): - if cmd.get("name") == command_name: - ext_dir = project_root / ".specify" / "extensions" / ext_id - return ext_dir / cmd["file"], ext_id - # 2. Scan extension directories - exts_dir = project_root / ".specify" / "extensions" - if exts_dir.is_dir(): - for ext_dir in sorted(exts_dir.iterdir()): - cmds_dir = ext_dir / "commands" - if cmds_dir.is_dir(): - for f in cmds_dir.glob("*.md"): - if f.stem == command_name: - return f, ext_dir.name - # 3. Check core templates - core = project_root / ".specify" / "templates" / "commands" - if core.is_dir(): - stem = command_name.replace("speckit.", "").replace("spec.", "") - candidate = core / f"{stem}.md" - if candidate.exists(): - return candidate, None - return None, None - - -def _extract_script_path(template_path, project_root, ext_id): - content = template_path.read_text(encoding="utf-8") - m = re.match(r'^---\\n(.*?)\\n---', content, re.DOTALL) - if not m: - return None - fm = m.group(1) - scripts_m = re.search(r'scripts:\\s*\\n((?:\\s+\\w+:.*\\n)*)', fm) - if not scripts_m: - return None - variants = {} - for line in scripts_m.group(1).strip().splitlines(): - kv = re.match(r'\\s+(\\w+):\\s*(.+)', line) - if kv: - variants[kv.group(1)] = kv.group(2).strip().strip('"\\'') - default = "ps" if platform.system().lower().startswith("win") else "sh" - script_rel = variants.get(default) or variants.get("sh") or variants.get("py") - if not script_rel: - return None - if ext_id: - script_abs = project_root / ".specify" / "extensions" / ext_id / script_rel - else: - script_abs = project_root / ".specify" / script_rel - return str(script_abs) if script_abs.exists() else None - - -def main(): - if len(sys.argv) < 3: - sys.exit(0) - command_name = sys.argv[1] - event_name = sys.argv[2] - project_root = _project_root() - payload = sys.stdin.read() if not sys.stdin.isatty() else "{}" - template, ext_id = _find_command_template(command_name, project_root) - if not template: - sys.exit(0) - script_path = _extract_script_path(template, project_root, ext_id) - if not script_path: - sys.exit(0) - try: - result = subprocess.run( - [script_path], input=payload, capture_output=True, text=True, timeout=120, - ) - if result.stdout: - print(result.stdout, end="") - if result.returncode != 0: - if result.stderr: - print(result.stderr, file=sys.stderr, end="") - sys.exit(2) - sys.exit(0) - except subprocess.TimeoutExpired: - print(f"Hook {command_name} timed out", file=sys.stderr) - sys.exit(2) - except Exception as e: - print(f"Hook {command_name} error: {e}", file=sys.stderr) - sys.exit(2) - - -if __name__ == "__main__": - main() -''' - -# -- TS plugin template (opencode) ---------------------------------------- - -_TS_PLUGIN_TEMPLATE = '''import {{ execSync }} from 'child_process'; -import * as path from 'path'; - -const BRIDGE = path.join(process.cwd(), '.specify', 'hooks', 'bridge.py'); - -function runHook(command: string, event: string, input: any): void {{ - try {{ - execSync(`python3 ${{BRIDGE}} ${{command}} ${{event}}`, {{ - input: JSON.stringify(input), - stdio: ['pipe', 'inherit', 'inherit'], - timeout: 60000, - }}); - }} catch (e) {{ - process.exit(2); - }} -}} - -{hook_entries} - -export default (async ({{ client, project, directory, $ }}) => {{ - return {{ -{plugin_returns} - }}; -}}); -''' - - -# -- Hook resolution ------------------------------------------------------- - -def resolve_hooks( - integration_key: str, - integration_config: dict[str, Any] | None, - project_root: Path, - parsed_options: dict[str, Any] | None, -) -> dict[str, dict[str, Any]]: - """Resolve the final hook set for an integration. - - Layer 1: ``--hooks false`` → return ``{}`` - Layer 2: ``.specify/integration-hooks.yml`` → replace entirely if key present - Layer 3: extension-declared ``runtime_hooks:`` → appended - Layer 4: ``config["hooks"]`` → built-in baseline - """ - # Layer 1: CLI flag gate - if parsed_options: - hooks_flag = str(parsed_options.get("hooks", "true")).lower() - if hooks_flag in ("false", "0", "no", "off"): - return {} - - # Layer 4: built-in defaults from integration config - hooks: dict[str, dict[str, Any]] = {} - if integration_config and isinstance(integration_config.get("hooks"), dict): - hooks = dict(integration_config["hooks"]) - - # Layer 3: extension-declared runtime hooks - ext_hooks = collect_extension_runtime_hooks(project_root) - hooks.update(ext_hooks) - - # Layer 2: user YAML override (replaces entirely if key present) - override_file = project_root / YAML_OVERRIDE_FILENAME - if override_file.exists(): - try: - override = yaml.safe_load(override_file.read_text(encoding="utf-8")) or {} - except yaml.YAMLError: - logger.warning("Could not parse %s; ignoring override", override_file) - override = {} - integrations = override.get("integrations", {}) if isinstance(override, dict) else {} - if integration_key in integrations: - key_data = integrations[integration_key] - if isinstance(key_data, dict): - hooks = key_data.get("hooks", {}) or {} - else: - hooks = {} - - return hooks - - -def collect_extension_runtime_hooks(project_root: Path) -> dict[str, dict[str, Any]]: - """Scan all installed extensions for ``runtime_hooks:`` declarations.""" - hooks: dict[str, dict[str, Any]] = {} - exts_dir = project_root / ".specify" / "extensions" - if not exts_dir.is_dir(): - return hooks - - for ext_dir in sorted(exts_dir.iterdir()): - if not ext_dir.is_dir(): - continue - ext_yml = ext_dir / "extension.yml" - if not ext_yml.exists(): - continue - try: - data = yaml.safe_load(ext_yml.read_text(encoding="utf-8")) or {} - except yaml.YAMLError: - continue - if not isinstance(data, dict): - continue - runtime = data.get("runtime_hooks", {}) or {} - if not isinstance(runtime, dict): - continue - for event, config in runtime.items(): - if isinstance(config, dict): - hooks[event] = config - return hooks - - -# -- Adapter infrastructure ------------------------------------------------ - -class HookAdapter(ABC): - """Abstract base for per-agent native hook config generation/merge. - - Subclasses set ``CANONICAL_TO_NATIVE`` to map canonical event names - (e.g. ``PreToolUse``) to the agent's native event names. Events not - in the mapping are silently skipped with a warning at install time. - """ - - # Maps canonical event name → agent-native event name. - # Empty dict means no events are supported (adapter is a no-op). - CANONICAL_TO_NATIVE: dict[str, str] = {} - - @property - @abstractmethod - def config_file_rel(self) -> str: - """Project-relative path to the native settings file.""" - - @abstractmethod - def generate_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> tuple[str, Any]: - """Return (format, fragment) where format is 'json' or 'toml'.""" - - @abstractmethod - def merge_fragment(self, dst: Path, fragment: Any, *, format: str) -> None: - """Merge fragment into the native config file at *dst*.""" - - @abstractmethod - def remove_entries(self, dst: Path) -> None: - """Remove all Specify-authored hook entries from *dst*.""" - - def _native_event(self, canonical: str) -> str | None: - """Return the native event name for a canonical name, or None.""" - return self.CANONICAL_TO_NATIVE.get(canonical) - - def _filter_hooks(self, hooks: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: - """Filter hooks to only those supported by this adapter. - - Prints a warning for each unsupported event and returns a dict - containing only the supported ones. - """ - import sys - filtered: dict[str, dict[str, Any]] = {} - for event, config in hooks.items(): - if self._native_event(event) is not None: - filtered[event] = config - else: - adapter_name = type(self).__name__ - print( - f"\u26a0\ufe0f {adapter_name} does not support '{event}' events; skipping", - file=sys.stderr, - ) - return filtered - - def install( - self, - project_root: Path, - manifest: "IntegrationManifest", - hooks: dict[str, dict[str, Any]], - ) -> list[Path]: - """Generate bridge, merge native config, return created files.""" - # Filter to only events this adapter supports - hooks = self._filter_hooks(hooks) - if not hooks: - return [] - - created: list[Path] = [] - - # Generate bridge script - bridge_dir = project_root / HOOK_BRIDGE_DIR - bridge_dir.mkdir(parents=True, exist_ok=True) - bridge_path = bridge_dir / HOOK_BRIDGE_FILENAME - bridge_path.write_text(_BRIDGE_TEMPLATE, encoding="utf-8") - bridge_path.chmod(0o755) - manifest.record_file( - str(bridge_path.relative_to(project_root)), - bridge_path.read_bytes(), - ) - created.append(bridge_path) - - # Generate / merge native config - config_path = project_root / self.config_file_rel - fmt, fragment = self.generate_fragment(hooks, project_root) - self.merge_fragment(config_path, fragment, format=fmt) - - # Record or mark as existing - if config_path.exists(): - rel = str(config_path.relative_to(project_root)) - if rel not in manifest.files: - manifest.record_existing(rel) - - created.append(config_path) - return created - - def remove(self, project_root: Path, manifest: "IntegrationManifest") -> None: - """Remove Specify-authored entries from native config.""" - config_path = project_root / self.config_file_rel - if config_path.exists(): - self.remove_entries(config_path) - - -class JSONHookAdapter(HookAdapter): - """Base for agents using JSON settings files. - - Subclasses set ``config_file_rel``, ``CANONICAL_TO_NATIVE``, and - optionally ``bridge_path_prefix`` (e.g. ``${CLAUDE_PROJECT_DIR}/``). - The default ``_build_fragment()`` produces Claude-style nested - matcher-groups. Override for different JSON structures (e.g. Cursor's - flat format). - """ - - config_file_rel: str = "" - bridge_path_prefix: str = "" - - def generate_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> tuple[str, Any]: - fragment = self._build_fragment(hooks, project_root) - return "json", fragment - - @abstractmethod - def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: - ... - - def _build_nested_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: - """Build Claude-style nested matcher-groups fragment. - - Shared by Claude, Qwen, Devin, Gemini, Tabnine — all use the same - nested ``hooks.{Event}[].{matcher, hooks[]}`` structure. - """ - result: dict[str, list] = {"hooks": {}} - for event, config in hooks.items(): - native = self._native_event(event) - if native is None: - continue - matcher = config.get("matcher", "*") - timeout = config.get("timeout", 60) - command = config.get("command", "") - entry = { - "type": "command", - "command": "python3", - "args": [ - self.bridge_path_prefix + HOOK_BRIDGE_REL, - command, - event, - ], - "timeout": timeout, - _SPECKIT_MARKER: True, - } - result["hooks"][native] = [{ - "matcher": matcher, - "hooks": [entry], - }] - return result - - def merge_fragment(self, dst: Path, fragment: Any, *, format: str) -> None: - if format != "json": - return - existing: dict = {} - if dst.exists(): - try: - existing = json.loads(dst.read_text(encoding="utf-8")) - if not isinstance(existing, dict): - existing = {} - except (json.JSONDecodeError, OSError): - logger.warning("Could not parse %s; skipping merge", dst) - return - - # Merge: only touch the "hooks" key - existing_hooks = existing.get("hooks", {}) - our_hooks = fragment.get("hooks", {}) - - for event, entries in our_hooks.items(): - existing_list = existing_hooks.get(event, []) - if not isinstance(existing_list, list): - existing_list = [] - # Remove our old entries for this event (by marker), then append new - existing_list = [e for e in existing_list if not _has_marker(e)] - existing_list.extend(entries) - existing_hooks[event] = existing_list - - existing["hooks"] = existing_hooks - dst.parent.mkdir(parents=True, exist_ok=True) - dst.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") - - def remove_entries(self, dst: Path) -> None: - try: - existing = json.loads(dst.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - return - if not isinstance(existing, dict): - return - hooks = existing.get("hooks", {}) - if not isinstance(hooks, dict): - return - cleaned: dict[str, list] = {} - for event, entries in hooks.items(): - if not isinstance(entries, list): - continue - kept_entries = [] - for entry in entries: - if not isinstance(entry, dict): - kept_entries.append(entry) - continue - # Claude nested format: entry has "hooks" list - inner = entry.get("hooks") - if isinstance(inner, list): - kept_inner = [h for h in inner if not _has_marker(h)] - if kept_inner: - entry["hooks"] = kept_inner - kept_entries.append(entry) - # else: drop the entire matcher-group (all our hooks) - elif _has_marker(entry): - pass # drop this flat entry (Cursor format) - else: - kept_entries.append(entry) - if kept_entries: - cleaned[event] = kept_entries - if cleaned: - existing["hooks"] = cleaned - else: - existing.pop("hooks", None) - dst.parent.mkdir(parents=True, exist_ok=True) - dst.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") - - -class ClaudeHookAdapter(JSONHookAdapter): - """Claude Code: nested matcher-groups in .claude/settings.json.""" - - config_file_rel = ".claude/settings.json" - bridge_path_prefix = "${CLAUDE_PROJECT_DIR}/" - CANONICAL_TO_NATIVE = { - "PreToolUse": "PreToolUse", - "PostToolUse": "PostToolUse", - "Stop": "Stop", - "SessionStart": "SessionStart", - "SessionEnd": "SessionEnd", - "UserPromptSubmit": "UserPromptSubmit", - } - - def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: - return self._build_nested_fragment(hooks, project_root) - - -class CursorHookAdapter(JSONHookAdapter): - """Cursor: flat handler arrays in .cursor/hooks.json.""" - - config_file_rel = ".cursor/hooks.json" - CANONICAL_TO_NATIVE = { - "PreToolUse": "preToolUse", - "PostToolUse": "postToolUse", - "Stop": "stop", - "SessionStart": "sessionStart", - "SessionEnd": "sessionEnd", - "UserPromptSubmit": "beforeSubmitPrompt", - } - - def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: - result: dict[str, list] = {"hooks": {}} - for event, config in hooks.items(): - native = self._native_event(event) - if native is None: - continue - matcher = config.get("matcher", "*") - timeout = config.get("timeout", 60) - command = config.get("command", "") - entry = { - "command": f"python3 {HOOK_BRIDGE_REL} {command} {event}", - "type": "command", - "timeout": timeout, - "matcher": matcher, - _SPECKIT_MARKER: True, - } - result["hooks"][native] = [entry] - return result - - -class CodexHookAdapter(HookAdapter): - """Codex CLI: TOML [[hooks.*]] in config.toml.""" - - config_file_rel = ".codex/config.toml" - CANONICAL_TO_NATIVE = { - "PreToolUse": "PreToolUse", - "PostToolUse": "PostToolUse", - "Stop": "Stop", - "SessionStart": "SessionStart", - "SessionEnd": "SessionEnd", - "UserPromptSubmit": "UserPromptSubmit", - } - - def generate_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> tuple[str, Any]: - lines: list[str] = [] - for event, config in hooks.items(): - native = self._native_event(event) - if native is None: - continue - matcher = config.get("matcher", "*") - timeout = config.get("timeout", 60) - command = config.get("command", "") - bridge_abs = f"$(git rev-parse --show-toplevel)/{HOOK_BRIDGE_REL}" - lines.append(f'[[hooks.{native}]]') - lines.append(f'matcher = "{matcher}"') - lines.append('') - lines.append(f'[[hooks.{native}.hooks]]') - lines.append('type = "command"') - lines.append(f'command = \'python3 {bridge_abs} {command} {event}\'') - lines.append(f'timeout = {timeout}') - lines.append('speckit_marker = true') - lines.append('') - return "toml", "\n".join(lines) - - def merge_fragment(self, dst: Path, fragment: Any, *, format: str) -> None: - if format != "toml": - return - existing = "" - if dst.exists(): - existing = dst.read_text(encoding="utf-8") - # Remove old speckit-marked blocks - existing = re.sub( - r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*', - "", - existing, - flags=re.DOTALL, - ) - # Append new - dst.parent.mkdir(parents=True, exist_ok=True) - dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8") - - def remove_entries(self, dst: Path) -> None: - if not dst.exists(): - return - existing = dst.read_text(encoding="utf-8") - cleaned = re.sub( - r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*', - "", - existing, - flags=re.DOTALL, - ) - dst.write_text(cleaned, encoding="utf-8") - - -class OpencodeHookAdapter(HookAdapter): - """opencode: TypeScript plugin in .opencode/plugin/ + opencode.json merge.""" - - config_file_rel = "opencode.json" - _PLUGIN_REL = ".opencode/plugin/speckit-hooks.ts" - CANONICAL_TO_NATIVE = { - "PreToolUse": "tool.execute.before", - "PostToolUse": "tool.execute.after", - } - - def install( - self, - project_root: Path, - manifest: "IntegrationManifest", - hooks: dict[str, dict[str, Any]], - ) -> list[Path]: - # Filter to only events this adapter supports - hooks = self._filter_hooks(hooks) - if not hooks: - return [] - - created: list[Path] = [] - - # Generate bridge script - bridge_dir = project_root / HOOK_BRIDGE_DIR - bridge_dir.mkdir(parents=True, exist_ok=True) - bridge_path = bridge_dir / HOOK_BRIDGE_FILENAME - bridge_path.write_text(_BRIDGE_TEMPLATE, encoding="utf-8") - bridge_path.chmod(0o755) - manifest.record_file( - str(bridge_path.relative_to(project_root)), - bridge_path.read_bytes(), - ) - created.append(bridge_path) - - # Generate TS plugin - plugin_path = project_root / self._PLUGIN_REL - plugin_path.parent.mkdir(parents=True, exist_ok=True) - plugin_path.write_text(self._build_plugin(hooks), encoding="utf-8") - manifest.record_file( - str(plugin_path.relative_to(project_root)), - plugin_path.read_bytes(), - ) - created.append(plugin_path) - - # Merge plugin path into opencode.json - config_path = project_root / self.config_file_rel - self._merge_plugin_ref(config_path) - rel = str(config_path.relative_to(project_root)) - if rel not in manifest.files: - manifest.record_existing(rel) - - created.append(config_path) - return created - - def generate_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> tuple[str, Any]: - return "skip", None - - def merge_fragment(self, dst: Path, fragment: Any, *, format: str) -> None: - pass - - def remove_entries(self, dst: Path) -> None: - if not dst.exists(): - return - try: - existing = json.loads(dst.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - return - if not isinstance(existing, dict): - return - plugins = existing.get("plugin", []) - if isinstance(plugins, list): - plugins = [p for p in plugins if p != f"./{self._PLUGIN_REL}"] - if plugins: - existing["plugin"] = plugins - else: - existing.pop("plugin", None) - dst.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") - - def _build_plugin(self, hooks: dict[str, dict[str, Any]]) -> str: - hook_entries: list[str] = [] - plugin_returns: list[str] = [] - for event, config in hooks.items(): - native = self._native_event(event) - if native is None: - continue - command = config.get("command", "") - matcher = config.get("matcher", "*") - ts_hook = native # already "tool.execute.before" etc. - match_cond = "" - if matcher and matcher != "*": - tools = [t.strip().strip('"') for t in matcher.split("|")] - checks = " || ".join(f"input.tool === '{t.lower()}'" for t in tools) - match_cond = f"if ({checks}) {{" - else: - match_cond = "{" - hook_entries.append( - f"function _{event}(input: any) {match_cond}\n" - f" runHook('{command}', '{event}', input);\n" - f" }}" - ) - plugin_returns.append( - f" \"{ts_hook}\": async (input: any, output: any) => {{\n" - f" _{event}(input);\n" - f" }}," - ) - return _TS_PLUGIN_TEMPLATE.format( - hook_entries="\n\n".join(hook_entries), - plugin_returns="\n".join(plugin_returns), - ) - - def _merge_plugin_ref(self, config_path: Path) -> None: - existing: dict = {} - if config_path.exists(): - try: - existing = json.loads(config_path.read_text(encoding="utf-8")) - if not isinstance(existing, dict): - existing = {} - except (json.JSONDecodeError, OSError): - return - plugins = existing.get("plugin", []) - if not isinstance(plugins, list): - plugins = [] - ref = f"./{self._PLUGIN_REL}" - if ref not in plugins: - plugins.append(ref) - existing["plugin"] = plugins - config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") - - -# -- Phase 1: JSON config-file adapters (nested matcher-groups) ------------ - -class QwenHookAdapter(JSONHookAdapter): - """Qwen Code: nested matcher-groups in .qwen/settings.json. - - Qwen uses the same event names and JSON structure as Claude Code. - """ - - config_file_rel = ".qwen/settings.json" - CANONICAL_TO_NATIVE = { - "PreToolUse": "PreToolUse", - "PostToolUse": "PostToolUse", - "Stop": "Stop", - "SessionStart": "SessionStart", - "SessionEnd": "SessionEnd", - "UserPromptSubmit": "UserPromptSubmit", - } - - def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: - return self._build_nested_fragment(hooks, project_root) - - -class GeminiHookAdapter(JSONHookAdapter): - """Gemini CLI: nested matcher-groups in .gemini/settings.json. - - Gemini uses different event names (BeforeTool, AfterTool, AfterAgent). - """ - - config_file_rel = ".gemini/settings.json" - CANONICAL_TO_NATIVE = { - "PreToolUse": "BeforeTool", - "PostToolUse": "AfterTool", - "Stop": "AfterAgent", - "SessionStart": "SessionStart", - "SessionEnd": "SessionEnd", - } - - def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: - return self._build_nested_fragment(hooks, project_root) - - -class DevinHookAdapter(JSONHookAdapter): - """Devin for Terminal: hooks in .devin/hooks.v1.json. - - Devin uses the same event names and JSON structure as Claude Code. - Also reads .claude/settings.json for compatibility, but we write to - the native format for independence. - """ - - config_file_rel = ".devin/hooks.v1.json" - CANONICAL_TO_NATIVE = { - "PreToolUse": "PreToolUse", - "PostToolUse": "PostToolUse", - "Stop": "Stop", - "SessionStart": "SessionStart", - "SessionEnd": "SessionEnd", - "UserPromptSubmit": "UserPromptSubmit", - } - - def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: - return self._build_nested_fragment(hooks, project_root) - - -class TabnineHookAdapter(JSONHookAdapter): - """Tabnine CLI: nested matcher-groups in .tabnine/agent/settings.json. - - Tabnine uses different event names (BeforeTool, AfterTool), like Gemini. - """ - - config_file_rel = ".tabnine/agent/settings.json" - CANONICAL_TO_NATIVE = { - "PreToolUse": "BeforeTool", - "PostToolUse": "AfterTool", - "SessionStart": "SessionStart", - "SessionEnd": "SessionEnd", - } - - def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict: - return self._build_nested_fragment(hooks, project_root) - - -# -- Adapter registry ----------------------------------------------------- - -_ADAPTERS: dict[str, type[HookAdapter]] = { - "claude": ClaudeHookAdapter, - "cursor-agent": CursorHookAdapter, - "codex": CodexHookAdapter, - "opencode": OpencodeHookAdapter, - "qwen": QwenHookAdapter, - "gemini": GeminiHookAdapter, - "devin": DevinHookAdapter, - "tabnine": TabnineHookAdapter, -} - - -def resolve_adapter(integration_key: str) -> HookAdapter | None: - """Return a hook adapter for the integration key, or None if unsupported.""" - cls = _ADAPTERS.get(integration_key) - return cls() if cls else None - - -# -- Public entry points (called from IntegrationBase) -------------------- - -def install_integration_hooks( - integration: "IntegrationBase", - project_root: Path, - manifest: "IntegrationManifest", - parsed_options: dict[str, Any] | None = None, -) -> list[Path]: - """Install runtime hooks for an integration. - - Called from ``IntegrationBase.setup()`` after commands are written. - Returns list of created/managed files. No-ops gracefully when hooks - are disabled or the agent has no adapter. - """ - adapter = resolve_adapter(integration.key) - if adapter is None: - return [] - - hooks = resolve_hooks( - integration.key, - integration.config, - project_root, - parsed_options, - ) - if not hooks: - # Hooks disabled or empty — clean up any previous hooks - remove_integration_hooks(integration, project_root, manifest) - return [] - - # Remove old hooks before installing new ones so stale events - # from a previous install/upgrade are cleaned up. - remove_integration_hooks(integration, project_root, manifest) - - return adapter.install(project_root, manifest, hooks) - - -def remove_integration_hooks( - integration: "IntegrationBase", - project_root: Path, - manifest: "IntegrationManifest", -) -> None: - """Remove runtime hooks for an integration. - - Called from ``IntegrationBase.teardown()`` before manifest uninstall. - Removes only Specify-authored entries from native config files. - """ - adapter = resolve_adapter(integration.key) - if adapter is None: - return - - adapter.remove(project_root, manifest) - - # Remove bridge script if tracked - bridge_rel = HOOK_BRIDGE_REL - if bridge_rel in manifest.files: - bridge_path = project_root / bridge_rel - if bridge_path.exists(): - bridge_path.unlink(missing_ok=True) - manifest.remove(bridge_rel) - - # Remove opencode TS plugin if tracked - if integration.key == "opencode": - plugin_rel = str(OpencodeHookAdapter._PLUGIN_REL) - if plugin_rel in manifest.files: - plugin_path = project_root / plugin_rel - if plugin_path.exists(): - plugin_path.unlink(missing_ok=True) - manifest.remove(plugin_rel) - - -def hooks_stale_exclusions(integration_key: str) -> set[str]: - """Return project-relative paths to protect from stale cleanup.""" - adapter = resolve_adapter(integration_key) - if adapter is None: - return set() - exclusions = {adapter.config_file_rel} - if integration_key == "opencode": - exclusions.add(OpencodeHookAdapter._PLUGIN_REL) - return exclusions - - -# -- Helpers --------------------------------------------------------------- - -def _has_marker(entry: Any) -> bool: - """Check if a JSON hook entry has the Specify marker.""" - if isinstance(entry, dict): - return entry.get(_SPECKIT_MARKER, False) is True - return False - - -def _to_camel_case(snake_or_pascal: str) -> str: - """Convert PascalCase or snake_case to camelCase.""" - if not snake_or_pascal: - return snake_or_pascal - first = snake_or_pascal[0].lower() - rest = snake_or_pascal[1:] - # Handle PascalCase: PreToolUse -> preToolUse - return first + rest - - -# -- Extension manifest validation callout --------------------------------- - -def validate_runtime_hooks(data: dict[str, Any]) -> None: - """Validate ``runtime_hooks`` field in extension manifest data. - - Called from ``ExtensionManifest._validate()`` via a callout. - Raises ``ValidationError`` if ``runtime_hooks`` is present but malformed. - """ - from ..extensions import ValidationError - - runtime_hooks = data.get("runtime_hooks") - if "runtime_hooks" in data and not isinstance(runtime_hooks, dict): - raise ValidationError("Invalid runtime_hooks: expected a mapping") - if runtime_hooks: - for hook_name, hook_config in runtime_hooks.items(): - if not isinstance(hook_config, dict): - raise ValidationError( - f"Invalid runtime_hook '{hook_name}': expected a mapping" - ) - if not hook_config.get("command"): - raise ValidationError( - f"Runtime hook '{hook_name}' missing required 'command' field" - ) - if hook_name not in CANONICAL_RUNTIME_EVENTS: - raise ValidationError( - f"Unknown runtime_hook '{hook_name}': " - f"must be one of {sorted(CANONICAL_RUNTIME_EVENTS)}" - ) - - -def has_runtime_hooks(data: dict[str, Any]) -> bool: - """Return True if ``runtime_hooks`` is present and non-empty.""" - return bool(data.get("runtime_hooks")) diff --git a/src/specify_cli/integrations/_install_commands.py b/src/specify_cli/integrations/_install_commands.py index 4d7bb44f9b..aba6f5117f 100644 --- a/src/specify_cli/integrations/_install_commands.py +++ b/src/specify_cli/integrations/_install_commands.py @@ -139,12 +139,21 @@ def integration_install( integration.key, project_root, version=_get_speckit_version() ) + from ..events import resolve_events + events_map = resolve_events( + integration.key, + integration.config, + project_root, + parsed_options, + ) + try: integration.setup( project_root, manifest, parsed_options=parsed_options, script_type=selected_script, raw_options=raw_options, + events=events_map, ) manifest.save() new_installed = _dedupe_integration_keys([*installed_keys, integration.key]) diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py index 7116a744c4..79895cf146 100644 --- a/src/specify_cli/integrations/_migrate_commands.py +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -342,12 +342,20 @@ def integration_switch( target_integration.key, project_root, version=_get_speckit_version() ) + from ..events import resolve_events + events_map = resolve_events( + target_integration.key, + target_integration.config, + project_root, + parsed_options, + ) try: target_integration.setup( project_root, manifest, parsed_options=parsed_options, script_type=selected_script, raw_options=raw_options, + events=events_map, ) manifest.save() _set_default_integration( @@ -566,6 +574,13 @@ def integration_upgrade( console.print(f"Upgrading integration: [cyan]{key}[/cyan]") new_manifest = IntegrationManifest(key, project_root, version=_get_speckit_version()) + from ..events import resolve_events + events_map = resolve_events( + key, + integration.config, + project_root, + parsed_options, + ) try: integration.setup( project_root, @@ -573,6 +588,7 @@ def integration_upgrade( parsed_options=parsed_options, script_type=selected_script, raw_options=raw_options, + events=events_map, ) settings = _with_integration_setting( current, diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 477c69834e..7b0abde035 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -29,7 +29,7 @@ from .._toml_string import escape_toml_basic as _escape_toml_basic from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control -from ._hooks import install_integration_hooks, remove_integration_hooks +from ..events import install_integration_events, remove_integration_events if TYPE_CHECKING: from .manifest import IntegrationManifest @@ -159,7 +159,17 @@ def post_process_command_content(self, content: str) -> str: @classmethod def options(cls) -> list[IntegrationOption]: """Return options this integration accepts. Default: none.""" - return [] + opts = [] + if bool(getattr(cls, "CANONICAL_TO_NATIVE", None) and getattr(cls, "events_config_file", None)): + opts.append( + IntegrationOption( + "--events", + is_flag=False, + default="true", + help="Enable/disable runtime events (true|false, default: true)", + ) + ) + return opts def effective_invoke_separator( self, @@ -480,7 +490,11 @@ def stale_cleanup_exclusions(self) -> set[str]: tracking) would otherwise be deleted even though they are still managed. Subclasses list such paths here to protect them. """ - return set() + exclusions = set() + if self.supports_events(): + from ..events import events_stale_exclusions + exclusions.update(events_stale_exclusions(self.key)) + return exclusions def commands_dest(self, project_root: Path) -> Path: """Return the absolute path to the commands output directory. @@ -903,9 +917,32 @@ def teardown( Returns ``(removed, skipped)`` file lists. """ - remove_integration_hooks(self, project_root, manifest) + self.remove_events(project_root, manifest) return manifest.uninstall(project_root, force=force) + def emit_events( + self, + project_root: Path, + manifest: IntegrationManifest, + events: dict[str, dict[str, Any]] | None = None, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Emit native event configuration for this integration.""" + return install_integration_events(self, project_root, manifest, events or {}) + + def remove_events( + self, + project_root: Path, + manifest: IntegrationManifest, + ) -> None: + """Remove Specify-authored event entries from native config.""" + remove_integration_events(self, project_root, manifest) + + def supports_events(self) -> bool: + """Return True if this integration supports agent-native events.""" + return bool(getattr(self, "CANONICAL_TO_NATIVE", None) and getattr(self, "events_config_file", None)) + # -- Convenience helpers for subclasses ------------------------------- def install( @@ -1010,11 +1047,11 @@ def setup( created.append(dst_file) - # Install agent runtime hooks - hook_files = install_integration_hooks( - self, project_root, manifest, parsed_options + # Install agent runtime events + event_files = self.emit_events( + project_root, manifest, events=opts.get("events"), parsed_options=parsed_options ) - created.extend(hook_files) + created.extend(event_files) return created @@ -1223,11 +1260,11 @@ def setup( created.append(dst_file) - # Install agent runtime hooks - hook_files = install_integration_hooks( - self, project_root, manifest, parsed_options + # Install agent runtime events + event_files = self.emit_events( + project_root, manifest, events=opts.get("events"), parsed_options=parsed_options ) - created.extend(hook_files) + created.extend(event_files) return created @@ -1465,11 +1502,11 @@ def setup( created.append(dst_file) - # Install agent runtime hooks - hook_files = install_integration_hooks( - self, project_root, manifest, parsed_options + # Install agent runtime events + event_files = self.emit_events( + project_root, manifest, events=opts.get("events"), parsed_options=parsed_options ) - created.extend(hook_files) + created.extend(event_files) return created @@ -1706,10 +1743,10 @@ def setup( created.append(dst) - # Install agent runtime hooks - hook_files = install_integration_hooks( - self, project_root, manifest, parsed_options + # Install agent runtime events + event_files = self.emit_events( + project_root, manifest, events=opts.get("events"), parsed_options=parsed_options ) - created.extend(hook_files) + created.extend(event_files) return created diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 923a77607a..39732794af 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -54,6 +54,17 @@ class ClaudeIntegration(SkillsIntegration): } multi_install_safe = True + CANONICAL_TO_NATIVE = { + "session_start": "SessionStart", + "pre_tool_use": "PreToolUse", + "post_tool_use": "PostToolUse", + "session_end": "SessionEnd", + "user_prompt_submit": "UserPromptSubmit", + "stop": "Stop", + } + events_config_file = ".claude/settings.json" + events_format = "json-nested" + @staticmethod def inject_argument_hint(content: str, hint: str) -> str: """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter. diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index 7d1ff86e27..2ffa59ca4b 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -29,6 +29,17 @@ class CodexIntegration(SkillsIntegration): dev_no_symlink = True multi_install_safe = True + CANONICAL_TO_NATIVE = { + "session_start": "SessionStart", + "pre_tool_use": "PreToolUse", + "post_tool_use": "PostToolUse", + "session_end": "SessionEnd", + "user_prompt_submit": "UserPromptSubmit", + "stop": "Stop", + } + events_config_file = ".codex/config.toml" + events_format = "toml" + def build_exec_args( self, prompt: str, @@ -49,11 +60,13 @@ def build_exec_args( @classmethod def options(cls) -> list[IntegrationOption]: - return [ + opts = super().options() + opts.append( IntegrationOption( "--skills", is_flag=True, default=True, help="Install as agent skills (default for Codex)", - ), - ] + ) + ) + return opts diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index ce41c00d6f..38479c2bc6 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -118,6 +118,16 @@ class CopilotIntegration(IntegrationBase): "extension": ".agent.md", } + CANONICAL_TO_NATIVE = { + "session_start": "sessionStart", + "pre_tool_use": "preToolUse", + "post_tool_use": "postToolUse", + "session_end": "sessionEnd", + "user_prompt_submit": "userPromptSubmitted", + } + events_config_file = ".github/hooks/speckit.json" + events_format = "copilot-json" + # Mutable flag set by setup() — indicates the active scaffolding mode. _skills_mode: bool = False @@ -328,7 +338,9 @@ def stale_cleanup_exclusions(self) -> set[str]: be flagged stale and deleted, destroying user settings (and the file the integration still manages). """ - return {".vscode/settings.json"} + exclusions = super().stale_cleanup_exclusions() + exclusions.add(".vscode/settings.json") + return exclusions def post_process_skill_content(self, content: str) -> str: """Inject shared hook guidance into Copilot skill content. @@ -355,10 +367,18 @@ def setup( parsed_options = parsed_options or {} self._skills_mode = bool(parsed_options.get("skills")) if self._skills_mode: - return self._setup_skills(project_root, manifest, parsed_options, **opts) - if "skills" not in parsed_options: - _warn_legacy_markdown_default() - return self._setup_default(project_root, manifest, parsed_options, **opts) + created = self._setup_skills(project_root, manifest, parsed_options, **opts) + else: + if "skills" not in parsed_options: + _warn_legacy_markdown_default() + created = self._setup_default(project_root, manifest, parsed_options, **opts) + + # Install agent runtime events + event_files = self.emit_events( + project_root, manifest, events=opts.get("events"), parsed_options=parsed_options + ) + created.extend(event_files) + return created def _setup_default( self, diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py index 07f2a6318b..58bd89b21f 100644 --- a/src/specify_cli/integrations/cursor_agent/__init__.py +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -38,6 +38,17 @@ class CursorAgentIntegration(SkillsIntegration): multi_install_safe = True + CANONICAL_TO_NATIVE = { + "session_start": "sessionStart", + "pre_tool_use": "preToolUse", + "post_tool_use": "postToolUse", + "session_end": "sessionEnd", + "user_prompt_submit": "beforeSubmitPrompt", + "stop": "stop", + } + events_config_file = ".cursor/hooks.json" + events_format = "json-flat" + def build_exec_args( self, prompt: str, @@ -92,11 +103,13 @@ def build_exec_args( @classmethod def options(cls) -> list[IntegrationOption]: - return [ + opts = super().options() + opts.append( IntegrationOption( "--skills", is_flag=True, default=True, help="Install as agent skills (recommended for Cursor)", - ), - ] + ) + ) + return opts diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py index 0d60bc954d..6b2b925dfb 100644 --- a/src/specify_cli/integrations/devin/__init__.py +++ b/src/specify_cli/integrations/devin/__init__.py @@ -31,6 +31,17 @@ class DevinIntegration(SkillsIntegration): "extension": "/SKILL.md", } + CANONICAL_TO_NATIVE = { + "session_start": "SessionStart", + "pre_tool_use": "PreToolUse", + "post_tool_use": "PostToolUse", + "session_end": "SessionEnd", + "user_prompt_submit": "UserPromptSubmit", + "stop": "Stop", + } + events_config_file = ".devin/hooks.v1.json" + events_format = "json-nested" + def build_exec_args( self, prompt: str, diff --git a/src/specify_cli/integrations/gemini/__init__.py b/src/specify_cli/integrations/gemini/__init__.py index 9a459862af..e422a74261 100644 --- a/src/specify_cli/integrations/gemini/__init__.py +++ b/src/specify_cli/integrations/gemini/__init__.py @@ -19,3 +19,13 @@ class GeminiIntegration(TomlIntegration): "extension": ".toml", } multi_install_safe = True + + CANONICAL_TO_NATIVE = { + "session_start": "SessionStart", + "pre_tool_use": "BeforeTool", + "post_tool_use": "AfterTool", + "session_end": "SessionEnd", + "stop": "AfterAgent", + } + events_config_file = ".gemini/settings.json" + events_format = "json-nested" diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index 0f734b7f41..660fd0b5fa 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -20,6 +20,15 @@ class OpencodeIntegration(MarkdownIntegration): "extension": ".md", } + CANONICAL_TO_NATIVE = { + "pre_tool_use": "tool.execute.before", + "post_tool_use": "tool.execute.after", + "session_start": "session.created", + "session_end": "session.deleted", + } + events_config_file = "opencode.json" + events_format = "ts-plugin" + def build_exec_args( self, prompt: str, diff --git a/src/specify_cli/integrations/qwen/__init__.py b/src/specify_cli/integrations/qwen/__init__.py index 1e8c15bf91..2cd879f06b 100644 --- a/src/specify_cli/integrations/qwen/__init__.py +++ b/src/specify_cli/integrations/qwen/__init__.py @@ -19,3 +19,14 @@ class QwenIntegration(MarkdownIntegration): "extension": ".md", } multi_install_safe = True + + CANONICAL_TO_NATIVE = { + "session_start": "SessionStart", + "pre_tool_use": "PreToolUse", + "post_tool_use": "PostToolUse", + "session_end": "SessionEnd", + "user_prompt_submit": "UserPromptSubmit", + "stop": "Stop", + } + events_config_file = ".qwen/settings.json" + events_format = "json-nested" diff --git a/src/specify_cli/integrations/tabnine/__init__.py b/src/specify_cli/integrations/tabnine/__init__.py index 9edf1e1607..2651755319 100644 --- a/src/specify_cli/integrations/tabnine/__init__.py +++ b/src/specify_cli/integrations/tabnine/__init__.py @@ -19,3 +19,12 @@ class TabnineIntegration(TomlIntegration): "extension": ".toml", } multi_install_safe = True + + CANONICAL_TO_NATIVE = { + "session_start": "SessionStart", + "pre_tool_use": "BeforeTool", + "post_tool_use": "AfterTool", + "session_end": "SessionEnd", + } + events_config_file = ".tabnine/agent/settings.json" + events_format = "json-nested" diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py new file mode 100644 index 0000000000..5509f56ba3 --- /dev/null +++ b/tests/integrations/test_events.py @@ -0,0 +1,374 @@ +"""Tests for events module: integration runtime events.""" + +from __future__ import annotations + +import json +import platform +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +from specify_cli.events import ( + CANONICAL_EVENTS, + collect_extension_events, + events_stale_exclusions, + install_integration_events, + remove_integration_events, + resolve_events, + validate_events, + resolve_and_run_event_command, +) +from specify_cli.integrations.base import IntegrationBase +from specify_cli.integrations.manifest import IntegrationManifest +from specify_cli.integrations.claude import ClaudeIntegration +from specify_cli.integrations.cursor_agent import CursorAgentIntegration +from specify_cli.integrations.codex import CodexIntegration +from specify_cli.integrations.opencode import OpencodeIntegration +from specify_cli.integrations.qwen import QwenIntegration +from specify_cli.integrations.gemini import GeminiIntegration +from specify_cli.integrations.devin import DevinIntegration +from specify_cli.integrations.tabnine import TabnineIntegration +from specify_cli.integrations.copilot import CopilotIntegration + + +# -- resolve_events -------------------------------------------------------- + +class TestResolveEvents: + """Test the 4-layer event resolution chain.""" + + def test_layer1_disabled_returns_empty(self, tmp_path): + """--events false returns empty dict.""" + result = resolve_events( + "claude", + {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, + tmp_path, + {"events": "false"}, + ) + assert result == {} + + def test_layer4_built_in_defaults(self, tmp_path): + """Returns baseline defaults when no overrides exist.""" + result = resolve_events( + "claude", + {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, + tmp_path, + None, + ) + assert result == {"post_tool_use": {"command": "speckit.tdd.validate"}} + + def test_layer3_extension_events_appended(self, tmp_path): + """Extension-declared events are resolved and appended.""" + ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" + ext_dir.mkdir(parents=True) + ext_yml = ext_dir / "extension.yml" + ext_yml.write_text( + "events:\n session_start:\n command: speckit.my-ext.boot\n", + encoding="utf-8", + ) + + result = resolve_events( + "claude", + {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, + tmp_path, + None, + ) + assert "post_tool_use" in result + assert "session_start" in result + assert result["session_start"] == {"command": "speckit.my-ext.boot"} + + def test_layer2_yaml_override_replaces(self, tmp_path): + """integration-events.yml override replaces baseline entirely.""" + override_file = tmp_path / ".specify" / "integration-events.yml" + override_file.parent.mkdir(parents=True, exist_ok=True) + override_file.write_text( + "integrations:\n" + " claude:\n" + " events:\n" + " stop:\n" + " command: speckit.override.stop\n", + encoding="utf-8", + ) + + result = resolve_events( + "claude", + {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, + tmp_path, + None, + ) + assert result == {"stop": {"command": "speckit.override.stop"}} + + def test_layer2_empty_events_disables(self, tmp_path): + """Empty events override disables events.""" + override_file = tmp_path / ".specify" / "integration-events.yml" + override_file.parent.mkdir(parents=True, exist_ok=True) + override_file.write_text( + "integrations:\n" + " claude:\n" + " events: {}\n", + encoding="utf-8", + ) + + result = resolve_events( + "claude", + {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, + tmp_path, + None, + ) + assert result == {} + + def test_no_config_no_events(self, tmp_path): + """Safe fallback with empty config/options.""" + result = resolve_events("claude", None, tmp_path, None) + assert result == {} + + +# -- collect_extension_events ----------------------------------------------- + +class TestCollectExtensionEvents: + """Test scanning extension.yml files for events: declarations.""" + + def test_no_extensions_dir(self, tmp_path): + assert collect_extension_events(tmp_path) == {} + + def test_no_events_in_extension(self, tmp_path): + ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text("extension:\n id: my-ext\n", encoding="utf-8") + assert collect_extension_events(tmp_path) == {} + + def test_events_collected(self, tmp_path): + ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text( + "events:\n pre_tool_use:\n command: speckit.my-ext.check\n", + encoding="utf-8", + ) + result = collect_extension_events(tmp_path) + assert result == {"pre_tool_use": {"command": "speckit.my-ext.check"}} + + def test_invalid_yaml_skipped(self, tmp_path): + ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text("invalid: - - -", encoding="utf-8") + assert collect_extension_events(tmp_path) == {} + + +# -- Class-driven mappings -------------------------------------------------- + +class TestCanonicalEventMapping: + """Verify registry-driven mapping is correct on integration classes.""" + + def test_claude_identity(self): + integration = ClaudeIntegration() + assert integration.supports_events() is True + assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "PreToolUse" + assert integration.CANONICAL_TO_NATIVE["session_start"] == "SessionStart" + + def test_cursor_camelcase(self): + integration = CursorAgentIntegration() + assert integration.supports_events() is True + assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "preToolUse" + assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "beforeSubmitPrompt" + + def test_opencode_limited(self): + integration = OpencodeIntegration() + assert integration.supports_events() is True + assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "tool.execute.before" + assert "stop" not in integration.CANONICAL_TO_NATIVE + + def test_copilot_mapping(self): + integration = CopilotIntegration() + assert integration.supports_events() is True + assert integration.CANONICAL_TO_NATIVE["session_start"] == "sessionStart" + assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "userPromptSubmitted" + + +# -- validate_events -------------------------------------------------------- + +class TestValidateEvents: + """Test manifest validation.""" + + def test_unknown_event_rejected(self): + from specify_cli.extensions import ValidationError + data = {"events": {"unknown_event": {"command": "speckit.tdd.validate"}}} + with pytest.raises(ValidationError) as exc: + validate_events(data) + assert "Unknown event" in str(exc.value) + + def test_known_event_accepted(self): + data = {"events": {"pre_tool_use": {"command": "speckit.tdd.validate"}}} + validate_events(data) # no raise + + def test_all_canonical_events_accepted(self): + data = { + "events": { + name: {"command": "speckit.test"} + for name in CANONICAL_EVENTS + } + } + validate_events(data) # no raise + + +# -- Claude settings JSON merging ------------------------------------------- + +class TestClaudeJsonMerging: + """Test Claude settings JSON merging and cleanup.""" + + def test_merge_into_empty_file(self, tmp_path): + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + + events = { + "pre_tool_use": {"command": "speckit.tdd.validate", "matcher": "Edit|Write"}, + } + install_integration_events(integration, tmp_path, manifest, events) + + config_path = tmp_path / ".claude/settings.json" + assert config_path.is_file() + data = json.loads(config_path.read_text()) + assert "hooks" in data + assert "PreToolUse" in data["hooks"] + assert data["hooks"]["PreToolUse"][0]["matcher"] == "Edit|Write" + + def test_remove_preserves_user_hooks(self, tmp_path): + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + + # Pre-seed user setting + config_path = tmp_path / ".claude/settings.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + json.dumps( + { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "user-check", + } + ], + } + ] + } + } + ) + ) + + events = { + "pre_tool_use": {"command": "speckit.tdd.validate"}, + } + install_integration_events(integration, tmp_path, manifest, events) + remove_integration_events(integration, tmp_path, manifest) + + data = json.loads(config_path.read_text()) + assert "hooks" in data + assert "PreToolUse" in data["hooks"] + assert len(data["hooks"]["PreToolUse"]) == 1 + assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash" + + +# -- Copilot events JSON writing -------------------------------------------- + +class TestCopilotJsonWriting: + """Test Copilot dedicated .github/hooks/speckit.json generation.""" + + def test_copilot_json_generation(self, tmp_path): + integration = CopilotIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + + events = { + "session_start": {"command": "speckit.agent-context.update", "timeout": 60}, + } + install_integration_events(integration, tmp_path, manifest, events) + + config_path = tmp_path / ".github/hooks/speckit.json" + assert config_path.is_file() + data = json.loads(config_path.read_text()) + assert data["version"] == 1 + assert "hooks" in data + assert "sessionStart" in data["hooks"] + entry = data["hooks"]["sessionStart"][0] + assert entry["type"] == "command" + assert "speckit.agent-context.update" in entry["bash"] + assert "session_start" in entry["bash"] + assert entry["timeoutSec"] == 60 + + +# -- Opencode TS Plugin merging --------------------------------------------- + +class TestOpencodePluginMerging: + """Test Opencode typescript plugin generation.""" + + def test_opencode_ts_plugin_generation(self, tmp_path): + integration = OpencodeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + + events = { + "pre_tool_use": {"command": "speckit.tdd.validate", "matcher": "Edit"}, + "session_start": {"command": "speckit.agent-context.update"}, + } + install_integration_events(integration, tmp_path, manifest, events) + + plugin_path = tmp_path / ".opencode/plugin/speckit-events.ts" + assert plugin_path.is_file() + content = plugin_path.read_text() + assert "runEvent" in content + assert "tool.execute.before" in content + assert "session.created" in content + assert "speckit.tdd.validate" in content + assert "speckit.agent-context.update" in content + + +# -- Command runner test (core execution) ----------------------------------- + +class TestCommandRunner: + """Test the core command/script resolution and runner.""" + + def test_run_command_not_found(self, tmp_path): + code = resolve_and_run_event_command("nonexistent.command", "session_start", "{}", tmp_path) + assert code == 0 # no-ops gracefully + + def test_run_command_resolves_and_executes(self, tmp_path): + # Create a mock core command md file + cmd_dir = tmp_path / ".specify" / "templates" / "commands" + cmd_dir.mkdir(parents=True) + cmd_file = cmd_dir / "test.md" + cmd_file.write_text( + "---\n" + "description: \"Test\"\n" + "scripts:\n" + " sh: scripts/test.sh\n" + "---\n" + "Body\n", + encoding="utf-8", + ) + + script_dir = tmp_path / ".specify" / "scripts" + script_dir.mkdir(parents=True) + script_file = script_dir / "test.sh" + script_file.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + script_file.chmod(0o755) + + # Skip on Windows because sh is POSIX + if platform.system().lower().startswith("win"): + return + + code = resolve_and_run_event_command("speckit.test", "session_start", "{}", tmp_path) + assert code == 0 diff --git a/tests/integrations/test_hooks.py b/tests/integrations/test_hooks.py deleted file mode 100644 index cb1dd1c6e2..0000000000 --- a/tests/integrations/test_hooks.py +++ /dev/null @@ -1,678 +0,0 @@ -"""Tests for _hooks module: integration runtime hooks.""" - -from __future__ import annotations - -import json -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import yaml - -from specify_cli.integrations._hooks import ( - ClaudeHookAdapter, - CodexHookAdapter, - CursorHookAdapter, - DevinHookAdapter, - GeminiHookAdapter, - HookAdapter, - JSONHookAdapter, - OpencodeHookAdapter, - QwenHookAdapter, - TabnineHookAdapter, - _has_marker, - _to_camel_case, - CANONICAL_RUNTIME_EVENTS, - collect_extension_runtime_hooks, - hooks_stale_exclusions, - install_integration_hooks, - remove_integration_hooks, - resolve_adapter, - resolve_hooks, - validate_runtime_hooks, -) -from specify_cli.integrations.manifest import IntegrationManifest - - -# -- resolve_hooks -------------------------------------------------------- - -class TestResolveHooks: - """Test the 4-layer hook resolution chain.""" - - def test_layer1_disabled_returns_empty(self, tmp_path): - """--hooks false returns empty dict.""" - result = resolve_hooks( - "claude", - {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate"}}}, - tmp_path, - {"hooks": "false"}, - ) - assert result == {} - - def test_layer4_built_in_defaults(self, tmp_path): - """Built-in config hooks are returned when no override.""" - config = {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write"}}} - result = resolve_hooks("claude", config, tmp_path, None) - assert "PostToolUse" in result - assert result["PostToolUse"]["command"] == "speckit.tdd.validate" - - def test_layer3_extension_hooks_appended(self, tmp_path): - """Extension-declared runtime_hooks are appended to built-in.""" - ext_dir = tmp_path / ".specify" / "extensions" / "myext" - ext_dir.mkdir(parents=True) - (ext_dir / "extension.yml").write_text(yaml.dump({ - "schema_version": "1.0", - "extension": {"id": "myext", "name": "My Ext", "version": "1.0.0", - "description": "test"}, - "requires": {"speckit_version": ">=0.0.80"}, - "provides": {"commands": []}, - "runtime_hooks": { - "Stop": {"command": "speckit.myext.check", "matcher": "*"}, - }, - })) - config = {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate"}}} - result = resolve_hooks("claude", config, tmp_path, None) - assert "PostToolUse" in result - assert "Stop" in result - assert result["Stop"]["command"] == "speckit.myext.check" - - def test_layer2_yaml_override_replaces(self, tmp_path): - """YAML override replaces built-in and extension hooks entirely.""" - override_file = tmp_path / ".specify" / "integration-hooks.yml" - override_file.parent.mkdir(parents=True) - override_file.write_text(yaml.dump({ - "version": 1, - "integrations": { - "claude": { - "hooks": { - "PreToolUse": {"command": "speckit.protected_paths", "matcher": "Edit|Write"}, - }, - }, - }, - })) - config = {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate"}}} - result = resolve_hooks("claude", config, tmp_path, None) - assert "PreToolUse" in result - assert "PostToolUse" not in result # replaced, not merged - - def test_layer2_empty_hooks_disables(self, tmp_path): - """Empty hooks: {} in YAML override disables all hooks.""" - override_file = tmp_path / ".specify" / "integration-hooks.yml" - override_file.parent.mkdir(parents=True) - override_file.write_text(yaml.dump({ - "version": 1, - "integrations": {"claude": {"hooks": {}}}, - })) - config = {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate"}}} - result = resolve_hooks("claude", config, tmp_path, None) - assert result == {} - - def test_no_config_no_hooks(self, tmp_path): - """No config, no extensions, no override → empty.""" - result = resolve_hooks("gemini", None, tmp_path, None) - assert result == {} - - -# -- collect_extension_runtime_hooks -------------------------------------- - -class TestCollectExtensionRuntimeHooks: - """Test scanning installed extensions for runtime_hooks.""" - - def test_no_extensions_dir(self, tmp_path): - assert collect_extension_runtime_hooks(tmp_path) == {} - - def test_no_runtime_hooks_in_extension(self, tmp_path): - ext_dir = tmp_path / ".specify" / "extensions" / "myext" - ext_dir.mkdir(parents=True) - (ext_dir / "extension.yml").write_text(yaml.dump({ - "schema_version": "1.0", - "extension": {"id": "myext", "name": "My Ext", "version": "1.0.0", - "description": "test"}, - "requires": {"speckit_version": ">=0.0.80"}, - "provides": {"commands": [{"name": "speckit.myext.cmd", "file": "cmd.md"}]}, - "hooks": {"after_plan": {"command": "speckit.myext.cmd"}}, - })) - result = collect_extension_runtime_hooks(tmp_path) - assert result == {} - - def test_runtime_hooks_collected(self, tmp_path): - ext_dir = tmp_path / ".specify" / "extensions" / "tdd" - ext_dir.mkdir(parents=True) - (ext_dir / "extension.yml").write_text(yaml.dump({ - "schema_version": "1.0", - "extension": {"id": "tdd", "name": "TDD", "version": "1.0.0", - "description": "test"}, - "requires": {"speckit_version": ">=0.0.80"}, - "provides": {"commands": []}, - "runtime_hooks": { - "PostToolUse": {"command": "adlc.tdd.validate", "matcher": "Edit|Write"}, - "Stop": {"command": "adlc.tdd.validate", "matcher": "*"}, - }, - })) - result = collect_extension_runtime_hooks(tmp_path) - assert "PostToolUse" in result - assert "Stop" in result - assert result["PostToolUse"]["command"] == "adlc.tdd.validate" - - def test_invalid_yaml_skipped(self, tmp_path): - ext_dir = tmp_path / ".specify" / "extensions" / "broken" - ext_dir.mkdir(parents=True) - (ext_dir / "extension.yml").write_text("{{invalid yaml") - result = collect_extension_runtime_hooks(tmp_path) - assert result == {} - - -# -- Adapter resolution --------------------------------------------------- - -class TestResolveAdapter: - """Test adapter registry.""" - - def test_claude_returns_adapter(self): - adapter = resolve_adapter("claude") - assert isinstance(adapter, ClaudeHookAdapter) - - def test_cursor_returns_adapter(self): - adapter = resolve_adapter("cursor-agent") - assert isinstance(adapter, CursorHookAdapter) - - def test_codex_returns_adapter(self): - adapter = resolve_adapter("codex") - assert isinstance(adapter, CodexHookAdapter) - - def test_opencode_returns_adapter(self): - adapter = resolve_adapter("opencode") - assert isinstance(adapter, OpencodeHookAdapter) - - def test_gemini_returns_adapter(self): - adapter = resolve_adapter("gemini") - assert isinstance(adapter, GeminiHookAdapter) - - def test_qwen_returns_adapter(self): - adapter = resolve_adapter("qwen") - assert isinstance(adapter, QwenHookAdapter) - - def test_devin_returns_adapter(self): - adapter = resolve_adapter("devin") - assert isinstance(adapter, DevinHookAdapter) - - def test_tabnine_returns_adapter(self): - adapter = resolve_adapter("tabnine") - assert isinstance(adapter, TabnineHookAdapter) - - def test_copilot_returns_none(self): - assert resolve_adapter("copilot") is None - - def test_unknown_returns_none(self): - assert resolve_adapter("nonexistent") is None - - -# -- Canonical event mapping ----------------------------------------------- - -class TestCanonicalEventMapping: - """Test canonical→native event name translation per adapter.""" - - def test_claude_passthrough(self): - adapter = ClaudeHookAdapter() - assert adapter._native_event("PreToolUse") == "PreToolUse" - assert adapter._native_event("PostToolUse") == "PostToolUse" - assert adapter._native_event("Stop") == "Stop" - - def test_cursor_camelcase(self): - adapter = CursorHookAdapter() - assert adapter._native_event("PreToolUse") == "preToolUse" - assert adapter._native_event("PostToolUse") == "postToolUse" - assert adapter._native_event("UserPromptSubmit") == "beforeSubmitPrompt" - - def test_opencode_limited(self): - adapter = OpencodeHookAdapter() - assert adapter._native_event("PreToolUse") == "tool.execute.before" - assert adapter._native_event("PostToolUse") == "tool.execute.after" - assert adapter._native_event("Stop") is None # unsupported - - def test_gemini_mapping(self): - adapter = GeminiHookAdapter() - assert adapter._native_event("PreToolUse") == "BeforeTool" - assert adapter._native_event("PostToolUse") == "AfterTool" - assert adapter._native_event("Stop") == "AfterAgent" - - def test_qwen_identity(self): - adapter = QwenHookAdapter() - assert adapter._native_event("PreToolUse") == "PreToolUse" - - def test_devin_identity(self): - adapter = DevinHookAdapter() - assert adapter._native_event("PreToolUse") == "PreToolUse" - - def test_tabnine_mapping(self): - adapter = TabnineHookAdapter() - assert adapter._native_event("PreToolUse") == "BeforeTool" - assert adapter._native_event("PostToolUse") == "AfterTool" - assert adapter._native_event("Stop") is None # not supported - - -class TestUnknownEventRejection: - """Test that validate_runtime_hooks rejects unknown event names.""" - - def test_unknown_event_rejected(self): - from specify_cli.extensions import ValidationError - data = { - "runtime_hooks": { - "FooBar": {"command": "speckit.test"}, - }, - } - with pytest.raises(ValidationError, match="Unknown runtime_hook 'FooBar'"): - validate_runtime_hooks(data) - - def test_known_event_accepted(self): - data = { - "runtime_hooks": { - "PostToolUse": {"command": "speckit.test"}, - }, - } - validate_runtime_hooks(data) # should not raise - - def test_all_canonical_events_accepted(self): - for event in CANONICAL_RUNTIME_EVENTS: - data = {"runtime_hooks": {event: {"command": "speckit.test"}}} - validate_runtime_hooks(data) # should not raise - - -class TestUnsupportedEventWarning: - """Test that adapters warn and skip unsupported events.""" - - def test_opencode_skips_stop(self, tmp_path, capsys): - from specify_cli.integrations.manifest import IntegrationManifest - adapter = OpencodeHookAdapter() - manifest = IntegrationManifest("opencode", tmp_path) - hooks = { - "PostToolUse": {"command": "speckit.test", "matcher": "Edit|Write"}, - "Stop": {"command": "speckit.test", "matcher": "*"}, - } - adapter.install(tmp_path, manifest, hooks) - captured = capsys.readouterr() - assert "Stop" in captured.err - assert "skipping" in captured.err - # Plugin should only have PostToolUse, not Stop - plugin = tmp_path / ".opencode" / "plugin" / "speckit-hooks.ts" - content = plugin.read_text() - assert "tool.execute.after" in content - assert "tool.execute.before" not in content - - def test_tabnine_skips_stop(self, tmp_path, capsys): - adapter = TabnineHookAdapter() - from specify_cli.integrations.manifest import IntegrationManifest - manifest = IntegrationManifest("tabnine", tmp_path) - hooks = { - "PostToolUse": {"command": "speckit.test", "matcher": "Edit|Write"}, - "Stop": {"command": "speckit.test", "matcher": "*"}, - } - adapter.install(tmp_path, manifest, hooks) - captured = capsys.readouterr() - assert "Stop" in captured.err - settings = tmp_path / ".tabnine" / "agent" / "settings.json" - data = json.loads(settings.read_text()) - assert "AfterTool" in data.get("hooks", {}) - assert "Stop" not in data.get("hooks", {}) - - -# -- New adapter tests ----------------------------------------------------- - -class TestGeminiHookAdapter: - - def test_generate_fragment_uses_native_names(self): - adapter = GeminiHookAdapter() - hooks = { - "PreToolUse": {"command": "speckit.test", "matcher": "Edit|Write", "timeout": 10}, - } - fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp")) - assert fmt == "json" - assert "BeforeTool" in fragment["hooks"] - assert "PreToolUse" not in fragment["hooks"] - - def test_install_creates_settings(self, tmp_path): - from specify_cli.integrations.manifest import IntegrationManifest - adapter = GeminiHookAdapter() - manifest = IntegrationManifest("gemini", tmp_path) - hooks = {"PostToolUse": {"command": "speckit.test", "matcher": "Edit|Write", "timeout": 60}} - created = adapter.install(tmp_path, manifest, hooks) - settings = tmp_path / ".gemini" / "settings.json" - assert settings.exists() - data = json.loads(settings.read_text()) - assert "AfterTool" in data["hooks"] - - -class TestQwenHookAdapter: - - def test_generate_fragment_identity(self): - adapter = QwenHookAdapter() - hooks = { - "PreToolUse": {"command": "speckit.test", "matcher": "Edit|Write", "timeout": 10}, - } - fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp")) - assert "PreToolUse" in fragment["hooks"] - - -class TestDevinHookAdapter: - - def test_install_creates_hooks_file(self, tmp_path): - from specify_cli.integrations.manifest import IntegrationManifest - adapter = DevinHookAdapter() - manifest = IntegrationManifest("devin", tmp_path) - hooks = {"PostToolUse": {"command": "speckit.test", "matcher": "Edit|Write", "timeout": 60}} - created = adapter.install(tmp_path, manifest, hooks) - hooks_file = tmp_path / ".devin" / "hooks.v1.json" - assert hooks_file.exists() - data = json.loads(hooks_file.read_text()) - assert "PostToolUse" in data["hooks"] - - -class TestTabnineHookAdapter: - - def test_generate_fragment_native_names(self): - adapter = TabnineHookAdapter() - hooks = { - "PreToolUse": {"command": "speckit.test", "matcher": "Edit|Write", "timeout": 10}, - } - fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp")) - assert "BeforeTool" in fragment["hooks"] - - -# -- ClaudeHookAdapter ---------------------------------------------------- - -class TestClaudeHookAdapter: - """Test Claude Code native config generation and merge.""" - - def test_generate_fragment_structure(self): - adapter = ClaudeHookAdapter() - hooks = { - "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, - } - fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp")) - assert fmt == "json" - assert "hooks" in fragment - assert "PostToolUse" in fragment["hooks"] - entry_group = fragment["hooks"]["PostToolUse"][0] - assert entry_group["matcher"] == "Edit|Write" - handler = entry_group["hooks"][0] - assert handler["type"] == "command" - assert handler["command"] == "python3" - assert "speckit.tdd.validate" in handler["args"] - assert "PostToolUse" in handler["args"] - assert handler["timeout"] == 60 - - def test_merge_into_empty_file(self, tmp_path): - adapter = ClaudeHookAdapter() - config_path = tmp_path / ".claude" / "settings.json" - config_path.parent.mkdir(parents=True) - hooks = { - "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, - } - fmt, fragment = adapter.generate_fragment(hooks, tmp_path) - adapter.merge_fragment(config_path, fragment, format=fmt) - data = json.loads(config_path.read_text()) - assert "hooks" in data - assert "PostToolUse" in data["hooks"] - assert len(data["hooks"]["PostToolUse"]) == 1 - - def test_merge_preserves_user_keys(self, tmp_path): - adapter = ClaudeHookAdapter() - config_path = tmp_path / ".claude" / "settings.json" - config_path.parent.mkdir(parents=True) - config_path.write_text(json.dumps({ - "model": "claude-sonnet-4-20250514", - "permissions": {"allow": ["Bash(git *)"]}, - "hooks": { - "PostToolUse": [{ - "matcher": "Bash", - "hooks": [{"type": "command", "command": "/usr/bin/my-hook.sh"}], - }], - }, - })) - hooks = { - "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, - } - fmt, fragment = adapter.generate_fragment(hooks, tmp_path) - adapter.merge_fragment(config_path, fragment, format=fmt) - data = json.loads(config_path.read_text()) - assert data["model"] == "claude-sonnet-4-20250514" - assert data["permissions"]["allow"] == ["Bash(git *)"] - assert len(data["hooks"]["PostToolUse"]) == 2 # user hook + our hook - - def test_remove_entries_preserves_user_hooks(self, tmp_path): - adapter = ClaudeHookAdapter() - config_path = tmp_path / ".claude" / "settings.json" - config_path.parent.mkdir(parents=True) - config_path.write_text(json.dumps({ - "hooks": { - "PostToolUse": [ - {"matcher": "Bash", "hooks": [{"type": "command", "command": "/usr/bin/my-hook.sh"}]}, - {"matcher": "Edit|Write", "hooks": [{"type": "command", "command": "python3", - "args": [".specify/hooks/bridge.py", "speckit.tdd.validate", "PostToolUse"], - "__speckit_hook__": True}]}, - ], - }, - })) - adapter.remove_entries(config_path) - data = json.loads(config_path.read_text()) - assert len(data["hooks"]["PostToolUse"]) == 1 - assert data["hooks"]["PostToolUse"][0]["matcher"] == "Bash" - - -# -- CursorHookAdapter ---------------------------------------------------- - -class TestCursorHookAdapter: - """Test Cursor native config generation.""" - - def test_generate_fragment_camelcase(self): - adapter = CursorHookAdapter() - hooks = { - "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, - } - fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp")) - assert "postToolUse" in fragment["hooks"] - entry = fragment["hooks"]["postToolUse"][0] - assert "python3" in entry["command"] - assert entry["matcher"] == "Edit|Write" - - -# -- CodexHookAdapter ----------------------------------------------------- - -class TestCodexHookAdapter: - """Test Codex TOML config generation.""" - - def test_generate_fragment_toml(self): - adapter = CodexHookAdapter() - hooks = { - "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, - } - fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp")) - assert fmt == "toml" - assert "[[hooks.PostToolUse]]" in fragment - assert 'matcher = "Edit|Write"' in fragment - assert "speckit.tdd.validate" in fragment - assert "speckit_marker = true" in fragment - - -# -- OpencodeHookAdapter -------------------------------------------------- - -class TestOpencodeHookAdapter: - """Test opencode TS plugin generation.""" - - def test_install_generates_plugin_and_merges_config(self, tmp_path): - adapter = OpencodeHookAdapter() - manifest = MagicMock(spec=IntegrationManifest) - manifest.files = {} - manifest.record_file = MagicMock() - manifest.record_existing = MagicMock() - hooks = { - "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, - } - created = adapter.install(tmp_path, manifest, hooks) - plugin_path = tmp_path / ".opencode/plugin/speckit-hooks.ts" - assert plugin_path.exists() - content = plugin_path.read_text() - assert "tool.execute.after" in content - assert "speckit.tdd.validate" in content - config_path = tmp_path / "opencode.json" - assert config_path.exists() - config = json.loads(config_path.read_text()) - assert "./.opencode/plugin/speckit-hooks.ts" in config["plugin"] - - def test_remove_removes_plugin_ref(self, tmp_path): - adapter = OpencodeHookAdapter() - config_path = tmp_path / "opencode.json" - config_path.write_text(json.dumps({ - "plugin": ["./.opencode/plugin/speckit-hooks.ts", "./other.ts"], - })) - adapter.remove_entries(config_path) - config = json.loads(config_path.read_text()) - assert "./.opencode/plugin/speckit-hooks.ts" not in config["plugin"] - assert "./other.ts" in config["plugin"] - - -# -- Helpers -------------------------------------------------------------- - -class TestHelpers: - def test_has_marker_true(self): - assert _has_marker({"__speckit_hook__": True, "command": "foo"}) - - def test_has_marker_false(self): - assert not _has_marker({"command": "foo"}) - - def test_to_camel_case(self): - assert _to_camel_case("PostToolUse") == "postToolUse" - assert _to_camel_case("PreToolUse") == "preToolUse" - assert _to_camel_case("Stop") == "stop" - assert _to_camel_case("") == "" - - -# -- hooks_stale_exclusions ----------------------------------------------- - -class TestStaleExclusions: - def test_claude_returns_settings_path(self): - exclusions = hooks_stale_exclusions("claude") - assert ".claude/settings.json" in exclusions - - def test_opencode_returns_config_and_plugin(self): - exclusions = hooks_stale_exclusions("opencode") - assert "opencode.json" in exclusions - assert ".opencode/plugin/speckit-hooks.ts" in exclusions - - def test_gemini_returns_settings_path(self): - exclusions = hooks_stale_exclusions("gemini") - assert ".gemini/settings.json" in exclusions - - def test_copilot_returns_empty(self): - assert hooks_stale_exclusions("copilot") == set() - - -# -- Integration: install + remove round-trip ----------------------------- - -class TestInstallRemoveRoundTrip: - """End-to-end install → remove for Claude adapter.""" - - def test_claude_install_then_remove(self, tmp_path): - from specify_cli.integrations._hooks import HOOK_BRIDGE_REL - - manifest = IntegrationManifest("claude", tmp_path) - adapter = ClaudeHookAdapter() - hooks = { - "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, - } - created = adapter.install(tmp_path, manifest, hooks) - assert len(created) == 2 # bridge + settings.json - bridge = tmp_path / HOOK_BRIDGE_REL - assert bridge.exists() - settings = tmp_path / ".claude/settings.json" - assert settings.exists() - data = json.loads(settings.read_text()) - assert "PostToolUse" in data["hooks"] - - # Remove - adapter.remove(tmp_path, manifest) - data = json.loads(settings.read_text()) - # Our entries removed, but file still exists - assert "hooks" not in data or "PostToolUse" not in data.get("hooks", {}) - - def test_group_b_no_crash(self, tmp_path): - """Installing hooks for a Group B agent (no adapter) is a no-op.""" - mock_integration = MagicMock() - mock_integration.key = "copilot" - mock_integration.config = {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate"}}} - manifest = IntegrationManifest("gemini", tmp_path) - result = install_integration_hooks(mock_integration, tmp_path, manifest, None) - assert result == [] - - def test_upgrade_removes_stale_events(self, tmp_path): - """Upgrading from hook set A to hook set B removes stale events.""" - mock_integration = MagicMock() - mock_integration.key = "claude" - mock_integration.config = None - (tmp_path / ".specify").mkdir(parents=True) - - # v1: install PostToolUse only - mock_integration.config = {"hooks": { - "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, - }} - manifest_v1 = IntegrationManifest("claude", tmp_path) - install_integration_hooks(mock_integration, tmp_path, manifest_v1, None) - settings = tmp_path / ".claude" / "settings.json" - data = json.loads(settings.read_text()) - assert "PostToolUse" in data.get("hooks", {}) - - # v2: upgrade to PreToolUse + Stop (no PostToolUse) - mock_integration.config = {"hooks": { - "PreToolUse": {"command": "speckit.protected_paths", "matcher": "Edit|Write", "timeout": 10}, - "Stop": {"command": "speckit.tdd.validate", "matcher": "*", "timeout": 30}, - }} - manifest_v2 = IntegrationManifest("claude", tmp_path) - install_integration_hooks(mock_integration, tmp_path, manifest_v2, None) - data2 = json.loads(settings.read_text()) - hooks_v2 = data2.get("hooks", {}) - assert "PostToolUse" not in hooks_v2, "Stale PostToolUse should be removed on upgrade" - assert "PreToolUse" in hooks_v2 - assert "Stop" in hooks_v2 - - def test_upgrade_preserves_user_hooks(self, tmp_path): - """User-defined hooks survive upgrade.""" - mock_integration = MagicMock() - mock_integration.key = "claude" - mock_integration.config = None - (tmp_path / ".specify").mkdir(parents=True) - - # Pre-existing user hook - settings_dir = tmp_path / ".claude" - settings_dir.mkdir(parents=True) - (settings_dir / "settings.json").write_text(json.dumps({ - "hooks": { - "PostToolUse": [{ - "matcher": "Bash", - "hooks": [{"type": "command", "command": "/usr/bin/my-linter.sh"}], - }], - }, - })) - - # Install our hook - mock_integration.config = {"hooks": { - "PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60}, - }} - manifest = IntegrationManifest("claude", tmp_path) - install_integration_hooks(mock_integration, tmp_path, manifest, None) - data = json.loads((settings_dir / "settings.json").read_text()) - post_hooks = data["hooks"]["PostToolUse"] - assert len(post_hooks) == 2 # user hook + our hook - - # Upgrade: change our hook to PreToolUse only - mock_integration.config = {"hooks": { - "PreToolUse": {"command": "speckit.protected_paths", "matcher": "Edit|Write", "timeout": 10}, - }} - manifest_v2 = IntegrationManifest("claude", tmp_path) - install_integration_hooks(mock_integration, tmp_path, manifest_v2, None) - data2 = json.loads((settings_dir / "settings.json").read_text()) - hooks2 = data2.get("hooks", {}) - # User's PostToolUse hook should survive - assert "PostToolUse" in hooks2 - user_entry = hooks2["PostToolUse"][0] - assert user_entry["matcher"] == "Bash" - # Our new PreToolUse should be present - assert "PreToolUse" in hooks2 From 6ec91bb2069dbdcc025bce6ac270ea2c95c19a63 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Mon, 27 Jul 2026 16:15:07 +0300 Subject: [PATCH 03/20] fix(events): resolve ruff lint errors blocking CI Address Copilot review finding #18 (src/specify_cli/__init__.py event-command import missing # noqa: E402), #19 (unused console import in commands/event.py), and #20 (unused patch/yaml/Path/integration imports in test_events.py). Also fix two stray F541 f-string prefixes in _build_opencode_plugin that ruff flagged in the same job. Bump dev version 0.14.2.dev0 -> 0.14.2.dev1 and add a CHANGELOG entry per the AGENTS.md convention for Specify CLI __init__.py changes. Refs: PR #3704 Copilot inline review (findings #18, #19, #20) Assisted-by: opencode (model: glm-5.2, autonomous) --- CHANGELOG.md | 11 +++++++++++ pyproject.toml | 2 +- src/specify_cli/__init__.py | 2 +- src/specify_cli/commands/event.py | 2 -- src/specify_cli/events.py | 4 ++-- tests/integrations/test_events.py | 11 +---------- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c21f1329..34782622c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ +## [Unreleased] + +### Fixed + +- fix(events): resolve ruff lint errors in events module (E402, F401, F541) — + add `# noqa: E402` to event-command registration import, remove unused + `console` import from `commands/event.py`, remove unused imports from + `tests/integrations/test_events.py`, and drop stray f-string prefixes in the + opencode plugin builder. Unblocks the CI ruff job for the agent-runtime + events feature (#3704). + ## [0.14.1] - 2026-07-23 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 60eda21d85..8cb8bd32b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.14.2.dev0" +version = "0.14.2.dev1" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index c74db40875..fcf2d8f1be 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -510,7 +510,7 @@ def version( # ===== Event Commands ===== -from .commands.event import register as _register_event_cmds +from .commands.event import register as _register_event_cmds # noqa: E402 _register_event_cmds(app) # Re-export selected helpers to preserve the public import surface. diff --git a/src/specify_cli/commands/event.py b/src/specify_cli/commands/event.py index bca6877ddf..fa8d196d16 100644 --- a/src/specify_cli/commands/event.py +++ b/src/specify_cli/commands/event.py @@ -6,8 +6,6 @@ import sys import typer -from .._console import console - event_app = typer.Typer( name="event", help="Manage and execute event-driven commands", diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index a4f126aa42..1749f12167 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -620,9 +620,9 @@ def _build_opencode_plugin(filtered_events: dict[str, dict[str, Any]], canonical if event_handlers: plugin_returns.append( - f" event: async ({{ event }}) => {{\n" + " event: async ({ event }) => {\n" + "\n".join(event_handlers) + "\n" - f" }}," + " }," ) return _TS_PLUGIN_TEMPLATE.format( diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 5509f56ba3..44a9c8c52a 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -4,32 +4,23 @@ import json import platform -from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest -import yaml from specify_cli.events import ( CANONICAL_EVENTS, collect_extension_events, - events_stale_exclusions, install_integration_events, remove_integration_events, resolve_events, validate_events, resolve_and_run_event_command, ) -from specify_cli.integrations.base import IntegrationBase from specify_cli.integrations.manifest import IntegrationManifest from specify_cli.integrations.claude import ClaudeIntegration from specify_cli.integrations.cursor_agent import CursorAgentIntegration -from specify_cli.integrations.codex import CodexIntegration from specify_cli.integrations.opencode import OpencodeIntegration -from specify_cli.integrations.qwen import QwenIntegration -from specify_cli.integrations.gemini import GeminiIntegration -from specify_cli.integrations.devin import DevinIntegration -from specify_cli.integrations.tabnine import TabnineIntegration from specify_cli.integrations.copilot import CopilotIntegration From 61f0a45e975b79c37412e98d11db851331e9997b Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Mon, 27 Jul 2026 16:38:32 +0300 Subject: [PATCH 04/20] fix(events): make generated native hooks actually execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review findings that left generated event hooks inert or schema-invalid after the rework: - #2: the resolved events map now carries an ordered list of handlers per event (dict[str, list[dict]]) so two extensions declaring the same event both run instead of the last one silently winning. collect_extension_events accumulates; every adapter emits one native entry per handler. - #6: Claude/Gemini/Qwen/Devin/Tabnine native schema accepts a single 'command' string, not command+args. Each adapter now renders one complete shell invocation of the dispatcher via _dispatcher_command(). - #7: Gemini measures hook timeouts in milliseconds; add events_timeout_unit attr and _native_timeout() so the 60s default becomes 60000ms instead of terminating the dispatcher after 60ms. - #4: _resolve_event_command_argv() replaces _extract_script_path() — scripts: values are command strings (e.g. 'scripts/bash/setup-plan.sh --json'), not bare paths. Resolves the project's sh/ps/py variant, splits safely into argv, and prepends the interpreter for .py. - #5: bundled-template fallback now uses _locate_core_pack()/_repo_root() (core_pack/commands, not the non-existent core_pack/templates/commands). - #16: all formatters use IntegrationBase.resolve_python_interpreter() so generated commands honor the project venv and never hard-code python3 (absent on Windows). The opencode TS plugin bakes in the same resolved interpreter. - #13: opencode TS plugin runEvent() now throws on failure instead of process.exit(2), which killed the OpenCode host process; only the failing hook is rejected. - #21: user YAML override is validated (event names, non-empty command strings) before returning; a malformed override is warned about and ignored rather than crashing installation on cfg.get(). Bump dev version 0.14.2.dev1 -> 0.14.2.dev2 (gemini/__init__.py change) and add a CHANGELOG entry. Refs: PR #3704 Copilot inline review (findings #2, #4, #5, #6, #7, #13, #16, #21) Assisted-by: opencode (model: glm-5.2, autonomous) --- CHANGELOG.md | 12 + pyproject.toml | 2 +- src/specify_cli/events.py | 522 +++++++++++++----- .../integrations/gemini/__init__.py | 5 + tests/integrations/test_events.py | 122 +++- 5 files changed, 530 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34782622c8..0d1287a9a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ `tests/integrations/test_events.py`, and drop stray f-string prefixes in the opencode plugin builder. Unblocks the CI ruff job for the agent-runtime events feature (#3704). +- fix(events): make generated native hooks actually execute. The resolved + events map now carries an ordered list of handlers per event so two + extensions declaring the same event both run (#2). Each adapter renders a + single complete shell `command` string (Claude/Gemini/Qwen/Devin/Tabnine + reject `command`+`args`) using a portably-resolved Python interpreter + instead of hard-coded `python3` (#6, #16). Gemini timeouts are converted + from seconds to milliseconds (#7). The core command runner resolves the + project's sh/ps/py variant and splits the script command into argv instead + of treating `scripts:` strings as bare paths (#4), and the bundled-template + fallback now uses the canonical `_assets` resolvers (#5). OpenCode TS + plugin failures propagate via `throw new Error` instead of + `process.exit(2)`, which killed the host agent (#13). ## [0.14.1] - 2026-07-23 diff --git a/pyproject.toml b/pyproject.toml index 8cb8bd32b3..27ffecb013 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.14.2.dev1" +version = "0.14.2.dev2" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 1749f12167..645fdb1457 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -10,7 +10,9 @@ import json import logging +import os import re +import shlex import sys import subprocess import platform @@ -125,14 +127,17 @@ def main(): const DISPATCHER = path.join(process.cwd(), '.specify', 'events.py'); function runEvent(command: string, event: string, input: any): void {{ + let result; try {{ - execSync(`python3 ${{DISPATCHER}} ${{command}} ${{event}}`, {{ + result = execSync(`{interpreter} ${{DISPATCHER}} ${{command}} ${{event}}`, {{ input: JSON.stringify(input), stdio: ['pipe', 'inherit', 'inherit'], timeout: 60000, }}); }} catch (e) {{ - process.exit(2); + // Propagate to OpenCode's hook machinery so only this hook is rejected, + // not the entire host process. process.exit() would kill the agent. + throw new Error(`specify event ${{command}} (${{event}}) failed: ${{(e as Error).message}}`); }} }} @@ -174,7 +179,7 @@ def _find_command_template(command_name: str, project_root: Path) -> tuple[Path if f.stem == command_name: return f, ext_dir.name - # 3. Check core templates + # 3. Check core templates in the project core = project_root / ".specify" / "templates" / "commands" if core.is_dir(): stem = command_name.replace("speckit.", "").replace("spec.", "") @@ -182,23 +187,43 @@ def _find_command_template(command_name: str, project_root: Path) -> tuple[Path if candidate.exists(): return candidate, None - # Fallback to package bundled templates - try: - import inspect - pkg_dir = Path(inspect.getfile(validate_events)).resolve().parent - core_pack = pkg_dir / "core_pack" / "templates" / "commands" - if core_pack.is_dir(): - stem = command_name.replace("speckit.", "").replace("spec.", "") - candidate = core_pack / f"{stem}.md" - if candidate.exists(): - return candidate, None - except Exception: - pass + # 4. Fallback to package-bundled templates via the canonical asset + # resolvers (wheel: core_pack/commands; source: repo-root + # templates/commands). The previous bespoke inspect.getfile() math + # pointed at core_pack/templates/commands, which never exists in a + # wheel build (force-include maps templates/commands -> core_pack/commands). + from ._assets import _locate_core_pack, _repo_root + core_pack = _locate_core_pack() + candidate_dirs = [ + core_pack / "commands" if core_pack is not None else None, + _repo_root() / "templates" / "commands", + ] + stem = command_name.replace("speckit.", "").replace("spec.", "") + for candidate_dir in candidate_dirs: + if candidate_dir is None or not candidate_dir.is_dir(): + continue + candidate = candidate_dir / f"{stem}.md" + if candidate.exists(): + return candidate, None return None, None -def _extract_script_path(template_path: Path, project_root: Path, ext_id: str | None) -> str | None: +def _resolve_event_command_argv( + template_path: Path, project_root: Path, ext_id: str | None +) -> list[str] | None: + """Resolve a command template's ``scripts:`` entry to a runnable argv. + + ``scripts:`` values are command strings (e.g. ``scripts/bash/setup-plan.sh --json``), + not bare paths, so joining the whole value into a ``Path`` made ``exists()`` + false and real commands silently no-op'd. This resolves the stored variant + (honoring the project's sh/ps/py selection), splits the command string + safely into argv, and prepends the appropriate interpreter (Python for + ``.py``, the platform shell otherwise). Returns ``None`` if no runnable + script is declared. + """ + from .integrations.base import IntegrationBase + content = template_path.read_text(encoding="utf-8") m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) if not m: @@ -213,15 +238,66 @@ def _extract_script_path(template_path: Path, project_root: Path, ext_id: str | scripts = fm_data.get("scripts", {}) if not isinstance(scripts, dict): return None - default = "ps" if platform.system().lower().startswith("win") else "sh" - script_rel = scripts.get(default) or scripts.get("sh") or scripts.get("py") - if not script_rel: + # Determine the requested variant from the project's persisted selection, + # falling back to the platform default — same logic MarkdownIntegration + # uses for command scaffolding. + requested = _load_project_script_type(project_root) + try: + variant = IntegrationBase.select_script_variant(requested, scripts) + except ValueError: + return None + script_cmd = scripts.get(variant) + if not isinstance(script_cmd, str) or not script_cmd.strip(): return None + + # Resolve the script's project-relative base so a leading path component + # (e.g. ``scripts/bash/setup-plan.sh``) is anchored under .specify/ (core) + # or .specify/extensions// (extension). + if variant == "py": + # .py files aren't directly executable on Windows; prefix the resolved + # interpreter. build_python_invocation returns a shell-quoted command + # string, so split it back into argv for subprocess. + invocation = IntegrationBase.build_python_invocation(script_cmd, project_root) + try: + return shlex.split(invocation, posix=(os.name != "nt")) + except ValueError: + return None + + # sh / ps: the command string is " [args...]". Resolve the + # leading path component against the project, then rejoin with the + # remaining tokens. + tokens = shlex.split(script_cmd, posix=(os.name != "nt")) + if not tokens: + return None + script_rel = tokens[0] if ext_id: - script_abs = project_root / ".specify" / "extensions" / ext_id / script_rel + base = project_root / ".specify" / "extensions" / ext_id else: - script_abs = project_root / ".specify" / script_rel - return str(script_abs) if script_abs.exists() else None + base = project_root / ".specify" + script_abs = base / script_rel + if not script_abs.exists(): + return None + return [str(script_abs), *tokens[1:]] + + +def _load_project_script_type(project_root: Path) -> str: + """Return the project's persisted script type ('sh'|'ps'|'py'). + + Falls back to the platform default when init-options are absent or + unreadable so event dispatch still works in a partially-initialized + project. + """ + default = "ps" if platform.system().lower().startswith("win") else "sh" + try: + from ._init_options import load_init_options + opts = load_init_options(project_root) + if isinstance(opts, dict): + script = opts.get("script") + if isinstance(script, str) and script in ("sh", "ps", "py"): + return script + except Exception: + pass + return default def resolve_and_run_event_command(command_name: str, event_name: str, payload: str, project_root: Path) -> int: @@ -230,13 +306,13 @@ def resolve_and_run_event_command(command_name: str, event_name: str, payload: s if not template_path: logger.warning("Event command '%s' not found", command_name) return 0 - script_path = _extract_script_path(template_path, project_root, ext_id) - if not script_path: + argv = _resolve_event_command_argv(template_path, project_root, ext_id) + if not argv: logger.warning("No script found for event command '%s'", command_name) return 0 try: result = subprocess.run( - [script_path], + argv, input=payload, capture_output=True, text=True, @@ -259,29 +335,98 @@ def resolve_and_run_event_command(command_name: str, event_name: str, payload: s # -- Sourcing events map (CLI/Orchestration domain) ------------------------- +# Resolved events map: each canonical event name maps to an *ordered list* of +# handler configs. Built-in defaults and per-extension declarations both +# contribute, so two extensions declaring ``session_start`` both run (finding +# #2) instead of the last one silently winning. +ResolvedEvents = dict[str, list[dict[str, Any]]] + + +def _normalize_handlers(value: Any) -> list[dict[str, Any]]: + """Coerce a single handler config or a list of them into a validated list. + + Accepts both the legacy single-mapping shape (``{command: ...}``) and the + explicit list shape (``[{command: ...}, ...]``). Drops any entry that is + not a mapping or lacks a ``command`` with a warning, so a malformed user + override never reaches installation and crashes on ``cfg.get(...)`` (#21). + """ + if isinstance(value, dict): + value = [value] + if not isinstance(value, list): + return [] + handlers: list[dict[str, Any]] = [] + for entry in value: + if not isinstance(entry, dict): + logger.warning("Skipping malformed event handler (expected a mapping): %r", entry) + continue + handlers.append(entry) + return handlers + + +def _validate_resolved_event(event_name: str, handlers: list[dict[str, Any]]) -> None: + """Validate a resolved event's handlers, raising a user-facing error. + + Raised for structural problems the user must fix (unknown event name, + handler missing a ``command``, or ``command`` not a non-empty string per + #17). Malformed-but-skipable entries are already dropped by + ``_normalize_handlers``. + """ + from .extensions import ValidationError + + if event_name not in CANONICAL_EVENTS: + raise ValidationError( + f"Unknown event '{event_name}': must be one of {sorted(CANONICAL_EVENTS)}" + ) + for handler in handlers: + command = handler.get("command") + if not isinstance(command, str) or not command.strip(): + raise ValidationError( + f"Event '{event_name}' handler missing required non-empty 'command' string" + ) + + def resolve_events( integration_key: str, integration_config: dict[str, Any] | None, project_root: Path, parsed_options: dict[str, Any] | None, -) -> dict[str, dict[str, Any]]: - """Resolve the final event set for an integration.""" +) -> ResolvedEvents: + """Resolve the final event set for an integration. + + Returns a mapping of canonical event name → ordered list of handler + configs. Layers (lowest → highest precedence): + + 1. CLI gate ``--events false`` → empty map (caller still removes prior + native hooks; see ``install_integration_events``). + 2. Built-in defaults from ``integration_config["events"]`` (single-config + per event, wrapped as one-element lists). + 3. Extension-declared ``events:`` — appended per extension so multiple + extensions can declare the same event (#2). + 4. User YAML override (``.specify/integration-events.yml``) — replaces the + accumulated set entirely when the integration key is present. Validated + (#21) before returning; a malformed override is warned about and + ignored rather than crashing downstream. + """ # Layer 1: CLI flag gate if parsed_options: events_flag = str(parsed_options.get("events", "true")).lower() if events_flag in ("false", "0", "no", "off"): return {} - # Layer 4: built-in defaults from integration config - events: dict[str, dict[str, Any]] = {} + events: ResolvedEvents = {} + + # Layer 2: built-in defaults from integration config if integration_config and isinstance(integration_config.get("events"), dict): - events = dict(integration_config["events"]) + for ev, cfg in integration_config["events"].items(): + handlers = _normalize_handlers(cfg) + if handlers: + events.setdefault(ev, []).extend(handlers) - # Layer 3: extension-declared events - ext_events = collect_extension_events(project_root) - events.update(ext_events) + # Layer 3: extension-declared events (accumulated, not overwriting) + for ev, handlers in collect_extension_events(project_root).items(): + events.setdefault(ev, []).extend(handlers) - # Layer 2: user YAML override (replaces entirely if key present) + # Layer 4: user YAML override (replaces entirely if key present) override_file = project_root / YAML_OVERRIDE_FILENAME if override_file.exists(): try: @@ -290,19 +435,47 @@ def resolve_events( logger.warning("Could not parse %s; ignoring override", override_file) override = {} integrations = override.get("integrations", {}) if isinstance(override, dict) else {} - if integration_key in integrations: + if isinstance(integrations, dict) and integration_key in integrations: key_data = integrations[integration_key] - if isinstance(key_data, dict): - events = key_data.get("events", {}) or {} + key_events = key_data.get("events", {}) if isinstance(key_data, dict) else {} + if not isinstance(key_events, dict): + logger.warning( + "Override %s: 'events' for '%s' is not a mapping; ignoring override", + override_file, integration_key, + ) else: - events = {} + resolved_override: ResolvedEvents = {} + for ev, raw in key_events.items(): + handlers = _normalize_handlers(raw) + if not handlers: + logger.warning( + "Override %s: event '%s' has no valid handler; skipping entry", + override_file, ev, + ) + continue + try: + _validate_resolved_event(ev, handlers) + except Exception as exc: + logger.warning( + "Override %s: invalid event '%s': %s; ignoring override", + override_file, ev, exc, + ) + resolved_override = {} + break + resolved_override[ev] = handlers + events = resolved_override return events -def collect_extension_events(project_root: Path) -> dict[str, dict[str, Any]]: - """Scan all installed extensions for ``events:`` declarations.""" - events: dict[str, dict[str, Any]] = {} +def collect_extension_events(project_root: Path) -> ResolvedEvents: + """Scan all installed extensions for ``events:`` declarations. + + Returns a mapping of event name → list of handler configs. Multiple + extensions declaring the same event each contribute a handler (in + extension-directory sort order), so callers can emit all of them (#2). + """ + events: ResolvedEvents = {} exts_dir = project_root / ".specify" / "extensions" if not exts_dir.is_dir(): return events @@ -323,29 +496,86 @@ def collect_extension_events(project_root: Path) -> dict[str, dict[str, Any]]: if not isinstance(runtime, dict): continue for event, config in runtime.items(): - if isinstance(config, dict): - events[event] = config + handlers = _normalize_handlers(config) + if handlers: + events.setdefault(event, []).extend(handlers) return events # -- Writing/Merging Config (Integration domain) --------------------------- +def _resolve_interpreter(project_root: Path) -> str: + """Resolve a portable Python interpreter for native hook commands (#16). + + Delegates to ``IntegrationBase.resolve_python_interpreter`` so generated + commands honor the project venv and never hard-code ``python3`` (which is + commonly absent on Windows even when ``py.exe``/``python.exe`` exist). + """ + from .integrations.base import IntegrationBase + return IntegrationBase.resolve_python_interpreter(project_root) + + +def _native_timeout(integration: IntegrationBase, timeout_seconds: Any) -> int: + """Return the timeout in the unit the integration's native config expects. + + Claude/Cursor/Codex/Copilot measure timeouts in seconds; Gemini measures + in milliseconds (#7). An integration declares its unit via + ``events_timeout_unit`` (``"s"`` default, ``"ms"`` for Gemini). + """ + try: + seconds = int(timeout_seconds) + except (TypeError, ValueError): + seconds = 60 + if getattr(integration, "events_timeout_unit", "s") == "ms": + return seconds * 1000 + return seconds + + +def _dispatcher_command( + integration: IntegrationBase, + project_root: Path, + command_name: str, + event_name: str, +) -> str: + """Build the single shell command string that invokes the dispatcher (#6). + + Claude/Gemini/Qwen/Devin/Tabnine accept one ``command`` string (not a + ``command``+``args`` split), so each adapter renders a complete invocation: + `` ``. The interpreter is + resolved portably (#16); Claude's dispatcher path is prefixed with + ``${CLAUDE_PROJECT_DIR}/`` (Claude expands it before shell execution). + """ + interpreter = _resolve_interpreter(project_root) + if integration.key == "claude": + dispatcher = "${CLAUDE_PROJECT_DIR}/" + EVENTS_DISPATCHER_REL + else: + dispatcher = EVENTS_DISPATCHER_REL + return f"{interpreter} {dispatcher} {command_name} {event_name}" + + def install_integration_events( integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest, - events: dict[str, dict[str, Any]], + events: ResolvedEvents, ) -> list[Path]: - """Generate dispatcher, merge native config, return created files.""" + """Generate dispatcher, merge native config, return created files. + + ``events`` maps each canonical event to an ordered list of handler configs + (#2); every handler is emitted as a separate native hook entry so two + extensions declaring ``session_start`` both run. + """ canonical_to_native = getattr(integration, "CANONICAL_TO_NATIVE", {}) if not canonical_to_native: return [] - # Filter to only supported events - filtered: dict[str, dict[str, Any]] = {} - for ev, cfg in events.items(): + # Filter to only supported events, preserving all handlers per event. + filtered: ResolvedEvents = {} + for ev, handlers in events.items(): + if not isinstance(handlers, list): + continue if ev in canonical_to_native: - filtered[ev] = cfg + filtered[ev] = handlers else: print( f"\u26a0\ufe0f {integration.key} does not support '{ev}' events; skipping", @@ -382,7 +612,10 @@ def install_integration_events( plugin_rel = ".opencode/plugin/speckit-events.ts" plugin_path = project_root / plugin_rel plugin_path.parent.mkdir(parents=True, exist_ok=True) - plugin_path.write_text(_build_opencode_plugin(filtered, canonical_to_native), encoding="utf-8") + plugin_path.write_text( + _build_opencode_plugin(filtered, canonical_to_native, _resolve_interpreter(project_root)), + encoding="utf-8", + ) manifest.record_file( plugin_rel, plugin_path.read_bytes(), @@ -397,19 +630,26 @@ def install_integration_events( created.append(config_path) elif fmt == "copilot-json": - # Copilot hooks JSON write (creates dedicated .github/hooks/speckit.json) - copilot_hooks = {} - for ev, cfg in filtered.items(): + # Copilot hooks JSON write (dedicated .github/hooks/speckit.json). + # Each handler becomes its own entry in the native event's list (#2), + # with bash/powershell variants sharing one resolved interpreter (#16). + copilot_hooks: dict[str, list[dict[str, Any]]] = {} + for ev, handlers in filtered.items(): native = canonical_to_native[ev] - command = cfg.get("command", "") - copilot_hooks[native] = [ - { - "type": "command", - "bash": f"python3 {EVENTS_DISPATCHER_REL} {command} {ev}", - "powershell": f"python3 {EVENTS_DISPATCHER_REL} {command} {ev}", - "timeoutSec": cfg.get("timeout", 60), - } - ] + entries: list[dict[str, Any]] = [] + for cfg in handlers: + command = cfg.get("command", "") + dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev) + entries.append( + { + "type": "command", + "bash": dispatcher_cmd, + "powershell": dispatcher_cmd, + "timeoutSec": _native_timeout(integration, cfg.get("timeout", 60)), + _SPECKIT_MARKER: True, + } + ) + copilot_hooks[native] = entries fragment = {"version": 1, "hooks": copilot_hooks} config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text(json.dumps(fragment, indent=2) + "\n", encoding="utf-8") @@ -419,21 +659,23 @@ def install_integration_events( created.append(config_path) elif fmt == "toml": - # Codex config.toml custom merge + # Codex config.toml custom merge. One [[hooks..hooks]] block + # per handler so multiple handlers per event all emit (#2). lines: list[str] = [] - for ev, cfg in filtered.items(): + for ev, handlers in filtered.items(): native = canonical_to_native[ev] - timeout = cfg.get("timeout", 60) - command = cfg.get("command", "") - lines.append(f'[[hooks.{native}]]') - lines.append(f'matcher = "{cfg.get("matcher", "*")}"') - lines.append('') - lines.append(f'[[hooks.{native}.hooks]]') - lines.append('type = "command"') - lines.append(f'command = \'python3 {EVENTS_DISPATCHER_REL} {command} {ev}\'') - lines.append(f'timeout = {timeout}') - lines.append('speckit_marker = true') - lines.append('') + for cfg in handlers: + command = cfg.get("command", "") + dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev) + lines.append(f'[[hooks.{native}]]') + lines.append(f'matcher = "{cfg.get("matcher", "*")}"') + lines.append('') + lines.append(f'[[hooks.{native}.hooks]]') + lines.append('type = "command"') + lines.append(f'command = {_toml_quote(dispatcher_cmd)}') + lines.append(f'timeout = {_native_timeout(integration, cfg.get("timeout", 60))}') + lines.append('speckit_marker = true') + lines.append('') _merge_toml_fragment(config_path, "\n".join(lines)) rel = str(config_path.relative_to(project_root)) if rel not in manifest.files: @@ -441,20 +683,25 @@ def install_integration_events( created.append(config_path) elif fmt == "json-flat": - # Cursor hooks.json custom merge - cursor_hooks = {} - for ev, cfg in filtered.items(): + # Cursor hooks.json custom merge. Flat command-string entries, one + # per handler (#2), single resolved command string (#6/#16). + cursor_hooks: dict[str, list[dict[str, Any]]] = {} + for ev, handlers in filtered.items(): native = canonical_to_native[ev] - command = cfg.get("command", "") - cursor_hooks[native] = [ - { - "command": f"python3 {EVENTS_DISPATCHER_REL} {command} {ev}", - "type": "command", - "timeout": cfg.get("timeout", 60), - "matcher": cfg.get("matcher", "*"), - _SPECKIT_MARKER: True, - } - ] + entries: list[dict[str, Any]] = [] + for cfg in handlers: + command = cfg.get("command", "") + dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev) + entries.append( + { + "command": dispatcher_cmd, + "type": "command", + "timeout": _native_timeout(integration, cfg.get("timeout", 60)), + "matcher": cfg.get("matcher", "*"), + _SPECKIT_MARKER: True, + } + ) + cursor_hooks[native] = entries _merge_json_fragment(config_path, cursor_hooks) rel = str(config_path.relative_to(project_root)) if rel not in manifest.files: @@ -462,28 +709,30 @@ def install_integration_events( created.append(config_path) elif fmt == "json-nested": - # Claude/Qwen/Gemini/Devin/Tabnine nested config JSON merge - nested_hooks = {} - bridge_path_prefix = "${CLAUDE_PROJECT_DIR}/" if integration.key == "claude" else "" - for ev, cfg in filtered.items(): + # Claude/Qwen/Gemini/Devin/Tabnine nested config JSON merge. + # Native schema is a single ``command`` string per hook (not + # command+args), so each handler renders one complete dispatcher + # invocation (#6). Gemini timeouts are converted to ms (#7). + nested_hooks: dict[str, list[dict[str, Any]]] = {} + for ev, handlers in filtered.items(): native = canonical_to_native[ev] - command = cfg.get("command", "") + matcher = handlers[0].get("matcher", "*") if handlers else "*" + inner_hooks: list[dict[str, Any]] = [] + for cfg in handlers: + command = cfg.get("command", "") + dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev) + inner_hooks.append( + { + "type": "command", + "command": dispatcher_cmd, + "timeout": _native_timeout(integration, cfg.get("timeout", 60)), + _SPECKIT_MARKER: True, + } + ) nested_hooks[native] = [ { - "matcher": cfg.get("matcher", "*"), - "hooks": [ - { - "type": "command", - "command": "python3", - "args": [ - bridge_path_prefix + EVENTS_DISPATCHER_REL, - command, - ev, - ], - "timeout": cfg.get("timeout", 60), - _SPECKIT_MARKER: True, - } - ], + "matcher": matcher, + "hooks": inner_hooks, } ] _merge_json_fragment(config_path, nested_hooks) @@ -579,29 +828,55 @@ def has_events(data: dict[str, Any]) -> bool: # -- Helper merging functions ---------------------------------------------- -def _build_opencode_plugin(filtered_events: dict[str, dict[str, Any]], canonical_to_native: dict[str, str]) -> str: +def _toml_quote(value: str) -> str: + """Render *value* as a TOML basic string via the shared escaper.""" + from ._toml_string import escape_toml_basic + return escape_toml_basic(value) + + +def _build_opencode_plugin( + filtered_events: ResolvedEvents, + canonical_to_native: dict[str, str], + interpreter: str, +) -> str: + """Render the opencode TS plugin for the resolved event set. + + Each canonical event may carry multiple handlers (#2); all handlers for a + native event are invoked from one generated function. The dispatcher is + launched with the resolved Python interpreter (#16) instead of a + hard-coded ``python3``. + """ event_entries: list[str] = [] plugin_returns: list[str] = [] event_handlers: list[str] = [] - for ev, cfg in filtered_events.items(): + for ev, handlers in filtered_events.items(): native = canonical_to_native[ev] - command = cfg.get("command", "") - matcher = cfg.get("matcher", "*") + + # Build the body: one runEvent() call per handler, with an optional + # tool-name matcher guard for tool.execute.* hooks. + body_lines: list[str] = [] + for cfg in handlers: + command = str(cfg.get("command", "")) + matcher = cfg.get("matcher", "*") + if native.startswith("tool.execute."): + if matcher and matcher != "*": + tools = [t.strip().strip('"') for t in matcher.split("|")] + checks = " || ".join(f"input.tool === '{t.lower()}'" for t in tools) + body_lines.append( + f" if ({checks}) {{ runEvent('{command}', '{ev}', input); }}" + ) + else: + body_lines.append(f" runEvent('{command}', '{ev}', input);") + else: + body_lines.append(f" runEvent('{command}', '{ev}', input);") if native.startswith("tool.execute."): ts_hook = native - match_cond = "" - if matcher and matcher != "*": - tools = [t.strip().strip('"') for t in matcher.split("|")] - checks = " || ".join(f"input.tool === '{t.lower()}'" for t in tools) - match_cond = f"if ({checks}) {{" - else: - match_cond = "{" event_entries.append( - f"function _{ev}(input: any) {match_cond}\n" - f" runEvent('{command}', '{ev}', input);\n" - f" }}" + f"function _{ev}(input: any) {{\n" + + "\n".join(body_lines) + "\n" + " }" ) plugin_returns.append( f" \"{ts_hook}\": async (input: any, output: any) => {{\n" @@ -611,8 +886,8 @@ def _build_opencode_plugin(filtered_events: dict[str, dict[str, Any]], canonical else: event_entries.append( f"function _{ev}(input: any) {{\n" - f" runEvent('{command}', '{ev}', input);\n" - f" }}" + + "\n".join(body_lines) + "\n" + " }" ) event_handlers.append( f" if (event.type === '{native}') {{ _{ev}(event); }}" @@ -626,6 +901,7 @@ def _build_opencode_plugin(filtered_events: dict[str, dict[str, Any]], canonical ) return _TS_PLUGIN_TEMPLATE.format( + interpreter=interpreter, event_entries="\n\n".join(event_entries), plugin_returns="\n".join(plugin_returns), ) diff --git a/src/specify_cli/integrations/gemini/__init__.py b/src/specify_cli/integrations/gemini/__init__.py index e422a74261..be87093bfd 100644 --- a/src/specify_cli/integrations/gemini/__init__.py +++ b/src/specify_cli/integrations/gemini/__init__.py @@ -29,3 +29,8 @@ class GeminiIntegration(TomlIntegration): } events_config_file = ".gemini/settings.json" events_format = "json-nested" + # Gemini measures hook timeouts in milliseconds, unlike Claude/Cursor/Codex + # which use seconds. The shared formatter converts via _native_timeout (#7) + # so the default 60s becomes 60000ms instead of terminating the dispatcher + # after 60ms. + events_timeout_unit = "ms" diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 44a9c8c52a..e698c644ce 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -47,7 +47,7 @@ def test_layer4_built_in_defaults(self, tmp_path): tmp_path, None, ) - assert result == {"post_tool_use": {"command": "speckit.tdd.validate"}} + assert result == {"post_tool_use": [{"command": "speckit.tdd.validate"}]} def test_layer3_extension_events_appended(self, tmp_path): """Extension-declared events are resolved and appended.""" @@ -67,7 +67,22 @@ def test_layer3_extension_events_appended(self, tmp_path): ) assert "post_tool_use" in result assert "session_start" in result - assert result["session_start"] == {"command": "speckit.my-ext.boot"} + assert result["session_start"] == [{"command": "speckit.my-ext.boot"}] + + def test_layer3_multiple_extensions_same_event_accumulate(self, tmp_path): + """Two extensions declaring the same event both run (#2).""" + for ext_id, cmd in (("my-ext", "speckit.my-ext.boot"), ("other-ext", "speckit.other.boot")): + ext_dir = tmp_path / ".specify" / "extensions" / ext_id + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text( + f"events:\n session_start:\n command: {cmd}\n", + encoding="utf-8", + ) + result = resolve_events("claude", None, tmp_path, None) + assert result["session_start"] == [ + {"command": "speckit.my-ext.boot"}, + {"command": "speckit.other.boot"}, + ] def test_layer2_yaml_override_replaces(self, tmp_path): """integration-events.yml override replaces baseline entirely.""" @@ -88,7 +103,7 @@ def test_layer2_yaml_override_replaces(self, tmp_path): tmp_path, None, ) - assert result == {"stop": {"command": "speckit.override.stop"}} + assert result == {"stop": [{"command": "speckit.override.stop"}]} def test_layer2_empty_events_disables(self, tmp_path): """Empty events override disables events.""" @@ -137,7 +152,7 @@ def test_events_collected(self, tmp_path): encoding="utf-8", ) result = collect_extension_events(tmp_path) - assert result == {"pre_tool_use": {"command": "speckit.my-ext.check"}} + assert result == {"pre_tool_use": [{"command": "speckit.my-ext.check"}]} def test_invalid_yaml_skipped(self, tmp_path): ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" @@ -215,7 +230,7 @@ def test_merge_into_empty_file(self, tmp_path): manifest.record_existing = MagicMock() events = { - "pre_tool_use": {"command": "speckit.tdd.validate", "matcher": "Edit|Write"}, + "pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit|Write"}], } install_integration_events(integration, tmp_path, manifest, events) @@ -225,6 +240,36 @@ def test_merge_into_empty_file(self, tmp_path): assert "hooks" in data assert "PreToolUse" in data["hooks"] assert data["hooks"]["PreToolUse"][0]["matcher"] == "Edit|Write" + # #6: native schema is a single `command` string, not command+args. + inner = data["hooks"]["PreToolUse"][0]["hooks"][0] + assert isinstance(inner["command"], str) + assert "args" not in inner + assert "speckit.tdd.validate" in inner["command"] + assert "pre_tool_use" in inner["command"] + # The dispatcher path must be prefixed with ${CLAUDE_PROJECT_DIR}/ for Claude. + assert "${CLAUDE_PROJECT_DIR}/" in inner["command"] + + def test_claude_emits_all_handlers_for_same_event(self, tmp_path): + """#2: two handlers on the same event both appear in the native config.""" + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + + events = { + "pre_tool_use": [ + {"command": "speckit.tdd.validate"}, + {"command": "speckit.other.check"}, + ], + } + install_integration_events(integration, tmp_path, manifest, events) + + data = json.loads((tmp_path / ".claude/settings.json").read_text()) + inner_hooks = data["hooks"]["PreToolUse"][0]["hooks"] + commands = [h["command"] for h in inner_hooks] + assert any("speckit.tdd.validate" in c for c in commands) + assert any("speckit.other.check" in c for c in commands) def test_remove_preserves_user_hooks(self, tmp_path): integration = ClaudeIntegration() @@ -257,7 +302,7 @@ def test_remove_preserves_user_hooks(self, tmp_path): ) events = { - "pre_tool_use": {"command": "speckit.tdd.validate"}, + "pre_tool_use": [{"command": "speckit.tdd.validate"}], } install_integration_events(integration, tmp_path, manifest, events) remove_integration_events(integration, tmp_path, manifest) @@ -282,7 +327,7 @@ def test_copilot_json_generation(self, tmp_path): manifest.record_existing = MagicMock() events = { - "session_start": {"command": "speckit.agent-context.update", "timeout": 60}, + "session_start": [{"command": "speckit.agent-context.update", "timeout": 60}], } install_integration_events(integration, tmp_path, manifest, events) @@ -294,11 +339,28 @@ def test_copilot_json_generation(self, tmp_path): assert "sessionStart" in data["hooks"] entry = data["hooks"]["sessionStart"][0] assert entry["type"] == "command" + # #6: a complete shell command string (not command+args). assert "speckit.agent-context.update" in entry["bash"] assert "session_start" in entry["bash"] + assert entry["bash"] == entry["powershell"] assert entry["timeoutSec"] == 60 +# -- Gemini timeout unit (#7) ------------------------------------------------ + +class TestGeminiTimeoutUnit: + """Gemini measures hook timeouts in milliseconds, not seconds.""" + + def test_gemini_timeout_converted_to_ms(self, tmp_path): + from specify_cli.integrations.gemini import GeminiIntegration + from specify_cli.events import _native_timeout + + integration = GeminiIntegration() + # 60 (seconds) -> 60000 (ms) for Gemini; unchanged for seconds-based agents. + assert _native_timeout(integration, 60) == 60000 + assert _native_timeout(ClaudeIntegration(), 60) == 60 + + # -- Opencode TS Plugin merging --------------------------------------------- class TestOpencodePluginMerging: @@ -312,8 +374,8 @@ def test_opencode_ts_plugin_generation(self, tmp_path): manifest.record_existing = MagicMock() events = { - "pre_tool_use": {"command": "speckit.tdd.validate", "matcher": "Edit"}, - "session_start": {"command": "speckit.agent-context.update"}, + "pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit"}], + "session_start": [{"command": "speckit.agent-context.update"}], } install_integration_events(integration, tmp_path, manifest, events) @@ -325,6 +387,48 @@ def test_opencode_ts_plugin_generation(self, tmp_path): assert "session.created" in content assert "speckit.tdd.validate" in content assert "speckit.agent-context.update" in content + # #13: failures must propagate via throw, not process.exit(2) which + # would kill the OpenCode host process. + assert "process.exit(2)" not in content + assert "throw new Error" in content + + def test_opencode_ts_plugin_uses_resolved_interpreter(self, tmp_path): + """#16: the dispatcher is launched with a resolved interpreter (venv + when present), not a hard-coded ``python3``.""" + integration = OpencodeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + + # Create a project venv so resolve_python_interpreter returns it. + venv_bin = tmp_path / ".venv" / "bin" / "python" + venv_bin.parent.mkdir(parents=True) + venv_bin.write_text("#!/bin/sh\n") + + events = {"session_start": [{"command": "speckit.boot"}]} + install_integration_events(integration, tmp_path, manifest, events) + content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text() + assert ".venv/bin/python" in content + + def test_opencode_ts_plugin_emits_all_handlers(self, tmp_path): + """#2: multiple handlers on the same native event all invoke runEvent.""" + integration = OpencodeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + + events = { + "session_start": [ + {"command": "speckit.first.boot"}, + {"command": "speckit.second.boot"}, + ], + } + install_integration_events(integration, tmp_path, manifest, events) + content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text() + assert "speckit.first.boot" in content + assert "speckit.second.boot" in content # -- Command runner test (core execution) ----------------------------------- From 0d546a2e5c4a9410853f521615a3a49a327c4107 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Mon, 27 Jul 2026 16:50:18 +0300 Subject: [PATCH 05/20] fix(events): merge/teardown idempotency and data safety Address Copilot review findings on native-config merge and teardown: - #9: _has_marker now recurses into nested 'hooks' arrays so a matcher-group containing Specify-owned inner hooks is recognized and replaced on upgrade instead of accumulating duplicates. - #11: _merge_json_fragment strips ALL Specify-marked entries from every event before adding the new set, so an override that drops an event (pre_tool_use -> stop) removes the stale marked entry instead of leaving it active. - #3: an empty resolved map (--events false / disabled override) now runs the native-config removal path instead of early-returning, so prior Specify hooks are stripped. The shared dispatcher is left untouched (#10). - #14: teardown deletes a Spec-Kit-created config that is now empty of user content (rather than leaving '{}' that confused manifest.uninstall()), while preserving pre-existing configs with user hooks/settings. - #10: the shared .specify/events.py dispatcher is deleted only when no other installed event-capable integration's manifest still references it, so uninstalling one multi-install integration doesn't break the others. - #8: Copilot's .github/hooks/speckit.json now merges owned entries (with markers) into a pre-existing file instead of overwriting, and teardown removes only owned entries (deleting the file when no user hooks remain). - #22/#23: JSON/JSONC parse failures in native configs (Claude/Cursor/etc. and opencode.json) abort the merge with a warning instead of resetting user content to '{}'. - #12: write destinations are validated (symlinked-ancestor rejection + containment) before any bytes are written, so a symlinked .specify or native config directory can't redirect writes outside the repository. Refs: PR #3704 Copilot inline review (findings #3, #8, #9, #10, #11, #12, #14, #22, #23) Assisted-by: opencode (model: glm-5.2, autonomous) --- src/specify_cli/events.py | 441 +++++++++++++++++++++++------- tests/integrations/test_events.py | 289 ++++++++++++++++++++ 2 files changed, 637 insertions(+), 93 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 645fdb1457..149483bf8e 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -582,15 +582,21 @@ def install_integration_events( file=sys.stderr, ) + # #3: an empty resolved map (--events false, or override disabling events) + # must still strip prior Specify hooks from this integration's native + # config rather than leaving them active. The shared dispatcher is left + # untouched — another integration may still reference it (#10). if not filtered: + _remove_native_event_hooks(integration, project_root, manifest) return [] created: list[Path] = [] - # 1. Generate events.py dispatcher script + # 1. Generate events.py dispatcher script (#12: validate destination first) dispatcher_dir = project_root / EVENTS_DISPATCHER_DIR - dispatcher_dir.mkdir(parents=True, exist_ok=True) dispatcher_path = dispatcher_dir / EVENTS_DISPATCHER_FILENAME + _ensure_safe_destination(dispatcher_path) + dispatcher_dir.mkdir(parents=True, exist_ok=True) dispatcher_path.write_text(_EVENTS_DISPATCHER_TEMPLATE, encoding="utf-8") dispatcher_path.chmod(0o755) manifest.record_file( @@ -611,6 +617,7 @@ def install_integration_events( # Opencode TS plugin custom merge plugin_rel = ".opencode/plugin/speckit-events.ts" plugin_path = project_root / plugin_rel + _ensure_safe_destination(plugin_path) plugin_path.parent.mkdir(parents=True, exist_ok=True) plugin_path.write_text( _build_opencode_plugin(filtered, canonical_to_native, _resolve_interpreter(project_root)), @@ -630,9 +637,12 @@ def install_integration_events( created.append(config_path) elif fmt == "copilot-json": - # Copilot hooks JSON write (dedicated .github/hooks/speckit.json). - # Each handler becomes its own entry in the native event's list (#2), - # with bash/powershell variants sharing one resolved interpreter (#16). + # Copilot dedicated .github/hooks/speckit.json. Each handler becomes + # its own entry in the native event's list (#2), with bash/powershell + # variants sharing one resolved interpreter (#16). Entries carry the + # ownership marker so a pre-existing user-authored file is merged + # (owned entries replaced) rather than overwritten (#8), and teardown + # removes only owned entries. copilot_hooks: dict[str, list[dict[str, Any]]] = {} for ev, handlers in filtered.items(): native = canonical_to_native[ev] @@ -650,9 +660,7 @@ def install_integration_events( } ) copilot_hooks[native] = entries - fragment = {"version": 1, "hooks": copilot_hooks} - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text(json.dumps(fragment, indent=2) + "\n", encoding="utf-8") + _merge_copilot_json(config_path, copilot_hooks) rel = str(config_path.relative_to(project_root)) if rel not in manifest.files: manifest.record_existing(rel) @@ -744,33 +752,86 @@ def install_integration_events( return created -def remove_integration_events(integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest) -> None: - """Remove Specify-authored event entries from native config.""" +def _remove_native_event_hooks( + integration: IntegrationBase, + project_root: Path, + manifest: IntegrationManifest, +) -> None: + """Remove Specify-authored hooks from *this* integration's native config. + + Used both by full teardown and by the empty-resolved-map install path (#3). + Does NOT touch the shared dispatcher (another integration may still + reference it — #10). + """ fmt = getattr(integration, "events_format", None) config_file = getattr(integration, "events_config_file", None) - if config_file: - config_path = project_root / config_file - if config_path.exists(): - if fmt == "copilot-json": - # Clean delete of dedicated file - config_path.unlink(missing_ok=True) - manifest.remove(config_file) - elif fmt == "toml": - _remove_toml_entries(config_path) - elif fmt in ("json-nested", "json-flat"): - _remove_json_entries(config_path) - elif fmt == "ts-plugin": - _remove_opencode_entries(config_path) - - # Clean up dispatcher script + if not config_file: + return + config_path = project_root / config_file + if not config_path.exists(): + return + deleted = False + if fmt == "copilot-json": + deleted = _remove_copilot_entries(config_path) + elif fmt == "toml": + deleted = _remove_toml_entries(config_path) + elif fmt in ("json-nested", "json-flat"): + deleted = _remove_json_entries(config_path) + elif fmt == "ts-plugin": + deleted = _remove_opencode_entries(config_path) + if deleted: + manifest.remove(config_file) + + +def _other_event_integrations_reference_dispatcher( + project_root: Path, excluding_key: str +) -> bool: + """Return True if another installed event-capable integration still + references the shared ``.specify/events.py`` dispatcher (#10). + + Inspects each installed integration's manifest (excluding *excluding_key*) + for the dispatcher path so uninstalling one multi-install event-capable + integration doesn't delete the dispatcher the others still rely on. + """ + from .integrations._helpers import _read_integration_json + from .integrations.manifest import IntegrationManifest + from .integration_state import installed_integration_keys + + state = _read_integration_json(project_root) + for key in installed_integration_keys(state): + if key == excluding_key: + continue + try: + manifest = IntegrationManifest.load(key, project_root) + except Exception: + continue + if EVENTS_DISPATCHER_REL in manifest.files: + return True + return False + + +def remove_integration_events( + integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest +) -> None: + """Remove Specify-authored event entries from native config. + + The shared ``.specify/events.py`` dispatcher is deleted only when no other + installed event-capable integration still references it (#10); otherwise + it is left in place so multi-install setups don't lose the dispatcher + mid-stream. + """ + _remove_native_event_hooks(integration, project_root, manifest) + + # Clean up dispatcher script only if no other integration still uses it. dispatcher_rel = EVENTS_DISPATCHER_REL if dispatcher_rel in manifest.files: - dispatcher_path = project_root / dispatcher_rel - if dispatcher_path.exists(): - dispatcher_path.unlink(missing_ok=True) - manifest.remove(dispatcher_rel) + if not _other_event_integrations_reference_dispatcher(project_root, integration.key): + dispatcher_path = project_root / dispatcher_rel + if dispatcher_path.exists(): + dispatcher_path.unlink(missing_ok=True) + manifest.remove(dispatcher_rel) - # Clean up opencode TS plugin + # Clean up opencode TS plugin (owned solely by the opencode integration). if integration.key == "opencode": plugin_rel = ".opencode/plugin/speckit-events.ts" if plugin_rel in manifest.files: @@ -908,32 +969,32 @@ def _build_opencode_plugin( def _merge_opencode_plugin_ref(config_path: Path, ref: str) -> None: - existing: dict = {} - if config_path.exists(): - try: - existing = json.loads(config_path.read_text(encoding="utf-8")) - except Exception: - existing = {} - if not isinstance(existing, dict): - existing = {} + """Merge the speckit-events plugin ref into opencode.json. + + Aborts with a warning (#23) when the file cannot be parsed (e.g. JSONC or + malformed JSON) instead of resetting user configuration to ``{}``. + """ + existing = _load_user_json(config_path) + if existing is None: + return plugins = existing.get("plugin", []) if not isinstance(plugins, list): plugins = [] if ref not in plugins: plugins.append(ref) existing["plugin"] = plugins - config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") + _safe_write_json(config_path, existing) -def _remove_opencode_entries(config_path: Path) -> None: - if not config_path.exists(): - return - try: - existing = json.loads(config_path.read_text(encoding="utf-8")) - except Exception: - return - if not isinstance(existing, dict): - return +def _remove_opencode_entries(config_path: Path) -> bool: + """Remove the speckit-events plugin ref from opencode.json (#23). + + Returns True if the file was deleted (now empty of user content), False + otherwise. Aborts without writing when the file cannot be parsed. + """ + existing = _load_user_json(config_path) + if existing is None: + return False plugins = existing.get("plugin", []) if isinstance(plugins, list): ref = "./.opencode/plugin/speckit-events.ts" @@ -942,10 +1003,15 @@ def _remove_opencode_entries(config_path: Path) -> None: existing["plugin"] = plugins else: existing.pop("plugin", None) - config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") + if not existing: + config_path.unlink(missing_ok=True) + return True + _safe_write_json(config_path, existing) + return False def _merge_toml_fragment(dst: Path, fragment: str) -> None: + _ensure_safe_destination(dst) existing = "" if dst.exists(): existing = dst.read_text(encoding="utf-8") @@ -959,9 +1025,13 @@ def _merge_toml_fragment(dst: Path, fragment: str) -> None: dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8") -def _remove_toml_entries(dst: Path) -> None: +def _remove_toml_entries(dst: Path) -> bool: + """Remove Specify-marked TOML entries; delete the file if now empty (#14). + + Returns True if the file was deleted (no user content remained). + """ if not dst.exists(): - return + return False existing = dst.read_text(encoding="utf-8") cleaned = re.sub( r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*', @@ -969,16 +1039,104 @@ def _remove_toml_entries(dst: Path) -> None: existing, flags=re.DOTALL, ) + # If only whitespace/comments remain, the file had no user content — + # delete it rather than leaving an empty stub that confuses uninstall. + stripped = "\n".join( + line for line in cleaned.splitlines() + if line.strip() and not line.strip().startswith("#") + ) + if not stripped: + dst.unlink(missing_ok=True) + return True dst.write_text(cleaned, encoding="utf-8") + return False + + +def _merge_copilot_json(dst: Path, new_hooks: dict[str, list]) -> None: + """Merge Specify-owned hooks into Copilot's dedicated hooks JSON (#8). + + A pre-existing user-authored ``.github/hooks/speckit.json`` is merged + (owned entries replaced via markers) rather than overwritten, and a + parse failure aborts instead of resetting user content (#22). + """ + existing = _load_user_json(dst) + if existing is None: + return + if not isinstance(existing, dict): + existing = {} + existing.setdefault("version", 1) + existing_hooks = existing.get("hooks", {}) + if not isinstance(existing_hooks, dict): + existing_hooks = {} + # #11: strip ALL Specify-marked entries from every event first. + cleaned_hooks: dict[str, list] = {} + for event, entries in existing_hooks.items(): + if not isinstance(entries, list): + continue + kept_entries = _drop_marked_entries(entries) + if kept_entries: + cleaned_hooks[event] = kept_entries + for event, entries in new_hooks.items(): + cleaned_hooks.setdefault(event, []).extend(entries) + if cleaned_hooks: + existing["hooks"] = cleaned_hooks + else: + existing.pop("hooks", None) + _safe_write_json(dst, existing) + + +def _remove_copilot_entries(dst: Path) -> bool: + """Remove Specify-owned hooks from Copilot's hooks JSON (#8, #14). + + Deletes the file when no user-authored hooks remain; otherwise keeps the + file with user content. Aborts (no write) on parse failure (#22). + """ + existing = _load_user_json(dst) + if existing is None: + return False + if not isinstance(existing, dict): + return False + hooks = existing.get("hooks", {}) + if not isinstance(hooks, dict): + hooks = {} + cleaned: dict[str, list] = {} + for event, entries in hooks.items(): + if not isinstance(entries, list): + continue + kept_entries = _drop_marked_entries(entries) + if kept_entries: + cleaned[event] = kept_entries + if cleaned: + existing["hooks"] = cleaned + else: + existing.pop("hooks", None) + # Dedicated Spec-Kit file: delete when only the (Spec-Kit-invented) + # ``version`` key would remain — no user content to preserve. + user_keys = {k for k in existing if k != "version"} + if not user_keys: + dst.unlink(missing_ok=True) + return True + _safe_write_json(dst, existing) + return False def _merge_json_fragment(dst: Path, new_hooks: dict) -> None: - existing: dict = {} - if dst.exists(): - try: - existing = json.loads(dst.read_text(encoding="utf-8")) - except Exception: - pass + """Merge Specify-authored hook entries into a native JSON config. + + Idempotent: removes ALL prior Specify-marked entries from every event in + the existing config first (#11), so an override that drops an event (e.g. + ``pre_tool_use`` → ``stop``) doesn't leave stale marked entries behind. + Marker detection recurses into nested ``hooks`` arrays (#9) so a + matcher-group containing Specify-owned inner hooks is recognized and + replaced rather than duplicated on every upgrade. + + Aborts with a warning (no write) when the existing file cannot be parsed + (#22) — e.g. JSONC with comments — instead of resetting user content to + ``{}``. + """ + existing = _load_user_json(dst) + if existing is None: + return if not isinstance(existing, dict): existing = {} @@ -987,59 +1145,156 @@ def _merge_json_fragment(dst: Path, new_hooks: dict) -> None: if not isinstance(existing_hooks, dict): existing_hooks = {} + # #11: strip ALL Specify-marked entries from every event first. + cleaned_hooks: dict[str, list] = {} + for event, entries in existing_hooks.items(): + if not isinstance(entries, list): + continue + kept_entries = _drop_marked_entries(entries) + if kept_entries: + cleaned_hooks[event] = kept_entries + + # Then add the newly resolved set. for event, entries in new_hooks.items(): - existing_list = existing_hooks.get(event, []) - if not isinstance(existing_list, list): - existing_list = [] - # Filter out existing entries carrying our marker - existing_list = [e for e in existing_list if not _has_marker(e)] - existing_list.extend(entries) - existing_hooks[event] = existing_list - - existing[hooks_key] = existing_hooks - dst.parent.mkdir(parents=True, exist_ok=True) - dst.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") + cleaned_hooks.setdefault(event, []).extend(entries) + + if cleaned_hooks: + existing[hooks_key] = cleaned_hooks + else: + existing.pop(hooks_key, None) + _safe_write_json(dst, existing) + + +def _drop_marked_entries(entries: list) -> list: + """Return *entries* with Specify-marked hooks removed, preserving user hooks. + + Handles both flat entries (marker on the entry itself) and nested entries + (marker on inner ``hooks`` elements). A nested matcher-group whose inner + hooks are all Specify-owned is dropped; one with surviving user inner + hooks is kept with only the user hooks retained (#9). + """ + kept: list = [] + for entry in entries: + if not isinstance(entry, dict): + kept.append(entry) + continue + inner = entry.get("hooks") + if isinstance(inner, list): + kept_inner = [h for h in inner if not _has_marker(h)] + if kept_inner: + entry["hooks"] = kept_inner + kept.append(entry) + # else: outer group was entirely Specify-owned → drop + elif _has_marker(entry): + pass # flat Specify-owned entry → drop + else: + kept.append(entry) + return kept -def _remove_json_entries(dst: Path) -> None: +def _load_user_json(path: Path) -> dict | None: + """Load a user-owned JSON file, aborting (None) on parse failure (#22/#23). + + Returns the parsed dict, or ``None`` when the file is missing or cannot be + parsed (e.g. JSONC with comments, or temporarily malformed JSON). Callers + must skip the merge rather than resetting user content to ``{}``. + """ + if not path.exists(): + return {} try: - existing = json.loads(dst.read_text(encoding="utf-8")) - except Exception: - return - if not isinstance(existing, dict): - return + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, ValueError) as exc: + logger.warning( + "Could not parse %s (may contain JSONC comments or be malformed); " + "skipping event-config merge to preserve user content.", + path, + ) + logger.debug("Parse error detail: %s", exc) + return None + if not isinstance(data, dict): + logger.warning("%s is not a JSON object; skipping event-config merge.", path) + return None + return data + + +def _safe_write_json(dst: Path, data: dict) -> None: + """Write *data* as JSON to *dst* after validating the destination (#12).""" + _ensure_safe_destination(dst) + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + +def _ensure_safe_destination(dst: Path) -> None: + """Validate a write target is a regular path inside the project (#12). + + Walks each path component and rejects symlinks (which could escape the + project — e.g. a symlinked ``.claude`` or ``.specify`` directory pointing + outside the repo would redirect writes to external files). Then validates + lexical containment so ``..`` traversal is also rejected. + """ + from .agents import CommandRegistrar + + # Walk each component so a symlinked ancestor (e.g. ``.claude`` → outside) + # cannot be silently followed. Mirrors IntegrationManifest.record_existing. + walked = dst.anchor and Path(dst.anchor) or Path("/") + for part in dst.relative_to(dst.anchor).parts if dst.anchor else dst.parts: + walked = walked / part + if walked.is_symlink(): + raise ValueError( + f"Refusing to write event config through a symlink: {walked}" + ) + + # Containment check against the nearest existing ancestor directory. + base = dst.parent + while not base.exists() and base != base.parent: + base = base.parent + CommandRegistrar._ensure_inside(dst, base) + + +def _remove_json_entries(dst: Path) -> bool: + """Remove Specify-authored entries; delete the file if now empty (#14). + + Returns True if the file was deleted (Spec Kit created it and no user + content remains), False otherwise. + """ + existing = _load_user_json(dst) + if existing is None: + return False hooks = existing.get("hooks", {}) if not isinstance(hooks, dict): - return + return False cleaned: dict[str, list] = {} for event, entries in hooks.items(): if not isinstance(entries, list): continue - kept_entries = [] - for entry in entries: - if not isinstance(entry, dict): - kept_entries.append(entry) - continue - inner = entry.get("hooks") - if isinstance(inner, list): - kept_inner = [h for h in inner if not _has_marker(h)] - if kept_inner: - entry["hooks"] = kept_inner - kept_entries.append(entry) - elif _has_marker(entry): - pass - else: - kept_entries.append(entry) + kept_entries = _drop_marked_entries(entries) if kept_entries: cleaned[event] = kept_entries if cleaned: existing["hooks"] = cleaned else: existing.pop("hooks", None) - dst.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") + # #14: if the config is now empty (no user content), delete the file + # rather than leaving a ``{}`` that confuses manifest.uninstall(). + if not existing: + dst.unlink(missing_ok=True) + return True + _safe_write_json(dst, existing) + return False def _has_marker(entry: Any) -> bool: - if isinstance(entry, dict): - return entry.get(_SPECKIT_MARKER, False) is True + """Return True if *entry* (or any nested inner hook) is Specify-marked (#9). + + Flat entries carry the marker directly; nested matcher-groups carry it on + their inner ``hooks`` elements, so detection recurses one level to + recognize groups that are (wholly or partly) Specify-owned. + """ + if not isinstance(entry, dict): + return False + if entry.get(_SPECKIT_MARKER, False) is True: + return True + inner = entry.get("hooks") + if isinstance(inner, list): + return any(_has_marker(h) for h in inner) return False diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index e698c644ce..6b8ae32c34 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import platform from unittest.mock import MagicMock @@ -10,6 +11,7 @@ from specify_cli.events import ( CANONICAL_EVENTS, + EVENTS_DISPATCHER_REL, collect_extension_events, install_integration_events, remove_integration_events, @@ -467,3 +469,290 @@ def test_run_command_resolves_and_executes(self, tmp_path): code = resolve_and_run_event_command("speckit.test", "session_start", "{}", tmp_path) assert code == 0 + + +# -- Merge/teardown idempotency & safety (Tier 3) ---------------------------- + +def _claude_manifest(tmp_path): + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + manifest.remove = MagicMock() + return manifest + + +class TestMergeIdempotency: + """#9/#11: marker recursion and full-clean-before-add.""" + + def test_upgrade_does_not_duplicate_nested_hooks(self, tmp_path): + """#9: re-running install replaces prior Specify inner hooks instead of + appending a second matcher-group on every upgrade.""" + integration = ClaudeIntegration() + config_path = tmp_path / ".claude/settings.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + + events = {"pre_tool_use": [{"command": "speckit.tdd.validate"}]} + for _ in range(2): + manifest = _claude_manifest(tmp_path) + install_integration_events(integration, tmp_path, manifest, events) + + data = json.loads(config_path.read_text()) + groups = data["hooks"]["PreToolUse"] + # Exactly one matcher-group for Specify (no duplication). + assert len(groups) == 1 + inner = groups[0]["hooks"] + assert len(inner) == 1 + assert "speckit.tdd.validate" in inner[0]["command"] + + def test_override_change_removes_stale_event(self, tmp_path): + """#11: when the resolved set changes from pre_tool_use to stop, the + old marked pre_tool_use entry is removed, not left active.""" + integration = ClaudeIntegration() + config_path = tmp_path / ".claude/settings.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + + install_integration_events( + integration, tmp_path, _claude_manifest(tmp_path), + {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, + ) + install_integration_events( + integration, tmp_path, _claude_manifest(tmp_path), + {"stop": [{"command": "speckit.end"}]}, + ) + + data = json.loads(config_path.read_text()) + assert "PreToolUse" not in data["hooks"] + assert "Stop" in data["hooks"] + + +class TestEmptyMapRemoval: + """#3: --events false / empty resolved map strips prior hooks.""" + + def test_empty_events_removes_prior_hooks(self, tmp_path): + integration = ClaudeIntegration() + config_path = tmp_path / ".claude/settings.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + + install_integration_events( + integration, tmp_path, _claude_manifest(tmp_path), + {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, + ) + assert config_path.is_file() + + # Now resolve to empty (--events false): prior hooks must be removed. + install_integration_events(integration, tmp_path, _claude_manifest(tmp_path), {}) + + # The dispatcher is shared and left in place (#10); only native hooks + # are stripped. The settings file had no user content → deleted (#14). + assert not config_path.exists() or "hooks" not in json.loads(config_path.read_text()) + + +class TestTeardownDataSafety: + """#14/#22/#23: preserve user content, delete Spec-Kit-created empties.""" + + def test_remove_deletes_spec_kit_created_config(self, tmp_path): + """#14: a config Spec Kit created from scratch is deleted (not left as + ``{}``) so manifest.uninstall() doesn't preserve an empty stub.""" + integration = ClaudeIntegration() + manifest = _claude_manifest(tmp_path) + install_integration_events( + integration, tmp_path, manifest, + {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, + ) + config_path = tmp_path / ".claude/settings.json" + assert config_path.is_file() + + remove_integration_events(integration, tmp_path, manifest) + assert not config_path.exists() + + def test_remove_preserves_user_content_in_config(self, tmp_path): + """#14: a pre-existing config with user content is kept (user hooks + survive teardown).""" + integration = ClaudeIntegration() + config_path = tmp_path / ".claude/settings.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{"type": "command", "command": "user-check"}], + }] + }, + "userSetting": True, + })) + + manifest = _claude_manifest(tmp_path) + install_integration_events( + integration, tmp_path, manifest, + {"stop": [{"command": "speckit.end"}]}, + ) + remove_integration_events(integration, tmp_path, manifest) + + data = json.loads(config_path.read_text()) + # User hook and setting preserved; Specify hook gone. + assert data["userSetting"] is True + assert "Stop" not in data.get("hooks", {}) + assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash" + + def test_jsonc_config_not_reset_on_merge(self, tmp_path): + """#22: a JSONC/unparseable native config is left untouched on merge.""" + integration = ClaudeIntegration() + config_path = tmp_path / ".claude/settings.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + jsonc = '{\n // my comment\n "hooks": {}\n}\n' + config_path.write_text(jsonc) + + install_integration_events( + integration, tmp_path, _claude_manifest(tmp_path), + {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, + ) + # User content preserved verbatim — not reset to {}. + assert config_path.read_text() == jsonc + + def test_jsonc_opencode_config_not_reset(self, tmp_path): + """#23: a malformed opencode.json is preserved, not reset to {}.""" + integration = OpencodeIntegration() + config_path = tmp_path / "opencode.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + malformed = "{ not valid json" + config_path.write_text(malformed) + + install_integration_events( + integration, tmp_path, _claude_manifest(tmp_path), + {"session_start": [{"command": "speckit.boot"}]}, + ) + assert config_path.read_text() == malformed + + +class TestCopilotMergeTeardown: + """#8: Copilot dedicated hooks JSON merges owned entries / teardown + removes only owned entries.""" + + def test_copilot_merge_preserves_user_hooks(self, tmp_path): + integration = CopilotIntegration() + config_path = tmp_path / ".github/hooks/speckit.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps({ + "version": 1, + "hooks": { + "sessionStart": [{"type": "command", "bash": "user-hook"}], + }, + })) + + install_integration_events( + integration, tmp_path, _claude_manifest(tmp_path), + {"session_start": [{"command": "speckit.boot"}]}, + ) + data = json.loads(config_path.read_text()) + entries = data["hooks"]["sessionStart"] + bash_cmds = [e.get("bash") for e in entries] + assert "user-hook" in bash_cmds + assert any("speckit.boot" in c for c in bash_cmds) + + def test_copilot_teardown_removes_only_owned_entries(self, tmp_path): + integration = CopilotIntegration() + config_path = tmp_path / ".github/hooks/speckit.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps({ + "version": 1, + "hooks": { + "sessionStart": [{"type": "command", "bash": "user-hook"}], + }, + })) + + manifest = _claude_manifest(tmp_path) + install_integration_events( + integration, tmp_path, manifest, + {"session_start": [{"command": "speckit.boot"}]}, + ) + remove_integration_events(integration, tmp_path, manifest) + + data = json.loads(config_path.read_text()) + # User hook preserved; Spec-Kit entry gone. + assert data["hooks"]["sessionStart"][0]["bash"] == "user-hook" + + def test_copilot_teardown_deletes_spec_kit_only_file(self, tmp_path): + """#8/#14: when the file held only Spec-Kit entries, teardown deletes it.""" + integration = CopilotIntegration() + manifest = _claude_manifest(tmp_path) + install_integration_events( + integration, tmp_path, manifest, + {"session_start": [{"command": "speckit.boot"}]}, + ) + config_path = tmp_path / ".github/hooks/speckit.json" + assert config_path.is_file() + remove_integration_events(integration, tmp_path, manifest) + assert not config_path.exists() + + +class TestSharedDispatcherRefcount: + """#10: the shared .specify/events.py dispatcher is not deleted while + another installed event-capable integration still references it.""" + + def test_dispatcher_kept_when_other_integration_references_it(self, tmp_path): + # Simulate two event-capable integrations installed: claude (the one + # being uninstalled) and codex (still installed). The codex manifest + # lists the dispatcher, so removing claude must not delete it. + from specify_cli.integrations.codex import CodexIntegration + + claude = ClaudeIntegration() + codex = CodexIntegration() + + # Install claude's events (writes dispatcher + claude config). + claude_manifest = _claude_manifest(tmp_path) + install_integration_events( + claude, tmp_path, claude_manifest, + {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, + ) + # Install codex's events (re-writes shared dispatcher + codex config). + codex_manifest = MagicMock(spec=IntegrationManifest) + codex_manifest.files = {} + codex_manifest.record_file = MagicMock() + codex_manifest.record_existing = MagicMock() + codex_manifest.remove = MagicMock() + install_integration_events( + codex, tmp_path, codex_manifest, + {"pre_tool_use": [{"command": "speckit.codex.check"}]}, + ) + + # Persist a codex manifest on disk so the refcount check finds it. + codex_disk = IntegrationManifest(codex.key, tmp_path, version="test") + codex_disk._files = {EVENTS_DISPATCHER_REL: "x"} + codex_disk.save() + + # Write the integration-state JSON so installed_integration_keys sees codex. + import json as _json + state_path = tmp_path / ".specify" / "integrations.json" + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text(_json.dumps({ + "default_integration": "claude", + "installed_integrations": ["claude", "codex"], + })) + + dispatcher_path = tmp_path / EVENTS_DISPATCHER_REL + assert dispatcher_path.exists() + + # Removing claude should leave the dispatcher (codex still uses it). + remove_integration_events(claude, tmp_path, claude_manifest) + assert dispatcher_path.exists() + + +class TestSafeWriteDestination: + """#12: write targets are validated before any bytes are written.""" + + def test_symlinked_config_dir_rejected(self, tmp_path): + integration = ClaudeIntegration() + # Create a symlinked .claude directory pointing outside the project. + outside = tmp_path / "outside" + outside.mkdir() + linked = tmp_path / ".claude" + os.symlink(outside, linked) + + with pytest.raises(ValueError, match="(?i)symlink|escapes|outside"): + install_integration_events( + integration, tmp_path, _claude_manifest(tmp_path), + {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, + ) + # No content written through the symlink. + assert not (outside / "settings.json").exists() From 2623ddbd5f662d1786e569013533ea915504ae19 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Mon, 27 Jul 2026 17:01:43 +0300 Subject: [PATCH 06/20] fix(events): honor enabled flag, refresh on extension lifecycle, strict command validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review findings on sourcing, validation, and lifecycle: - #1: collect_extension_events now honors the extension registry's 'enabled' flag — a disabled extension's events are skipped so disabling an extension actually deactivates its runtime hooks. Adds refresh_integration_events(), wired into extension add/remove/enable/disable, so installing, removing, enabling, or disabling an extension regenerates each installed event-capable integration's native event config (the documented install-after-init flow is no longer inert, and disabled/removed extension events are stripped). - #17: validate_events now requires 'command' to be a non-empty string, not merely truthy, so a value like 'command: [foo]' is rejected at manifest load instead of rendering into invalid native configuration. - #15: updated PR #3704 description to the implemented events terminology (.specify/events.py, events:, --events, integration-events.yml) replacing the stale bridge.py / runtime_hooks: / --hooks false / integration-hooks.yml references that no longer match the shipped API. (#21 — user YAML override validation — was addressed in the prior tier.) Refs: PR #3704 Copilot inline review (findings #1, #15, #17) Assisted-by: opencode (model: glm-5.2, autonomous) --- src/specify_cli/events.py | 67 +++++++++++- src/specify_cli/extensions/_commands.py | 20 ++++ tests/integrations/test_events.py | 130 +++++++++++++++++++++++- 3 files changed, 214 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 149483bf8e..d1028fdee8 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -474,15 +474,36 @@ def collect_extension_events(project_root: Path) -> ResolvedEvents: Returns a mapping of event name → list of handler configs. Multiple extensions declaring the same event each contribute a handler (in extension-directory sort order), so callers can emit all of them (#2). + + Honors the extension registry's ``enabled`` flag (#1): an explicitly + disabled extension's events are skipped so disabling an extension actually + deactivates its runtime hooks. Extensions absent from the registry (e.g. + a partially-staged install) are still included to preserve the on-disk + scan behavior. """ + from .extensions import ExtensionRegistry + events: ResolvedEvents = {} exts_dir = project_root / ".specify" / "extensions" if not exts_dir.is_dir(): return events + # Build the set of explicitly-disabled extension IDs. Extensions not + # tracked in the registry are treated as enabled (backward compat). + disabled_ids: set[str] = set() + try: + registry = ExtensionRegistry(exts_dir) + for ext_id, meta in registry.list_by_priority(include_disabled=True): + if not isinstance(meta, dict) or not meta.get("enabled", True): + disabled_ids.add(ext_id) + except Exception: + pass + for ext_dir in sorted(exts_dir.iterdir()): if not ext_dir.is_dir(): continue + if ext_dir.name in disabled_ids: + continue ext_yml = ext_dir / "extension.yml" if not ext_yml.exists(): continue @@ -856,6 +877,44 @@ def events_stale_exclusions(integration_key: str) -> set[str]: return exclusions +def refresh_integration_events(project_root: Path) -> None: + """Re-resolve and re-emit native event config for every installed + event-capable integration (#1). + + Called after extension state changes (install/uninstall/enable/disable) + so that extension-declared events are regenerated in each installed + integration's native config — otherwise the documented install-after- + ``specify init`` flow is inert and disabled/removed extension events stay + active. Each integration is refreshed independently; a failure for one + logs a warning without aborting the others. + """ + from .integrations import get_integration + from .integrations._helpers import _read_integration_json + from .integrations.manifest import IntegrationManifest + from .integration_state import installed_integration_keys + + state = _read_integration_json(project_root) + for key in installed_integration_keys(state): + integration = get_integration(key) + if integration is None or not integration.supports_events(): + continue + try: + manifest = IntegrationManifest.load(key, project_root) + except Exception as exc: + logger.warning("Could not load manifest for '%s'; skipping event refresh: %s", key, exc) + continue + try: + # Strip prior Specify hooks, then re-emit from the freshly + # resolved set (which now honors the registry's enabled flag). + _remove_native_event_hooks(integration, project_root, manifest) + events_map = resolve_events(key, integration.config, project_root, None) + if events_map: + install_integration_events(integration, project_root, manifest, events_map) + manifest.save() + except Exception as exc: + logger.warning("Failed to refresh events for '%s': %s", key, exc) + + # -- Manifest validation --------------------------------------------------- def validate_events(data: dict[str, Any]) -> None: @@ -871,9 +930,13 @@ def validate_events(data: dict[str, Any]) -> None: raise ValidationError( f"Invalid event '{event_name}': expected a mapping" ) - if not event_config.get("command"): + command = event_config.get("command") + # #17: command must be a non-empty string. A truthy non-string + # (e.g. command: [foo]) would pass a bare truthiness check and + # later render into invalid native configuration. + if not isinstance(command, str) or not command.strip(): raise ValidationError( - f"Event '{event_name}' missing required 'command' field" + f"Event '{event_name}' missing required 'command' string" ) if event_name not in CANONICAL_EVENTS: raise ValidationError( diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 7ae4f8e9f0..235bac45cb 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -627,6 +627,11 @@ def extension_add( console.print(f"\n[bold]{_escape_markup(str(manifest.name))}[/bold] (v{_escape_markup(str(manifest.version))})") console.print(f" {_escape_markup(str(manifest.description))}") + # #1: regenerate native event config for installed event-capable + # integrations so the new extension's events take effect immediately. + from ..events import refresh_integration_events + refresh_integration_events(project_root) + for warning in manifest.warnings: console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}") @@ -733,6 +738,11 @@ def extension_remove( console.print(f"\nConfig files preserved in .specify/extensions/{safe_extension_id}/") else: console.print(f"\nConfig files backed up to .specify/extensions/.backup/{safe_extension_id}/") + + # #1: regenerate native event config so the removed extension's events + # are stripped from installed integrations. + from ..events import refresh_integration_events + refresh_integration_events(project_root) console.print(f"\nTo reinstall: specify extension add {safe_extension_id}") else: console.print("[red]Error:[/red] Failed to remove extension") @@ -1501,6 +1511,11 @@ def extension_enable( console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' enabled") + # #1: regenerate native event config so the enabled extension's events + # are re-emitted in installed integrations. + from ..events import refresh_integration_events + refresh_integration_events(project_root) + @extension_app.command("disable") def extension_disable( @@ -1545,6 +1560,11 @@ def extension_disable( console.print("\nCommands will no longer be available. Hooks will not execute.") console.print(f"To re-enable: specify extension enable {_escape_markup(str(extension_id))}") + # #1: regenerate native event config so the disabled extension's events + # are stripped from installed integrations. + from ..events import refresh_integration_events + refresh_integration_events(project_root) + @extension_app.command("set-priority") def extension_set_priority( diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 6b8ae32c34..c9467880d0 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -723,7 +723,7 @@ def test_dispatcher_kept_when_other_integration_references_it(self, tmp_path): # Write the integration-state JSON so installed_integration_keys sees codex. import json as _json - state_path = tmp_path / ".specify" / "integrations.json" + state_path = tmp_path / ".specify" / "integration.json" state_path.parent.mkdir(parents=True, exist_ok=True) state_path.write_text(_json.dumps({ "default_integration": "claude", @@ -756,3 +756,131 @@ def test_symlinked_config_dir_rejected(self, tmp_path): ) # No content written through the symlink. assert not (outside / "settings.json").exists() + + +# -- Validation & lifecycle (Tier 4) ----------------------------------------- + +class TestValidateEventsCommandType: + """#17: command must be a non-empty string, not just truthy.""" + + def test_non_string_command_rejected(self): + from specify_cli.extensions import ValidationError + data = {"events": {"pre_tool_use": {"command": ["speckit.tdd.validate"]}}} + with pytest.raises(ValidationError, match="(?i)command.*string"): + validate_events(data) + + def test_empty_string_command_rejected(self): + from specify_cli.extensions import ValidationError + data = {"events": {"pre_tool_use": {"command": " "}}} + with pytest.raises(ValidationError, match="(?i)command.*string"): + validate_events(data) + + +class TestCollectExtensionEventsEnabledFlag: + """#1: collect_extension_events honors the registry's enabled flag.""" + + def test_disabled_extension_events_skipped(self, tmp_path): + from specify_cli.extensions import ExtensionRegistry + + ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text( + "extension:\n id: my-ext\n name: My Ext\n version: 1.0.0\n" + " description: test\n" + "schema_version: '1.0'\n" + "requires:\n speckit_version: '>=0.1'\n" + "provides:\n commands: []\n" + "events:\n session_start:\n command: speckit.my-ext.boot\n", + encoding="utf-8", + ) + registry = ExtensionRegistry(tmp_path / ".specify" / "extensions") + registry.add("my-ext", {"enabled": False}) + + result = collect_extension_events(tmp_path) + assert result == {} + + def test_enabled_extension_events_collected(self, tmp_path): + from specify_cli.extensions import ExtensionRegistry + + ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text( + "extension:\n id: my-ext\n name: My Ext\n version: 1.0.0\n" + " description: test\n" + "schema_version: '1.0'\n" + "requires:\n speckit_version: '>=0.1'\n" + "provides:\n commands: []\n" + "events:\n session_start:\n command: speckit.my-ext.boot\n", + encoding="utf-8", + ) + registry = ExtensionRegistry(tmp_path / ".specify" / "extensions") + registry.add("my-ext", {"enabled": True}) + + result = collect_extension_events(tmp_path) + assert result == {"session_start": [{"command": "speckit.my-ext.boot"}]} + + +class TestRefreshIntegrationEvents: + """#1: refresh_integration_events regenerates native config after + extension state changes.""" + + def test_refresh_strips_removed_extension_events(self, tmp_path): + from specify_cli.events import refresh_integration_events + from specify_cli.integrations.manifest import IntegrationManifest + + # Install claude with an event sourced from a (simulated) extension. + integration = ClaudeIntegration() + manifest = IntegrationManifest(integration.key, tmp_path, version="test") + install_integration_events( + integration, tmp_path, manifest, + {"pre_tool_use": [{"command": "speckit.my-ext.check"}]}, + ) + manifest.save() + config_path = tmp_path / ".claude/settings.json" + assert config_path.is_file() + + # Record claude as installed so refresh finds it. + state_path = tmp_path / ".specify" / "integration.json" + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text(json.dumps({ + "default_integration": "claude", + "installed_integrations": ["claude"], + })) + # No extension declares events now → refresh should strip the prior hook + # (the config is deleted when no user content remains, #14). + refresh_integration_events(tmp_path) + + if config_path.exists(): + data = json.loads(config_path.read_text()) + assert "PreToolUse" not in data.get("hooks", {}) + # If the file is gone, the hooks were stripped (and the empty config + # deleted) — also correct. + + def test_refresh_emits_newly_declared_extension_events(self, tmp_path): + from specify_cli.events import refresh_integration_events + from specify_cli.integrations.manifest import IntegrationManifest + + integration = ClaudeIntegration() + manifest = IntegrationManifest(integration.key, tmp_path, version="test") + # Initially no events. + manifest.save() + config_path = tmp_path / ".claude/settings.json" + + # Declare an extension event on disk. + ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text( + "events:\n session_start:\n command: speckit.my-ext.boot\n", + encoding="utf-8", + ) + state_path = tmp_path / ".specify" / "integration.json" + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text(json.dumps({ + "default_integration": "claude", + "installed_integrations": ["claude"], + })) + + refresh_integration_events(tmp_path) + + data = json.loads(config_path.read_text()) + assert "SessionStart" in data["hooks"] From c5b9a93f7e5169c12628ddd3d1fed9049d35f946 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Mon, 27 Jul 2026 22:13:27 +0300 Subject: [PATCH 07/20] revert: drop CHANGELOG.md/pyproject.toml version bumps from events fixes Per maintainer request, the events PR no longer carries CHANGELOG entries or pyproject version revs. This restores both files to their pre-PR (da6c20d9) state: pyproject.toml back to 0.14.2.dev0 and the [Unreleased] block removed from CHANGELOG.md. The AGENTS.md version-rev convention for __init__.py changes is intentionally waived for this PR by maintainer decision. This also clears the pending merge conflicts with upstream/main on these two files (upstream's 0.14.2 release commit c0fe0e43): our side now makes no net change to them relative to the merge-base, so a future upstream merge takes theirs on both without conflict. Assisted-by: opencode (model: glm-5.2, autonomous) --- CHANGELOG.md | 23 ----------------------- pyproject.toml | 2 +- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d1287a9a9..e0c21f1329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,29 +2,6 @@ -## [Unreleased] - -### Fixed - -- fix(events): resolve ruff lint errors in events module (E402, F401, F541) — - add `# noqa: E402` to event-command registration import, remove unused - `console` import from `commands/event.py`, remove unused imports from - `tests/integrations/test_events.py`, and drop stray f-string prefixes in the - opencode plugin builder. Unblocks the CI ruff job for the agent-runtime - events feature (#3704). -- fix(events): make generated native hooks actually execute. The resolved - events map now carries an ordered list of handlers per event so two - extensions declaring the same event both run (#2). Each adapter renders a - single complete shell `command` string (Claude/Gemini/Qwen/Devin/Tabnine - reject `command`+`args`) using a portably-resolved Python interpreter - instead of hard-coded `python3` (#6, #16). Gemini timeouts are converted - from seconds to milliseconds (#7). The core command runner resolves the - project's sh/ps/py variant and splits the script command into argv instead - of treating `scripts:` strings as bare paths (#4), and the bundled-template - fallback now uses the canonical `_assets` resolvers (#5). OpenCode TS - plugin failures propagate via `throw new Error` instead of - `process.exit(2)`, which killed the host agent (#13). - ## [0.14.1] - 2026-07-23 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 27ffecb013..60eda21d85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.14.2.dev2" +version = "0.14.2.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" From aaab4d238f74a8611931fbae70b15fae5b57b48b Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Mon, 27 Jul 2026 22:15:11 +0300 Subject: [PATCH 08/20] fix(events): compose --events into Copilot/Devin options() (#8, #9) Copilot and Devin are event-capable, but their options() overrides returned only --skills without calling super(), so the base class never declared --events. The documented --integration-options "--events false" opt-out was therefore rejected as unknown for both adapters. Both now compose with super().options() (mirroring Codex and Cursor) so --events is declared alongside --skills. Added a TestEventCapableOptionsCompo sition test class asserting --events appears in Copilot, Devin, Cursor, and Codex options() output. Refs: PR #3704 Copilot review 4790195897 (findings #8, #9) Assisted-by: opencode (model: glm-5.2, autonomous) --- .../integrations/copilot/__init__.py | 9 +++- .../integrations/devin/__init__.py | 9 +++- tests/integrations/test_events.py | 44 +++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 38479c2bc6..965f5692c7 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -172,14 +172,19 @@ def invoke_separator_for_mode(self, skills_enabled: bool) -> str: @classmethod def options(cls) -> list[IntegrationOption]: - return [ + # Compose with super() so the base class declares --events for this + # event-capable integration; otherwise --integration-options + # "--events false" is rejected as unknown (#9). + opts = super().options() + opts.append( IntegrationOption( "--skills", is_flag=True, default=False, help="Scaffold commands as agent skills (speckit-/SKILL.md) instead of .agent.md files", ), - ] + ) + return opts def _resolve_executable(self) -> str: """Return the Copilot CLI executable, respecting the env-var override. diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py index 6b2b925dfb..2de70173a3 100644 --- a/src/specify_cli/integrations/devin/__init__.py +++ b/src/specify_cli/integrations/devin/__init__.py @@ -66,11 +66,16 @@ def build_exec_args( @classmethod def options(cls) -> list[IntegrationOption]: - return [ + # Compose with super() so the base class declares --events for this + # event-capable integration; otherwise --integration-options + # "--events false" is rejected as unknown (#8). + opts = super().options() + opts.append( IntegrationOption( "--skills", is_flag=True, default=True, help="Install as agent skills (default for Devin)", ), - ] + ) + return opts diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index c9467880d0..63fad55456 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -193,6 +193,50 @@ def test_copilot_mapping(self): assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "userPromptSubmitted" +# -- Event-capable adapters declare --events (#8, #9) ------------------------ + +class TestEventCapableOptionsComposition: + """Event-capable integrations must declare --events so the documented + --events false opt-out is accepted.""" + + def _has_option(self, opts, name): + return any(o.name == name for o in opts) + + def test_copilot_declares_events(self): + # #9: CopilotIntegration.options() composed with super() so --events + # is declared alongside --skills. + opts = CopilotIntegration().options() + assert self._has_option(opts, "--skills") + assert self._has_option(opts, "--events"), ( + "Copilot is event-capable but --events is not declared; " + "--integration-options \"--events false\" would be rejected." + ) + + def test_devin_declares_events(self): + # #8: DevinIntegration.options() composed with super() so --events + # is declared alongside --skills. + from specify_cli.integrations.devin import DevinIntegration + opts = DevinIntegration().options() + assert self._has_option(opts, "--skills") + assert self._has_option(opts, "--events"), ( + "Devin is event-capable but --events is not declared; " + "--integration-options \"--events false\" would be rejected." + ) + + def test_cursor_declares_events(self): + # Cursor already composed correctly; assert it stays that way. + opts = CursorAgentIntegration().options() + assert self._has_option(opts, "--skills") + assert self._has_option(opts, "--events") + + def test_codex_declares_events(self): + # Codex already composed correctly; assert it stays that way. + from specify_cli.integrations.codex import CodexIntegration + opts = CodexIntegration().options() + assert self._has_option(opts, "--skills") + assert self._has_option(opts, "--events") + + # -- validate_events -------------------------------------------------------- class TestValidateEvents: From 524c2107bbf97d94d3752f8849fb614d4408e688 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Mon, 27 Jul 2026 22:29:25 +0300 Subject: [PATCH 09/20] fix(events): Cursor version field, matcher grouping, Copilot cross-OS Address three Copilot review findings on native-config generation: - #7: Cursor's .cursor/hooks.json schema requires top-level "version": 1, but json-flat used _merge_json_fragment() which only writes hooks, so a freshly generated file was missing the required schema version. Added a version kwarg to _merge_json_fragment (preserving a user's value if present) and the Cursor json-flat branch now passes version=1. - S3: json-nested placed all handlers under the first handler's matcher, so two extensions registering the same event with different matchers both ran for the first matcher and neither for the later. Handlers are now grouped by distinct matcher, emitting one matcher-group per matcher (handlers sharing a matcher stay in one group). - S4: Copilot's bash and powershell fields both received the same host-resolved command, so a config generated on Linux wrote a POSIX venv path into the PowerShell hook (and vice-versa). _dispatcher_command gains a target_os kwarg; Copilot now emits an independent POSIX interpreter (python3) for bash and a Windows interpreter (python) for powershell, so the checked-in config works on either OS. Tests: added TestCursorJsonWriting (version present + preserved) and matcher-grouping regressions (per-distinct-matcher, shared-matcher); updated the Copilot generation test to assert bash != powershell with OS-appropriate interpreters. Refs: PR #3704 Copilot review 4790195897 (findings #7, S3, S4) Assisted-by: opencode (model: glm-5.2, autonomous) --- src/specify_cli/events.py | 85 ++++++++++++++++++++++------ tests/integrations/test_events.py | 92 ++++++++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 19 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index d1028fdee8..e27fe2b588 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -536,6 +536,25 @@ def _resolve_interpreter(project_root: Path) -> str: return IntegrationBase.resolve_python_interpreter(project_root) +def _resolve_interpreter_for_target(target_os: str) -> str: + """Resolve a Python interpreter for a target OS, independent of the host (#S4). + + Copilot's native config carries both a ``bash`` (POSIX) and a + ``powershell`` (Windows) variant in the same checked-in file. Resolving + both with the *host* interpreter writes a Linux venv path into the + PowerShell hook (or vice-versa), so the config fails on the other OS. + Each variant instead gets a portable interpreter for its target shell; + the dispatcher script's own ``_find_specify()`` does per-OS venv + resolution at runtime. + """ + if target_os == "windows": + # Windows: ``python`` is the most portable on PATH; the py launcher + # (``py -3``) is the recommended fallback when ``python`` is absent. + return "python" + # POSIX (bash): ``python3`` is universally available. + return "python3" + + def _native_timeout(integration: IntegrationBase, timeout_seconds: Any) -> int: """Return the timeout in the unit the integration's native config expects. @@ -557,6 +576,8 @@ def _dispatcher_command( project_root: Path, command_name: str, event_name: str, + *, + target_os: str = "host", ) -> str: """Build the single shell command string that invokes the dispatcher (#6). @@ -565,8 +586,16 @@ def _dispatcher_command( `` ``. The interpreter is resolved portably (#16); Claude's dispatcher path is prefixed with ``${CLAUDE_PROJECT_DIR}/`` (Claude expands it before shell execution). + + ``target_os`` selects an OS-appropriate interpreter for adapters that emit + both POSIX and Windows variants into one checked-in file (Copilot): ``host`` + uses the host-resolved interpreter (venv-aware), while ``posix``/``windows`` + emit portable interpreters so the config works on either OS (#S4). """ - interpreter = _resolve_interpreter(project_root) + if target_os == "host": + interpreter = _resolve_interpreter(project_root) + else: + interpreter = _resolve_interpreter_for_target(target_os) if integration.key == "claude": dispatcher = "${CLAUDE_PROJECT_DIR}/" + EVENTS_DISPATCHER_REL else: @@ -659,23 +688,30 @@ def install_integration_events( elif fmt == "copilot-json": # Copilot dedicated .github/hooks/speckit.json. Each handler becomes - # its own entry in the native event's list (#2), with bash/powershell - # variants sharing one resolved interpreter (#16). Entries carry the - # ownership marker so a pre-existing user-authored file is merged - # (owned entries replaced) rather than overwritten (#8), and teardown - # removes only owned entries. + # its own entry in the native event's list (#2). The bash and + # powershell variants get independent OS-targeted interpreters (#S4) + # so a config generated on Linux doesn't write a POSIX venv path into + # the PowerShell hook (and vice-versa). Entries carry the ownership + # marker so a pre-existing user-authored file is merged (owned entries + # replaced) rather than overwritten (#8), and teardown removes only + # owned entries. copilot_hooks: dict[str, list[dict[str, Any]]] = {} for ev, handlers in filtered.items(): native = canonical_to_native[ev] entries: list[dict[str, Any]] = [] for cfg in handlers: command = cfg.get("command", "") - dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev) + bash_cmd = _dispatcher_command( + integration, project_root, command, ev, target_os="posix" + ) + ps_cmd = _dispatcher_command( + integration, project_root, command, ev, target_os="windows" + ) entries.append( { "type": "command", - "bash": dispatcher_cmd, - "powershell": dispatcher_cmd, + "bash": bash_cmd, + "powershell": ps_cmd, "timeoutSec": _native_timeout(integration, cfg.get("timeout", 60)), _SPECKIT_MARKER: True, } @@ -731,7 +767,9 @@ def install_integration_events( } ) cursor_hooks[native] = entries - _merge_json_fragment(config_path, cursor_hooks) + # #7: Cursor's .cursor/hooks.json schema requires top-level + # "version": 1; ensure it (preserving a user's value if present). + _merge_json_fragment(config_path, cursor_hooks, version=1) rel = str(config_path.relative_to(project_root)) if rel not in manifest.files: manifest.record_existing(rel) @@ -742,15 +780,20 @@ def install_integration_events( # Native schema is a single ``command`` string per hook (not # command+args), so each handler renders one complete dispatcher # invocation (#6). Gemini timeouts are converted to ms (#7). + # Handlers are grouped by distinct matcher so each matcher gets its + # own matcher-group (S3); previously all handlers were placed under + # the first handler's matcher, so two extensions registering the same + # event with different matchers both ran for the first matcher and + # neither for the later. nested_hooks: dict[str, list[dict[str, Any]]] = {} for ev, handlers in filtered.items(): native = canonical_to_native[ev] - matcher = handlers[0].get("matcher", "*") if handlers else "*" - inner_hooks: list[dict[str, Any]] = [] + by_matcher: dict[str, list[dict[str, Any]]] = {} for cfg in handlers: + matcher = cfg.get("matcher", "*") command = cfg.get("command", "") dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev) - inner_hooks.append( + by_matcher.setdefault(matcher, []).append( { "type": "command", "command": dispatcher_cmd, @@ -759,10 +802,8 @@ def install_integration_events( } ) nested_hooks[native] = [ - { - "matcher": matcher, - "hooks": inner_hooks, - } + {"matcher": matcher, "hooks": inner} + for matcher, inner in by_matcher.items() ] _merge_json_fragment(config_path, nested_hooks) rel = str(config_path.relative_to(project_root)) @@ -1183,7 +1224,7 @@ def _remove_copilot_entries(dst: Path) -> bool: return False -def _merge_json_fragment(dst: Path, new_hooks: dict) -> None: +def _merge_json_fragment(dst: Path, new_hooks: dict, *, version: int | None = None) -> None: """Merge Specify-authored hook entries into a native JSON config. Idempotent: removes ALL prior Specify-marked entries from every event in @@ -1196,6 +1237,11 @@ def _merge_json_fragment(dst: Path, new_hooks: dict) -> None: Aborts with a warning (no write) when the existing file cannot be parsed (#22) — e.g. JSONC with comments — instead of resetting user content to ``{}``. + + When *version* is given, the top-level ``version`` field is ensured + (preserving a user's value if present) so formats that require it — e.g. + Cursor's ``.cursor/hooks.json`` schema (``version: 1``) — stay valid on a + freshly generated file (#7). """ existing = _load_user_json(dst) if existing is None: @@ -1203,6 +1249,9 @@ def _merge_json_fragment(dst: Path, new_hooks: dict) -> None: if not isinstance(existing, dict): existing = {} + if version is not None: + existing.setdefault("version", version) + hooks_key = "hooks" existing_hooks = existing.get(hooks_key, {}) if not isinstance(existing_hooks, dict): diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 63fad55456..6b7893f0b8 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -388,10 +388,100 @@ def test_copilot_json_generation(self, tmp_path): # #6: a complete shell command string (not command+args). assert "speckit.agent-context.update" in entry["bash"] assert "session_start" in entry["bash"] - assert entry["bash"] == entry["powershell"] + # S4: bash and powershell get independent OS-targeted interpreters so + # a config generated on one OS works on the other. + assert "speckit.agent-context.update" in entry["powershell"] + assert entry["bash"] != entry["powershell"] + assert entry["bash"].startswith("python3 ") + assert entry["powershell"].startswith("python ") assert entry["timeoutSec"] == 60 +# -- Cursor hooks.json version + matcher grouping (#7, S3) ------------------- + +class TestCursorJsonWriting: + """#7: .cursor/hooks.json requires top-level version:1; S3: matcher grouping.""" + + def test_cursor_json_includes_version(self, tmp_path): + integration = CursorAgentIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + + install_integration_events( + integration, tmp_path, manifest, + {"session_start": [{"command": "speckit.boot"}]}, + ) + data = json.loads((tmp_path / ".cursor/hooks.json").read_text()) + assert data["version"] == 1 + assert "sessionStart" in data["hooks"] + + def test_cursor_json_preserves_user_version(self, tmp_path): + integration = CursorAgentIntegration() + config_path = tmp_path / ".cursor/hooks.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps({"version": 1, "hooks": {}})) + + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + install_integration_events( + integration, tmp_path, manifest, + {"session_start": [{"command": "speckit.boot"}]}, + ) + data = json.loads(config_path.read_text()) + assert data["version"] == 1 + + def test_nested_matcher_grouping_per_distinct_matcher(self, tmp_path): + """S3: two handlers with different matchers produce two matcher-groups.""" + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + + events = { + "pre_tool_use": [ + {"command": "speckit.first", "matcher": "Edit"}, + {"command": "speckit.second", "matcher": "Bash"}, + ], + } + install_integration_events(integration, tmp_path, manifest, events) + data = json.loads((tmp_path / ".claude/settings.json").read_text()) + groups = data["hooks"]["PreToolUse"] + matchers = sorted(g["matcher"] for g in groups) + assert matchers == ["Bash", "Edit"] + # Each group holds exactly its own handler. + by_matcher = {g["matcher"]: g["hooks"] for g in groups} + assert len(by_matcher["Edit"]) == 1 + assert "speckit.first" in by_matcher["Edit"][0]["command"] + assert len(by_matcher["Bash"]) == 1 + assert "speckit.second" in by_matcher["Bash"][0]["command"] + + def test_nested_shared_matcher_stays_one_group(self, tmp_path): + """S3: handlers sharing a matcher stay in a single matcher-group.""" + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + + events = { + "pre_tool_use": [ + {"command": "speckit.first", "matcher": "Edit"}, + {"command": "speckit.second", "matcher": "Edit"}, + ], + } + install_integration_events(integration, tmp_path, manifest, events) + data = json.loads((tmp_path / ".claude/settings.json").read_text()) + groups = data["hooks"]["PreToolUse"] + assert len(groups) == 1 + assert groups[0]["matcher"] == "Edit" + assert len(groups[0]["hooks"]) == 2 + + # -- Gemini timeout unit (#7) ------------------------------------------------ class TestGeminiTimeoutUnit: From ceee5071d03fe01218959ac985b20b42d287402e Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Mon, 27 Jul 2026 22:36:47 +0300 Subject: [PATCH 10/20] fix(events): anchor py scripts and prefix ps launcher in command runner Address two Copilot review findings on the core command runner: - S2: the py variant called build_python_invocation() on the raw scripts: command string, which left 'scripts/...' anchored at the project root instead of under .specify/ (or .specify/extensions//). Every event command in a project configured with --script py launched a nonexistent project-root path. The py branch now shares the same base-anchoring as sh/ps and prepends the resolved interpreter as argv (no shell quoting needed for subprocess.run(shell=False)). - S6: the ps variant returned the .ps1 path as the executable, but Windows subprocess.run(shell=False) cannot execute a PowerShell script directly, so event dispatch failed on the default Windows script type. The ps branch now prefixes argv with 'pwsh -File' (PowerShell 7+), falling back to 'powershell -File' (Windows PowerShell) when pwsh is absent. Tests: added test_py_variant_anchored_under_specify and test_ps_variant_prefixed_with_powershell_launcher covering the new argv shapes (interpreter + .specify-anchored path; launcher -File + path). Refs: PR #3704 Copilot review 4790195897 (findings S2, S6) Assisted-by: opencode (model: glm-5.2, autonomous) --- src/specify_cli/events.py | 52 ++++++++++++++++++------------- tests/integrations/test_events.py | 52 +++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 22 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index e27fe2b588..9bb5618a3d 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -13,6 +13,7 @@ import os import re import shlex +import shutil import sys import subprocess import platform @@ -250,34 +251,41 @@ def _resolve_event_command_argv( if not isinstance(script_cmd, str) or not script_cmd.strip(): return None - # Resolve the script's project-relative base so a leading path component - # (e.g. ``scripts/bash/setup-plan.sh``) is anchored under .specify/ (core) - # or .specify/extensions// (extension). - if variant == "py": - # .py files aren't directly executable on Windows; prefix the resolved - # interpreter. build_python_invocation returns a shell-quoted command - # string, so split it back into argv for subprocess. - invocation = IntegrationBase.build_python_invocation(script_cmd, project_root) - try: - return shlex.split(invocation, posix=(os.name != "nt")) - except ValueError: - return None - - # sh / ps: the command string is " [args...]". Resolve the - # leading path component against the project, then rejoin with the - # remaining tokens. - tokens = shlex.split(script_cmd, posix=(os.name != "nt")) - if not tokens: - return None - script_rel = tokens[0] + # Base under which the script's leading path component is anchored — + # .specify/ (core) or .specify/extensions// (extension). All variants + # share this anchoring so a `scripts/...` token resolves correctly (S2: + # the py branch previously invoked build_python_invocation() on the raw + # command string, leaving `scripts/...` anchored at the project root). if ext_id: base = project_root / ".specify" / "extensions" / ext_id else: base = project_root / ".specify" - script_abs = base / script_rel + + tokens = shlex.split(script_cmd, posix=(os.name != "nt")) + if not tokens: + return None + script_abs = base / tokens[0] if not script_abs.exists(): return None - return [str(script_abs), *tokens[1:]] + rest_args = tokens[1:] + + if variant == "py": + # .py files aren't directly executable on Windows; prefix the resolved + # interpreter. argv is passed to subprocess.run(shell=False), so no + # shell quoting is needed. + interpreter = IntegrationBase.resolve_python_interpreter(project_root) + return [interpreter, str(script_abs), *rest_args] + + if variant == "ps": + # PowerShell scripts cannot be executed directly by + # subprocess.run(shell=False); invoke via `pwsh -File` (PowerShell 7+), + # falling back to `powershell -File` (Windows PowerShell) when pwsh is + # absent (S6). The default Windows script type would otherwise fail. + launcher = shutil.which("pwsh") or shutil.which("powershell") or "pwsh" + return [launcher, "-File", str(script_abs), *rest_args] + + # sh: the script is chmod'd executable during install. + return [str(script_abs), *rest_args] def _load_project_script_type(project_root: Path) -> str: diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 6b7893f0b8..f855766ea1 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -604,6 +604,58 @@ def test_run_command_resolves_and_executes(self, tmp_path): code = resolve_and_run_event_command("speckit.test", "session_start", "{}", tmp_path) assert code == 0 + def test_py_variant_anchored_under_specify(self, tmp_path): + """S2: the py variant resolves scripts/... under .specify/, not the + project root, and prepends the resolved interpreter.""" + from specify_cli.events import _resolve_event_command_argv + + cmd_dir = tmp_path / ".specify" / "templates" / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "boot.md").write_text( + "---\n" + "description: \"Boot\"\n" + "scripts:\n" + " py: scripts/python/boot.py\n" + "---\nBody\n", + encoding="utf-8", + ) + py_dir = tmp_path / ".specify" / "scripts" / "python" + py_dir.mkdir(parents=True) + (py_dir / "boot.py").write_text("import sys; sys.exit(0)\n", encoding="utf-8") + + argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) + assert argv is not None + # Interpreter first, then the .specify-anchored script path. + assert len(argv) >= 2 + assert argv[1].endswith(".specify/scripts/python/boot.py") + assert ".specify" in argv[1] + + def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path): + """S6: the ps variant prefixes argv with pwsh/powershell -File so + subprocess.run(shell=False) can execute the .ps1 script.""" + from specify_cli.events import _resolve_event_command_argv + + cmd_dir = tmp_path / ".specify" / "templates" / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "boot.md").write_text( + "---\n" + "description: \"Boot\"\n" + "scripts:\n" + " ps: scripts/powershell/boot.ps1\n" + "---\nBody\n", + encoding="utf-8", + ) + ps_dir = tmp_path / ".specify" / "scripts" / "powershell" + ps_dir.mkdir(parents=True) + (ps_dir / "boot.ps1").write_text("exit 0\n", encoding="utf-8") + + argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) + assert argv is not None + # Launcher (pwsh or powershell), -File, then the .specify-anchored script. + assert argv[0] in ("pwsh", "powershell") or argv[0].endswith("pwsh") or argv[0].endswith("powershell") + assert argv[1] == "-File" + assert argv[2].endswith(".specify/scripts/powershell/boot.ps1") + # -- Merge/teardown idempotency & safety (Tier 3) ---------------------------- From be43bc4c929bd58eb1bc0414a6ecc052d65686d8 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Mon, 27 Jul 2026 22:51:04 +0300 Subject: [PATCH 11/20] fix(events): skip-tracking on parse fail, drop dispatcher claim on retain, honor --events false in refresh, preserve layers on invalid override Address four Copilot review findings on merge/teardown/refresh safety: - S5: _merge_json_fragment/_merge_opencode_plugin_ref/_merge_copilot_json now return bool (wrote). Install branches skip manifest.record_existing() and created.append() when a merge was skipped on parse failure, so a user's JSONC/malformed native config is not tracked and manifest.uninstall() can't later delete the untouched file. - S1: remove_integration_events now drops this integration's manifest claim on the shared dispatcher (manifest.remove) even when the file is retained because another integration references it. Previously the retained file stayed tracked, so the subsequent manifest.uninstall() in teardown() saw the matching hash and deleted the file another integration still depended on. The unit test now exercises full teardown() (not just remove_integration_events) to cover the gap. - S7: refresh_integration_events reads each integration's stored parsed_options via _resolve_integration_options and passes them to resolve_events, so a persisted --events false is honored across extension add/enable/disable instead of being discarded (which re-enabled events the user had disabled). - #10: an invalid override entry now abandons the entire override and keeps the accumulated built-in + extension layers, instead of resetting resolved_override to {} and assigning that empty map to events (which silently disabled all hooks on a single typo). Only a fully-valid override (including an explicit events: {}) replaces the prior layers. Tests: added TestOverridePreserveLayers (invalid entry keeps layers; explicit empty disables), TestSkippedMergeNotTracked (JSONC not recorded), and TestDispatcherManifestClaimDroppedOnRetain (full teardown keeps dispatcher when another integration references it). Added S7 refresh-honors-events-false regression. Refs: PR #3704 Copilot review 4790195897 (findings S5, S1, S7, #10) Assisted-by: opencode (model: glm-5.2, autonomous) --- src/specify_cli/events.py | 108 ++++++++++++------- tests/integrations/test_events.py | 169 ++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 37 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 9bb5618a3d..b18699913e 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -452,7 +452,15 @@ def resolve_events( override_file, integration_key, ) else: + # Validate every entry before adopting the override. A single + # invalid entry abandons the whole override and keeps the + # accumulated built-in + extension layers (#10): previously a + # typo reset resolved_override to {} and then assigned that + # empty map to events, silently disabling all hooks despite + # the "ignored" warning. Only a fully-valid override (including + # an explicit `events: {}`) replaces the prior layers. resolved_override: ResolvedEvents = {} + override_valid = True for ev, raw in key_events.items(): handlers = _normalize_handlers(raw) if not handlers: @@ -465,13 +473,15 @@ def resolve_events( _validate_resolved_event(ev, handlers) except Exception as exc: logger.warning( - "Override %s: invalid event '%s': %s; ignoring override", + "Override %s: invalid event '%s': %s; ignoring entire override", override_file, ev, exc, ) - resolved_override = {} + override_valid = False break resolved_override[ev] = handlers - events = resolved_override + if override_valid: + events = resolved_override + # else: keep the accumulated built-in + extension layers. return events @@ -687,12 +697,15 @@ def install_integration_events( ) created.append(plugin_path) - # Merge plugin path into opencode.json - _merge_opencode_plugin_ref(config_path, f"./{plugin_rel}") - rel = str(config_path.relative_to(project_root)) - if rel not in manifest.files: - manifest.record_existing(rel) - created.append(config_path) + # Merge plugin path into opencode.json. S5: only track the config + # file when the merge actually wrote; a skipped merge (JSONC/malformed) + # must not be tracked or manifest.uninstall() would later delete the + # user's untouched file. + if _merge_opencode_plugin_ref(config_path, f"./{plugin_rel}"): + rel = str(config_path.relative_to(project_root)) + if rel not in manifest.files: + manifest.record_existing(rel) + created.append(config_path) elif fmt == "copilot-json": # Copilot dedicated .github/hooks/speckit.json. Each handler becomes @@ -725,11 +738,12 @@ def install_integration_events( } ) copilot_hooks[native] = entries - _merge_copilot_json(config_path, copilot_hooks) - rel = str(config_path.relative_to(project_root)) - if rel not in manifest.files: - manifest.record_existing(rel) - created.append(config_path) + # S5: only track when the merge wrote (skips on JSONC/malformed). + if _merge_copilot_json(config_path, copilot_hooks): + rel = str(config_path.relative_to(project_root)) + if rel not in manifest.files: + manifest.record_existing(rel) + created.append(config_path) elif fmt == "toml": # Codex config.toml custom merge. One [[hooks..hooks]] block @@ -777,11 +791,12 @@ def install_integration_events( cursor_hooks[native] = entries # #7: Cursor's .cursor/hooks.json schema requires top-level # "version": 1; ensure it (preserving a user's value if present). - _merge_json_fragment(config_path, cursor_hooks, version=1) - rel = str(config_path.relative_to(project_root)) - if rel not in manifest.files: - manifest.record_existing(rel) - created.append(config_path) + # S5: only track when the merge wrote (skips on JSONC/malformed). + if _merge_json_fragment(config_path, cursor_hooks, version=1): + rel = str(config_path.relative_to(project_root)) + if rel not in manifest.files: + manifest.record_existing(rel) + created.append(config_path) elif fmt == "json-nested": # Claude/Qwen/Gemini/Devin/Tabnine nested config JSON merge. @@ -813,11 +828,12 @@ def install_integration_events( {"matcher": matcher, "hooks": inner} for matcher, inner in by_matcher.items() ] - _merge_json_fragment(config_path, nested_hooks) - rel = str(config_path.relative_to(project_root)) - if rel not in manifest.files: - manifest.record_existing(rel) - created.append(config_path) + # S5: only track when the merge wrote (skips on JSONC/malformed). + if _merge_json_fragment(config_path, nested_hooks): + rel = str(config_path.relative_to(project_root)) + if rel not in manifest.files: + manifest.record_existing(rel) + created.append(config_path) return created @@ -892,14 +908,19 @@ def remove_integration_events( """ _remove_native_event_hooks(integration, project_root, manifest) - # Clean up dispatcher script only if no other integration still uses it. + # Drop this integration's manifest claim on the shared dispatcher and + # delete the file only when no other installed event-capable integration + # still references it (#10). The manifest.remove() runs in both branches + # (S1): if we retain the file but leave it tracked, the subsequent + # manifest.uninstall() in teardown() sees the matching hash and deletes + # the file another integration still depends on. dispatcher_rel = EVENTS_DISPATCHER_REL if dispatcher_rel in manifest.files: + manifest.remove(dispatcher_rel) if not _other_event_integrations_reference_dispatcher(project_root, integration.key): dispatcher_path = project_root / dispatcher_rel if dispatcher_path.exists(): dispatcher_path.unlink(missing_ok=True) - manifest.remove(dispatcher_rel) # Clean up opencode TS plugin (owned solely by the opencode integration). if integration.key == "opencode": @@ -938,7 +959,7 @@ def refresh_integration_events(project_root: Path) -> None: logs a warning without aborting the others. """ from .integrations import get_integration - from .integrations._helpers import _read_integration_json + from .integrations._helpers import _read_integration_json, _resolve_integration_options from .integrations.manifest import IntegrationManifest from .integration_state import installed_integration_keys @@ -956,7 +977,13 @@ def refresh_integration_events(project_root: Path) -> None: # Strip prior Specify hooks, then re-emit from the freshly # resolved set (which now honors the registry's enabled flag). _remove_native_event_hooks(integration, project_root, manifest) - events_map = resolve_events(key, integration.config, project_root, None) + # S7: resolve this integration's persisted parsed_options so a + # stored --events false is honored across extension lifecycle + # changes; passing None would re-enable events the user disabled. + _, parsed_options = _resolve_integration_options(integration, state, key, None) + events_map = resolve_events( + key, integration.config, project_root, parsed_options + ) if events_map: install_integration_events(integration, project_root, manifest, events_map) manifest.save() @@ -1080,15 +1107,16 @@ def _build_opencode_plugin( ) -def _merge_opencode_plugin_ref(config_path: Path, ref: str) -> None: +def _merge_opencode_plugin_ref(config_path: Path, ref: str) -> bool: """Merge the speckit-events plugin ref into opencode.json. Aborts with a warning (#23) when the file cannot be parsed (e.g. JSONC or - malformed JSON) instead of resetting user configuration to ``{}``. + malformed JSON) instead of resetting user configuration to ``{}``. Returns + False when skipped so callers avoid tracking the untouched file (S5). """ existing = _load_user_json(config_path) if existing is None: - return + return False plugins = existing.get("plugin", []) if not isinstance(plugins, list): plugins = [] @@ -1096,6 +1124,7 @@ def _merge_opencode_plugin_ref(config_path: Path, ref: str) -> None: plugins.append(ref) existing["plugin"] = plugins _safe_write_json(config_path, existing) + return True def _remove_opencode_entries(config_path: Path) -> bool: @@ -1164,16 +1193,17 @@ def _remove_toml_entries(dst: Path) -> bool: return False -def _merge_copilot_json(dst: Path, new_hooks: dict[str, list]) -> None: +def _merge_copilot_json(dst: Path, new_hooks: dict[str, list]) -> bool: """Merge Specify-owned hooks into Copilot's dedicated hooks JSON (#8). A pre-existing user-authored ``.github/hooks/speckit.json`` is merged (owned entries replaced via markers) rather than overwritten, and a - parse failure aborts instead of resetting user content (#22). + parse failure aborts instead of resetting user content (#22). Returns + False when skipped so callers avoid tracking the untouched file (S5). """ existing = _load_user_json(dst) if existing is None: - return + return False if not isinstance(existing, dict): existing = {} existing.setdefault("version", 1) @@ -1195,6 +1225,7 @@ def _merge_copilot_json(dst: Path, new_hooks: dict[str, list]) -> None: else: existing.pop("hooks", None) _safe_write_json(dst, existing) + return True def _remove_copilot_entries(dst: Path) -> bool: @@ -1232,7 +1263,7 @@ def _remove_copilot_entries(dst: Path) -> bool: return False -def _merge_json_fragment(dst: Path, new_hooks: dict, *, version: int | None = None) -> None: +def _merge_json_fragment(dst: Path, new_hooks: dict, *, version: int | None = None) -> bool: """Merge Specify-authored hook entries into a native JSON config. Idempotent: removes ALL prior Specify-marked entries from every event in @@ -1244,7 +1275,9 @@ def _merge_json_fragment(dst: Path, new_hooks: dict, *, version: int | None = No Aborts with a warning (no write) when the existing file cannot be parsed (#22) — e.g. JSONC with comments — instead of resetting user content to - ``{}``. + ``{}``. Returns False when the merge was skipped so callers avoid tracking + the untouched file (S5: otherwise manifest.uninstall() later deletes the + user's JSONC/malformed file). When *version* is given, the top-level ``version`` field is ensured (preserving a user's value if present) so formats that require it — e.g. @@ -1253,7 +1286,7 @@ def _merge_json_fragment(dst: Path, new_hooks: dict, *, version: int | None = No """ existing = _load_user_json(dst) if existing is None: - return + return False if not isinstance(existing, dict): existing = {} @@ -1283,6 +1316,7 @@ def _merge_json_fragment(dst: Path, new_hooks: dict, *, version: int | None = No else: existing.pop(hooks_key, None) _safe_write_json(dst, existing) + return True def _drop_marked_entries(entries: list) -> list: diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index f855766ea1..d133b986b7 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -1070,3 +1070,172 @@ def test_refresh_emits_newly_declared_extension_events(self, tmp_path): data = json.loads(config_path.read_text()) assert "SessionStart" in data["hooks"] + + def test_refresh_honors_stored_events_false(self, tmp_path): + """S7: a stored --events false must be honored across extension + lifecycle refresh; passing None would re-enable events.""" + from specify_cli.events import refresh_integration_events + from specify_cli.integrations.manifest import IntegrationManifest + + integration = ClaudeIntegration() + manifest = IntegrationManifest(integration.key, tmp_path, version="test") + manifest.save() + config_path = tmp_path / ".claude/settings.json" + + # Declare an extension event on disk. + ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text( + "events:\n session_start:\n command: speckit.my-ext.boot\n", + encoding="utf-8", + ) + # Store the integration with --events false in parsed_options. + state_path = tmp_path / ".specify" / "integration.json" + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text(json.dumps({ + "default_integration": "claude", + "installed_integrations": ["claude"], + "integration_settings": { + "claude": {"parsed_options": {"events": "false"}}, + }, + })) + + refresh_integration_events(tmp_path) + + # No hooks should have been re-created (CLI gate honored). + if config_path.exists(): + assert "hooks" not in json.loads(config_path.read_text()) + + +# -- Override preserve-layers (#10) ------------------------------------------ + +class TestOverridePreserveLayers: + """#10: an invalid override entry abandons the whole override and keeps + the accumulated built-in + extension layers, instead of disabling all + hooks.""" + + def test_invalid_override_entry_keeps_prior_layers(self, tmp_path): + override_file = tmp_path / ".specify" / "integration-events.yml" + override_file.parent.mkdir(parents=True, exist_ok=True) + # One valid entry, one invalid (non-string command) — the whole + # override is ignored, built-in defaults survive. + override_file.write_text( + "integrations:\n" + " claude:\n" + " events:\n" + " stop:\n" + " command: speckit.valid.stop\n" + " pre_tool_use:\n" + " command: [not-a-string]\n", + encoding="utf-8", + ) + result = resolve_events( + "claude", + {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, + tmp_path, + None, + ) + # Built-in default survived (override was abandoned on the invalid entry). + assert "post_tool_use" in result + assert result["post_tool_use"] == [{"command": "speckit.tdd.validate"}] + + def test_explicit_empty_override_disables(self, tmp_path): + """A fully-valid explicit `events: {}` override still disables.""" + override_file = tmp_path / ".specify" / "integration-events.yml" + override_file.parent.mkdir(parents=True, exist_ok=True) + override_file.write_text( + "integrations:\n" + " claude:\n" + " events: {}\n", + encoding="utf-8", + ) + result = resolve_events( + "claude", + {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, + tmp_path, + None, + ) + assert result == {} + + +# -- Skipped-merge not tracked (S5) ------------------------------------------ + +class TestSkippedMergeNotTracked: + """S5: when a merge is skipped on parse failure, the untouched file is + not recorded in the manifest, so uninstall() won't later delete it.""" + + def test_jsonc_native_config_not_tracked(self, tmp_path): + integration = ClaudeIntegration() + config_path = tmp_path / ".claude/settings.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + jsonc = '{\n // my comment\n "hooks": {}\n}\n' + config_path.write_text(jsonc) + + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + manifest.remove = MagicMock() + + install_integration_events( + integration, tmp_path, manifest, + {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, + ) + # The JSONC file must NOT have been recorded (would cause uninstall() + # to delete it later). Only the dispatcher (which we wrote) is tracked. + recorded_rels = [c.args[0] for c in manifest.record_existing.call_args_list] + assert str(config_path.relative_to(tmp_path)) not in recorded_rels + # User content preserved verbatim. + assert config_path.read_text() == jsonc + + +# -- Dispatcher manifest claim dropped on retain (S1) ------------------------ + +class TestDispatcherManifestClaimDroppedOnRetain: + """S1: when the dispatcher is retained (another integration references + it), this integration's manifest still drops its claim so the subsequent + manifest.uninstall() in teardown() doesn't delete the shared file.""" + + def test_full_teardown_keeps_dispatcher_when_other_references_it(self, tmp_path): + from specify_cli.integrations.codex import CodexIntegration + from specify_cli.integrations.manifest import IntegrationManifest + + claude = ClaudeIntegration() + codex = CodexIntegration() + + # Install claude's events (writes dispatcher + claude config). + claude_manifest = IntegrationManifest(claude.key, tmp_path, version="test") + install_integration_events( + claude, tmp_path, claude_manifest, + {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, + ) + claude_manifest.save() + + # Install codex's events (re-writes shared dispatcher + codex config). + codex_manifest = IntegrationManifest(codex.key, tmp_path, version="test") + install_integration_events( + codex, tmp_path, codex_manifest, + {"pre_tool_use": [{"command": "speckit.codex.check"}]}, + ) + codex_manifest.save() + + # integration.json: both installed, codex is default. + state_path = tmp_path / ".specify" / "integration.json" + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text(json.dumps({ + "default_integration": "codex", + "installed_integrations": ["claude", "codex"], + })) + + dispatcher_path = tmp_path / EVENTS_DISPATCHER_REL + assert dispatcher_path.exists() + + # Full teardown of claude (remove + manifest.uninstall): the dispatcher + # must survive because codex's manifest still references it. + remove_integration_events(claude, tmp_path, claude_manifest) + claude_manifest.uninstall(tmp_path, force=True) + + assert dispatcher_path.exists(), ( + "Shared dispatcher was deleted by teardown() despite another " + "integration referencing it (S1)." + ) From c74e5a44eb2a5132d0768e56c9876c457bedb836 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Mon, 27 Jul 2026 23:48:38 +0300 Subject: [PATCH 12/20] test(extensions): update stale validation-message assertion The 'no commands/hooks/events' validation message changed to 'Extension must provide at least one command, hook, or event' when the events feature added a third provider kind, but test_no_commands_no_hooks still matched the old 'must provide at least one command or hook' text and failed on every CI job. Update the regex to the current message. Refs: PR #3704 CI failure (test_extensions.py:579) Assisted-by: opencode (model: glm-5.2, autonomous) --- tests/test_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 61826aad5b..851a4dc9ce 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -576,7 +576,7 @@ def test_no_commands_no_hooks(self, temp_dir, valid_manifest_data): with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) - with pytest.raises(ValidationError, match="must provide at least one command or hook"): + with pytest.raises(ValidationError, match="must provide at least one command, hook, or event"): ExtensionManifest(manifest_path) def test_hooks_only_extension(self, temp_dir, valid_manifest_data): From 123e10a33c2f5f20e49aaaf6b5d8520ff6d8328f Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Tue, 28 Jul 2026 00:05:29 +0300 Subject: [PATCH 13/20] fix(events): forced-teardown data safety, manifest-driven command resolution, toml teardown safe-dest Address three findings from Copilot review 4791088500: - S9: _remove_native_event_hooks now unconditionally drops this integration's manifest claim on the native config, not only when the file was deleted. Previously a config whose owned entries were cleaned but user content retained stayed tracked, so teardown(force=True) -> manifest.uninstall( force=True) deleted the entire user-owned settings file. This is the config-file mirror of the earlier shared-dispatcher fix. - S8: _find_command_template resolved extension event commands via a broken registry lookup (the registry stores per-agent registered_commands name-lists, not a {name, file} map) and a file-stem scan that only matched when the .md stem equaled the command name. A manifest mapping speckit.selftest.extension -> commands/selftest.md resolved as missing. It now enumerates installed extensions via ExtensionManager.get_extension() and matches provides.commands[].name -> file, with the directory scan and core-template lookups kept as fallbacks. - R3: _remove_toml_entries now validates the destination with _ensure_safe_destination before read/write, matching the merge path, so a symlink swap of .codex/config.toml after install can't make teardown overwrite a file outside the project. Tests: forced full teardown preserves a user settings file; an extension command whose file stem differs from its name resolves via the manifest; TOML teardown rejects a symlinked config destination. Refs: PR #3704 Copilot review 4791088500 (findings S8, S9, R3) Assisted-by: opencode (model: glm-5.2, autonomous) --- src/specify_cli/events.py | 64 ++++++++++++------- tests/integrations/test_events.py | 103 ++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 22 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index b18699913e..518c6e74f2 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -155,23 +155,35 @@ def main(): # -- Command runner logic (core) -------------------------------------------- def _find_command_template(command_name: str, project_root: Path) -> tuple[Path | None, str | None]: - # 1. Check extension .registry - registry = project_root / ".specify" / "extensions" / ".registry" - if registry.exists(): - try: - data = json.loads(registry.read_text(encoding="utf-8")) - except Exception: - data = {} - for ext_id, meta in data.items(): - if not isinstance(meta, dict): + # 1. Resolve via installed extension manifests (authoritative). The + # registry stores per-agent ``registered_commands`` name-lists, not a + # ``{name, file}`` map, so the command→file mapping lives only in each + # extension's ``extension.yml`` ``provides.commands`` (S8). Match the + # command name to its declared ``file`` so commands whose file stem + # differs from the command name (e.g. ``speckit.selftest.extension`` → + # ``commands/selftest.md``) resolve correctly. + exts_dir = project_root / ".specify" / "extensions" + try: + from .extensions import ExtensionManager + manager = ExtensionManager(project_root) + for ext_id in sorted(manager.registry.keys()): + manifest = manager.get_extension(ext_id) + if manifest is None: continue - for cmd in meta.get("commands", []): - if cmd.get("name") == command_name: - ext_dir = project_root / ".specify" / "extensions" / ext_id - return ext_dir / cmd["file"], ext_id + for cmd in manifest.commands: + if not isinstance(cmd, dict): + continue + if cmd.get("name") == command_name and cmd.get("file"): + candidate = exts_dir / ext_id / cmd["file"] + if candidate.exists(): + return candidate, ext_id + except Exception: + # Fall through to the on-disk scan if the registry/manifests can't be + # read; event dispatch should degrade gracefully, not crash. + pass - # 2. Scan extension directories - exts_dir = project_root / ".specify" / "extensions" + # 2. Scan extension directories by file stem (covers extensions present on + # disk but not resolvable via the manifest above). if exts_dir.is_dir(): for ext_dir in sorted(exts_dir.iterdir()): cmds_dir = ext_dir / "commands" @@ -856,17 +868,21 @@ def _remove_native_event_hooks( config_path = project_root / config_file if not config_path.exists(): return - deleted = False if fmt == "copilot-json": - deleted = _remove_copilot_entries(config_path) + _remove_copilot_entries(config_path) elif fmt == "toml": - deleted = _remove_toml_entries(config_path) + _remove_toml_entries(config_path) elif fmt in ("json-nested", "json-flat"): - deleted = _remove_json_entries(config_path) + _remove_json_entries(config_path) elif fmt == "ts-plugin": - deleted = _remove_opencode_entries(config_path) - if deleted: - manifest.remove(config_file) + _remove_opencode_entries(config_path) + # Always drop this integration's manifest claim on the native config, + # whether the file was deleted or retained with user content (S9). If we + # kept a retained file tracked, teardown()'s manifest.uninstall(force=True) + # would delete the entire user-owned settings file. After cleanup the file + # is either gone or contains only user content, so this integration must + # no longer claim it for teardown purposes. + manifest.remove(config_file) def _other_event_integrations_reference_dispatcher( @@ -1173,6 +1189,10 @@ def _remove_toml_entries(dst: Path) -> bool: """ if not dst.exists(): return False + # R3: validate the destination before reading/writing so a symlink swap of + # the config after install can't make teardown overwrite a file outside + # the project (the merge/write path already validates; teardown must too). + _ensure_safe_destination(dst) existing = dst.read_text(encoding="utf-8") cleaned = re.sub( r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*', diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index d133b986b7..9380695501 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -576,6 +576,46 @@ def test_run_command_not_found(self, tmp_path): code = resolve_and_run_event_command("nonexistent.command", "session_start", "{}", tmp_path) assert code == 0 # no-ops gracefully + def test_extension_command_resolves_when_file_stem_differs(self, tmp_path): + """S8: an extension command whose declared file differs from its + command name resolves via the manifest, not a file-stem scan.""" + from specify_cli.events import _find_command_template + from specify_cli.extensions import ExtensionRegistry + + ext_id = "selftest" + ext_dir = tmp_path / ".specify" / "extensions" / ext_id + cmds_dir = ext_dir / "commands" + cmds_dir.mkdir(parents=True) + # Command name is speckit.selftest.extension but the file is selftest.md. + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n" + " id: selftest\n" + " name: Selftest\n" + " version: 1.0.0\n" + " description: test\n" + "requires:\n" + " speckit_version: '>=0.1'\n" + "provides:\n" + " commands:\n" + " - name: speckit.selftest.extension\n" + " file: commands/selftest.md\n", + encoding="utf-8", + ) + (cmds_dir / "selftest.md").write_text( + "---\ndescription: \"x\"\n---\nBody\n", encoding="utf-8" + ) + ExtensionRegistry(tmp_path / ".specify" / "extensions").add( + ext_id, {"enabled": True} + ) + + template, resolved_ext = _find_command_template( + "speckit.selftest.extension", tmp_path + ) + assert template is not None + assert template.name == "selftest.md" + assert resolved_ext == ext_id + def test_run_command_resolves_and_executes(self, tmp_path): # Create a mock core command md file cmd_dir = tmp_path / ".specify" / "templates" / "commands" @@ -781,6 +821,43 @@ def test_remove_preserves_user_content_in_config(self, tmp_path): assert "Stop" not in data.get("hooks", {}) assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash" + def test_forced_full_teardown_preserves_user_config(self, tmp_path): + """S9: a full teardown(force=True) — which runs manifest.uninstall( + force=True) after remove_events — must not delete a pre-existing user + settings file whose owned entries were cleaned but user content kept. + Uses a real manifest to exercise the uninstall path.""" + from specify_cli.integrations.manifest import IntegrationManifest + + integration = ClaudeIntegration() + config_path = tmp_path / ".claude/settings.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{"type": "command", "command": "user-check"}], + }] + }, + "userSetting": True, + })) + + manifest = IntegrationManifest(integration.key, tmp_path, version="test") + install_integration_events( + integration, tmp_path, manifest, + {"stop": [{"command": "speckit.end"}]}, + ) + manifest.save() + + # Full teardown: remove_events + manifest.uninstall(force=True). + integration.teardown(tmp_path, manifest, force=True) + + assert config_path.exists(), ( + "Forced teardown deleted the user's settings file (S9)." + ) + data = json.loads(config_path.read_text()) + assert data["userSetting"] is True + assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash" + def test_jsonc_config_not_reset_on_merge(self, tmp_path): """#22: a JSONC/unparseable native config is left untouched on merge.""" integration = ClaudeIntegration() @@ -943,6 +1020,32 @@ def test_symlinked_config_dir_rejected(self, tmp_path): # No content written through the symlink. assert not (outside / "settings.json").exists() + def test_toml_teardown_rejects_symlinked_config(self, tmp_path): + """R3: TOML teardown validates the destination before read/write, so a + symlink swap after install can't make uninstall overwrite an external + file.""" + from specify_cli.integrations.codex import CodexIntegration + + integration = CodexIntegration() + manifest = _claude_manifest(tmp_path) + install_integration_events( + integration, tmp_path, manifest, + {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, + ) + config_path = tmp_path / ".codex" / "config.toml" + assert config_path.is_file() + + # Swap the config for a symlink pointing outside the project. + outside = tmp_path / "outside.toml" + outside.write_text("external = true\n") + config_path.unlink() + os.symlink(outside, config_path) + + with pytest.raises(ValueError, match="(?i)symlink|escapes|outside"): + remove_integration_events(integration, tmp_path, manifest) + # External file untouched. + assert outside.read_text() == "external = true\n" + # -- Validation & lifecycle (Tier 4) ----------------------------------------- From 6761c94813edc827ad1df6f13ad0f74ee711dfa8 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Tue, 28 Jul 2026 00:23:00 +0300 Subject: [PATCH 14/20] fix(events): subprocess cwd, shell quoting, TOML matcher escaping, Tabnine ms Address four findings from Copilot review 4791088500: - R1: the generated dispatcher and resolve_and_run_event_command now run their subprocesses with cwd set to the dispatcher-derived project root. Previously 'specify event run' (and the resolved script) inherited the agent's working directory, but event_run resolves the project via Path.cwd(), so a hook fired from a subdirectory targeted the wrong project and reported the command missing. - R2: _dispatcher_command now shell-quotes each component (interpreter, command, event) for the target shell (POSIX via shlex.quote; PowerShell via single-quoted literals with doubled quotes). An interpreter path containing spaces or an extension/override command containing shell metacharacters is passed as a single argument instead of being reinterpreted by the native hook shell. Claude's prefix is left unquoted so the shell still expands it (prefix + relative path are fixed, safe strings). - R4: the Codex TOML matcher is now rendered through the shared TOML escaper like command, so a matcher containing a quote/backslash/newline/control character no longer produces malformed config.toml. - R5: Tabnine declares events_timeout_unit='ms' (its hook schema mirrors Gemini's BeforeTool/AfterTool), so the 60s default becomes 60000ms instead of timeout: 60 (60 ms), which would terminate the dispatcher immediately. Tests: cwd-forced execution from a subdirectory; POSIX/PowerShell quoting of metacharacter and space-bearing components; TOML matcher with a quote parses cleanly; Tabnine timeout converts to 60000. Updated the Copilot generation test for the new quoted args. Refs: PR #3704 Copilot review 4791088500 (findings R1, R2, R4, R5) Assisted-by: opencode (model: glm-5.2, autonomous) --- src/specify_cli/events.py | 37 ++++- .../integrations/tabnine/__init__.py | 6 + tests/integrations/test_events.py | 132 +++++++++++++++++- 3 files changed, 171 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 518c6e74f2..bedd30bc75 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -92,6 +92,10 @@ def main(): event_name = sys.argv[2] payload = sys.stdin.read() if not sys.stdin.isatty() else "{}" + # The agent may fire the hook from any subdirectory, but `event run` + # resolves the project from the process CWD. Anchor the subprocess at the + # dispatcher-derived project root so the correct project is used. + project_root = Path(__file__).parent.parent.resolve() specify_args = _find_specify() + ["event", "run", command_name, event_name] try: result = subprocess.run( @@ -100,6 +104,7 @@ def main(): capture_output=True, text=True, timeout=120, + cwd=str(project_root), ) if result.stdout: sys.stdout.write(result.stdout) @@ -337,6 +342,7 @@ def resolve_and_run_event_command(command_name: str, event_name: str, payload: s capture_output=True, text=True, timeout=120, + cwd=str(project_root), ) if result.stdout: sys.stdout.write(result.stdout) @@ -601,6 +607,21 @@ def _native_timeout(integration: IntegrationBase, timeout_seconds: Any) -> int: return seconds +def _shell_quote(value: str, target_os: str) -> str: + """Quote *value* as one argument for the target shell (R2). + + POSIX shells use ``shlex.quote``; PowerShell uses a single-quoted literal + with embedded quotes doubled. Prevents a component containing spaces (e.g. + a venv interpreter path under a directory with spaces) or shell + metacharacters (a malformed extension/override ``command``) from breaking + the hook or being interpreted by the native shell instead of passed as one + dispatcher argument. + """ + if target_os == "windows" or (target_os == "host" and os.name == "nt"): + return "'" + value.replace("'", "''") + "'" + return shlex.quote(value) + + def _dispatcher_command( integration: IntegrationBase, project_root: Path, @@ -621,16 +642,28 @@ def _dispatcher_command( both POSIX and Windows variants into one checked-in file (Copilot): ``host`` uses the host-resolved interpreter (venv-aware), while ``posix``/``windows`` emit portable interpreters so the config works on either OS (#S4). + + Each component is shell-quoted for the target shell (R2) so an interpreter + path with spaces or a command/event containing shell metacharacters is + passed as a single argument rather than reinterpreted by the native shell. + The Claude dispatcher keeps its ``${CLAUDE_PROJECT_DIR}`` prefix unquoted so + the shell still expands the variable; both the prefix and the relative + dispatcher path are fixed, metacharacter-free strings. """ if target_os == "host": interpreter = _resolve_interpreter(project_root) else: interpreter = _resolve_interpreter_for_target(target_os) if integration.key == "claude": + # Leave ${CLAUDE_PROJECT_DIR} unquoted so the shell expands it; the + # prefix and the relative path are both fixed and metacharacter-free. dispatcher = "${CLAUDE_PROJECT_DIR}/" + EVENTS_DISPATCHER_REL else: dispatcher = EVENTS_DISPATCHER_REL - return f"{interpreter} {dispatcher} {command_name} {event_name}" + q_interp = _shell_quote(interpreter, target_os) + q_command = _shell_quote(command_name, target_os) + q_event = _shell_quote(event_name, target_os) + return f"{q_interp} {dispatcher} {q_command} {q_event}" def install_integration_events( @@ -767,7 +800,7 @@ def install_integration_events( command = cfg.get("command", "") dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev) lines.append(f'[[hooks.{native}]]') - lines.append(f'matcher = "{cfg.get("matcher", "*")}"') + lines.append(f'matcher = {_toml_quote(str(cfg.get("matcher", "*")))}') lines.append('') lines.append(f'[[hooks.{native}.hooks]]') lines.append('type = "command"') diff --git a/src/specify_cli/integrations/tabnine/__init__.py b/src/specify_cli/integrations/tabnine/__init__.py index 2651755319..868b8915f5 100644 --- a/src/specify_cli/integrations/tabnine/__init__.py +++ b/src/specify_cli/integrations/tabnine/__init__.py @@ -28,3 +28,9 @@ class TabnineIntegration(TomlIntegration): } events_config_file = ".tabnine/agent/settings.json" events_format = "json-nested" + # Tabnine mirrors Gemini's hook schema (BeforeTool/AfterTool) and, like + # Gemini, measures hook timeouts in milliseconds. Declaring the unit makes + # the shared formatter convert the 60s default to 60000ms instead of + # emitting timeout: 60 (60 ms), which would terminate the dispatcher + # before it starts (R5). + events_timeout_unit = "ms" diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 9380695501..7ec8b37bc4 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -5,6 +5,8 @@ import json import os import platform +import shlex +from pathlib import Path from unittest.mock import MagicMock import pytest @@ -389,11 +391,18 @@ def test_copilot_json_generation(self, tmp_path): assert "speckit.agent-context.update" in entry["bash"] assert "session_start" in entry["bash"] # S4: bash and powershell get independent OS-targeted interpreters so - # a config generated on one OS works on the other. + # a config generated on one OS works on the other. R2: command/event + # args are shell-quoted for each target shell. assert "speckit.agent-context.update" in entry["powershell"] assert entry["bash"] != entry["powershell"] + # bash uses POSIX interpreter python3 (shlex.quote leaves safe tokens + # bare); powershell uses python single-quoted for PowerShell. assert entry["bash"].startswith("python3 ") - assert entry["powershell"].startswith("python ") + assert entry["powershell"].startswith("'python' ") + # PowerShell always single-quotes; POSIX leaves metacharacter-free + # identifiers bare (shlex.quote only quotes when needed). + assert "'speckit.agent-context.update'" in entry["powershell"] + assert "speckit.agent-context.update" in entry["bash"] assert entry["timeoutSec"] == 60 @@ -496,6 +505,91 @@ def test_gemini_timeout_converted_to_ms(self, tmp_path): assert _native_timeout(integration, 60) == 60000 assert _native_timeout(ClaudeIntegration(), 60) == 60 + def test_tabnine_timeout_converted_to_ms(self): + """R5: Tabnine mirrors Gemini's ms-based hook schema.""" + from specify_cli.integrations.tabnine import TabnineIntegration + from specify_cli.events import _native_timeout + + assert _native_timeout(TabnineIntegration(), 60) == 60000 + + +# -- Shell quoting & matcher escaping (R2, R4) ------------------------------- + +class TestDispatcherCommandQuoting: + """R2: dispatcher command components are shell-quoted so spaces and shell + metacharacters are passed as single arguments, not reinterpreted.""" + + def test_command_metacharacters_are_quoted_posix(self, tmp_path): + from specify_cli.events import _dispatcher_command + + cmd = _dispatcher_command( + ClaudeIntegration(), tmp_path, "speckit.x; rm -rf /", "pre_tool_use", + target_os="posix", + ) + # The metacharacter-bearing command is single-quoted as one argument. + assert "'speckit.x; rm -rf /'" in cmd + + def test_interpreter_with_space_is_quoted_posix(self, tmp_path): + import shlex + from specify_cli.events import _dispatcher_command + # Simulate a venv interpreter under a path with spaces. + venv = tmp_path / ".venv" / "bin" / "python" + venv.parent.mkdir(parents=True) + venv.write_text("#!/bin/sh\n") + proj = tmp_path + cmd = _dispatcher_command( + ClaudeIntegration(), proj, "speckit.x.y", "stop", target_os="host", + ) + # The command must tokenize back into interpreter + dispatcher + 2 args. + tokens = shlex.split(cmd) + # dispatcher token retains the ${CLAUDE_PROJECT_DIR} prefix (unquoted). + assert any("events.py" in t for t in tokens) + assert "speckit.x.y" in tokens + assert "stop" in tokens + + def test_windows_target_uses_powershell_quoting(self, tmp_path): + from specify_cli.events import _dispatcher_command + + cmd = _dispatcher_command( + CopilotIntegration(), tmp_path, "speckit.x.y", "session_start", + target_os="windows", + ) + # PowerShell single-quoted literals. + assert "'speckit.x.y'" in cmd + assert "'session_start'" in cmd + + +class TestTomlMatcherEscaping: + """R4: the TOML matcher is escaped like command, not raw-interpolated.""" + + def test_matcher_with_quote_stays_valid_toml(self, tmp_path): + from specify_cli.integrations.codex import CodexIntegration + + integration = CodexIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + manifest.remove = MagicMock() + + # A matcher containing a double quote would break a raw TOML basic + # string; it must be escaped. + install_integration_events( + integration, tmp_path, manifest, + {"pre_tool_use": [{"command": "speckit.x.y", "matcher": 'Ba"sh'}]}, + ) + content = (tmp_path / ".codex" / "config.toml").read_text() + # Round-trips through a TOML parser without error. + try: + import tomllib + parsed = tomllib.loads(content) + except ModuleNotFoundError: + import tomli as tomllib # type: ignore + parsed = tomllib.loads(content) + # The matcher value survived intact. + group = parsed["hooks"]["PreToolUse"][0] + assert group["matcher"] == 'Ba"sh' + # -- Opencode TS Plugin merging --------------------------------------------- @@ -696,6 +790,40 @@ def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path): assert argv[1] == "-File" assert argv[2].endswith(".specify/scripts/powershell/boot.ps1") + def test_run_command_executes_with_project_root_cwd(self, tmp_path): + """R1: the event command runs with cwd set to the project root, not the + caller's arbitrary working directory, so project-relative script logic + resolves correctly even when the agent fires the hook elsewhere.""" + if platform.system().lower().startswith("win"): + return + cmd_dir = tmp_path / ".specify" / "templates" / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "cwd.md").write_text( + "---\ndescription: \"cwd\"\nscripts:\n sh: scripts/cwd.sh\n---\nBody\n", + encoding="utf-8", + ) + script_dir = tmp_path / ".specify" / "scripts" + script_dir.mkdir(parents=True) + out_file = tmp_path / "cwd.out" + script = script_dir / "cwd.sh" + # The script records its working directory. + script.write_text(f"#!/bin/sh\npwd > {shlex.quote(str(out_file))}\nexit 0\n", encoding="utf-8") + script.chmod(0o755) + + # Invoke from a different working directory to prove cwd is forced. + import os as _os + prev = _os.getcwd() + subdir = tmp_path / "sub" + subdir.mkdir() + try: + _os.chdir(subdir) + code = resolve_and_run_event_command("speckit.cwd", "session_start", "{}", tmp_path) + finally: + _os.chdir(prev) + assert code == 0 + recorded = out_file.read_text().strip() + assert Path(recorded).resolve() == tmp_path.resolve() + # -- Merge/teardown idempotency & safety (Tier 3) ---------------------------- From ea333696206401c07414dd0eb634b090d4d19ce4 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Tue, 28 Jul 2026 09:40:25 +0300 Subject: [PATCH 15/20] fix(events): POSIX dispatcher path constant + platform-agnostic tests Three Windows test failures, one a real cross-OS bug: - W1 (bug): EVENTS_DISPATCHER_REL was str(Path('.specify')/'events.py'), which yields '.specify\events.py' on Windows. Manifest keys are stored in POSIX form (.as_posix()), so 'dispatcher_rel in manifest.files' was always False on Windows: the shared-dispatcher manifest-claim drop was skipped and manifest.uninstall(force=True) deleted the dispatcher another integration still depended on. Make it a POSIX constant (.as_posix()) so it matches manifest keys on every platform. - W2/W3 (tests): the py/ps argv assertions used endswith() and an exact launcher-name set that broke on Windows backslash paths and the pwsh.EXE/full-path launcher returned by shutil.which. Compare in POSIX form and match the launcher by case-insensitive stem. Refs: PR #3704 Windows CI failures Assisted-by: opencode (model: glm-5.2, autonomous) --- src/specify_cli/events.py | 8 +++++++- tests/integrations/test_events.py | 15 +++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index bedd30bc75..bdac5fd5a1 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -32,7 +32,13 @@ EVENTS_DISPATCHER_DIR = Path(".specify") EVENTS_DISPATCHER_FILENAME = "events.py" -EVENTS_DISPATCHER_REL = str(EVENTS_DISPATCHER_DIR / EVENTS_DISPATCHER_FILENAME) +# POSIX-form (forward-slash) relative path so it matches manifest keys, which +# are always stored in POSIX form (record_file/record_existing normalize via +# .as_posix()). On Windows, str(Path(".specify")/"events.py") yields +# ".specify\\events.py", which never matched a manifest key, so the shared- +# dispatcher manifest-claim drop was skipped and uninstall(force=True) deleted +# the dispatcher another integration still depended on. +EVENTS_DISPATCHER_REL = (EVENTS_DISPATCHER_DIR / EVENTS_DISPATCHER_FILENAME).as_posix() YAML_OVERRIDE_FILENAME = Path(".specify") / "integration-events.yml" diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 7ec8b37bc4..6b9e9952bc 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -6,7 +6,7 @@ import os import platform import shlex -from pathlib import Path +from pathlib import Path, PurePath from unittest.mock import MagicMock import pytest @@ -759,9 +759,10 @@ def test_py_variant_anchored_under_specify(self, tmp_path): argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) assert argv is not None - # Interpreter first, then the .specify-anchored script path. + # Interpreter first, then the .specify-anchored script path. Compare in + # POSIX form so the assertion holds on Windows (backslash paths) too. assert len(argv) >= 2 - assert argv[1].endswith(".specify/scripts/python/boot.py") + assert PurePath(argv[1]).as_posix().endswith(".specify/scripts/python/boot.py") assert ".specify" in argv[1] def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path): @@ -785,10 +786,12 @@ def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path): argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) assert argv is not None - # Launcher (pwsh or powershell), -File, then the .specify-anchored script. - assert argv[0] in ("pwsh", "powershell") or argv[0].endswith("pwsh") or argv[0].endswith("powershell") + # Launcher (pwsh or powershell), -File, then the .specify-anchored + # script. shutil.which may return a full path with an .EXE suffix on + # Windows, so match by stem (case-insensitive). + assert PurePath(argv[0]).stem.lower() in ("pwsh", "powershell") assert argv[1] == "-File" - assert argv[2].endswith(".specify/scripts/powershell/boot.ps1") + assert PurePath(argv[2]).as_posix().endswith(".specify/scripts/powershell/boot.ps1") def test_run_command_executes_with_project_root_cwd(self, tmp_path): """R1: the event command runs with cwd set to the project root, not the From f7715a5c138b63cc20045a702cef75446c36dfde Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Tue, 28 Jul 2026 09:48:29 +0300 Subject: [PATCH 16/20] fix(events): override layer preservation, matcher validation, event command-ref canonicalization Address four Copilot review findings: - C4: a malformed override handler (e.g. "stop: []" or "stop: bad-value") normalizes to no handlers. Previously the entry was skipped and the override still adopted, so an override whose only entry was malformed silently disabled every built-in and extension hook. The empty-handler case now abandons the whole override (keeps prior layers); an explicit "events: {}" (no entries) remains a valid disable. - C6: a non-mapping integration entry (e.g. "claude: bad") was coerced to "events: {}" and treated as a valid explicit disable. It now warns and abandons the override, keeping the accumulated layers. Only an explicitly present, mapping-valued "events" field replaces the prior layers. - C10: matcher is now validated as a string (or absent) in both validate_events (manifest) and _validate_resolved_event (override). A non-string matcher such as "matcher: []" previously passed validation but crashed by_matcher.setdefault(matcher, ...) with TypeError: unhashable type, aborting init or refresh. - C11: ExtensionManifest._validate now applies the same rename + alias-lift canonicalization to event command references that it already applies to hook references. An event referencing an auto-corrected command (e.g. my-ext.boot -> speckit.my-ext.boot) previously kept the obsolete name, so dispatch reported no command and the event silently no-oped. Tests: empty-handler/non-mapping override preserves layers; non-string matcher rejected in manifest and abandoned in override; event command ref lifted to canonical form with a warning. Refs: PR #3704 Copilot review (findings C4, C6, C10, C11) Assisted-by: opencode (model: glm-5.2, autonomous) --- src/specify_cli/events.py | 102 +++++++++++++++------- src/specify_cli/extensions/__init__.py | 27 ++++++ tests/integrations/test_events.py | 116 +++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 33 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index bdac5fd5a1..cff7b7b2b6 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -415,6 +415,15 @@ def _validate_resolved_event(event_name: str, handlers: list[dict[str, Any]]) -> raise ValidationError( f"Event '{event_name}' handler missing required non-empty 'command' string" ) + # C10: matcher must be a string (or absent). A non-string matcher such + # as `matcher: []` passes extension validation but later crashes + # by_matcher.setdefault(matcher, ...) with TypeError: unhashable type. + matcher = handler.get("matcher") + if matcher is not None and not isinstance(matcher, str): + raise ValidationError( + f"Event '{event_name}' handler has invalid 'matcher': " + "must be a string" + ) def resolve_events( @@ -469,43 +478,63 @@ def resolve_events( integrations = override.get("integrations", {}) if isinstance(override, dict) else {} if isinstance(integrations, dict) and integration_key in integrations: key_data = integrations[integration_key] - key_events = key_data.get("events", {}) if isinstance(key_data, dict) else {} - if not isinstance(key_events, dict): + if not isinstance(key_data, dict): + # C6: a non-mapping integration entry (e.g. `claude: bad`) must + # not be treated as a valid explicit disable. Warn and abandon + # the override, keeping the accumulated built-in + extension + # layers. Only an explicitly present, mapping-valued `events` + # field replaces the prior layers. logger.warning( - "Override %s: 'events' for '%s' is not a mapping; ignoring override", + "Override %s: entry for '%s' is not a mapping; ignoring override", override_file, integration_key, ) else: - # Validate every entry before adopting the override. A single - # invalid entry abandons the whole override and keeps the - # accumulated built-in + extension layers (#10): previously a - # typo reset resolved_override to {} and then assigned that - # empty map to events, silently disabling all hooks despite - # the "ignored" warning. Only a fully-valid override (including - # an explicit `events: {}`) replaces the prior layers. - resolved_override: ResolvedEvents = {} - override_valid = True - for ev, raw in key_events.items(): - handlers = _normalize_handlers(raw) - if not handlers: - logger.warning( - "Override %s: event '%s' has no valid handler; skipping entry", - override_file, ev, - ) - continue - try: - _validate_resolved_event(ev, handlers) - except Exception as exc: - logger.warning( - "Override %s: invalid event '%s': %s; ignoring entire override", - override_file, ev, exc, - ) - override_valid = False - break - resolved_override[ev] = handlers - if override_valid: - events = resolved_override - # else: keep the accumulated built-in + extension layers. + key_events = key_data.get("events", {}) + if not isinstance(key_events, dict): + logger.warning( + "Override %s: 'events' for '%s' is not a mapping; ignoring override", + override_file, integration_key, + ) + else: + # Validate every entry before adopting the override. A single + # invalid entry abandons the whole override and keeps the + # accumulated built-in + extension layers (#10): previously a + # typo reset resolved_override to {} and then assigned that + # empty map to events, silently disabling all hooks despite + # the "ignored" warning. Only a fully-valid override (including + # an explicit `events: {}`) replaces the prior layers. + resolved_override: ResolvedEvents = {} + override_valid = True + for ev, raw in key_events.items(): + handlers = _normalize_handlers(raw) + if not handlers: + # C4: a malformed handler (e.g. `stop: []` or + # `stop: bad-value`) normalizes to no handlers. + # Abandon the whole override (keep prior layers) + # rather than skipping the entry — otherwise an + # override whose only entry is malformed silently + # disabled every built-in and extension hook. An + # explicit `events: {}` (no entries) remains a + # valid disable. + logger.warning( + "Override %s: event '%s' has no valid handler; ignoring entire override", + override_file, ev, + ) + override_valid = False + break + try: + _validate_resolved_event(ev, handlers) + except Exception as exc: + logger.warning( + "Override %s: invalid event '%s': %s; ignoring entire override", + override_file, ev, exc, + ) + override_valid = False + break + resolved_override[ev] = handlers + if override_valid: + events = resolved_override + # else: keep the accumulated built-in + extension layers. return events @@ -1074,6 +1103,13 @@ def validate_events(data: dict[str, Any]) -> None: f"Unknown event '{event_name}': " f"must be one of {sorted(CANONICAL_EVENTS)}" ) + # C10: matcher must be a string (or absent). A non-string matcher + # such as `matcher: []` would later crash by_matcher.setdefault. + matcher = event_config.get("matcher") + if matcher is not None and not isinstance(matcher, str): + raise ValidationError( + f"Event '{event_name}' has invalid 'matcher': must be a string" + ) def has_events(data: dict[str, Any]) -> bool: diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 5e3408ce20..de33984fef 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -433,6 +433,33 @@ def _validate(self): f"The extension author should update the manifest." ) + # C11: apply the same rename + alias-lift canonicalization to event + # command references. Without this, an event referencing a command + # that was auto-corrected (e.g. speckit.boot -> speckit..boot) + # keeps the obsolete name, dispatch reports no command, and the event + # silently no-ops. + events_data = self.data.get("events", {}) + if isinstance(events_data, dict): + for event_name, event_config in events_data.items(): + if not isinstance(event_config, dict): + continue + command_ref = event_config.get("command") + if not isinstance(command_ref, str): + continue + after_rename = rename_map.get(command_ref, command_ref) + parts = after_rename.split(".") + if len(parts) == 2 and parts[0] == ext["id"]: + final_ref = f"speckit.{ext['id']}.{parts[1]}" + else: + final_ref = after_rename + if final_ref != command_ref: + event_config["command"] = final_ref + self.warnings.append( + f"Event '{event_name}' referenced command '{command_ref}'; " + f"updated to canonical form '{final_ref}'. " + f"The extension author should update the manifest." + ) + @staticmethod def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]: """Try to auto-correct a non-conforming command name to the required pattern. diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 6b9e9952bc..abafa1b935 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -1391,6 +1391,122 @@ def test_explicit_empty_override_disables(self, tmp_path): ) assert result == {} + def test_empty_handler_override_abandons_override(self, tmp_path): + """C4: a malformed handler (`stop: []` or `stop: bad-value`) abandons + the whole override and keeps prior layers, rather than disabling.""" + override_file = tmp_path / ".specify" / "integration-events.yml" + override_file.parent.mkdir(parents=True, exist_ok=True) + override_file.write_text( + "integrations:\n" + " claude:\n" + " events:\n" + " stop: []\n", + encoding="utf-8", + ) + result = resolve_events( + "claude", + {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, + tmp_path, + None, + ) + # Built-in default survived (override abandoned on the empty handler). + assert "post_tool_use" in result + + def test_non_mapping_integration_entry_abandons_override(self, tmp_path): + """C6: a non-mapping integration entry (`claude: bad`) is ignored as + malformed, not treated as a valid explicit disable.""" + override_file = tmp_path / ".specify" / "integration-events.yml" + override_file.parent.mkdir(parents=True, exist_ok=True) + override_file.write_text( + "integrations:\n" + " claude: bad\n", + encoding="utf-8", + ) + result = resolve_events( + "claude", + {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, + tmp_path, + None, + ) + # Built-in default survived (override ignored as malformed). + assert "post_tool_use" in result + + +# -- Matcher string validation (C10) ----------------------------------------- + +class TestMatcherValidation: + """C10: matcher must be a string; a non-string matcher is rejected at + validation time so it can't crash by_matcher.setdefault later.""" + + def test_non_string_matcher_rejected_in_manifest(self): + from specify_cli.extensions import ValidationError + data = {"events": {"pre_tool_use": {"command": "speckit.x.y", "matcher": []}}} + with pytest.raises(ValidationError, match="(?i)matcher.*string"): + validate_events(data) + + def test_non_string_matcher_rejected_in_override(self, tmp_path): + override_file = tmp_path / ".specify" / "integration-events.yml" + override_file.parent.mkdir(parents=True, exist_ok=True) + # A matcher of [] would crash by_matcher.setdefault; the override must + # be abandoned (built-in defaults survive) rather than crash. + override_file.write_text( + "integrations:\n" + " claude:\n" + " events:\n" + " pre_tool_use:\n" + " command: speckit.x.y\n" + " matcher: []\n", + encoding="utf-8", + ) + result = resolve_events( + "claude", + {"events": {"stop": {"command": "speckit.end"}}}, + tmp_path, + None, + ) + # Built-in default survived (override abandoned on the bad matcher). + assert "stop" in result + + +# -- Event command-ref canonicalization (C11) -------------------------------- + +class TestEventCommandRefCanonicalization: + """C11: an event referencing a command that was auto-corrected is itself + rewritten to the canonical name (mirrors hook reference rewriting).""" + + def test_event_command_ref_lifted_to_canonical(self, tmp_path): + from specify_cli.extensions import ExtensionManifest + import yaml as _yaml + + manifest_path = tmp_path / "extension.yml" + manifest_path.write_text( + _yaml.dump({ + "schema_version": "1.0", + "extension": { + "id": "my-ext", + "name": "My Ext", + "version": "1.0.0", + "description": "test", + }, + "requires": {"speckit_version": ">=0.1"}, + "provides": { + "commands": [ + {"name": "speckit.my-ext.boot", "file": "commands/boot.md"} + ] + }, + "events": { + "session_start": {"command": "my-ext.boot"}, + }, + }), + encoding="utf-8", + ) + manifest = ExtensionManifest(manifest_path) + assert manifest.data["events"]["session_start"]["command"] == "speckit.my-ext.boot" + assert any( + "Event 'session_start' referenced command 'my-ext.boot'" in w + for w in manifest.warnings + ) + # -- Skipped-merge not tracked (S5) ------------------------------------------ From a51ff920d9a24c44c16a0091539fc482d5136b50 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Tue, 28 Jul 2026 10:58:16 +0300 Subject: [PATCH 17/20] fix(events): protect shared dispatcher from stale cleanup, delete Cursor version stub, non-destructive refresh Address three Copilot review findings: - C3: the shared .specify/events.py dispatcher is now in events_stale_exclusions(). It is written into every event-capable integration's manifest but reference-counted across them; an upgrade with --events false omits events.py from the new manifest, so the generic stale pass would delete it without the refcount check, breaking any other installed event-capable integration. Its deletion is left to remove_integration_events(), which checks the refcount. - C5: _remove_json_entries now deletes a Spec-Kit-created Cursor file that retains only {"version": 1} after all owned hooks are removed (we added the version field), mirroring _remove_copilot_entries. Previously the generic remover only deleted a literally-empty object, so clean teardown left a generated stub behind. - C12: refresh_integration_events now resolves first and calls install_integration_events once, instead of running the destructive _remove_native_event_hooks pre-step before resolution. A later failure (invalid destination, write error, formatter error) no longer destroys the working native config before the new one is written. install_integration_events already removes stale Specify-marked entries and handles an empty map (stripping prior hooks), so the pre-step was both unsafe and redundant. Tests: dispatcher in stale exclusions; Cursor version-only stub deleted on teardown; refresh failure preserves the pre-existing config (no pre-strip). Refs: PR #3704 Copilot review (findings C3, C5, C12) Assisted-by: opencode (model: glm-5.2, autonomous) --- src/specify_cli/events.py | 35 +++++++++--- tests/integrations/test_events.py | 89 ++++++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index cff7b7b2b6..c888f42798 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -1028,6 +1028,13 @@ def events_stale_exclusions(integration_key: str) -> set[str]: exclusions.add(config_file) if integration_key == "opencode": exclusions.add(".opencode/plugin/speckit-events.ts") + # C3: the shared dispatcher is written into every event-capable + # integration's manifest but is reference-counted across them. An upgrade + # with --events false omits events.py from the new manifest, so the generic + # stale pass would delete it without the refcount check, breaking any other + # installed event-capable integration. Protect it here; its deletion is + # left to remove_integration_events(), which checks the refcount. + exclusions.add(EVENTS_DISPATCHER_REL) return exclusions @@ -1058,9 +1065,14 @@ def refresh_integration_events(project_root: Path) -> None: logger.warning("Could not load manifest for '%s'; skipping event refresh: %s", key, exc) continue try: - # Strip prior Specify hooks, then re-emit from the freshly - # resolved set (which now honors the registry's enabled flag). - _remove_native_event_hooks(integration, project_root, manifest) + # C12: resolve first, then call install_integration_events once. + # The previous flow ran _remove_native_event_hooks *before* + # resolution, so any later failure (invalid destination, write + # error, formatter error) destroyed the working native config + # before the new one was written. install_integration_events + # already removes stale Specify-marked entries and handles an + # empty map (stripping prior hooks), so the destructive pre-step + # is both unsafe and redundant. # S7: resolve this integration's persisted parsed_options so a # stored --events false is honored across extension lifecycle # changes; passing None would re-enable events the user disabled. @@ -1068,8 +1080,10 @@ def refresh_integration_events(project_root: Path) -> None: events_map = resolve_events( key, integration.config, project_root, parsed_options ) - if events_map: - install_integration_events(integration, project_root, manifest, events_map) + # install_integration_events handles both the populated case + # (writes new config, stripping stale owned entries) and the empty + # case (strips prior hooks for --events false / disabled override). + install_integration_events(integration, project_root, manifest, events_map) manifest.save() except Exception as exc: logger.warning("Failed to refresh events for '%s': %s", key, exc) @@ -1523,9 +1537,14 @@ def _remove_json_entries(dst: Path) -> bool: existing["hooks"] = cleaned else: existing.pop("hooks", None) - # #14: if the config is now empty (no user content), delete the file - # rather than leaving a ``{}`` that confuses manifest.uninstall(). - if not existing: + # #14/C5: if the config is now empty of user content, delete the file + # rather than leaving a stub that confuses manifest.uninstall(). A + # Spec-Kit-created Cursor file retains {"version": 1} after all owned + # hooks are removed (we added the version field); treat the version-only + # case as empty too, mirroring _remove_copilot_entries, so clean teardown + # doesn't leave a generated stub behind. + user_keys = {k for k in existing if k != "version"} + if not user_keys: dst.unlink(missing_ok=True) return True _safe_write_json(dst, existing) diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index abafa1b935..db7a70e39f 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -7,7 +7,7 @@ import platform import shlex from pathlib import Path, PurePath -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -1589,3 +1589,90 @@ def test_full_teardown_keeps_dispatcher_when_other_references_it(self, tmp_path) "Shared dispatcher was deleted by teardown() despite another " "integration referencing it (S1)." ) + + +# -- Dispatcher stale-cleanup exclusion (C3) --------------------------------- + +class TestDispatcherStaleExclusion: + """C3: the shared dispatcher is excluded from the generic upgrade stale + pass so an --events false upgrade doesn't delete it and break other + installed event-capable integrations.""" + + def test_dispatcher_in_stale_exclusions(self): + from specify_cli.events import events_stale_exclusions + exclusions = events_stale_exclusions("claude") + assert EVENTS_DISPATCHER_REL in exclusions + + +# -- Cursor version-only stub deletion (C5) ---------------------------------- + +class TestCursorVersionOnlyStubDeletion: + """C5: a Spec-Kit-created Cursor file retaining only {"version": 1} after + all owned hooks are removed is deleted, not left as a generated stub.""" + + def test_version_only_cursor_file_deleted_on_teardown(self, tmp_path): + integration = CursorAgentIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + manifest.remove = MagicMock() + + install_integration_events( + integration, tmp_path, manifest, + {"session_start": [{"command": "speckit.boot"}]}, + ) + config_path = tmp_path / ".cursor/hooks.json" + assert config_path.is_file() + assert json.loads(config_path.read_text()).get("version") == 1 + + remove_integration_events(integration, tmp_path, manifest) + # No user content remained (only the Spec-Kit-managed version field) → + # the file is deleted for a clean teardown, not left as a stub. + assert not config_path.exists() + + +# -- Non-destructive refresh (C12) ------------------------------------------- + +class TestNonDestructiveRefresh: + """C12: refresh resolves first then installs once; a failure during install + no longer destroys the working native config before the new one is written.""" + + def test_refresh_failure_preserves_existing_config(self, tmp_path): + from specify_cli.events import refresh_integration_events + from specify_cli.integrations.manifest import IntegrationManifest + + integration = ClaudeIntegration() + manifest = IntegrationManifest(integration.key, tmp_path, version="test") + install_integration_events( + integration, tmp_path, manifest, + {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, + ) + manifest.save() + config_path = tmp_path / ".claude/settings.json" + original = config_path.read_text() + + # Declare an extension event so refresh would try to re-emit. + ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text( + "events:\n session_start:\n command: speckit.my-ext.boot\n", + encoding="utf-8", + ) + state_path = tmp_path / ".specify" / "integration.json" + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text(json.dumps({ + "default_integration": "claude", + "installed_integrations": ["claude"], + })) + + # Force install_integration_events to fail mid-refresh. + with patch( + "specify_cli.events.install_integration_events", + side_effect=RuntimeError("simulated write failure"), + ): + refresh_integration_events(tmp_path) + + # The pre-existing config was NOT destroyed before the failure + # (install handles cleanup atomically; refresh no longer pre-strips). + assert config_path.read_text() == original From cb66271eb9cbf7b6067de8a3b694e692e94bf089 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Tue, 28 Jul 2026 11:08:54 +0300 Subject: [PATCH 18/20] fix(events): host target uses POSIX quoting, Claude dispatcher double-quoted, & for windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two Copilot review findings on the shell-quoting added in the prior round (R2): - C1: _shell_quote("host") now always uses POSIX shlex.quote, not PowerShell single-quoting on Windows. The single-command-string formats (Claude/Gemini/Qwen/Devin/Tabnine) are run via the agent's POSIX-ish shell (Git Bash on Windows), and a single-quoted 'python' is not invoked as a command by PowerShell without the call operator — so generated hooks failed to launch the dispatcher on Windows. Safe tokens pass through bare (python3, speckit.ext.cmd) on every platform. PowerShell single-quoting is now used only for the explicit target_os="windows" (Copilot's powershell field), where the quoted interpreter is prefixed with "& " so it is actually invoked. - C2: Claude's ${CLAUDE_PROJECT_DIR} dispatcher path is now double-quoted ("${CLAUDE_PROJECT_DIR}/.specify/events.py") so the variable still expands (double quotes allow expansion in POSIX shells) but a project path containing spaces no longer word-splits and breaks dispatcher launch. Tests: host target never emits PowerShell quotes; windows target carries the & call operator; Claude dispatcher is double-quoted; updated Copilot generation assertions for the &-prefixed powershell command. Refs: PR #3704 Copilot review (findings C1, C2) Assisted-by: opencode (model: glm-5.2, autonomous) --- src/specify_cli/events.py | 49 ++++++++++++++++++++----------- tests/integrations/test_events.py | 35 ++++++++++++++++++---- 2 files changed, 62 insertions(+), 22 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index c888f42798..5efa99900d 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -645,15 +645,24 @@ def _native_timeout(integration: IntegrationBase, timeout_seconds: Any) -> int: def _shell_quote(value: str, target_os: str) -> str: """Quote *value* as one argument for the target shell (R2). - POSIX shells use ``shlex.quote``; PowerShell uses a single-quoted literal - with embedded quotes doubled. Prevents a component containing spaces (e.g. - a venv interpreter path under a directory with spaces) or shell - metacharacters (a malformed extension/override ``command``) from breaking - the hook or being interpreted by the native shell instead of passed as one - dispatcher argument. + ``host`` and ``posix`` targets use ``shlex.quote`` (POSIX shells). Safe + tokens — ``python3``, ``speckit.ext.cmd`` — pass through bare, so a + single-``command``-string hook (Claude/Gemini/etc.) stays invocable on + every platform. ``windows`` targets use a PowerShell single-quoted literal + with embedded quotes doubled, for Copilot's dedicated ``powershell`` field. + + Prevents a component containing spaces (e.g. a venv interpreter path under + a directory with spaces) or shell metacharacters (a malformed + extension/override ``command``) from breaking the hook or being + interpreted by the native shell instead of passed as one dispatcher + argument. """ - if target_os == "windows" or (target_os == "host" and os.name == "nt"): + if target_os == "windows": return "'" + value.replace("'", "''") + "'" + # "host" and "posix" both use POSIX quoting. On Windows the single- + # command-string formats (Claude/Gemini/Qwen/Devin/Tabnine) are run via + # Git Bash or the agent's POSIX-ish shell, so POSIX quoting is correct and + # avoids emitting 'python' (which PowerShell wouldn't invoke without &). return shlex.quote(value) @@ -681,24 +690,30 @@ def _dispatcher_command( Each component is shell-quoted for the target shell (R2) so an interpreter path with spaces or a command/event containing shell metacharacters is passed as a single argument rather than reinterpreted by the native shell. - The Claude dispatcher keeps its ``${CLAUDE_PROJECT_DIR}`` prefix unquoted so - the shell still expands the variable; both the prefix and the relative - dispatcher path are fixed, metacharacter-free strings. + The Claude dispatcher is double-quoted (``"${CLAUDE_PROJECT_DIR}/..."``) so + the variable still expands but a project path with spaces doesn't + word-split (C2). For the explicit ``windows`` target (Copilot's + powershell field) the quoted interpreter is prefixed with PowerShell's + call operator ``&`` so the quoted command is actually invoked (C1). """ if target_os == "host": interpreter = _resolve_interpreter(project_root) else: interpreter = _resolve_interpreter_for_target(target_os) - if integration.key == "claude": - # Leave ${CLAUDE_PROJECT_DIR} unquoted so the shell expands it; the - # prefix and the relative path are both fixed and metacharacter-free. - dispatcher = "${CLAUDE_PROJECT_DIR}/" + EVENTS_DISPATCHER_REL - else: - dispatcher = EVENTS_DISPATCHER_REL q_interp = _shell_quote(interpreter, target_os) q_command = _shell_quote(command_name, target_os) q_event = _shell_quote(event_name, target_os) - return f"{q_interp} {dispatcher} {q_command} {q_event}" + if integration.key == "claude": + # C2: double-quote so ${CLAUDE_PROJECT_DIR} still expands (double + # quotes allow variable expansion in POSIX shells) but a project path + # containing spaces doesn't word-split. + dispatcher = '"${CLAUDE_PROJECT_DIR}/' + EVENTS_DISPATCHER_REL + '"' + else: + dispatcher = _shell_quote(EVENTS_DISPATCHER_REL, target_os) + # C1: PowerShell won't invoke a single-quoted command without the call + # operator. Prefix & for the explicit windows target only. + prefix = "& " if target_os == "windows" else "" + return f"{prefix}{q_interp} {dispatcher} {q_command} {q_event}" def install_integration_events( diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index db7a70e39f..5c1a432c1b 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -294,8 +294,11 @@ def test_merge_into_empty_file(self, tmp_path): assert "args" not in inner assert "speckit.tdd.validate" in inner["command"] assert "pre_tool_use" in inner["command"] - # The dispatcher path must be prefixed with ${CLAUDE_PROJECT_DIR}/ for Claude. + # The dispatcher path must be prefixed with ${CLAUDE_PROJECT_DIR}/ for + # Claude, and double-quoted so a project path with spaces doesn't + # word-split (C2) while the variable still expands. assert "${CLAUDE_PROJECT_DIR}/" in inner["command"] + assert '"${CLAUDE_PROJECT_DIR}/.specify/events.py"' in inner["command"] def test_claude_emits_all_handlers_for_same_event(self, tmp_path): """#2: two handlers on the same event both appear in the native config.""" @@ -396,9 +399,10 @@ def test_copilot_json_generation(self, tmp_path): assert "speckit.agent-context.update" in entry["powershell"] assert entry["bash"] != entry["powershell"] # bash uses POSIX interpreter python3 (shlex.quote leaves safe tokens - # bare); powershell uses python single-quoted for PowerShell. + # bare); powershell uses python single-quoted with the & call operator + # so the quoted command is actually invoked (C1). assert entry["bash"].startswith("python3 ") - assert entry["powershell"].startswith("'python' ") + assert entry["powershell"].startswith("& 'python' ") # PowerShell always single-quotes; POSIX leaves metacharacter-free # identifiers bare (shlex.quote only quotes when needed). assert "'speckit.agent-context.update'" in entry["powershell"] @@ -542,7 +546,8 @@ def test_interpreter_with_space_is_quoted_posix(self, tmp_path): ) # The command must tokenize back into interpreter + dispatcher + 2 args. tokens = shlex.split(cmd) - # dispatcher token retains the ${CLAUDE_PROJECT_DIR} prefix (unquoted). + # dispatcher token carries the ${CLAUDE_PROJECT_DIR} prefix (double- + # quoted in the raw string, but shlex.split strips the quotes). assert any("events.py" in t for t in tokens) assert "speckit.x.y" in tokens assert "stop" in tokens @@ -554,10 +559,30 @@ def test_windows_target_uses_powershell_quoting(self, tmp_path): CopilotIntegration(), tmp_path, "speckit.x.y", "session_start", target_os="windows", ) - # PowerShell single-quoted literals. + # PowerShell single-quoted literals, and the & call operator so the + # quoted interpreter is actually invoked (C1). + assert cmd.startswith("& ") assert "'speckit.x.y'" in cmd assert "'session_start'" in cmd + def test_host_target_never_emits_powershell_quotes(self, tmp_path): + """C1: the host target uses POSIX quoting on every platform so a + single-command-string hook (Claude/Gemini/etc.) stays invocable — + never 'python' (which PowerShell wouldn't invoke without &).""" + from specify_cli.events import _shell_quote + # Safe tokens pass through bare under host (POSIX), not PS-quoted. + assert _shell_quote("python3", "host") == "python3" + assert _shell_quote("speckit.x.y", "host") == "speckit.x.y" + + def test_claude_dispatcher_double_quoted_for_spaces(self, tmp_path): + """C2: Claude's ${CLAUDE_PROJECT_DIR} dispatcher path is double-quoted + so a project path containing spaces doesn't word-split.""" + from specify_cli.events import _dispatcher_command + cmd = _dispatcher_command( + ClaudeIntegration(), tmp_path, "speckit.x.y", "stop", target_os="host", + ) + assert '"${CLAUDE_PROJECT_DIR}/.specify/events.py"' in cmd + class TestTomlMatcherEscaping: """R4: the TOML matcher is escaped like command, not raw-interpolated.""" From e113956afc754ccb13fdd3fde5a372de6fac6d11 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Tue, 28 Jul 2026 11:20:26 +0300 Subject: [PATCH 19/20] fix(events): opencode TS plugin resolves dispatcher from directory, execFileSync argv, forwards input+output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three Copilot review findings on the opencode TS plugin: - C8: the dispatcher and interpreter are now resolved per-project at plugin load from the `directory` OpenCode passes to the plugin factory, not process.cwd(). OpenCode may be launched from a parent directory or host another workspace, in which case process.cwd() pointed at the wrong project and every event failed. The resolver prefers a project-local venv interpreter, then falls back to python3. - C9: the dispatcher is launched with execFileSync and an argv array [interpreter, dispatcher, command, event] instead of a shell command string built by interpolating the interpreter/command/event into a template literal. Command/event strings are only validated as non-empty, so quotes or backticks could previously break the generated TypeScript and shell metacharacters could execute outside the dispatcher; an interpreter path with spaces also failed. No shell is involved now. - C7: tool callbacks now forward both `input` and `output` to runEvent (combined into one JSON payload), so pre_tool_use can inspect the tool arguments and post_tool_use can inspect the result — the primary payload for those events. Previously only `input` was forwarded. Tests: plugin resolves dispatcher/interpreter from `directory` (no process.cwd() path.join), uses execFileSync (no shell string), and forwards output to runEvent for both pre/post_tool_use. Refs: PR #3704 Copilot review (findings C7, C8, C9) Assisted-by: opencode (model: glm-5.2, autonomous) --- src/specify_cli/events.py | 65 +++++++++++++++++++++---------- tests/integrations/test_events.py | 41 ++++++++++++++++--- 2 files changed, 80 insertions(+), 26 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 5efa99900d..f60deecf6d 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -133,16 +133,36 @@ def main(): # -- TS plugin template (opencode) ---------------------------------------- -_TS_PLUGIN_TEMPLATE = '''import {{ execSync }} from 'child_process'; +_TS_PLUGIN_TEMPLATE = '''import {{ execFileSync }} from 'child_process'; import * as path from 'path'; -const DISPATCHER = path.join(process.cwd(), '.specify', 'events.py'); +// The dispatcher + interpreter are resolved per-project at plugin load from +// the `directory` OpenCode passes to the plugin factory (C8), not +// process.cwd() — OpenCode may be launched from a parent directory or host +// another workspace, in which case process.cwd() points at the wrong project. +let DISPATCHER = ''; +let INTERPRETER = ''; + +function resolveDispatcher(directory: string): void {{ + DISPATCHER = path.join(directory, '.specify', 'events.py'); + // Prefer a project-local venv interpreter, then fall back to python3. + const venvPy = path.join(directory, '.venv', 'bin', 'python'); + const venvWin = path.join(directory, '.venv', 'Scripts', 'python.exe'); + INTERPRETER = ( + (require('fs').existsSync(venvPy) && venvPy) || + (require('fs').existsSync(venvWin) && venvWin) || + 'python3' + ) as string; +}} -function runEvent(command: string, event: string, input: any): void {{ - let result; +function runEvent(command: string, event: string, input: any, output: any): void {{ + if (!DISPATCHER) return; try {{ - result = execSync(`{interpreter} ${{DISPATCHER}} ${{command}} ${{event}}`, {{ - input: JSON.stringify(input), + // execFileSync with an argv array invokes the interpreter directly — no + // shell — so command/event strings with metacharacters can't break out + // of the dispatcher argument (C9). + execFileSync(INTERPRETER, [DISPATCHER, command, event], {{ + input: JSON.stringify({{ input, output }}), stdio: ['pipe', 'inherit', 'inherit'], timeout: 60000, }}); @@ -156,6 +176,7 @@ def main(): {event_entries} export default (async ({{ client, project, directory, $ }}) => {{ + resolveDispatcher(directory); return {{ {plugin_returns} }}; @@ -783,7 +804,7 @@ def install_integration_events( _ensure_safe_destination(plugin_path) plugin_path.parent.mkdir(parents=True, exist_ok=True) plugin_path.write_text( - _build_opencode_plugin(filtered, canonical_to_native, _resolve_interpreter(project_root)), + _build_opencode_plugin(filtered, canonical_to_native), encoding="utf-8", ) manifest.record_file( @@ -1157,14 +1178,16 @@ def _toml_quote(value: str) -> str: def _build_opencode_plugin( filtered_events: ResolvedEvents, canonical_to_native: dict[str, str], - interpreter: str, ) -> str: """Render the opencode TS plugin for the resolved event set. Each canonical event may carry multiple handlers (#2); all handlers for a - native event are invoked from one generated function. The dispatcher is - launched with the resolved Python interpreter (#16) instead of a - hard-coded ``python3``. + native event are invoked from one generated function. The dispatcher and + interpreter are resolved per-project at plugin load from the ``directory`` + OpenCode passes (C8); the dispatcher is launched with ``execFileSync`` and + an argv array (C9). Both the ``input`` and ``output`` callback arguments + are forwarded to ``runEvent`` (C7) so pre_tool_use can inspect tool + arguments and post_tool_use can inspect the result. """ event_entries: list[str] = [] plugin_returns: list[str] = [] @@ -1173,8 +1196,9 @@ def _build_opencode_plugin( for ev, handlers in filtered_events.items(): native = canonical_to_native[ev] - # Build the body: one runEvent() call per handler, with an optional - # tool-name matcher guard for tool.execute.* hooks. + # Build the body: one runEvent() call per handler, forwarding both + # input and output (C7). An optional tool-name matcher guard applies + # to tool.execute.* hooks. body_lines: list[str] = [] for cfg in handlers: command = str(cfg.get("command", "")) @@ -1184,33 +1208,33 @@ def _build_opencode_plugin( tools = [t.strip().strip('"') for t in matcher.split("|")] checks = " || ".join(f"input.tool === '{t.lower()}'" for t in tools) body_lines.append( - f" if ({checks}) {{ runEvent('{command}', '{ev}', input); }}" + f" if ({checks}) {{ runEvent('{command}', '{ev}', input, output); }}" ) else: - body_lines.append(f" runEvent('{command}', '{ev}', input);") + body_lines.append(f" runEvent('{command}', '{ev}', input, output);") else: - body_lines.append(f" runEvent('{command}', '{ev}', input);") + body_lines.append(f" runEvent('{command}', '{ev}', input, output);") if native.startswith("tool.execute."): ts_hook = native event_entries.append( - f"function _{ev}(input: any) {{\n" + f"function _{ev}(input: any, output: any) {{\n" + "\n".join(body_lines) + "\n" " }" ) plugin_returns.append( f" \"{ts_hook}\": async (input: any, output: any) => {{\n" - f" _{ev}(input);\n" + f" _{ev}(input, output);\n" f" }}," ) else: event_entries.append( - f"function _{ev}(input: any) {{\n" + f"function _{ev}(input: any, output: any) {{\n" + "\n".join(body_lines) + "\n" " }" ) event_handlers.append( - f" if (event.type === '{native}') {{ _{ev}(event); }}" + f" if (event.type === '{native}') {{ _{ev}(event, event); }}" ) if event_handlers: @@ -1221,7 +1245,6 @@ def _build_opencode_plugin( ) return _TS_PLUGIN_TEMPLATE.format( - interpreter=interpreter, event_entries="\n\n".join(event_entries), plugin_returns="\n".join(plugin_returns), ) diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 5c1a432c1b..db780fca46 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -647,16 +647,18 @@ def test_opencode_ts_plugin_generation(self, tmp_path): assert "process.exit(2)" not in content assert "throw new Error" in content - def test_opencode_ts_plugin_uses_resolved_interpreter(self, tmp_path): - """#16: the dispatcher is launched with a resolved interpreter (venv - when present), not a hard-coded ``python3``.""" + def test_opencode_ts_plugin_resolves_interpreter_and_directory_at_load(self, tmp_path): + """C8/C9: the dispatcher + interpreter are resolved per-project at + plugin load from the `directory` OpenCode passes (not process.cwd()), + preferring a project venv, and the dispatcher is launched via + execFileSync (argv, no shell).""" integration = OpencodeIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() - # Create a project venv so resolve_python_interpreter returns it. + # Create a project venv so the plugin's runtime resolver prefers it. venv_bin = tmp_path / ".venv" / "bin" / "python" venv_bin.parent.mkdir(parents=True) venv_bin.write_text("#!/bin/sh\n") @@ -664,7 +666,14 @@ def test_opencode_ts_plugin_uses_resolved_interpreter(self, tmp_path): events = {"session_start": [{"command": "speckit.boot"}]} install_integration_events(integration, tmp_path, manifest, events) content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text() - assert ".venv/bin/python" in content + # Runtime venv-interpreter preference is baked into the resolver. + assert ".venv" in content and "python" in content + # Dispatcher is resolved from `directory`, not process.cwd() (C8). + assert "path.join(process.cwd()" not in content + assert "directory" in content + # execFileSync (argv, no shell) instead of a shell command string (C9). + assert "execFileSync" in content + assert "execSync(`" not in content def test_opencode_ts_plugin_emits_all_handlers(self, tmp_path): """#2: multiple handlers on the same native event all invoke runEvent.""" @@ -685,6 +694,28 @@ def test_opencode_ts_plugin_emits_all_handlers(self, tmp_path): assert "speckit.first.boot" in content assert "speckit.second.boot" in content + def test_opencode_ts_plugin_forwards_output(self, tmp_path): + """C7: tool callbacks forward both input and output to runEvent so + pre_tool_use can inspect tool args and post_tool_use the result.""" + integration = OpencodeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + + events = { + "pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit"}], + "post_tool_use": [{"command": "speckit.tdd.after"}], + } + install_integration_events(integration, tmp_path, manifest, events) + content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text() + # runEvent signature carries both input and output. + assert "runEvent('speckit.tdd.validate', 'pre_tool_use', input, output)" in content + assert "runEvent('speckit.tdd.after', 'post_tool_use', input, output)" in content + # Tool callbacks pass both arguments through. + assert "_pre_tool_use(input, output)" in content + assert "_post_tool_use(input, output)" in content + # -- Command runner test (core execution) ----------------------------------- From 9b3f24965f0dc45d11971812b5e58856277878d6 Mon Sep 17 00:00:00 2001 From: Lior Kanfi Date: Tue, 28 Jul 2026 11:30:49 +0300 Subject: [PATCH 20/20] fix(events): Qwen ms timeout, Devin root-nested format, Copilot agentStop Address three Copilot review findings on adapter mappings (verified against each agent's published hook documentation): - U1: Qwen Code command hooks measure timeout in milliseconds (default 60000), per the Qwen Code hooks docs. The adapter previously inherited the seconds default, so every generated handler got timeout: 60 (60 ms) and was killed before the dispatcher could start. Declare events_timeout_unit="ms". - U2: Devin's .devin/hooks.v1.json is a root event map ({"PreToolUse": [...]}) with no top-level "hooks" wrapper (the docs state "the hooks object is the entire file"). The adapter reused json-nested, which writes events under a "hooks" key Devin never reads. Add a json-root-nested format with a matching writer (_merge_json_root) and remover (_remove_json_root_entries) that operate on the root event keys, sharing the matcher-grouping, marker, and JSONC-abort behavior of the nested variants. - U3: Copilot CLI supports the canonical per-turn stop lifecycle as native agentStop; add "stop": "agentStop" to the mapping so an extension's stop handler fires for Copilot. Tests: Qwen timeout converts to 60000; Devin events written at the root (no "hooks" wrapper) and teardown preserves user root entries; Copilot stop maps to agentStop. Refs: PR #3704 Copilot review (findings U1, U2, U3) Assisted-by: opencode (model: glm-5.2, autonomous) --- src/specify_cli/events.py | 98 +++++++++++++++++++ .../integrations/copilot/__init__.py | 3 + .../integrations/devin/__init__.py | 5 +- src/specify_cli/integrations/qwen/__init__.py | 6 ++ tests/integrations/test_events.py | 69 +++++++++++++ 5 files changed, 180 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index f60deecf6d..b023457a70 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -951,6 +951,36 @@ def install_integration_events( manifest.record_existing(rel) created.append(config_path) + elif fmt == "json-root-nested": + # Devin hooks.v1.json: a root event map ({"PreToolUse": [...]}) with + # no top-level "hooks" wrapper (U2). Same matcher-grouping and single + # command string as json-nested, but written to the root. + root_hooks: dict[str, list[dict[str, Any]]] = {} + for ev, handlers in filtered.items(): + native = canonical_to_native[ev] + by_matcher: dict[str, list[dict[str, Any]]] = {} + for cfg in handlers: + matcher = cfg.get("matcher", "*") + command = cfg.get("command", "") + dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev) + by_matcher.setdefault(matcher, []).append( + { + "type": "command", + "command": dispatcher_cmd, + "timeout": _native_timeout(integration, cfg.get("timeout", 60)), + _SPECKIT_MARKER: True, + } + ) + root_hooks[native] = [ + {"matcher": matcher, "hooks": inner} + for matcher, inner in by_matcher.items() + ] + if _merge_json_root(config_path, root_hooks): + rel = str(config_path.relative_to(project_root)) + if rel not in manifest.files: + manifest.record_existing(rel) + created.append(config_path) + return created @@ -978,6 +1008,8 @@ def _remove_native_event_hooks( _remove_toml_entries(config_path) elif fmt in ("json-nested", "json-flat"): _remove_json_entries(config_path) + elif fmt == "json-root-nested": + _remove_json_root_entries(config_path) elif fmt == "ts-plugin": _remove_opencode_entries(config_path) # Always drop this integration's manifest claim on the native config, @@ -1466,6 +1498,72 @@ def _merge_json_fragment(dst: Path, new_hooks: dict, *, version: int | None = No return True +def _merge_json_root(dst: Path, new_hooks: dict) -> bool: + """Merge Specify-authored hooks into a root-nested JSON config (Devin U2). + + Devin's ``.devin/hooks.v1.json`` is a root event map + (``{"PreToolUse": [...]}``) with no ``hooks`` wrapper, so the event keys + are top-level. Same idempotent strip-all-marked-then-add semantics and + JSONC-abort behavior as ``_merge_json_fragment``. + """ + existing = _load_user_json(dst) + if existing is None: + return False + if not isinstance(existing, dict): + existing = {} + + # #11: strip ALL Specify-marked entries from every root event first. + cleaned: dict[str, list] = {} + for event, entries in existing.items(): + if not isinstance(entries, list): + # Preserve non-list user fields at the root (Devin has none, but + # be defensive against a mixed user file). + cleaned[event] = entries # type: ignore[assignment] + continue + kept_entries = _drop_marked_entries(entries) + if kept_entries: + cleaned[event] = kept_entries + + # Then add the newly resolved set (list values only). + for event, entries in new_hooks.items(): + cleaned.setdefault(event, []).extend(entries) + + if cleaned: + existing = cleaned + else: + existing = {} + if not existing: + dst.unlink(missing_ok=True) + return True + _safe_write_json(dst, existing) + return True + + +def _remove_json_root_entries(dst: Path) -> bool: + """Remove Specify-authored entries from a root-nested JSON config (Devin U2). + + Deletes the file when no user content remains (C5/#14 mirror). + """ + existing = _load_user_json(dst) + if existing is None: + return False + if not isinstance(existing, dict): + return False + cleaned: dict[str, Any] = {} + for event, entries in existing.items(): + if not isinstance(entries, list): + cleaned[event] = entries + continue + kept_entries = _drop_marked_entries(entries) + if kept_entries: + cleaned[event] = kept_entries + if not cleaned: + dst.unlink(missing_ok=True) + return True + _safe_write_json(dst, cleaned) + return False + + def _drop_marked_entries(entries: list) -> list: """Return *entries* with Specify-marked hooks removed, preserving user hooks. diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 965f5692c7..8a8db6f587 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -124,6 +124,9 @@ class CopilotIntegration(IntegrationBase): "post_tool_use": "postToolUse", "session_end": "sessionEnd", "user_prompt_submit": "userPromptSubmitted", + # Copilot CLI supports the canonical per-turn stop lifecycle as native + # agentStop (U3); mapping it so an extension's stop handler fires. + "stop": "agentStop", } events_config_file = ".github/hooks/speckit.json" events_format = "copilot-json" diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py index 2de70173a3..dea6b5d228 100644 --- a/src/specify_cli/integrations/devin/__init__.py +++ b/src/specify_cli/integrations/devin/__init__.py @@ -40,7 +40,10 @@ class DevinIntegration(SkillsIntegration): "stop": "Stop", } events_config_file = ".devin/hooks.v1.json" - events_format = "json-nested" + # Devin's hooks.v1.json is a root event map ({"PreToolUse": [...]}) with no + # top-level "hooks" wrapper (U2), unlike the settings.json formats. The + # json-root-nested writer/remover operate directly on the root event keys. + events_format = "json-root-nested" def build_exec_args( self, diff --git a/src/specify_cli/integrations/qwen/__init__.py b/src/specify_cli/integrations/qwen/__init__.py index 2cd879f06b..7ab55d978b 100644 --- a/src/specify_cli/integrations/qwen/__init__.py +++ b/src/specify_cli/integrations/qwen/__init__.py @@ -30,3 +30,9 @@ class QwenIntegration(MarkdownIntegration): } events_config_file = ".qwen/settings.json" events_format = "json-nested" + # Qwen Code's command hooks measure timeout in milliseconds (default + # 60000), per the Qwen Code hooks documentation. Declaring the unit makes + # the shared formatter convert the 60s default to 60000ms instead of + # emitting timeout: 60 (60 ms), which would terminate the dispatcher + # before it starts (U1). + events_timeout_unit = "ms" diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index db780fca46..7fbbdb2d77 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -516,6 +516,75 @@ def test_tabnine_timeout_converted_to_ms(self): assert _native_timeout(TabnineIntegration(), 60) == 60000 + def test_qwen_timeout_converted_to_ms(self): + """U1: Qwen Code command hooks use milliseconds (default 60000).""" + from specify_cli.integrations.qwen import QwenIntegration + from specify_cli.events import _native_timeout + + assert _native_timeout(QwenIntegration(), 60) == 60000 + + +# -- Devin root-nested format (U2) + Copilot agentStop (U3) ------------------ + +class TestDevinRootNestedFormat: + """U2: Devin's hooks.v1.json is a root event map with no 'hooks' wrapper.""" + + def test_devin_events_written_at_root(self, tmp_path): + from specify_cli.integrations.devin import DevinIntegration + integration = DevinIntegration() + assert integration.events_format == "json-root-nested" + + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + manifest.remove = MagicMock() + + install_integration_events( + integration, tmp_path, manifest, + {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, + ) + data = json.loads((tmp_path / ".devin/hooks.v1.json").read_text()) + # Event keys are top-level (no "hooks" wrapper). + assert "PreToolUse" in data + assert "hooks" not in data + + def test_devin_teardown_removes_owned_and_preserves_user(self, tmp_path): + from specify_cli.integrations.devin import DevinIntegration + integration = DevinIntegration() + config_path = tmp_path / ".devin/hooks.v1.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps({ + "PreToolUse": [{ + "matcher": "exec", + "hooks": [{"type": "command", "command": "user-check"}], + }] + })) + + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + manifest.remove = MagicMock() + install_integration_events( + integration, tmp_path, manifest, + {"stop": [{"command": "speckit.end"}]}, + ) + remove_integration_events(integration, tmp_path, manifest) + + data = json.loads(config_path.read_text()) + # User hook preserved at the root; Specify's Stop gone. + assert "Stop" not in data + assert data["PreToolUse"][0]["matcher"] == "exec" + + +class TestCopilotAgentStop: + """U3: Copilot maps the canonical stop lifecycle to native agentStop.""" + + def test_copilot_stop_mapping(self): + integration = CopilotIntegration() + assert integration.CANONICAL_TO_NATIVE.get("stop") == "agentStop" + # -- Shell quoting & matcher escaping (R2, R4) -------------------------------