diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1d5a34073c..fcf2d8f1be 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 # noqa: E402 +_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..764fde7f6b --- /dev/null +++ b/src/specify_cli/commands/event.py @@ -0,0 +1,39 @@ +"""specify event * command handlers.""" + +from __future__ import annotations + +from pathlib import Path +import sys +import typer + +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)"), + timeout: int = typer.Argument( + 120, help="Per-handler timeout in seconds (passed through from the native hook config)" + ), +): + """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, timeout=timeout + ) + 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..0c483ffc73 --- /dev/null +++ b/src/specify_cli/events.py @@ -0,0 +1,1874 @@ +"""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 os +import re +import shlex +import shutil +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" +# 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" + +_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 _has_specify_cli(py_exe): + """Probe whether *py_exe* can import specify_cli (R2). + + A project-local venv commonly exists but does not have Spec Kit installed + (it's installed globally or via uv tool). Selecting that interpreter with + ``-m specify_cli`` would fail every event instead of reaching the PATH + ``specify`` fallback. Returns True only if the import succeeds. + """ + try: + result = subprocess.run( + [str(py_exe), "-c", "import specify_cli"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=10, + ) + return result.returncode == 0 + except Exception: + return False + + +def _find_specify(): + project_root = Path(__file__).parent.parent.resolve() + # 1. Check local virtualenv for a `specify` executable (preferred — no + # import probe needed, the binary only exists if Spec Kit is installed + # in that venv). + 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 — but only if this venv's python can + # actually import specify_cli (R2). A project-local venv that lacks + # Spec Kit would otherwise shadow the PATH `specify` fallback and fail + # every event. + for candidate in ("venv", ".venv"): + py_exe = project_root / candidate / "bin" / "python" + if py_exe.exists() and _has_specify_cli(py_exe): + return [str(py_exe), "-m", "specify_cli"] + win_py = project_root / candidate / "Scripts" / "python.exe" + if win_py.exists() and _has_specify_cli(win_py): + 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] + # Optional 4th arg: the per-handler timeout (in seconds), passed through + # from the native hook config so the inner subprocess doesn't impose a + # smaller fixed cap (S4). Defaults to 120 for already-deployed dispatchers + # that don't pass it. + timeout = 120 + if len(sys.argv) >= 4: + try: + timeout = int(sys.argv[3]) + except (TypeError, ValueError): + timeout = 120 + 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, str(timeout)] + try: + result = subprocess.run( + specify_args, + input=payload, + capture_output=True, + text=True, + timeout=timeout, + cwd=str(project_root), + ) + 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 {{ execFileSync }} from 'child_process'; +import * as path from 'path'; + +// 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 canImportSpecifyCli(py: string): boolean {{ + // R2: a project-local venv commonly lacks Spec Kit (installed globally or + // via uv tool). Probe the interpreter can import specify_cli before + // selecting it, so an unrelated venv doesn't shadow the PATH fallback. + try {{ + execFileSync(py, ['-c', 'import specify_cli'], {{ + stdio: ['ignore', 'ignore', 'ignore'], + timeout: 10000, + }}); + return true; + }} catch (e) {{ + return false; + }} +}} + +function resolveDispatcher(directory: string): void {{ + DISPATCHER = path.join(directory, '.specify', 'events.py'); + // Prefer a project-local venv interpreter that can import specify_cli (R2), + // then fall back to a platform-appropriate PATH interpreter (S2: python on + // Windows, where python3 is commonly absent; python3 on POSIX). + const venvPy = path.join(directory, '.venv', 'bin', 'python'); + const venvWin = path.join(directory, '.venv', 'Scripts', 'python.exe'); + INTERPRETER = ( + (require('fs').existsSync(venvPy) && canImportSpecifyCli(venvPy) && venvPy) || + (require('fs').existsSync(venvWin) && canImportSpecifyCli(venvWin) && venvWin) || + (process.platform === 'win32' ? 'python' : 'python3') + ) as string; +}} + +function runEvent(command: string, event: string, input: any, output: any): void {{ + if (!DISPATCHER) return; + try {{ + // 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, + }}); + }} catch (e) {{ + // 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}}`); + }} +}} + +{event_entries} + +export default (async ({{ client, project, directory, $ }}) => {{ + resolveDispatcher(directory); + return {{ +{plugin_returns} + }}; +}}); +''' + + +# -- Command runner logic (core) -------------------------------------------- + +def _find_command_template(command_name: str, project_root: Path) -> tuple[Path | None, str | None]: + # 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 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 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" + 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 in the project + 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 + + # 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 _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: + 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 + # 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 + + # 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" + + 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 + 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 on POSIX. On Windows + # subprocess.run(shell=False) can't execute a .sh directly, so prefix a + # bash/sh launcher when one is available (mirroring the ps branch's + # pwsh -File handling, S5). + if os.name == "nt": + launcher = shutil.which("bash") or shutil.which("sh") + if launcher: + return [launcher, str(script_abs), *rest_args] + return [str(script_abs), *rest_args] + + +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, + *, + timeout: int = 120, +) -> int: + """Core entry point to resolve and execute an event-driven command. + + *timeout* is the per-handler timeout in seconds, passed through from the + native hook config via the dispatcher (S4) so a handler configured above + the previous fixed 120s cap can run for its full duration. + """ + 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 + 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( + argv, + input=payload, + capture_output=True, + text=True, + timeout=timeout, + cwd=str(project_root), + ) + 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) ------------------------- + +# 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" + ) + # 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( + integration_key: str, + integration_config: dict[str, Any] | None, + project_root: Path, + parsed_options: dict[str, Any] | None, +) -> 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 {} + + events: ResolvedEvents = {} + + # Layer 2: built-in defaults from integration config + if integration_config and isinstance(integration_config.get("events"), dict): + for ev, cfg in integration_config["events"].items(): + handlers = _normalize_handlers(cfg) + if handlers: + events.setdefault(ev, []).extend(handlers) + + # Layer 3: extension-declared events (accumulated, not overwriting) + for ev, handlers in collect_extension_events(project_root).items(): + events.setdefault(ev, []).extend(handlers) + + # Layer 4: 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 isinstance(integrations, dict) and integration_key in integrations: + key_data = integrations[integration_key] + 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: entry for '%s' is not a mapping; ignoring override", + override_file, integration_key, + ) + else: + 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 + + +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). + + 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. + + Events are read from a validated ``ExtensionManifest`` (R1) rather than + the raw ``extension.yml`` YAML, so the command-reference canonicalization + applied during install validation (C11, e.g. ``my-ext.boot`` → + ``speckit.my-ext.boot``) is reflected — otherwise refresh would emit the + obsolete name and ``_find_command_template`` could not match it, leaving + the hook silently inert. + """ + from .extensions import ExtensionManager, ExtensionRegistry + + events: ResolvedEvents = {} + exts_dir = project_root / ".specify" / "extensions" + if not exts_dir.is_dir(): + return events + + manager = ExtensionManager(project_root) + + # 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 + + # Union of extension IDs to consider: registry-tracked IDs (validated + # manifests, canonicalized refs) plus on-disk dirs not yet in the registry + # (partially-staged installs). The latter fall back to the raw YAML since + # no validated manifest is available, preserving the on-disk scan behavior. + registry_ids = set() + try: + registry_ids = set(manager.registry.keys()) + except Exception: + pass + on_disk_ids = { + d.name for d in exts_dir.iterdir() if d.is_dir() and (d / "extension.yml").exists() + } + for ext_id in sorted(registry_ids | on_disk_ids): + if ext_id in disabled_ids: + continue + # Prefer the validated manifest (canonicalized command refs, R1); + # fall back to the raw YAML for an on-disk extension not yet + # registered (a malformed extension shouldn't abort collection). + runtime: dict[str, Any] = {} + if ext_id in registry_ids: + try: + manifest = manager.get_extension(ext_id) + except Exception: + manifest = None + if manifest is not None: + runtime = manifest.data.get("events", {}) or {} + if not runtime: + ext_yml = exts_dir / ext_id / "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(): + 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 _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. + + 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 _shell_quote(value: str, target_os: str) -> str: + """Quote *value* as one argument for the target shell (R2). + + ``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": + 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) + + +def _dispatcher_command( + integration: IntegrationBase, + project_root: Path, + command_name: str, + event_name: str, + *, + target_os: str = "host", + timeout_seconds: Any = None, +) -> 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). + + ``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). + + 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 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). + + When *timeout_seconds* is given, the resolved timeout (in the + integration's native unit) is appended as a 4th argument so the dispatcher + and inner runner honor the per-handler timeout instead of a fixed 120s cap + that would kill a handler configured for longer (S4). + """ + if target_os == "host": + interpreter = _resolve_interpreter(project_root) + else: + interpreter = _resolve_interpreter_for_target(target_os) + q_interp = _shell_quote(interpreter, target_os) + q_command = _shell_quote(command_name, target_os) + q_event = _shell_quote(event_name, target_os) + 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 "" + base = f"{prefix}{q_interp} {dispatcher} {q_command} {q_event}" + if timeout_seconds is not None: + resolved = _native_timeout(integration, timeout_seconds) + # Add a small buffer so the inner subprocess isn't killed at the same + # instant the native cap fires (the agent kills the dispatcher process, + # not the grandchild; the inner cap is a backstop, not the bound). + inner_cap = resolved + 5 + base += f" {_shell_quote(str(inner_cap), target_os)}" + return base + + +def install_integration_events( + integration: IntegrationBase, + project_root: Path, + manifest: IntegrationManifest, + events: ResolvedEvents, +) -> list[Path]: + """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, 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] = handlers + else: + print( + f"\u26a0\ufe0f {integration.key} does not support '{ev}' events; skipping", + 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. S3: also run the shared- + # dispatcher refcount cleanup so an --events false upgrade of the last + # event integration doesn't orphan .specify/events.py permanently (the + # new manifest no longer claims it and stale cleanup excludes it). + if not filtered: + _remove_native_event_hooks(integration, project_root, manifest) + _cleanup_shared_dispatcher(integration, project_root, manifest) + return [] + + created: list[Path] = [] + + # 1. Generate events.py dispatcher script (#12: validate destination first) + dispatcher_dir = project_root / EVENTS_DISPATCHER_DIR + 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( + 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 + _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), + encoding="utf-8", + ) + manifest.record_file( + plugin_rel, + plugin_path.read_bytes(), + ) + created.append(plugin_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 + # 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", "") + bash_cmd = _dispatcher_command( + integration, project_root, command, ev, target_os="posix", + timeout_seconds=cfg.get("timeout", 60), + ) + ps_cmd = _dispatcher_command( + integration, project_root, command, ev, target_os="windows", + timeout_seconds=cfg.get("timeout", 60), + ) + entries.append( + { + "type": "command", + "bash": bash_cmd, + "powershell": ps_cmd, + "timeoutSec": _native_timeout(integration, cfg.get("timeout", 60)), + _SPECKIT_MARKER: True, + } + ) + copilot_hooks[native] = entries + # 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 + # per handler so multiple handlers per event all emit (#2). + lines: list[str] = [] + for ev, handlers in filtered.items(): + native = canonical_to_native[ev] + for cfg in handlers: + command = cfg.get("command", "") + dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev, timeout_seconds=cfg.get("timeout", 60)) + lines.append(f'[[hooks.{native}]]') + lines.append(f'matcher = {_toml_quote(str(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: + manifest.record_existing(rel) + created.append(config_path) + + elif fmt == "json-flat": + # 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] + entries: list[dict[str, Any]] = [] + for cfg in handlers: + command = cfg.get("command", "") + dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev, timeout_seconds=cfg.get("timeout", 60)) + 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 + # #7: Cursor's .cursor/hooks.json schema requires top-level + # "version": 1; ensure it (preserving a user's value if present). + # 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. + # 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] + 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, timeout_seconds=cfg.get("timeout", 60)) + by_matcher.setdefault(matcher, []).append( + { + "type": "command", + "command": dispatcher_cmd, + "timeout": _native_timeout(integration, cfg.get("timeout", 60)), + _SPECKIT_MARKER: True, + } + ) + nested_hooks[native] = [ + {"matcher": matcher, "hooks": inner} + for matcher, inner in by_matcher.items() + ] + # 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) + + 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, timeout_seconds=cfg.get("timeout", 60)) + 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 + + +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 not config_file: + return + config_path = project_root / config_file + if not config_path.exists(): + return + if fmt == "copilot-json": + _remove_copilot_entries(config_path) + elif fmt == "toml": + _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, + # 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( + 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 _cleanup_shared_dispatcher( + integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest +) -> None: + """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, S3). + + 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. Used by full teardown and by the empty-resolved-map install + path so an ``--events false`` upgrade of the last event integration + doesn't orphan ``.specify/events.py`` permanently (S3). + """ + 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) + + +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) + _cleanup_shared_dispatcher(integration, project_root, manifest) + + # 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: + 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") + # 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 + + +class EventRefreshError(RuntimeError): + """Raised when refreshing one or more integrations' event config failed. + + Aggregates per-integration failures so a lifecycle command + (extension add/remove/enable/disable) can surface that an extension was + not fully deactivated — a stale native hook may still be active (R3). + """ + + def __init__(self, failures: list[tuple[str, str]]) -> None: + self.failures = failures + details = "; ".join(f"{key}: {detail}" for key, detail in failures) + super().__init__( + f"event refresh failed for {len(failures)} integration(s): {details}" + ) + + +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 + is logged and accumulated but does not abort the others. If any + integration failed, :class:`EventRefreshError` is raised at the end so + the lifecycle command can't claim the extension was fully deactivated + while a stale native hook may still be active (R3). + """ + from .integrations import get_integration + from .integrations._helpers import _read_integration_json, _resolve_integration_options + from .integrations.manifest import IntegrationManifest + from .integration_state import installed_integration_keys + + state = _read_integration_json(project_root) + failures: list[tuple[str, str]] = [] + 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) + failures.append((key, f"manifest load: {exc}")) + continue + try: + # 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. + _, parsed_options = _resolve_integration_options(integration, state, key, None) + events_map = resolve_events( + key, integration.config, project_root, parsed_options + ) + # 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) + failures.append((key, str(exc))) + + if failures: + raise EventRefreshError(failures) + + +# -- 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" + ) + 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' string" + ) + if event_name not in CANONICAL_EVENTS: + raise ValidationError( + 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: + """Return True if ``events`` is present and non-empty.""" + return bool(data.get("events")) + + +# -- Helper merging functions ---------------------------------------------- + +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], +) -> 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 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] = [] + event_handlers: list[str] = [] + + for ev, handlers in filtered_events.items(): + native = canonical_to_native[ev] + # S1: serialize every interpolated value as a JSON string literal so a + # quote/backslash/backtick in a command or matcher can't break the + # generated TypeScript or inject code. json.dumps produces a valid + # TS/JS string literal (double-quoted, fully escaped). + ev_lit = json.dumps(ev) + native_lit = json.dumps(native) + + # 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", "")) + command_lit = json.dumps(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 === {json.dumps(t.lower())}" for t in tools + ) + body_lines.append( + f" if ({checks}) {{ runEvent({command_lit}, {ev_lit}, input, output); }}" + ) + else: + body_lines.append( + f" runEvent({command_lit}, {ev_lit}, input, output);" + ) + else: + body_lines.append( + f" runEvent({command_lit}, {ev_lit}, input, output);" + ) + + if native.startswith("tool.execute."): + ts_hook = native + event_entries.append( + f"function _{ev}(input: any, output: any) {{\n" + + "\n".join(body_lines) + "\n" + " }" + ) + plugin_returns.append( + f" {json.dumps(ts_hook)}: async (input: any, output: any) => {{\n" + f" _{ev}(input, output);\n" + f" }}," + ) + else: + event_entries.append( + f"function _{ev}(input: any, output: any) {{\n" + + "\n".join(body_lines) + "\n" + " }" + ) + event_handlers.append( + f" if (event.type === {native_lit}) {{ _{ev}(event, event); }}" + ) + + if event_handlers: + plugin_returns.append( + " event: async ({ event }) => {\n" + + "\n".join(event_handlers) + "\n" + " }," + ) + + 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) -> 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 ``{}``. Returns + False when skipped so callers avoid tracking the untouched file (S5). + """ + existing = _load_user_json(config_path) + if existing is None: + return False + plugins = existing.get("plugin", []) + if not isinstance(plugins, list): + plugins = [] + if ref not in plugins: + plugins.append(ref) + existing["plugin"] = plugins + _safe_write_json(config_path, existing) + return True + + +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" + plugins = [p for p in plugins if p != ref] + if plugins: + existing["plugin"] = plugins + else: + existing.pop("plugin", None) + 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") + 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) -> 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 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*', + "", + 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]) -> 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). Returns + False when skipped so callers avoid tracking the untouched file (S5). + """ + existing = _load_user_json(dst) + if existing is None: + return False + 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) + return True + + +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, *, 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 + 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 + ``{}``. 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. + 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: + return False + 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): + 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(): + 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) + 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. + + 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 _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: + 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 False + 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) + # #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) + return False + + +def _has_marker(entry: Any) -> bool: + """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/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 00a6d92b41..de33984fef 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. @@ -428,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/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 7ae4f8e9f0..becdec885a 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -60,6 +60,30 @@ def _display_project_path(*args, **kwargs): return _f(*args, **kwargs) +def _refresh_events_and_warn(project_root: Path) -> None: + """Refresh native event config and surface failures (R3). + + The extension has already been added/removed/enabled/disabled by the time + this runs, so a refresh failure must not abort the command — but it must + be surfaced, because a stale native hook may still be active (e.g. a + disabled extension's hook still resolves and runs). Prints a warning with + the per-integration failures so the user knows deactivation was incomplete. + """ + from ..events import EventRefreshError, refresh_integration_events + + try: + refresh_integration_events(project_root) + except EventRefreshError as exc: + console.print( + f"\n[yellow]⚠[/yellow] Extension updated, but event refresh failed " + f"for {len(exc.failures)} integration(s); a stale native hook may " + f"still be active. Re-run [cyan]specify integration upgrade " + f"[cyan][/cyan][/cyan] to retry." + ) + for key, detail in exc.failures: + console.print(f" {key}: {_escape_markup(detail)}") + + def _load_catalog_command_config(project_root: Path, config_path: Path) -> dict: """Load extension catalog CLI config with user-facing shape errors.""" try: @@ -627,6 +651,10 @@ 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. + _refresh_events_and_warn(project_root) + for warning in manifest.warnings: console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}") @@ -733,6 +761,10 @@ 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. + _refresh_events_and_warn(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 +1533,10 @@ 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. + _refresh_events_and_warn(project_root) + @extension_app.command("disable") def extension_disable( @@ -1545,6 +1581,10 @@ 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. + _refresh_events_and_warn(project_root) + @extension_app.command("set-priority") def extension_set_priority( 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 ccc8587709..7b0abde035 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 ..events import install_integration_events, remove_integration_events if TYPE_CHECKING: from .manifest import IntegrationManifest @@ -158,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, @@ -479,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. @@ -902,8 +917,32 @@ def teardown( Returns ``(removed, skipped)`` file lists. """ + 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( @@ -1008,6 +1047,12 @@ def setup( created.append(dst_file) + # 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 @@ -1215,6 +1260,12 @@ def setup( created.append(dst_file) + # 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 @@ -1451,6 +1502,12 @@ def setup( created.append(dst_file) + # 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 @@ -1686,4 +1743,10 @@ def setup( created.append(dst) + # 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 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..8a8db6f587 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -118,6 +118,19 @@ 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", + # 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" + # Mutable flag set by setup() — indicates the active scaffolding mode. _skills_mode: bool = False @@ -162,14 +175,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. @@ -328,7 +346,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 +375,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..dea6b5d228 100644 --- a/src/specify_cli/integrations/devin/__init__.py +++ b/src/specify_cli/integrations/devin/__init__.py @@ -31,6 +31,20 @@ 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" + # 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, prompt: str, @@ -55,11 +69,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/src/specify_cli/integrations/gemini/__init__.py b/src/specify_cli/integrations/gemini/__init__.py index 9a459862af..2200e707c8 100644 --- a/src/specify_cli/integrations/gemini/__init__.py +++ b/src/specify_cli/integrations/gemini/__init__.py @@ -19,3 +19,22 @@ 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", + # Gemini exposes BeforeAgent for the per-turn prompt-submit lifecycle + # point (S6); its own Claude-hook migration maps UserPromptSubmit to + # BeforeAgent. Mapping it so extension handlers fire. + "user_prompt_submit": "BeforeAgent", + "stop": "AfterAgent", + } + 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/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..7ab55d978b 100644 --- a/src/specify_cli/integrations/qwen/__init__.py +++ b/src/specify_cli/integrations/qwen/__init__.py @@ -19,3 +19,20 @@ 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" + # 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/src/specify_cli/integrations/tabnine/__init__.py b/src/specify_cli/integrations/tabnine/__init__.py index 9edf1e1607..5e8a803e6c 100644 --- a/src/specify_cli/integrations/tabnine/__init__.py +++ b/src/specify_cli/integrations/tabnine/__init__.py @@ -19,3 +19,23 @@ 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", + # Tabnine's Gemini-compatible schema also provides BeforeAgent and + # AfterAgent (S7); mapping them so user_prompt_submit and stop + # extension handlers fire instead of being skipped. + "user_prompt_submit": "BeforeAgent", + "stop": "AfterAgent", + } + 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 new file mode 100644 index 0000000000..2066ba51b2 --- /dev/null +++ b/tests/integrations/test_events.py @@ -0,0 +1,2010 @@ +"""Tests for events module: integration runtime events.""" + +from __future__ import annotations + +import json +import os +import platform +import shlex +from pathlib import Path, PurePath +from unittest.mock import MagicMock, patch + +import pytest + +from specify_cli.events import ( + CANONICAL_EVENTS, + EVENTS_DISPATCHER_REL, + collect_extension_events, + install_integration_events, + remove_integration_events, + resolve_events, + validate_events, + resolve_and_run_event_command, +) +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.opencode import OpencodeIntegration +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_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.""" + 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) == {} + + def test_event_command_ref_canonicalized_via_manifest(self, tmp_path): + """R1: events are read from a validated ExtensionManifest, so an + obsolete command ref (e.g. my-ext.boot) is canonicalized + (speckit.my-ext.boot) the same way hook refs are at install.""" + from specify_cli.extensions import ExtensionRegistry + import yaml as _yaml + + ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" + ext_dir.mkdir(parents=True) + # Manifest declares an alias-form event command ref (my-ext.boot) + # alongside the command it resolves to; the validated manifest lifts + # the ref to speckit.my-ext.boot (C11). + (ext_dir / "extension.yml").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", + ) + ExtensionRegistry(tmp_path / ".specify" / "extensions").add( + "my-ext", {"enabled": True} + ) + + result = collect_extension_events(tmp_path) + # The ref was canonicalized to speckit.my-ext.boot by the validated + # manifest, so dispatch can match it (raw-YAML reading would have + # emitted the obsolete my-ext.boot and the hook would no-op). + assert result == {"session_start": [{"command": "speckit.my-ext.boot"}]} + + +# -- 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" + + def test_gemini_mapping_includes_before_agent(self): + # S6: Gemini exposes BeforeAgent for user_prompt_submit and AfterAgent + # for stop (verified against Gemini CLI's hooks docs). + from specify_cli.integrations.gemini import GeminiIntegration + integration = GeminiIntegration() + assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "BeforeTool" + assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "BeforeAgent" + assert integration.CANONICAL_TO_NATIVE["stop"] == "AfterAgent" + + def test_tabnine_mapping_includes_before_agent(self): + # S7: Tabnine's Gemini-compatible schema provides BeforeAgent/AfterAgent. + from specify_cli.integrations.tabnine import TabnineIntegration + integration = TabnineIntegration() + assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "BeforeAgent" + assert integration.CANONICAL_TO_NATIVE["stop"] == "AfterAgent" + + +# -- 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: + """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" + # #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, 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.""" + 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() + 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" + # #6: a complete shell command string (not command+args). + 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. 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 with the & call operator + # so the quoted command is actually invoked (C1). + assert entry["bash"].startswith("python3 ") + 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 + + +# -- 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: + """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 + + 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 + + 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) ------------------------------- + +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 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 + + 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, 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.""" + + 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 --------------------------------------------- + +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 + # #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_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 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") + + events = {"session_start": [{"command": "speckit.boot"}]} + install_integration_events(integration, tmp_path, manifest, events) + content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text() + # 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 + # R2: venv interpreter is probed for specify_cli importability before + # selection (an unrelated project venv shouldn't shadow the fallback). + assert "canImportSpecifyCli" in content + # S2: the PATH fallback is python on Windows (python3 is commonly + # absent there), python3 on POSIX. + assert "process.platform === 'win32'" in content + assert "'python'" in content + assert "'python3'" 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 + + 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. S1: command/event + # are JSON string literals (double-quoted, escaped). + 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 + + def test_opencode_ts_plugin_escapes_metacharacters(self, tmp_path): + """S1: command/matcher values with quotes/backticks are serialized as + JSON string literals so they can't break the generated TypeScript or + inject code.""" + integration = OpencodeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + + # A command and matcher containing characters that would break a + # single-quoted TS literal. + events = { + "pre_tool_use": [{"command": "speckit.x'y`code", "matcher": "Ed'it"}], + } + install_integration_events(integration, tmp_path, manifest, events) + content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text() + # The value must appear inside a JSON double-quoted literal, not a + # single-quoted TS literal (which a quote/backtick would break). + assert json.dumps("speckit.x'y`code") in content + # The dangerous single-quoted form (runEvent('speckit.x'y...')) — + # where the embedded quote would terminate the literal — is absent. + assert "runEvent('speckit.x" not in content + assert json.dumps("ed'it") 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_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" + 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 + + 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. Compare in + # POSIX form so the assertion holds on Windows (backslash paths) too. + assert len(argv) >= 2 + 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): + """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. 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 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 + 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() + + def test_dispatcher_probes_venv_for_specify_cli(self, tmp_path): + """R2: the generated dispatcher probes a project venv python for + specify_cli importability before selecting it, so an unrelated + project venv doesn't shadow the PATH `specify` fallback.""" + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + install_integration_events( + integration, tmp_path, manifest, + {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, + ) + content = (tmp_path / EVENTS_DISPATCHER_REL).read_text() + assert "_has_specify_cli" in content + assert "import specify_cli" in content + # The fallback is still present for when no venv qualifies. + assert '["specify"]' in content + + def test_dispatcher_threads_per_handler_timeout(self, tmp_path): + """S4: the generated dispatcher reads an optional 4th timeout arg and + uses it for the inner subprocess, instead of a fixed 120s cap that + would kill a handler configured for longer.""" + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + install_integration_events( + integration, tmp_path, manifest, + {"pre_tool_use": [{"command": "speckit.tdd.validate", "timeout": 300}]}, + ) + content = (tmp_path / EVENTS_DISPATCHER_REL).read_text() + # The dispatcher accepts a 4th argv element as the timeout. + assert "sys.argv[3]" in content + assert "timeout=timeout" in content + + def test_native_command_carries_resolved_timeout(self, tmp_path): + """S4: the generated native hook command appends the resolved timeout + so the dispatcher receives it. Claude uses seconds (default unit).""" + from specify_cli.events import _dispatcher_command + cmd = _dispatcher_command( + ClaudeIntegration(), tmp_path, "speckit.x.y", "stop", + timeout_seconds=300, + ) + # 300s + 5s buffer is appended as the 4th arg. + assert " 305" in cmd + + def test_sh_variant_uses_launcher_on_windows(self, tmp_path): + """S5: on Windows the sh variant prefixes a bash/sh launcher so + subprocess.run(shell=False) can execute the .sh 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( + "---\ndescription: \"Boot\"\nscripts:\n sh: scripts/bash/boot.sh\n---\nBody\n", + encoding="utf-8", + ) + sh_dir = tmp_path / ".specify" / "scripts" / "bash" + sh_dir.mkdir(parents=True) + (sh_dir / "boot.sh").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + + argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) + assert argv is not None + # On POSIX the script runs directly; on Windows a launcher prefixes it. + if platform.system().lower().startswith("win"): + assert PurePath(argv[0]).stem.lower() in ("bash", "sh") + assert PurePath(argv[1]).as_posix().endswith(".specify/scripts/bash/boot.sh") + else: + assert PurePath(argv[0]).as_posix().endswith(".specify/scripts/bash/boot.sh") + + +# -- 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_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() + 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" / "integration.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() + + 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) ----------------------------------------- + +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"] + + 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 == {} + + 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) ------------------------------------------ + +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)." + ) + + def test_empty_map_upgrade_deletes_dispatcher_for_last_integration(self, tmp_path): + """S3: an --events false upgrade (empty resolved map) of the last + event-capable integration deletes the shared dispatcher instead of + orphaning it (the new manifest wouldn't claim it and stale cleanup + excludes it).""" + from specify_cli.integrations.codex import CodexIntegration + from specify_cli.integrations.manifest import IntegrationManifest + + claude = ClaudeIntegration() + codex = CodexIntegration() + # Install both so the dispatcher is shared. + cm = IntegrationManifest(claude.key, tmp_path, version="test") + install_integration_events(claude, tmp_path, cm, {"pre_tool_use": [{"command": "speckit.x"}]}) + cm.save() + xm = IntegrationManifest(codex.key, tmp_path, version="test") + install_integration_events(codex, tmp_path, xm, {"pre_tool_use": [{"command": "speckit.y"}]}) + xm.save() + 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() + + # Codex upgrades to --events false (empty map): claude still references + # the dispatcher, so it must be retained. + install_integration_events(codex, tmp_path, xm, {}) + xm.save() + assert dispatcher_path.exists(), "Dispatcher deleted while claude still references it." + + # Now claude also goes --events false: no integration references the + # dispatcher, so it must be deleted (not orphaned). + install_integration_events(claude, tmp_path, cm, {}) + cm.save() + assert not dispatcher_path.exists(), ( + "Dispatcher orphaned after the last event integration disabled events (S3)." + ) + + +# -- 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. R3: the + # failure is now surfaced as EventRefreshError (aggregated) rather + # than silently swallowed. + from specify_cli.events import EventRefreshError + with patch( + "specify_cli.events.install_integration_events", + side_effect=RuntimeError("simulated write failure"), + ): + with pytest.raises(EventRefreshError, match="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 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):