refactor: rework integration events per maintainer review

- Rename hooks terminology to 'events' (events:, --events flag, events.py).
- Use snake_case names for canonical events consistent with spec-kit vocabulary.
- Fold event config adapters into integration classes via class attributes (CANONICAL_TO_NATIVE, events_config_file, events_format).
- Lift event command-script resolution to core 'specify event run' command.
- Split events sourcing from integration config writing.
- Support first-class Copilot CLI events JSON generation under '.github/hooks/speckit.json'.
- Rewrite and expand full test suite under 'tests/integrations/test_events.py'.

Assisted-by: opencode (model: litellm/gemini-3.5-flash, autonomous)
This commit is contained in:
Lior Kanfi
2026-07-25 17:17:17 +03:00
parent c5f1e96e81
commit da6c20d9f0
20 changed files with 1399 additions and 1686 deletions

View File

@@ -508,6 +508,11 @@ _register_extension_cmds(app)
from .integrations._commands import register as _register_integration_cmds # noqa: E402 from .integrations._commands import register as _register_integration_cmds # noqa: E402
_register_integration_cmds(app) _register_integration_cmds(app)
# ===== Event Commands =====
from .commands.event import register as _register_event_cmds
_register_event_cmds(app)
# Re-export selected helpers to preserve the public import surface. # Re-export selected helpers to preserve the public import surface.
from .integrations._helpers import ( # noqa: E402 from .integrations._helpers import ( # noqa: E402
_clear_init_options_for_integration as _clear_init_options_for_integration, _clear_init_options_for_integration as _clear_init_options_for_integration,

View File

@@ -0,0 +1,36 @@
"""specify event * command handlers."""
from __future__ import annotations
from pathlib import Path
import sys
import typer
from .._console import console
event_app = typer.Typer(
name="event",
help="Manage and execute event-driven commands",
add_completion=False,
)
@event_app.command("run")
def event_run(
command_name: str = typer.Argument(..., help="Name of the command to execute"),
event_name: str = typer.Argument(..., help="Canonical event name (e.g., session_start)"),
):
"""Resolve and run an event-driven command script with stdin payload."""
from ..events import resolve_and_run_event_command
# Read payload from stdin if available
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
# Run the event command
project_root = Path.cwd() # The agent runs events from project root
exit_code = resolve_and_run_event_command(command_name, event_name, payload, project_root)
raise typer.Exit(code=exit_code)
def register(app: typer.Typer) -> None:
app.add_typer(event_app, name="event")

View File

@@ -442,12 +442,20 @@ def register(app: typer.Typer) -> None:
if extra: if extra:
integration_parsed_options.update(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( resolved_integration.setup(
project_path, project_path,
manifest, manifest,
parsed_options=integration_parsed_options or None, parsed_options=integration_parsed_options or None,
script_type=selected_script, script_type=selected_script,
raw_options=integration_options, raw_options=integration_options,
events=events_map,
) )
manifest.save() manifest.save()

769
src/specify_cli/events.py Normal file
View File

@@ -0,0 +1,769 @@
"""Agent runtime events for integrations.
Provides:
- ``resolve_events`` — layered event resolution (CLI flag → YAML override → extension-declared → built-in).
- ``collect_extension_events`` — scan installed extension.yml files for ``events:``.
- ``install_integration_events`` / ``remove_integration_events`` — entry points called from ``IntegrationBase.setup()`` / ``teardown()``.
"""
from __future__ import annotations
import json
import logging
import re
import sys
import subprocess
import platform
from pathlib import Path
from typing import TYPE_CHECKING, Any
import yaml
if TYPE_CHECKING:
from .integrations.base import IntegrationBase
from .integrations.manifest import IntegrationManifest
logger = logging.getLogger(__name__)
# -- Constants -------------------------------------------------------------
EVENTS_DISPATCHER_DIR = Path(".specify")
EVENTS_DISPATCHER_FILENAME = "events.py"
EVENTS_DISPATCHER_REL = str(EVENTS_DISPATCHER_DIR / EVENTS_DISPATCHER_FILENAME)
YAML_OVERRIDE_FILENAME = Path(".specify") / "integration-events.yml"
_SPECKIT_MARKER = "__speckit_event__"
# Canonical event names (snake_case)
CANONICAL_EVENTS = frozenset({
"session_start",
"pre_tool_use",
"post_tool_use",
"session_end",
"user_prompt_submit",
"stop",
})
# -- Events Dispatcher template ---------------------------------------------
_EVENTS_DISPATCHER_TEMPLATE = '''#!/usr/bin/env python3
"""Specify CLI Event Dispatcher — dispatches agent runtime events.
Generated by: specify integration install/upgrade
Do not edit manually.
"""
import sys, subprocess, os
from pathlib import Path
def _find_specify():
project_root = Path(__file__).parent.parent.resolve()
# 1. Check local virtualenv
for candidate in ("venv", ".venv"):
py_bin = project_root / candidate / "bin" / "specify"
if py_bin.exists():
return [str(py_bin)]
# Windows virtualenv
win_bin = project_root / candidate / "Scripts" / "specify.exe"
if win_bin.exists():
return [str(win_bin)]
# 2. Check python -m specify_cli
for candidate in ("venv", ".venv"):
py_exe = project_root / candidate / "bin" / "python"
if py_exe.exists():
return [str(py_exe), "-m", "specify_cli"]
win_py = project_root / candidate / "Scripts" / "python.exe"
if win_py.exists():
return [str(win_py), "-m", "specify_cli"]
# 3. Fallback to system path specify
return ["specify"]
def main():
if len(sys.argv) < 3:
sys.exit(0)
command_name = sys.argv[1]
event_name = sys.argv[2]
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
specify_args = _find_specify() + ["event", "run", command_name, event_name]
try:
result = subprocess.run(
specify_args,
input=payload,
capture_output=True,
text=True,
timeout=120,
)
if result.stdout:
sys.stdout.write(result.stdout)
if result.returncode != 0:
if result.stderr:
sys.stderr.write(result.stderr)
sys.exit(result.returncode)
sys.exit(0)
except subprocess.TimeoutExpired:
print(f"Event {command_name} timed out", file=sys.stderr)
sys.exit(2)
except Exception as e:
print(f"Event {command_name} error: {e}", file=sys.stderr)
sys.exit(2)
if __name__ == "__main__":
main()
'''
# -- TS plugin template (opencode) ----------------------------------------
_TS_PLUGIN_TEMPLATE = '''import {{ execSync }} from 'child_process';
import * as path from 'path';
const DISPATCHER = path.join(process.cwd(), '.specify', 'events.py');
function runEvent(command: string, event: string, input: any): void {{
try {{
execSync(`python3 ${{DISPATCHER}} ${{command}} ${{event}}`, {{
input: JSON.stringify(input),
stdio: ['pipe', 'inherit', 'inherit'],
timeout: 60000,
}});
}} catch (e) {{
process.exit(2);
}}
}}
{event_entries}
export default (async ({{ client, project, directory, $ }}) => {{
return {{
{plugin_returns}
}};
}});
'''
# -- Command runner logic (core) --------------------------------------------
def _find_command_template(command_name: str, project_root: Path) -> tuple[Path | None, str | None]:
# 1. Check extension .registry
registry = project_root / ".specify" / "extensions" / ".registry"
if registry.exists():
try:
data = json.loads(registry.read_text(encoding="utf-8"))
except Exception:
data = {}
for ext_id, meta in data.items():
if not isinstance(meta, dict):
continue
for cmd in meta.get("commands", []):
if cmd.get("name") == command_name:
ext_dir = project_root / ".specify" / "extensions" / ext_id
return ext_dir / cmd["file"], ext_id
# 2. Scan extension directories
exts_dir = project_root / ".specify" / "extensions"
if exts_dir.is_dir():
for ext_dir in sorted(exts_dir.iterdir()):
cmds_dir = ext_dir / "commands"
if cmds_dir.is_dir():
for f in cmds_dir.glob("*.md"):
if f.stem == command_name:
return f, ext_dir.name
# 3. Check core templates
core = project_root / ".specify" / "templates" / "commands"
if core.is_dir():
stem = command_name.replace("speckit.", "").replace("spec.", "")
candidate = core / f"{stem}.md"
if candidate.exists():
return candidate, None
# Fallback to package bundled templates
try:
import inspect
pkg_dir = Path(inspect.getfile(validate_events)).resolve().parent
core_pack = pkg_dir / "core_pack" / "templates" / "commands"
if core_pack.is_dir():
stem = command_name.replace("speckit.", "").replace("spec.", "")
candidate = core_pack / f"{stem}.md"
if candidate.exists():
return candidate, None
except Exception:
pass
return None, None
def _extract_script_path(template_path: Path, project_root: Path, ext_id: str | None) -> str | None:
content = template_path.read_text(encoding="utf-8")
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not m:
return None
fm = m.group(1)
try:
fm_data = yaml.safe_load(fm) or {}
except Exception:
return None
if not isinstance(fm_data, dict):
return None
scripts = fm_data.get("scripts", {})
if not isinstance(scripts, dict):
return None
default = "ps" if platform.system().lower().startswith("win") else "sh"
script_rel = scripts.get(default) or scripts.get("sh") or scripts.get("py")
if not script_rel:
return None
if ext_id:
script_abs = project_root / ".specify" / "extensions" / ext_id / script_rel
else:
script_abs = project_root / ".specify" / script_rel
return str(script_abs) if script_abs.exists() else None
def resolve_and_run_event_command(command_name: str, event_name: str, payload: str, project_root: Path) -> int:
"""Core entry point to resolve and execute an event-driven command."""
template_path, ext_id = _find_command_template(command_name, project_root)
if not template_path:
logger.warning("Event command '%s' not found", command_name)
return 0
script_path = _extract_script_path(template_path, project_root, ext_id)
if not script_path:
logger.warning("No script found for event command '%s'", command_name)
return 0
try:
result = subprocess.run(
[script_path],
input=payload,
capture_output=True,
text=True,
timeout=120,
)
if result.stdout:
sys.stdout.write(result.stdout)
if result.returncode != 0:
if result.stderr:
sys.stderr.write(result.stderr)
return result.returncode
return 0
except subprocess.TimeoutExpired:
sys.stderr.write(f"Event command {command_name} timed out\n")
return 2
except Exception as e:
sys.stderr.write(f"Event command {command_name} error: {e}\n")
return 2
# -- Sourcing events map (CLI/Orchestration domain) -------------------------
def resolve_events(
integration_key: str,
integration_config: dict[str, Any] | None,
project_root: Path,
parsed_options: dict[str, Any] | None,
) -> dict[str, dict[str, Any]]:
"""Resolve the final event set for an integration."""
# Layer 1: CLI flag gate
if parsed_options:
events_flag = str(parsed_options.get("events", "true")).lower()
if events_flag in ("false", "0", "no", "off"):
return {}
# Layer 4: built-in defaults from integration config
events: dict[str, dict[str, Any]] = {}
if integration_config and isinstance(integration_config.get("events"), dict):
events = dict(integration_config["events"])
# Layer 3: extension-declared events
ext_events = collect_extension_events(project_root)
events.update(ext_events)
# Layer 2: user YAML override (replaces entirely if key present)
override_file = project_root / YAML_OVERRIDE_FILENAME
if override_file.exists():
try:
override = yaml.safe_load(override_file.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
logger.warning("Could not parse %s; ignoring override", override_file)
override = {}
integrations = override.get("integrations", {}) if isinstance(override, dict) else {}
if integration_key in integrations:
key_data = integrations[integration_key]
if isinstance(key_data, dict):
events = key_data.get("events", {}) or {}
else:
events = {}
return events
def collect_extension_events(project_root: Path) -> dict[str, dict[str, Any]]:
"""Scan all installed extensions for ``events:`` declarations."""
events: dict[str, dict[str, Any]] = {}
exts_dir = project_root / ".specify" / "extensions"
if not exts_dir.is_dir():
return events
for ext_dir in sorted(exts_dir.iterdir()):
if not ext_dir.is_dir():
continue
ext_yml = ext_dir / "extension.yml"
if not ext_yml.exists():
continue
try:
data = yaml.safe_load(ext_yml.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
continue
if not isinstance(data, dict):
continue
runtime = data.get("events", {}) or {}
if not isinstance(runtime, dict):
continue
for event, config in runtime.items():
if isinstance(config, dict):
events[event] = config
return events
# -- Writing/Merging Config (Integration domain) ---------------------------
def install_integration_events(
integration: IntegrationBase,
project_root: Path,
manifest: IntegrationManifest,
events: dict[str, dict[str, Any]],
) -> list[Path]:
"""Generate dispatcher, merge native config, return created files."""
canonical_to_native = getattr(integration, "CANONICAL_TO_NATIVE", {})
if not canonical_to_native:
return []
# Filter to only supported events
filtered: dict[str, dict[str, Any]] = {}
for ev, cfg in events.items():
if ev in canonical_to_native:
filtered[ev] = cfg
else:
print(
f"\u26a0\ufe0f {integration.key} does not support '{ev}' events; skipping",
file=sys.stderr,
)
if not filtered:
return []
created: list[Path] = []
# 1. Generate events.py dispatcher script
dispatcher_dir = project_root / EVENTS_DISPATCHER_DIR
dispatcher_dir.mkdir(parents=True, exist_ok=True)
dispatcher_path = dispatcher_dir / EVENTS_DISPATCHER_FILENAME
dispatcher_path.write_text(_EVENTS_DISPATCHER_TEMPLATE, encoding="utf-8")
dispatcher_path.chmod(0o755)
manifest.record_file(
str(dispatcher_path.relative_to(project_root)),
dispatcher_path.read_bytes(),
)
created.append(dispatcher_path)
# 2. Format-specific merge/write
fmt = getattr(integration, "events_format", "json-nested")
config_file = getattr(integration, "events_config_file", None)
if not config_file:
return created
config_path = project_root / config_file
if fmt == "ts-plugin":
# Opencode TS plugin custom merge
plugin_rel = ".opencode/plugin/speckit-events.ts"
plugin_path = project_root / plugin_rel
plugin_path.parent.mkdir(parents=True, exist_ok=True)
plugin_path.write_text(_build_opencode_plugin(filtered, canonical_to_native), encoding="utf-8")
manifest.record_file(
plugin_rel,
plugin_path.read_bytes(),
)
created.append(plugin_path)
# Merge plugin path into opencode.json
_merge_opencode_plugin_ref(config_path, f"./{plugin_rel}")
rel = str(config_path.relative_to(project_root))
if rel not in manifest.files:
manifest.record_existing(rel)
created.append(config_path)
elif fmt == "copilot-json":
# Copilot hooks JSON write (creates dedicated .github/hooks/speckit.json)
copilot_hooks = {}
for ev, cfg in filtered.items():
native = canonical_to_native[ev]
command = cfg.get("command", "")
copilot_hooks[native] = [
{
"type": "command",
"bash": f"python3 {EVENTS_DISPATCHER_REL} {command} {ev}",
"powershell": f"python3 {EVENTS_DISPATCHER_REL} {command} {ev}",
"timeoutSec": cfg.get("timeout", 60),
}
]
fragment = {"version": 1, "hooks": copilot_hooks}
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps(fragment, indent=2) + "\n", encoding="utf-8")
rel = str(config_path.relative_to(project_root))
if rel not in manifest.files:
manifest.record_existing(rel)
created.append(config_path)
elif fmt == "toml":
# Codex config.toml custom merge
lines: list[str] = []
for ev, cfg in filtered.items():
native = canonical_to_native[ev]
timeout = cfg.get("timeout", 60)
command = cfg.get("command", "")
lines.append(f'[[hooks.{native}]]')
lines.append(f'matcher = "{cfg.get("matcher", "*")}"')
lines.append('')
lines.append(f'[[hooks.{native}.hooks]]')
lines.append('type = "command"')
lines.append(f'command = \'python3 {EVENTS_DISPATCHER_REL} {command} {ev}\'')
lines.append(f'timeout = {timeout}')
lines.append('speckit_marker = true')
lines.append('')
_merge_toml_fragment(config_path, "\n".join(lines))
rel = str(config_path.relative_to(project_root))
if rel not in manifest.files:
manifest.record_existing(rel)
created.append(config_path)
elif fmt == "json-flat":
# Cursor hooks.json custom merge
cursor_hooks = {}
for ev, cfg in filtered.items():
native = canonical_to_native[ev]
command = cfg.get("command", "")
cursor_hooks[native] = [
{
"command": f"python3 {EVENTS_DISPATCHER_REL} {command} {ev}",
"type": "command",
"timeout": cfg.get("timeout", 60),
"matcher": cfg.get("matcher", "*"),
_SPECKIT_MARKER: True,
}
]
_merge_json_fragment(config_path, cursor_hooks)
rel = str(config_path.relative_to(project_root))
if rel not in manifest.files:
manifest.record_existing(rel)
created.append(config_path)
elif fmt == "json-nested":
# Claude/Qwen/Gemini/Devin/Tabnine nested config JSON merge
nested_hooks = {}
bridge_path_prefix = "${CLAUDE_PROJECT_DIR}/" if integration.key == "claude" else ""
for ev, cfg in filtered.items():
native = canonical_to_native[ev]
command = cfg.get("command", "")
nested_hooks[native] = [
{
"matcher": cfg.get("matcher", "*"),
"hooks": [
{
"type": "command",
"command": "python3",
"args": [
bridge_path_prefix + EVENTS_DISPATCHER_REL,
command,
ev,
],
"timeout": cfg.get("timeout", 60),
_SPECKIT_MARKER: True,
}
],
}
]
_merge_json_fragment(config_path, nested_hooks)
rel = str(config_path.relative_to(project_root))
if rel not in manifest.files:
manifest.record_existing(rel)
created.append(config_path)
return created
def remove_integration_events(integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest) -> None:
"""Remove Specify-authored event entries from native config."""
fmt = getattr(integration, "events_format", None)
config_file = getattr(integration, "events_config_file", None)
if config_file:
config_path = project_root / config_file
if config_path.exists():
if fmt == "copilot-json":
# Clean delete of dedicated file
config_path.unlink(missing_ok=True)
manifest.remove(config_file)
elif fmt == "toml":
_remove_toml_entries(config_path)
elif fmt in ("json-nested", "json-flat"):
_remove_json_entries(config_path)
elif fmt == "ts-plugin":
_remove_opencode_entries(config_path)
# Clean up dispatcher script
dispatcher_rel = EVENTS_DISPATCHER_REL
if dispatcher_rel in manifest.files:
dispatcher_path = project_root / dispatcher_rel
if dispatcher_path.exists():
dispatcher_path.unlink(missing_ok=True)
manifest.remove(dispatcher_rel)
# Clean up opencode TS plugin
if integration.key == "opencode":
plugin_rel = ".opencode/plugin/speckit-events.ts"
if plugin_rel in manifest.files:
plugin_path = project_root / plugin_rel
if plugin_path.exists():
plugin_path.unlink(missing_ok=True)
manifest.remove(plugin_rel)
def events_stale_exclusions(integration_key: str) -> set[str]:
"""Return project-relative paths to protect from stale cleanup."""
from .integrations import get_integration
integration = get_integration(integration_key)
if not integration:
return set()
exclusions = set()
config_file = getattr(integration, "events_config_file", None)
if config_file:
exclusions.add(config_file)
if integration_key == "opencode":
exclusions.add(".opencode/plugin/speckit-events.ts")
return exclusions
# -- Manifest validation ---------------------------------------------------
def validate_events(data: dict[str, Any]) -> None:
"""Validate ``events`` field in extension manifest data."""
from .extensions import ValidationError
events = data.get("events")
if "events" in data and not isinstance(events, dict):
raise ValidationError("Invalid events: expected a mapping")
if events:
for event_name, event_config in events.items():
if not isinstance(event_config, dict):
raise ValidationError(
f"Invalid event '{event_name}': expected a mapping"
)
if not event_config.get("command"):
raise ValidationError(
f"Event '{event_name}' missing required 'command' field"
)
if event_name not in CANONICAL_EVENTS:
raise ValidationError(
f"Unknown event '{event_name}': "
f"must be one of {sorted(CANONICAL_EVENTS)}"
)
def has_events(data: dict[str, Any]) -> bool:
"""Return True if ``events`` is present and non-empty."""
return bool(data.get("events"))
# -- Helper merging functions ----------------------------------------------
def _build_opencode_plugin(filtered_events: dict[str, dict[str, Any]], canonical_to_native: dict[str, str]) -> str:
event_entries: list[str] = []
plugin_returns: list[str] = []
event_handlers: list[str] = []
for ev, cfg in filtered_events.items():
native = canonical_to_native[ev]
command = cfg.get("command", "")
matcher = cfg.get("matcher", "*")
if native.startswith("tool.execute."):
ts_hook = native
match_cond = ""
if matcher and matcher != "*":
tools = [t.strip().strip('"') for t in matcher.split("|")]
checks = " || ".join(f"input.tool === '{t.lower()}'" for t in tools)
match_cond = f"if ({checks}) {{"
else:
match_cond = "{"
event_entries.append(
f"function _{ev}(input: any) {match_cond}\n"
f" runEvent('{command}', '{ev}', input);\n"
f" }}"
)
plugin_returns.append(
f" \"{ts_hook}\": async (input: any, output: any) => {{\n"
f" _{ev}(input);\n"
f" }},"
)
else:
event_entries.append(
f"function _{ev}(input: any) {{\n"
f" runEvent('{command}', '{ev}', input);\n"
f" }}"
)
event_handlers.append(
f" if (event.type === '{native}') {{ _{ev}(event); }}"
)
if event_handlers:
plugin_returns.append(
f" event: async ({{ event }}) => {{\n"
+ "\n".join(event_handlers) + "\n"
f" }},"
)
return _TS_PLUGIN_TEMPLATE.format(
event_entries="\n\n".join(event_entries),
plugin_returns="\n".join(plugin_returns),
)
def _merge_opencode_plugin_ref(config_path: Path, ref: str) -> None:
existing: dict = {}
if config_path.exists():
try:
existing = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
existing = {}
if not isinstance(existing, dict):
existing = {}
plugins = existing.get("plugin", [])
if not isinstance(plugins, list):
plugins = []
if ref not in plugins:
plugins.append(ref)
existing["plugin"] = plugins
config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
def _remove_opencode_entries(config_path: Path) -> None:
if not config_path.exists():
return
try:
existing = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
return
if not isinstance(existing, dict):
return
plugins = existing.get("plugin", [])
if isinstance(plugins, list):
ref = "./.opencode/plugin/speckit-events.ts"
plugins = [p for p in plugins if p != ref]
if plugins:
existing["plugin"] = plugins
else:
existing.pop("plugin", None)
config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
def _merge_toml_fragment(dst: Path, fragment: str) -> None:
existing = ""
if dst.exists():
existing = dst.read_text(encoding="utf-8")
existing = re.sub(
r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*',
"",
existing,
flags=re.DOTALL,
)
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
def _remove_toml_entries(dst: Path) -> None:
if not dst.exists():
return
existing = dst.read_text(encoding="utf-8")
cleaned = re.sub(
r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*',
"",
existing,
flags=re.DOTALL,
)
dst.write_text(cleaned, encoding="utf-8")
def _merge_json_fragment(dst: Path, new_hooks: dict) -> None:
existing: dict = {}
if dst.exists():
try:
existing = json.loads(dst.read_text(encoding="utf-8"))
except Exception:
pass
if not isinstance(existing, dict):
existing = {}
hooks_key = "hooks"
existing_hooks = existing.get(hooks_key, {})
if not isinstance(existing_hooks, dict):
existing_hooks = {}
for event, entries in new_hooks.items():
existing_list = existing_hooks.get(event, [])
if not isinstance(existing_list, list):
existing_list = []
# Filter out existing entries carrying our marker
existing_list = [e for e in existing_list if not _has_marker(e)]
existing_list.extend(entries)
existing_hooks[event] = existing_list
existing[hooks_key] = existing_hooks
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
def _remove_json_entries(dst: Path) -> None:
try:
existing = json.loads(dst.read_text(encoding="utf-8"))
except Exception:
return
if not isinstance(existing, dict):
return
hooks = existing.get("hooks", {})
if not isinstance(hooks, dict):
return
cleaned: dict[str, list] = {}
for event, entries in hooks.items():
if not isinstance(entries, list):
continue
kept_entries = []
for entry in entries:
if not isinstance(entry, dict):
kept_entries.append(entry)
continue
inner = entry.get("hooks")
if isinstance(inner, list):
kept_inner = [h for h in inner if not _has_marker(h)]
if kept_inner:
entry["hooks"] = kept_inner
kept_entries.append(entry)
elif _has_marker(entry):
pass
else:
kept_entries.append(entry)
if kept_entries:
cleaned[event] = kept_entries
if cleaned:
existing["hooks"] = cleaned
else:
existing.pop("hooks", None)
dst.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
def _has_marker(entry: Any) -> bool:
if isinstance(entry, dict):
return entry.get(_SPECKIT_MARKER, False) is True
return False

View File

@@ -300,17 +300,22 @@ class ExtensionManifest:
provides = self.data["provides"] provides = self.data["provides"]
commands = provides.get("commands", []) commands = provides.get("commands", [])
hooks = self.data.get("hooks") hooks = self.data.get("hooks")
events = self.data.get("events")
if "commands" in provides and not isinstance(commands, list): if "commands" in provides and not isinstance(commands, list):
raise ValidationError("Invalid provides.commands: expected a list") raise ValidationError("Invalid provides.commands: expected a list")
if "hooks" in self.data and not isinstance(hooks, dict): if "hooks" in self.data and not isinstance(hooks, dict):
raise ValidationError("Invalid hooks: expected a mapping") 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_commands = bool(commands)
has_hooks = bool(hooks) has_hooks = bool(hooks)
has_events = bool(events)
if not has_commands and not has_hooks: if not has_commands and not has_hooks and not has_events:
raise ValidationError("Extension must provide at least one command or hook") raise ValidationError("Extension must provide at least one command, hook, or event")
# Validate hook values (if present). # Validate hook values (if present).
# Each event is a single mapping or a list of mappings. # Each event is a single mapping or a list of mappings.

View File

@@ -1,975 +0,0 @@
"""Agent runtime hooks for integrations.
Provides:
- ``resolve_hooks`` — layered hook resolution (CLI flag → YAML override → extension-declared → built-in).
- ``collect_extension_runtime_hooks`` — scan installed extension.yml files for ``runtime_hooks:``.
- ``HookAdapter`` / adapters — generate and merge native hook config per agent CLI.
- ``install_integration_hooks`` / ``remove_integration_hooks`` — entry points called from ``IntegrationBase.setup()`` / ``teardown()``.
"""
from __future__ import annotations
import json
import logging
import re
from abc import ABC, abstractmethod
from pathlib import Path
from typing import TYPE_CHECKING, Any
import yaml
if TYPE_CHECKING:
from .base import IntegrationBase
from .manifest import IntegrationManifest
logger = logging.getLogger(__name__)
# -- Constants -------------------------------------------------------------
HOOK_BRIDGE_DIR = Path(".specify") / "hooks"
HOOK_BRIDGE_FILENAME = "bridge.py"
HOOK_BRIDGE_REL = str(HOOK_BRIDGE_DIR / HOOK_BRIDGE_FILENAME)
YAML_OVERRIDE_FILENAME = Path(".specify") / "integration-hooks.yml"
# Sentinel marker embedded in generated native config entries so we can
# identify and remove only our entries on uninstall/upgrade.
_SPECKIT_MARKER = "__speckit_hook__"
# Canonical runtime hook event names that extensions use in ``runtime_hooks:``.
# Adapters translate these to each agent's native event names via
# ``CANONICAL_TO_NATIVE``. Extensions never need to know agent-specific names.
CANONICAL_RUNTIME_EVENTS = frozenset({
"PreToolUse",
"PostToolUse",
"Stop",
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
})
# -- Bridge script template ------------------------------------------------
_BRIDGE_TEMPLATE = '''#!/usr/bin/env python3
"""Specify CLI Hook Bridge — dispatches agent runtime hooks to slash commands.
Generated by: specify integration install/upgrade
Do not edit manually. Regenerate with: specify integration upgrade
"""
import json, sys, subprocess, re, os, platform
from pathlib import Path
def _project_root():
for env_var in ("CLAUDE_PROJECT_DIR", "CURSOR_PROJECT_ROOT", "PROJECT_ROOT"):
val = os.environ.get(env_var)
if val and Path(val).is_dir():
return Path(val)
return Path.cwd()
def _find_command_template(command_name, project_root):
# 1. Check extension .registry
registry = project_root / ".specify" / "extensions" / ".registry"
if registry.exists():
try:
data = json.loads(registry.read_text(encoding="utf-8"))
except Exception:
data = {}
for ext_id, meta in data.items():
if not isinstance(meta, dict):
continue
for cmd in meta.get("commands", []):
if cmd.get("name") == command_name:
ext_dir = project_root / ".specify" / "extensions" / ext_id
return ext_dir / cmd["file"], ext_id
# 2. Scan extension directories
exts_dir = project_root / ".specify" / "extensions"
if exts_dir.is_dir():
for ext_dir in sorted(exts_dir.iterdir()):
cmds_dir = ext_dir / "commands"
if cmds_dir.is_dir():
for f in cmds_dir.glob("*.md"):
if f.stem == command_name:
return f, ext_dir.name
# 3. Check core templates
core = project_root / ".specify" / "templates" / "commands"
if core.is_dir():
stem = command_name.replace("speckit.", "").replace("spec.", "")
candidate = core / f"{stem}.md"
if candidate.exists():
return candidate, None
return None, None
def _extract_script_path(template_path, project_root, ext_id):
content = template_path.read_text(encoding="utf-8")
m = re.match(r'^---\\n(.*?)\\n---', content, re.DOTALL)
if not m:
return None
fm = m.group(1)
scripts_m = re.search(r'scripts:\\s*\\n((?:\\s+\\w+:.*\\n)*)', fm)
if not scripts_m:
return None
variants = {}
for line in scripts_m.group(1).strip().splitlines():
kv = re.match(r'\\s+(\\w+):\\s*(.+)', line)
if kv:
variants[kv.group(1)] = kv.group(2).strip().strip('"\\'')
default = "ps" if platform.system().lower().startswith("win") else "sh"
script_rel = variants.get(default) or variants.get("sh") or variants.get("py")
if not script_rel:
return None
if ext_id:
script_abs = project_root / ".specify" / "extensions" / ext_id / script_rel
else:
script_abs = project_root / ".specify" / script_rel
return str(script_abs) if script_abs.exists() else None
def main():
if len(sys.argv) < 3:
sys.exit(0)
command_name = sys.argv[1]
event_name = sys.argv[2]
project_root = _project_root()
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
template, ext_id = _find_command_template(command_name, project_root)
if not template:
sys.exit(0)
script_path = _extract_script_path(template, project_root, ext_id)
if not script_path:
sys.exit(0)
try:
result = subprocess.run(
[script_path], input=payload, capture_output=True, text=True, timeout=120,
)
if result.stdout:
print(result.stdout, end="")
if result.returncode != 0:
if result.stderr:
print(result.stderr, file=sys.stderr, end="")
sys.exit(2)
sys.exit(0)
except subprocess.TimeoutExpired:
print(f"Hook {command_name} timed out", file=sys.stderr)
sys.exit(2)
except Exception as e:
print(f"Hook {command_name} error: {e}", file=sys.stderr)
sys.exit(2)
if __name__ == "__main__":
main()
'''
# -- TS plugin template (opencode) ----------------------------------------
_TS_PLUGIN_TEMPLATE = '''import {{ execSync }} from 'child_process';
import * as path from 'path';
const BRIDGE = path.join(process.cwd(), '.specify', 'hooks', 'bridge.py');
function runHook(command: string, event: string, input: any): void {{
try {{
execSync(`python3 ${{BRIDGE}} ${{command}} ${{event}}`, {{
input: JSON.stringify(input),
stdio: ['pipe', 'inherit', 'inherit'],
timeout: 60000,
}});
}} catch (e) {{
process.exit(2);
}}
}}
{hook_entries}
export default (async ({{ client, project, directory, $ }}) => {{
return {{
{plugin_returns}
}};
}});
'''
# -- Hook resolution -------------------------------------------------------
def resolve_hooks(
integration_key: str,
integration_config: dict[str, Any] | None,
project_root: Path,
parsed_options: dict[str, Any] | None,
) -> dict[str, dict[str, Any]]:
"""Resolve the final hook set for an integration.
Layer 1: ``--hooks false`` → return ``{}``
Layer 2: ``.specify/integration-hooks.yml`` → replace entirely if key present
Layer 3: extension-declared ``runtime_hooks:`` → appended
Layer 4: ``config["hooks"]`` → built-in baseline
"""
# Layer 1: CLI flag gate
if parsed_options:
hooks_flag = str(parsed_options.get("hooks", "true")).lower()
if hooks_flag in ("false", "0", "no", "off"):
return {}
# Layer 4: built-in defaults from integration config
hooks: dict[str, dict[str, Any]] = {}
if integration_config and isinstance(integration_config.get("hooks"), dict):
hooks = dict(integration_config["hooks"])
# Layer 3: extension-declared runtime hooks
ext_hooks = collect_extension_runtime_hooks(project_root)
hooks.update(ext_hooks)
# Layer 2: user YAML override (replaces entirely if key present)
override_file = project_root / YAML_OVERRIDE_FILENAME
if override_file.exists():
try:
override = yaml.safe_load(override_file.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
logger.warning("Could not parse %s; ignoring override", override_file)
override = {}
integrations = override.get("integrations", {}) if isinstance(override, dict) else {}
if integration_key in integrations:
key_data = integrations[integration_key]
if isinstance(key_data, dict):
hooks = key_data.get("hooks", {}) or {}
else:
hooks = {}
return hooks
def collect_extension_runtime_hooks(project_root: Path) -> dict[str, dict[str, Any]]:
"""Scan all installed extensions for ``runtime_hooks:`` declarations."""
hooks: dict[str, dict[str, Any]] = {}
exts_dir = project_root / ".specify" / "extensions"
if not exts_dir.is_dir():
return hooks
for ext_dir in sorted(exts_dir.iterdir()):
if not ext_dir.is_dir():
continue
ext_yml = ext_dir / "extension.yml"
if not ext_yml.exists():
continue
try:
data = yaml.safe_load(ext_yml.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
continue
if not isinstance(data, dict):
continue
runtime = data.get("runtime_hooks", {}) or {}
if not isinstance(runtime, dict):
continue
for event, config in runtime.items():
if isinstance(config, dict):
hooks[event] = config
return hooks
# -- Adapter infrastructure ------------------------------------------------
class HookAdapter(ABC):
"""Abstract base for per-agent native hook config generation/merge.
Subclasses set ``CANONICAL_TO_NATIVE`` to map canonical event names
(e.g. ``PreToolUse``) to the agent's native event names. Events not
in the mapping are silently skipped with a warning at install time.
"""
# Maps canonical event name → agent-native event name.
# Empty dict means no events are supported (adapter is a no-op).
CANONICAL_TO_NATIVE: dict[str, str] = {}
@property
@abstractmethod
def config_file_rel(self) -> str:
"""Project-relative path to the native settings file."""
@abstractmethod
def generate_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> tuple[str, Any]:
"""Return (format, fragment) where format is 'json' or 'toml'."""
@abstractmethod
def merge_fragment(self, dst: Path, fragment: Any, *, format: str) -> None:
"""Merge fragment into the native config file at *dst*."""
@abstractmethod
def remove_entries(self, dst: Path) -> None:
"""Remove all Specify-authored hook entries from *dst*."""
def _native_event(self, canonical: str) -> str | None:
"""Return the native event name for a canonical name, or None."""
return self.CANONICAL_TO_NATIVE.get(canonical)
def _filter_hooks(self, hooks: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Filter hooks to only those supported by this adapter.
Prints a warning for each unsupported event and returns a dict
containing only the supported ones.
"""
import sys
filtered: dict[str, dict[str, Any]] = {}
for event, config in hooks.items():
if self._native_event(event) is not None:
filtered[event] = config
else:
adapter_name = type(self).__name__
print(
f"\u26a0\ufe0f {adapter_name} does not support '{event}' events; skipping",
file=sys.stderr,
)
return filtered
def install(
self,
project_root: Path,
manifest: "IntegrationManifest",
hooks: dict[str, dict[str, Any]],
) -> list[Path]:
"""Generate bridge, merge native config, return created files."""
# Filter to only events this adapter supports
hooks = self._filter_hooks(hooks)
if not hooks:
return []
created: list[Path] = []
# Generate bridge script
bridge_dir = project_root / HOOK_BRIDGE_DIR
bridge_dir.mkdir(parents=True, exist_ok=True)
bridge_path = bridge_dir / HOOK_BRIDGE_FILENAME
bridge_path.write_text(_BRIDGE_TEMPLATE, encoding="utf-8")
bridge_path.chmod(0o755)
manifest.record_file(
str(bridge_path.relative_to(project_root)),
bridge_path.read_bytes(),
)
created.append(bridge_path)
# Generate / merge native config
config_path = project_root / self.config_file_rel
fmt, fragment = self.generate_fragment(hooks, project_root)
self.merge_fragment(config_path, fragment, format=fmt)
# Record or mark as existing
if config_path.exists():
rel = str(config_path.relative_to(project_root))
if rel not in manifest.files:
manifest.record_existing(rel)
created.append(config_path)
return created
def remove(self, project_root: Path, manifest: "IntegrationManifest") -> None:
"""Remove Specify-authored entries from native config."""
config_path = project_root / self.config_file_rel
if config_path.exists():
self.remove_entries(config_path)
class JSONHookAdapter(HookAdapter):
"""Base for agents using JSON settings files.
Subclasses set ``config_file_rel``, ``CANONICAL_TO_NATIVE``, and
optionally ``bridge_path_prefix`` (e.g. ``${CLAUDE_PROJECT_DIR}/``).
The default ``_build_fragment()`` produces Claude-style nested
matcher-groups. Override for different JSON structures (e.g. Cursor's
flat format).
"""
config_file_rel: str = ""
bridge_path_prefix: str = ""
def generate_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> tuple[str, Any]:
fragment = self._build_fragment(hooks, project_root)
return "json", fragment
@abstractmethod
def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict:
...
def _build_nested_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict:
"""Build Claude-style nested matcher-groups fragment.
Shared by Claude, Qwen, Devin, Gemini, Tabnine — all use the same
nested ``hooks.{Event}[].{matcher, hooks[]}`` structure.
"""
result: dict[str, list] = {"hooks": {}}
for event, config in hooks.items():
native = self._native_event(event)
if native is None:
continue
matcher = config.get("matcher", "*")
timeout = config.get("timeout", 60)
command = config.get("command", "")
entry = {
"type": "command",
"command": "python3",
"args": [
self.bridge_path_prefix + HOOK_BRIDGE_REL,
command,
event,
],
"timeout": timeout,
_SPECKIT_MARKER: True,
}
result["hooks"][native] = [{
"matcher": matcher,
"hooks": [entry],
}]
return result
def merge_fragment(self, dst: Path, fragment: Any, *, format: str) -> None:
if format != "json":
return
existing: dict = {}
if dst.exists():
try:
existing = json.loads(dst.read_text(encoding="utf-8"))
if not isinstance(existing, dict):
existing = {}
except (json.JSONDecodeError, OSError):
logger.warning("Could not parse %s; skipping merge", dst)
return
# Merge: only touch the "hooks" key
existing_hooks = existing.get("hooks", {})
our_hooks = fragment.get("hooks", {})
for event, entries in our_hooks.items():
existing_list = existing_hooks.get(event, [])
if not isinstance(existing_list, list):
existing_list = []
# Remove our old entries for this event (by marker), then append new
existing_list = [e for e in existing_list if not _has_marker(e)]
existing_list.extend(entries)
existing_hooks[event] = existing_list
existing["hooks"] = existing_hooks
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
def remove_entries(self, dst: Path) -> None:
try:
existing = json.loads(dst.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return
if not isinstance(existing, dict):
return
hooks = existing.get("hooks", {})
if not isinstance(hooks, dict):
return
cleaned: dict[str, list] = {}
for event, entries in hooks.items():
if not isinstance(entries, list):
continue
kept_entries = []
for entry in entries:
if not isinstance(entry, dict):
kept_entries.append(entry)
continue
# Claude nested format: entry has "hooks" list
inner = entry.get("hooks")
if isinstance(inner, list):
kept_inner = [h for h in inner if not _has_marker(h)]
if kept_inner:
entry["hooks"] = kept_inner
kept_entries.append(entry)
# else: drop the entire matcher-group (all our hooks)
elif _has_marker(entry):
pass # drop this flat entry (Cursor format)
else:
kept_entries.append(entry)
if kept_entries:
cleaned[event] = kept_entries
if cleaned:
existing["hooks"] = cleaned
else:
existing.pop("hooks", None)
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
class ClaudeHookAdapter(JSONHookAdapter):
"""Claude Code: nested matcher-groups in .claude/settings.json."""
config_file_rel = ".claude/settings.json"
bridge_path_prefix = "${CLAUDE_PROJECT_DIR}/"
CANONICAL_TO_NATIVE = {
"PreToolUse": "PreToolUse",
"PostToolUse": "PostToolUse",
"Stop": "Stop",
"SessionStart": "SessionStart",
"SessionEnd": "SessionEnd",
"UserPromptSubmit": "UserPromptSubmit",
}
def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict:
return self._build_nested_fragment(hooks, project_root)
class CursorHookAdapter(JSONHookAdapter):
"""Cursor: flat handler arrays in .cursor/hooks.json."""
config_file_rel = ".cursor/hooks.json"
CANONICAL_TO_NATIVE = {
"PreToolUse": "preToolUse",
"PostToolUse": "postToolUse",
"Stop": "stop",
"SessionStart": "sessionStart",
"SessionEnd": "sessionEnd",
"UserPromptSubmit": "beforeSubmitPrompt",
}
def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict:
result: dict[str, list] = {"hooks": {}}
for event, config in hooks.items():
native = self._native_event(event)
if native is None:
continue
matcher = config.get("matcher", "*")
timeout = config.get("timeout", 60)
command = config.get("command", "")
entry = {
"command": f"python3 {HOOK_BRIDGE_REL} {command} {event}",
"type": "command",
"timeout": timeout,
"matcher": matcher,
_SPECKIT_MARKER: True,
}
result["hooks"][native] = [entry]
return result
class CodexHookAdapter(HookAdapter):
"""Codex CLI: TOML [[hooks.*]] in config.toml."""
config_file_rel = ".codex/config.toml"
CANONICAL_TO_NATIVE = {
"PreToolUse": "PreToolUse",
"PostToolUse": "PostToolUse",
"Stop": "Stop",
"SessionStart": "SessionStart",
"SessionEnd": "SessionEnd",
"UserPromptSubmit": "UserPromptSubmit",
}
def generate_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> tuple[str, Any]:
lines: list[str] = []
for event, config in hooks.items():
native = self._native_event(event)
if native is None:
continue
matcher = config.get("matcher", "*")
timeout = config.get("timeout", 60)
command = config.get("command", "")
bridge_abs = f"$(git rev-parse --show-toplevel)/{HOOK_BRIDGE_REL}"
lines.append(f'[[hooks.{native}]]')
lines.append(f'matcher = "{matcher}"')
lines.append('')
lines.append(f'[[hooks.{native}.hooks]]')
lines.append('type = "command"')
lines.append(f'command = \'python3 {bridge_abs} {command} {event}\'')
lines.append(f'timeout = {timeout}')
lines.append('speckit_marker = true')
lines.append('')
return "toml", "\n".join(lines)
def merge_fragment(self, dst: Path, fragment: Any, *, format: str) -> None:
if format != "toml":
return
existing = ""
if dst.exists():
existing = dst.read_text(encoding="utf-8")
# Remove old speckit-marked blocks
existing = re.sub(
r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*',
"",
existing,
flags=re.DOTALL,
)
# Append new
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
def remove_entries(self, dst: Path) -> None:
if not dst.exists():
return
existing = dst.read_text(encoding="utf-8")
cleaned = re.sub(
r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*',
"",
existing,
flags=re.DOTALL,
)
dst.write_text(cleaned, encoding="utf-8")
class OpencodeHookAdapter(HookAdapter):
"""opencode: TypeScript plugin in .opencode/plugin/ + opencode.json merge."""
config_file_rel = "opencode.json"
_PLUGIN_REL = ".opencode/plugin/speckit-hooks.ts"
CANONICAL_TO_NATIVE = {
"PreToolUse": "tool.execute.before",
"PostToolUse": "tool.execute.after",
}
def install(
self,
project_root: Path,
manifest: "IntegrationManifest",
hooks: dict[str, dict[str, Any]],
) -> list[Path]:
# Filter to only events this adapter supports
hooks = self._filter_hooks(hooks)
if not hooks:
return []
created: list[Path] = []
# Generate bridge script
bridge_dir = project_root / HOOK_BRIDGE_DIR
bridge_dir.mkdir(parents=True, exist_ok=True)
bridge_path = bridge_dir / HOOK_BRIDGE_FILENAME
bridge_path.write_text(_BRIDGE_TEMPLATE, encoding="utf-8")
bridge_path.chmod(0o755)
manifest.record_file(
str(bridge_path.relative_to(project_root)),
bridge_path.read_bytes(),
)
created.append(bridge_path)
# Generate TS plugin
plugin_path = project_root / self._PLUGIN_REL
plugin_path.parent.mkdir(parents=True, exist_ok=True)
plugin_path.write_text(self._build_plugin(hooks), encoding="utf-8")
manifest.record_file(
str(plugin_path.relative_to(project_root)),
plugin_path.read_bytes(),
)
created.append(plugin_path)
# Merge plugin path into opencode.json
config_path = project_root / self.config_file_rel
self._merge_plugin_ref(config_path)
rel = str(config_path.relative_to(project_root))
if rel not in manifest.files:
manifest.record_existing(rel)
created.append(config_path)
return created
def generate_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> tuple[str, Any]:
return "skip", None
def merge_fragment(self, dst: Path, fragment: Any, *, format: str) -> None:
pass
def remove_entries(self, dst: Path) -> None:
if not dst.exists():
return
try:
existing = json.loads(dst.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return
if not isinstance(existing, dict):
return
plugins = existing.get("plugin", [])
if isinstance(plugins, list):
plugins = [p for p in plugins if p != f"./{self._PLUGIN_REL}"]
if plugins:
existing["plugin"] = plugins
else:
existing.pop("plugin", None)
dst.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
def _build_plugin(self, hooks: dict[str, dict[str, Any]]) -> str:
hook_entries: list[str] = []
plugin_returns: list[str] = []
for event, config in hooks.items():
native = self._native_event(event)
if native is None:
continue
command = config.get("command", "")
matcher = config.get("matcher", "*")
ts_hook = native # already "tool.execute.before" etc.
match_cond = ""
if matcher and matcher != "*":
tools = [t.strip().strip('"') for t in matcher.split("|")]
checks = " || ".join(f"input.tool === '{t.lower()}'" for t in tools)
match_cond = f"if ({checks}) {{"
else:
match_cond = "{"
hook_entries.append(
f"function _{event}(input: any) {match_cond}\n"
f" runHook('{command}', '{event}', input);\n"
f" }}"
)
plugin_returns.append(
f" \"{ts_hook}\": async (input: any, output: any) => {{\n"
f" _{event}(input);\n"
f" }},"
)
return _TS_PLUGIN_TEMPLATE.format(
hook_entries="\n\n".join(hook_entries),
plugin_returns="\n".join(plugin_returns),
)
def _merge_plugin_ref(self, config_path: Path) -> None:
existing: dict = {}
if config_path.exists():
try:
existing = json.loads(config_path.read_text(encoding="utf-8"))
if not isinstance(existing, dict):
existing = {}
except (json.JSONDecodeError, OSError):
return
plugins = existing.get("plugin", [])
if not isinstance(plugins, list):
plugins = []
ref = f"./{self._PLUGIN_REL}"
if ref not in plugins:
plugins.append(ref)
existing["plugin"] = plugins
config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
# -- Phase 1: JSON config-file adapters (nested matcher-groups) ------------
class QwenHookAdapter(JSONHookAdapter):
"""Qwen Code: nested matcher-groups in .qwen/settings.json.
Qwen uses the same event names and JSON structure as Claude Code.
"""
config_file_rel = ".qwen/settings.json"
CANONICAL_TO_NATIVE = {
"PreToolUse": "PreToolUse",
"PostToolUse": "PostToolUse",
"Stop": "Stop",
"SessionStart": "SessionStart",
"SessionEnd": "SessionEnd",
"UserPromptSubmit": "UserPromptSubmit",
}
def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict:
return self._build_nested_fragment(hooks, project_root)
class GeminiHookAdapter(JSONHookAdapter):
"""Gemini CLI: nested matcher-groups in .gemini/settings.json.
Gemini uses different event names (BeforeTool, AfterTool, AfterAgent).
"""
config_file_rel = ".gemini/settings.json"
CANONICAL_TO_NATIVE = {
"PreToolUse": "BeforeTool",
"PostToolUse": "AfterTool",
"Stop": "AfterAgent",
"SessionStart": "SessionStart",
"SessionEnd": "SessionEnd",
}
def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict:
return self._build_nested_fragment(hooks, project_root)
class DevinHookAdapter(JSONHookAdapter):
"""Devin for Terminal: hooks in .devin/hooks.v1.json.
Devin uses the same event names and JSON structure as Claude Code.
Also reads .claude/settings.json for compatibility, but we write to
the native format for independence.
"""
config_file_rel = ".devin/hooks.v1.json"
CANONICAL_TO_NATIVE = {
"PreToolUse": "PreToolUse",
"PostToolUse": "PostToolUse",
"Stop": "Stop",
"SessionStart": "SessionStart",
"SessionEnd": "SessionEnd",
"UserPromptSubmit": "UserPromptSubmit",
}
def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict:
return self._build_nested_fragment(hooks, project_root)
class TabnineHookAdapter(JSONHookAdapter):
"""Tabnine CLI: nested matcher-groups in .tabnine/agent/settings.json.
Tabnine uses different event names (BeforeTool, AfterTool), like Gemini.
"""
config_file_rel = ".tabnine/agent/settings.json"
CANONICAL_TO_NATIVE = {
"PreToolUse": "BeforeTool",
"PostToolUse": "AfterTool",
"SessionStart": "SessionStart",
"SessionEnd": "SessionEnd",
}
def _build_fragment(self, hooks: dict[str, dict[str, Any]], project_root: Path) -> dict:
return self._build_nested_fragment(hooks, project_root)
# -- Adapter registry -----------------------------------------------------
_ADAPTERS: dict[str, type[HookAdapter]] = {
"claude": ClaudeHookAdapter,
"cursor-agent": CursorHookAdapter,
"codex": CodexHookAdapter,
"opencode": OpencodeHookAdapter,
"qwen": QwenHookAdapter,
"gemini": GeminiHookAdapter,
"devin": DevinHookAdapter,
"tabnine": TabnineHookAdapter,
}
def resolve_adapter(integration_key: str) -> HookAdapter | None:
"""Return a hook adapter for the integration key, or None if unsupported."""
cls = _ADAPTERS.get(integration_key)
return cls() if cls else None
# -- Public entry points (called from IntegrationBase) --------------------
def install_integration_hooks(
integration: "IntegrationBase",
project_root: Path,
manifest: "IntegrationManifest",
parsed_options: dict[str, Any] | None = None,
) -> list[Path]:
"""Install runtime hooks for an integration.
Called from ``IntegrationBase.setup()`` after commands are written.
Returns list of created/managed files. No-ops gracefully when hooks
are disabled or the agent has no adapter.
"""
adapter = resolve_adapter(integration.key)
if adapter is None:
return []
hooks = resolve_hooks(
integration.key,
integration.config,
project_root,
parsed_options,
)
if not hooks:
# Hooks disabled or empty — clean up any previous hooks
remove_integration_hooks(integration, project_root, manifest)
return []
# Remove old hooks before installing new ones so stale events
# from a previous install/upgrade are cleaned up.
remove_integration_hooks(integration, project_root, manifest)
return adapter.install(project_root, manifest, hooks)
def remove_integration_hooks(
integration: "IntegrationBase",
project_root: Path,
manifest: "IntegrationManifest",
) -> None:
"""Remove runtime hooks for an integration.
Called from ``IntegrationBase.teardown()`` before manifest uninstall.
Removes only Specify-authored entries from native config files.
"""
adapter = resolve_adapter(integration.key)
if adapter is None:
return
adapter.remove(project_root, manifest)
# Remove bridge script if tracked
bridge_rel = HOOK_BRIDGE_REL
if bridge_rel in manifest.files:
bridge_path = project_root / bridge_rel
if bridge_path.exists():
bridge_path.unlink(missing_ok=True)
manifest.remove(bridge_rel)
# Remove opencode TS plugin if tracked
if integration.key == "opencode":
plugin_rel = str(OpencodeHookAdapter._PLUGIN_REL)
if plugin_rel in manifest.files:
plugin_path = project_root / plugin_rel
if plugin_path.exists():
plugin_path.unlink(missing_ok=True)
manifest.remove(plugin_rel)
def hooks_stale_exclusions(integration_key: str) -> set[str]:
"""Return project-relative paths to protect from stale cleanup."""
adapter = resolve_adapter(integration_key)
if adapter is None:
return set()
exclusions = {adapter.config_file_rel}
if integration_key == "opencode":
exclusions.add(OpencodeHookAdapter._PLUGIN_REL)
return exclusions
# -- Helpers ---------------------------------------------------------------
def _has_marker(entry: Any) -> bool:
"""Check if a JSON hook entry has the Specify marker."""
if isinstance(entry, dict):
return entry.get(_SPECKIT_MARKER, False) is True
return False
def _to_camel_case(snake_or_pascal: str) -> str:
"""Convert PascalCase or snake_case to camelCase."""
if not snake_or_pascal:
return snake_or_pascal
first = snake_or_pascal[0].lower()
rest = snake_or_pascal[1:]
# Handle PascalCase: PreToolUse -> preToolUse
return first + rest
# -- Extension manifest validation callout ---------------------------------
def validate_runtime_hooks(data: dict[str, Any]) -> None:
"""Validate ``runtime_hooks`` field in extension manifest data.
Called from ``ExtensionManifest._validate()`` via a callout.
Raises ``ValidationError`` if ``runtime_hooks`` is present but malformed.
"""
from ..extensions import ValidationError
runtime_hooks = data.get("runtime_hooks")
if "runtime_hooks" in data and not isinstance(runtime_hooks, dict):
raise ValidationError("Invalid runtime_hooks: expected a mapping")
if runtime_hooks:
for hook_name, hook_config in runtime_hooks.items():
if not isinstance(hook_config, dict):
raise ValidationError(
f"Invalid runtime_hook '{hook_name}': expected a mapping"
)
if not hook_config.get("command"):
raise ValidationError(
f"Runtime hook '{hook_name}' missing required 'command' field"
)
if hook_name not in CANONICAL_RUNTIME_EVENTS:
raise ValidationError(
f"Unknown runtime_hook '{hook_name}': "
f"must be one of {sorted(CANONICAL_RUNTIME_EVENTS)}"
)
def has_runtime_hooks(data: dict[str, Any]) -> bool:
"""Return True if ``runtime_hooks`` is present and non-empty."""
return bool(data.get("runtime_hooks"))

View File

@@ -139,12 +139,21 @@ def integration_install(
integration.key, project_root, version=_get_speckit_version() 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: try:
integration.setup( integration.setup(
project_root, manifest, project_root, manifest,
parsed_options=parsed_options, parsed_options=parsed_options,
script_type=selected_script, script_type=selected_script,
raw_options=raw_options, raw_options=raw_options,
events=events_map,
) )
manifest.save() manifest.save()
new_installed = _dedupe_integration_keys([*installed_keys, integration.key]) new_installed = _dedupe_integration_keys([*installed_keys, integration.key])

View File

@@ -342,12 +342,20 @@ def integration_switch(
target_integration.key, project_root, version=_get_speckit_version() 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: try:
target_integration.setup( target_integration.setup(
project_root, manifest, project_root, manifest,
parsed_options=parsed_options, parsed_options=parsed_options,
script_type=selected_script, script_type=selected_script,
raw_options=raw_options, raw_options=raw_options,
events=events_map,
) )
manifest.save() manifest.save()
_set_default_integration( _set_default_integration(
@@ -566,6 +574,13 @@ def integration_upgrade(
console.print(f"Upgrading integration: [cyan]{key}[/cyan]") console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
new_manifest = IntegrationManifest(key, project_root, version=_get_speckit_version()) 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: try:
integration.setup( integration.setup(
project_root, project_root,
@@ -573,6 +588,7 @@ def integration_upgrade(
parsed_options=parsed_options, parsed_options=parsed_options,
script_type=selected_script, script_type=selected_script,
raw_options=raw_options, raw_options=raw_options,
events=events_map,
) )
settings = _with_integration_setting( settings = _with_integration_setting(
current, current,

View File

@@ -29,7 +29,7 @@ import yaml
from .._toml_string import escape_toml_basic as _escape_toml_basic 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 .._toml_string import has_illegal_toml_control as _has_illegal_toml_control
from ._hooks import install_integration_hooks, remove_integration_hooks from ..events import install_integration_events, remove_integration_events
if TYPE_CHECKING: if TYPE_CHECKING:
from .manifest import IntegrationManifest from .manifest import IntegrationManifest
@@ -159,7 +159,17 @@ class IntegrationBase(ABC):
@classmethod @classmethod
def options(cls) -> list[IntegrationOption]: def options(cls) -> list[IntegrationOption]:
"""Return options this integration accepts. Default: none.""" """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( def effective_invoke_separator(
self, self,
@@ -480,7 +490,11 @@ class IntegrationBase(ABC):
tracking) would otherwise be deleted even though they are still tracking) would otherwise be deleted even though they are still
managed. Subclasses list such paths here to protect them. 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: def commands_dest(self, project_root: Path) -> Path:
"""Return the absolute path to the commands output directory. """Return the absolute path to the commands output directory.
@@ -903,9 +917,32 @@ class IntegrationBase(ABC):
Returns ``(removed, skipped)`` file lists. Returns ``(removed, skipped)`` file lists.
""" """
remove_integration_hooks(self, project_root, manifest) self.remove_events(project_root, manifest)
return manifest.uninstall(project_root, force=force) 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 ------------------------------- # -- Convenience helpers for subclasses -------------------------------
def install( def install(
@@ -1010,11 +1047,11 @@ class MarkdownIntegration(IntegrationBase):
created.append(dst_file) created.append(dst_file)
# Install agent runtime hooks # Install agent runtime events
hook_files = install_integration_hooks( event_files = self.emit_events(
self, project_root, manifest, parsed_options project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
) )
created.extend(hook_files) created.extend(event_files)
return created return created
@@ -1223,11 +1260,11 @@ class TomlIntegration(IntegrationBase):
created.append(dst_file) created.append(dst_file)
# Install agent runtime hooks # Install agent runtime events
hook_files = install_integration_hooks( event_files = self.emit_events(
self, project_root, manifest, parsed_options project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
) )
created.extend(hook_files) created.extend(event_files)
return created return created
@@ -1465,11 +1502,11 @@ class YamlIntegration(IntegrationBase):
created.append(dst_file) created.append(dst_file)
# Install agent runtime hooks # Install agent runtime events
hook_files = install_integration_hooks( event_files = self.emit_events(
self, project_root, manifest, parsed_options project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
) )
created.extend(hook_files) created.extend(event_files)
return created return created
@@ -1706,10 +1743,10 @@ class SkillsIntegration(IntegrationBase):
created.append(dst) created.append(dst)
# Install agent runtime hooks # Install agent runtime events
hook_files = install_integration_hooks( event_files = self.emit_events(
self, project_root, manifest, parsed_options project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
) )
created.extend(hook_files) created.extend(event_files)
return created return created

View File

@@ -54,6 +54,17 @@ class ClaudeIntegration(SkillsIntegration):
} }
multi_install_safe = 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 = ".claude/settings.json"
events_format = "json-nested"
@staticmethod @staticmethod
def inject_argument_hint(content: str, hint: str) -> str: def inject_argument_hint(content: str, hint: str) -> str:
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter. """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.

View File

@@ -29,6 +29,17 @@ class CodexIntegration(SkillsIntegration):
dev_no_symlink = True dev_no_symlink = True
multi_install_safe = 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( def build_exec_args(
self, self,
prompt: str, prompt: str,
@@ -49,11 +60,13 @@ class CodexIntegration(SkillsIntegration):
@classmethod @classmethod
def options(cls) -> list[IntegrationOption]: def options(cls) -> list[IntegrationOption]:
return [ opts = super().options()
opts.append(
IntegrationOption( IntegrationOption(
"--skills", "--skills",
is_flag=True, is_flag=True,
default=True, default=True,
help="Install as agent skills (default for Codex)", help="Install as agent skills (default for Codex)",
), )
] )
return opts

View File

@@ -118,6 +118,16 @@ class CopilotIntegration(IntegrationBase):
"extension": ".agent.md", "extension": ".agent.md",
} }
CANONICAL_TO_NATIVE = {
"session_start": "sessionStart",
"pre_tool_use": "preToolUse",
"post_tool_use": "postToolUse",
"session_end": "sessionEnd",
"user_prompt_submit": "userPromptSubmitted",
}
events_config_file = ".github/hooks/speckit.json"
events_format = "copilot-json"
# Mutable flag set by setup() — indicates the active scaffolding mode. # Mutable flag set by setup() — indicates the active scaffolding mode.
_skills_mode: bool = False _skills_mode: bool = False
@@ -328,7 +338,9 @@ class CopilotIntegration(IntegrationBase):
be flagged stale and deleted, destroying user settings (and the file be flagged stale and deleted, destroying user settings (and the file
the integration still manages). 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: def post_process_skill_content(self, content: str) -> str:
"""Inject shared hook guidance into Copilot skill content. """Inject shared hook guidance into Copilot skill content.
@@ -355,10 +367,18 @@ class CopilotIntegration(IntegrationBase):
parsed_options = parsed_options or {} parsed_options = parsed_options or {}
self._skills_mode = bool(parsed_options.get("skills")) self._skills_mode = bool(parsed_options.get("skills"))
if self._skills_mode: if self._skills_mode:
return self._setup_skills(project_root, manifest, parsed_options, **opts) created = self._setup_skills(project_root, manifest, parsed_options, **opts)
if "skills" not in parsed_options: else:
_warn_legacy_markdown_default() if "skills" not in parsed_options:
return self._setup_default(project_root, manifest, parsed_options, **opts) _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( def _setup_default(
self, self,

View File

@@ -38,6 +38,17 @@ class CursorAgentIntegration(SkillsIntegration):
multi_install_safe = 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": "beforeSubmitPrompt",
"stop": "stop",
}
events_config_file = ".cursor/hooks.json"
events_format = "json-flat"
def build_exec_args( def build_exec_args(
self, self,
prompt: str, prompt: str,
@@ -92,11 +103,13 @@ class CursorAgentIntegration(SkillsIntegration):
@classmethod @classmethod
def options(cls) -> list[IntegrationOption]: def options(cls) -> list[IntegrationOption]:
return [ opts = super().options()
opts.append(
IntegrationOption( IntegrationOption(
"--skills", "--skills",
is_flag=True, is_flag=True,
default=True, default=True,
help="Install as agent skills (recommended for Cursor)", help="Install as agent skills (recommended for Cursor)",
), )
] )
return opts

View File

@@ -31,6 +31,17 @@ class DevinIntegration(SkillsIntegration):
"extension": "/SKILL.md", "extension": "/SKILL.md",
} }
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "PreToolUse",
"post_tool_use": "PostToolUse",
"session_end": "SessionEnd",
"user_prompt_submit": "UserPromptSubmit",
"stop": "Stop",
}
events_config_file = ".devin/hooks.v1.json"
events_format = "json-nested"
def build_exec_args( def build_exec_args(
self, self,
prompt: str, prompt: str,

View File

@@ -19,3 +19,13 @@ class GeminiIntegration(TomlIntegration):
"extension": ".toml", "extension": ".toml",
} }
multi_install_safe = True multi_install_safe = True
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "BeforeTool",
"post_tool_use": "AfterTool",
"session_end": "SessionEnd",
"stop": "AfterAgent",
}
events_config_file = ".gemini/settings.json"
events_format = "json-nested"

View File

@@ -20,6 +20,15 @@ class OpencodeIntegration(MarkdownIntegration):
"extension": ".md", "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( def build_exec_args(
self, self,
prompt: str, prompt: str,

View File

@@ -19,3 +19,14 @@ class QwenIntegration(MarkdownIntegration):
"extension": ".md", "extension": ".md",
} }
multi_install_safe = 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 = ".qwen/settings.json"
events_format = "json-nested"

View File

@@ -19,3 +19,12 @@ class TabnineIntegration(TomlIntegration):
"extension": ".toml", "extension": ".toml",
} }
multi_install_safe = True multi_install_safe = True
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "BeforeTool",
"post_tool_use": "AfterTool",
"session_end": "SessionEnd",
}
events_config_file = ".tabnine/agent/settings.json"
events_format = "json-nested"

View File

@@ -0,0 +1,374 @@
"""Tests for events module: integration runtime events."""
from __future__ import annotations
import json
import platform
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import yaml
from specify_cli.events import (
CANONICAL_EVENTS,
collect_extension_events,
events_stale_exclusions,
install_integration_events,
remove_integration_events,
resolve_events,
validate_events,
resolve_and_run_event_command,
)
from specify_cli.integrations.base import IntegrationBase
from specify_cli.integrations.manifest import IntegrationManifest
from specify_cli.integrations.claude import ClaudeIntegration
from specify_cli.integrations.cursor_agent import CursorAgentIntegration
from specify_cli.integrations.codex import CodexIntegration
from specify_cli.integrations.opencode import OpencodeIntegration
from specify_cli.integrations.qwen import QwenIntegration
from specify_cli.integrations.gemini import GeminiIntegration
from specify_cli.integrations.devin import DevinIntegration
from specify_cli.integrations.tabnine import TabnineIntegration
from specify_cli.integrations.copilot import CopilotIntegration
# -- resolve_events --------------------------------------------------------
class TestResolveEvents:
"""Test the 4-layer event resolution chain."""
def test_layer1_disabled_returns_empty(self, tmp_path):
"""--events false returns empty dict."""
result = resolve_events(
"claude",
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
tmp_path,
{"events": "false"},
)
assert result == {}
def test_layer4_built_in_defaults(self, tmp_path):
"""Returns baseline defaults when no overrides exist."""
result = resolve_events(
"claude",
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
tmp_path,
None,
)
assert result == {"post_tool_use": {"command": "speckit.tdd.validate"}}
def test_layer3_extension_events_appended(self, tmp_path):
"""Extension-declared events are resolved and appended."""
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
ext_dir.mkdir(parents=True)
ext_yml = ext_dir / "extension.yml"
ext_yml.write_text(
"events:\n session_start:\n command: speckit.my-ext.boot\n",
encoding="utf-8",
)
result = resolve_events(
"claude",
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
tmp_path,
None,
)
assert "post_tool_use" in result
assert "session_start" in result
assert result["session_start"] == {"command": "speckit.my-ext.boot"}
def test_layer2_yaml_override_replaces(self, tmp_path):
"""integration-events.yml override replaces baseline entirely."""
override_file = tmp_path / ".specify" / "integration-events.yml"
override_file.parent.mkdir(parents=True, exist_ok=True)
override_file.write_text(
"integrations:\n"
" claude:\n"
" events:\n"
" stop:\n"
" command: speckit.override.stop\n",
encoding="utf-8",
)
result = resolve_events(
"claude",
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
tmp_path,
None,
)
assert result == {"stop": {"command": "speckit.override.stop"}}
def test_layer2_empty_events_disables(self, tmp_path):
"""Empty events override disables events."""
override_file = tmp_path / ".specify" / "integration-events.yml"
override_file.parent.mkdir(parents=True, exist_ok=True)
override_file.write_text(
"integrations:\n"
" claude:\n"
" events: {}\n",
encoding="utf-8",
)
result = resolve_events(
"claude",
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
tmp_path,
None,
)
assert result == {}
def test_no_config_no_events(self, tmp_path):
"""Safe fallback with empty config/options."""
result = resolve_events("claude", None, tmp_path, None)
assert result == {}
# -- collect_extension_events -----------------------------------------------
class TestCollectExtensionEvents:
"""Test scanning extension.yml files for events: declarations."""
def test_no_extensions_dir(self, tmp_path):
assert collect_extension_events(tmp_path) == {}
def test_no_events_in_extension(self, tmp_path):
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
ext_dir.mkdir(parents=True)
(ext_dir / "extension.yml").write_text("extension:\n id: my-ext\n", encoding="utf-8")
assert collect_extension_events(tmp_path) == {}
def test_events_collected(self, tmp_path):
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
ext_dir.mkdir(parents=True)
(ext_dir / "extension.yml").write_text(
"events:\n pre_tool_use:\n command: speckit.my-ext.check\n",
encoding="utf-8",
)
result = collect_extension_events(tmp_path)
assert result == {"pre_tool_use": {"command": "speckit.my-ext.check"}}
def test_invalid_yaml_skipped(self, tmp_path):
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
ext_dir.mkdir(parents=True)
(ext_dir / "extension.yml").write_text("invalid: - - -", encoding="utf-8")
assert collect_extension_events(tmp_path) == {}
# -- Class-driven mappings --------------------------------------------------
class TestCanonicalEventMapping:
"""Verify registry-driven mapping is correct on integration classes."""
def test_claude_identity(self):
integration = ClaudeIntegration()
assert integration.supports_events() is True
assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "PreToolUse"
assert integration.CANONICAL_TO_NATIVE["session_start"] == "SessionStart"
def test_cursor_camelcase(self):
integration = CursorAgentIntegration()
assert integration.supports_events() is True
assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "preToolUse"
assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "beforeSubmitPrompt"
def test_opencode_limited(self):
integration = OpencodeIntegration()
assert integration.supports_events() is True
assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "tool.execute.before"
assert "stop" not in integration.CANONICAL_TO_NATIVE
def test_copilot_mapping(self):
integration = CopilotIntegration()
assert integration.supports_events() is True
assert integration.CANONICAL_TO_NATIVE["session_start"] == "sessionStart"
assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "userPromptSubmitted"
# -- validate_events --------------------------------------------------------
class TestValidateEvents:
"""Test manifest validation."""
def test_unknown_event_rejected(self):
from specify_cli.extensions import ValidationError
data = {"events": {"unknown_event": {"command": "speckit.tdd.validate"}}}
with pytest.raises(ValidationError) as exc:
validate_events(data)
assert "Unknown event" in str(exc.value)
def test_known_event_accepted(self):
data = {"events": {"pre_tool_use": {"command": "speckit.tdd.validate"}}}
validate_events(data) # no raise
def test_all_canonical_events_accepted(self):
data = {
"events": {
name: {"command": "speckit.test"}
for name in CANONICAL_EVENTS
}
}
validate_events(data) # no raise
# -- Claude settings JSON merging -------------------------------------------
class TestClaudeJsonMerging:
"""Test Claude settings JSON merging and cleanup."""
def test_merge_into_empty_file(self, tmp_path):
integration = ClaudeIntegration()
manifest = MagicMock(spec=IntegrationManifest)
manifest.files = {}
manifest.record_file = MagicMock()
manifest.record_existing = MagicMock()
events = {
"pre_tool_use": {"command": "speckit.tdd.validate", "matcher": "Edit|Write"},
}
install_integration_events(integration, tmp_path, manifest, events)
config_path = tmp_path / ".claude/settings.json"
assert config_path.is_file()
data = json.loads(config_path.read_text())
assert "hooks" in data
assert "PreToolUse" in data["hooks"]
assert data["hooks"]["PreToolUse"][0]["matcher"] == "Edit|Write"
def test_remove_preserves_user_hooks(self, tmp_path):
integration = ClaudeIntegration()
manifest = MagicMock(spec=IntegrationManifest)
manifest.files = {}
manifest.record_file = MagicMock()
manifest.record_existing = MagicMock()
# Pre-seed user setting
config_path = tmp_path / ".claude/settings.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
json.dumps(
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "user-check",
}
],
}
]
}
}
)
)
events = {
"pre_tool_use": {"command": "speckit.tdd.validate"},
}
install_integration_events(integration, tmp_path, manifest, events)
remove_integration_events(integration, tmp_path, manifest)
data = json.loads(config_path.read_text())
assert "hooks" in data
assert "PreToolUse" in data["hooks"]
assert len(data["hooks"]["PreToolUse"]) == 1
assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash"
# -- Copilot events JSON writing --------------------------------------------
class TestCopilotJsonWriting:
"""Test Copilot dedicated .github/hooks/speckit.json generation."""
def test_copilot_json_generation(self, tmp_path):
integration = CopilotIntegration()
manifest = MagicMock(spec=IntegrationManifest)
manifest.files = {}
manifest.record_file = MagicMock()
manifest.record_existing = MagicMock()
events = {
"session_start": {"command": "speckit.agent-context.update", "timeout": 60},
}
install_integration_events(integration, tmp_path, manifest, events)
config_path = tmp_path / ".github/hooks/speckit.json"
assert config_path.is_file()
data = json.loads(config_path.read_text())
assert data["version"] == 1
assert "hooks" in data
assert "sessionStart" in data["hooks"]
entry = data["hooks"]["sessionStart"][0]
assert entry["type"] == "command"
assert "speckit.agent-context.update" in entry["bash"]
assert "session_start" in entry["bash"]
assert entry["timeoutSec"] == 60
# -- Opencode TS Plugin merging ---------------------------------------------
class TestOpencodePluginMerging:
"""Test Opencode typescript plugin generation."""
def test_opencode_ts_plugin_generation(self, tmp_path):
integration = OpencodeIntegration()
manifest = MagicMock(spec=IntegrationManifest)
manifest.files = {}
manifest.record_file = MagicMock()
manifest.record_existing = MagicMock()
events = {
"pre_tool_use": {"command": "speckit.tdd.validate", "matcher": "Edit"},
"session_start": {"command": "speckit.agent-context.update"},
}
install_integration_events(integration, tmp_path, manifest, events)
plugin_path = tmp_path / ".opencode/plugin/speckit-events.ts"
assert plugin_path.is_file()
content = plugin_path.read_text()
assert "runEvent" in content
assert "tool.execute.before" in content
assert "session.created" in content
assert "speckit.tdd.validate" in content
assert "speckit.agent-context.update" in content
# -- Command runner test (core execution) -----------------------------------
class TestCommandRunner:
"""Test the core command/script resolution and runner."""
def test_run_command_not_found(self, tmp_path):
code = resolve_and_run_event_command("nonexistent.command", "session_start", "{}", tmp_path)
assert code == 0 # no-ops gracefully
def test_run_command_resolves_and_executes(self, tmp_path):
# Create a mock core command md file
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
cmd_dir.mkdir(parents=True)
cmd_file = cmd_dir / "test.md"
cmd_file.write_text(
"---\n"
"description: \"Test\"\n"
"scripts:\n"
" sh: scripts/test.sh\n"
"---\n"
"Body\n",
encoding="utf-8",
)
script_dir = tmp_path / ".specify" / "scripts"
script_dir.mkdir(parents=True)
script_file = script_dir / "test.sh"
script_file.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
script_file.chmod(0o755)
# Skip on Windows because sh is POSIX
if platform.system().lower().startswith("win"):
return
code = resolve_and_run_event_command("speckit.test", "session_start", "{}", tmp_path)
assert code == 0

View File

@@ -1,678 +0,0 @@
"""Tests for _hooks module: integration runtime hooks."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import yaml
from specify_cli.integrations._hooks import (
ClaudeHookAdapter,
CodexHookAdapter,
CursorHookAdapter,
DevinHookAdapter,
GeminiHookAdapter,
HookAdapter,
JSONHookAdapter,
OpencodeHookAdapter,
QwenHookAdapter,
TabnineHookAdapter,
_has_marker,
_to_camel_case,
CANONICAL_RUNTIME_EVENTS,
collect_extension_runtime_hooks,
hooks_stale_exclusions,
install_integration_hooks,
remove_integration_hooks,
resolve_adapter,
resolve_hooks,
validate_runtime_hooks,
)
from specify_cli.integrations.manifest import IntegrationManifest
# -- resolve_hooks --------------------------------------------------------
class TestResolveHooks:
"""Test the 4-layer hook resolution chain."""
def test_layer1_disabled_returns_empty(self, tmp_path):
"""--hooks false returns empty dict."""
result = resolve_hooks(
"claude",
{"hooks": {"PostToolUse": {"command": "speckit.tdd.validate"}}},
tmp_path,
{"hooks": "false"},
)
assert result == {}
def test_layer4_built_in_defaults(self, tmp_path):
"""Built-in config hooks are returned when no override."""
config = {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write"}}}
result = resolve_hooks("claude", config, tmp_path, None)
assert "PostToolUse" in result
assert result["PostToolUse"]["command"] == "speckit.tdd.validate"
def test_layer3_extension_hooks_appended(self, tmp_path):
"""Extension-declared runtime_hooks are appended to built-in."""
ext_dir = tmp_path / ".specify" / "extensions" / "myext"
ext_dir.mkdir(parents=True)
(ext_dir / "extension.yml").write_text(yaml.dump({
"schema_version": "1.0",
"extension": {"id": "myext", "name": "My Ext", "version": "1.0.0",
"description": "test"},
"requires": {"speckit_version": ">=0.0.80"},
"provides": {"commands": []},
"runtime_hooks": {
"Stop": {"command": "speckit.myext.check", "matcher": "*"},
},
}))
config = {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate"}}}
result = resolve_hooks("claude", config, tmp_path, None)
assert "PostToolUse" in result
assert "Stop" in result
assert result["Stop"]["command"] == "speckit.myext.check"
def test_layer2_yaml_override_replaces(self, tmp_path):
"""YAML override replaces built-in and extension hooks entirely."""
override_file = tmp_path / ".specify" / "integration-hooks.yml"
override_file.parent.mkdir(parents=True)
override_file.write_text(yaml.dump({
"version": 1,
"integrations": {
"claude": {
"hooks": {
"PreToolUse": {"command": "speckit.protected_paths", "matcher": "Edit|Write"},
},
},
},
}))
config = {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate"}}}
result = resolve_hooks("claude", config, tmp_path, None)
assert "PreToolUse" in result
assert "PostToolUse" not in result # replaced, not merged
def test_layer2_empty_hooks_disables(self, tmp_path):
"""Empty hooks: {} in YAML override disables all hooks."""
override_file = tmp_path / ".specify" / "integration-hooks.yml"
override_file.parent.mkdir(parents=True)
override_file.write_text(yaml.dump({
"version": 1,
"integrations": {"claude": {"hooks": {}}},
}))
config = {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate"}}}
result = resolve_hooks("claude", config, tmp_path, None)
assert result == {}
def test_no_config_no_hooks(self, tmp_path):
"""No config, no extensions, no override → empty."""
result = resolve_hooks("gemini", None, tmp_path, None)
assert result == {}
# -- collect_extension_runtime_hooks --------------------------------------
class TestCollectExtensionRuntimeHooks:
"""Test scanning installed extensions for runtime_hooks."""
def test_no_extensions_dir(self, tmp_path):
assert collect_extension_runtime_hooks(tmp_path) == {}
def test_no_runtime_hooks_in_extension(self, tmp_path):
ext_dir = tmp_path / ".specify" / "extensions" / "myext"
ext_dir.mkdir(parents=True)
(ext_dir / "extension.yml").write_text(yaml.dump({
"schema_version": "1.0",
"extension": {"id": "myext", "name": "My Ext", "version": "1.0.0",
"description": "test"},
"requires": {"speckit_version": ">=0.0.80"},
"provides": {"commands": [{"name": "speckit.myext.cmd", "file": "cmd.md"}]},
"hooks": {"after_plan": {"command": "speckit.myext.cmd"}},
}))
result = collect_extension_runtime_hooks(tmp_path)
assert result == {}
def test_runtime_hooks_collected(self, tmp_path):
ext_dir = tmp_path / ".specify" / "extensions" / "tdd"
ext_dir.mkdir(parents=True)
(ext_dir / "extension.yml").write_text(yaml.dump({
"schema_version": "1.0",
"extension": {"id": "tdd", "name": "TDD", "version": "1.0.0",
"description": "test"},
"requires": {"speckit_version": ">=0.0.80"},
"provides": {"commands": []},
"runtime_hooks": {
"PostToolUse": {"command": "adlc.tdd.validate", "matcher": "Edit|Write"},
"Stop": {"command": "adlc.tdd.validate", "matcher": "*"},
},
}))
result = collect_extension_runtime_hooks(tmp_path)
assert "PostToolUse" in result
assert "Stop" in result
assert result["PostToolUse"]["command"] == "adlc.tdd.validate"
def test_invalid_yaml_skipped(self, tmp_path):
ext_dir = tmp_path / ".specify" / "extensions" / "broken"
ext_dir.mkdir(parents=True)
(ext_dir / "extension.yml").write_text("{{invalid yaml")
result = collect_extension_runtime_hooks(tmp_path)
assert result == {}
# -- Adapter resolution ---------------------------------------------------
class TestResolveAdapter:
"""Test adapter registry."""
def test_claude_returns_adapter(self):
adapter = resolve_adapter("claude")
assert isinstance(adapter, ClaudeHookAdapter)
def test_cursor_returns_adapter(self):
adapter = resolve_adapter("cursor-agent")
assert isinstance(adapter, CursorHookAdapter)
def test_codex_returns_adapter(self):
adapter = resolve_adapter("codex")
assert isinstance(adapter, CodexHookAdapter)
def test_opencode_returns_adapter(self):
adapter = resolve_adapter("opencode")
assert isinstance(adapter, OpencodeHookAdapter)
def test_gemini_returns_adapter(self):
adapter = resolve_adapter("gemini")
assert isinstance(adapter, GeminiHookAdapter)
def test_qwen_returns_adapter(self):
adapter = resolve_adapter("qwen")
assert isinstance(adapter, QwenHookAdapter)
def test_devin_returns_adapter(self):
adapter = resolve_adapter("devin")
assert isinstance(adapter, DevinHookAdapter)
def test_tabnine_returns_adapter(self):
adapter = resolve_adapter("tabnine")
assert isinstance(adapter, TabnineHookAdapter)
def test_copilot_returns_none(self):
assert resolve_adapter("copilot") is None
def test_unknown_returns_none(self):
assert resolve_adapter("nonexistent") is None
# -- Canonical event mapping -----------------------------------------------
class TestCanonicalEventMapping:
"""Test canonical→native event name translation per adapter."""
def test_claude_passthrough(self):
adapter = ClaudeHookAdapter()
assert adapter._native_event("PreToolUse") == "PreToolUse"
assert adapter._native_event("PostToolUse") == "PostToolUse"
assert adapter._native_event("Stop") == "Stop"
def test_cursor_camelcase(self):
adapter = CursorHookAdapter()
assert adapter._native_event("PreToolUse") == "preToolUse"
assert adapter._native_event("PostToolUse") == "postToolUse"
assert adapter._native_event("UserPromptSubmit") == "beforeSubmitPrompt"
def test_opencode_limited(self):
adapter = OpencodeHookAdapter()
assert adapter._native_event("PreToolUse") == "tool.execute.before"
assert adapter._native_event("PostToolUse") == "tool.execute.after"
assert adapter._native_event("Stop") is None # unsupported
def test_gemini_mapping(self):
adapter = GeminiHookAdapter()
assert adapter._native_event("PreToolUse") == "BeforeTool"
assert adapter._native_event("PostToolUse") == "AfterTool"
assert adapter._native_event("Stop") == "AfterAgent"
def test_qwen_identity(self):
adapter = QwenHookAdapter()
assert adapter._native_event("PreToolUse") == "PreToolUse"
def test_devin_identity(self):
adapter = DevinHookAdapter()
assert adapter._native_event("PreToolUse") == "PreToolUse"
def test_tabnine_mapping(self):
adapter = TabnineHookAdapter()
assert adapter._native_event("PreToolUse") == "BeforeTool"
assert adapter._native_event("PostToolUse") == "AfterTool"
assert adapter._native_event("Stop") is None # not supported
class TestUnknownEventRejection:
"""Test that validate_runtime_hooks rejects unknown event names."""
def test_unknown_event_rejected(self):
from specify_cli.extensions import ValidationError
data = {
"runtime_hooks": {
"FooBar": {"command": "speckit.test"},
},
}
with pytest.raises(ValidationError, match="Unknown runtime_hook 'FooBar'"):
validate_runtime_hooks(data)
def test_known_event_accepted(self):
data = {
"runtime_hooks": {
"PostToolUse": {"command": "speckit.test"},
},
}
validate_runtime_hooks(data) # should not raise
def test_all_canonical_events_accepted(self):
for event in CANONICAL_RUNTIME_EVENTS:
data = {"runtime_hooks": {event: {"command": "speckit.test"}}}
validate_runtime_hooks(data) # should not raise
class TestUnsupportedEventWarning:
"""Test that adapters warn and skip unsupported events."""
def test_opencode_skips_stop(self, tmp_path, capsys):
from specify_cli.integrations.manifest import IntegrationManifest
adapter = OpencodeHookAdapter()
manifest = IntegrationManifest("opencode", tmp_path)
hooks = {
"PostToolUse": {"command": "speckit.test", "matcher": "Edit|Write"},
"Stop": {"command": "speckit.test", "matcher": "*"},
}
adapter.install(tmp_path, manifest, hooks)
captured = capsys.readouterr()
assert "Stop" in captured.err
assert "skipping" in captured.err
# Plugin should only have PostToolUse, not Stop
plugin = tmp_path / ".opencode" / "plugin" / "speckit-hooks.ts"
content = plugin.read_text()
assert "tool.execute.after" in content
assert "tool.execute.before" not in content
def test_tabnine_skips_stop(self, tmp_path, capsys):
adapter = TabnineHookAdapter()
from specify_cli.integrations.manifest import IntegrationManifest
manifest = IntegrationManifest("tabnine", tmp_path)
hooks = {
"PostToolUse": {"command": "speckit.test", "matcher": "Edit|Write"},
"Stop": {"command": "speckit.test", "matcher": "*"},
}
adapter.install(tmp_path, manifest, hooks)
captured = capsys.readouterr()
assert "Stop" in captured.err
settings = tmp_path / ".tabnine" / "agent" / "settings.json"
data = json.loads(settings.read_text())
assert "AfterTool" in data.get("hooks", {})
assert "Stop" not in data.get("hooks", {})
# -- New adapter tests -----------------------------------------------------
class TestGeminiHookAdapter:
def test_generate_fragment_uses_native_names(self):
adapter = GeminiHookAdapter()
hooks = {
"PreToolUse": {"command": "speckit.test", "matcher": "Edit|Write", "timeout": 10},
}
fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp"))
assert fmt == "json"
assert "BeforeTool" in fragment["hooks"]
assert "PreToolUse" not in fragment["hooks"]
def test_install_creates_settings(self, tmp_path):
from specify_cli.integrations.manifest import IntegrationManifest
adapter = GeminiHookAdapter()
manifest = IntegrationManifest("gemini", tmp_path)
hooks = {"PostToolUse": {"command": "speckit.test", "matcher": "Edit|Write", "timeout": 60}}
created = adapter.install(tmp_path, manifest, hooks)
settings = tmp_path / ".gemini" / "settings.json"
assert settings.exists()
data = json.loads(settings.read_text())
assert "AfterTool" in data["hooks"]
class TestQwenHookAdapter:
def test_generate_fragment_identity(self):
adapter = QwenHookAdapter()
hooks = {
"PreToolUse": {"command": "speckit.test", "matcher": "Edit|Write", "timeout": 10},
}
fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp"))
assert "PreToolUse" in fragment["hooks"]
class TestDevinHookAdapter:
def test_install_creates_hooks_file(self, tmp_path):
from specify_cli.integrations.manifest import IntegrationManifest
adapter = DevinHookAdapter()
manifest = IntegrationManifest("devin", tmp_path)
hooks = {"PostToolUse": {"command": "speckit.test", "matcher": "Edit|Write", "timeout": 60}}
created = adapter.install(tmp_path, manifest, hooks)
hooks_file = tmp_path / ".devin" / "hooks.v1.json"
assert hooks_file.exists()
data = json.loads(hooks_file.read_text())
assert "PostToolUse" in data["hooks"]
class TestTabnineHookAdapter:
def test_generate_fragment_native_names(self):
adapter = TabnineHookAdapter()
hooks = {
"PreToolUse": {"command": "speckit.test", "matcher": "Edit|Write", "timeout": 10},
}
fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp"))
assert "BeforeTool" in fragment["hooks"]
# -- ClaudeHookAdapter ----------------------------------------------------
class TestClaudeHookAdapter:
"""Test Claude Code native config generation and merge."""
def test_generate_fragment_structure(self):
adapter = ClaudeHookAdapter()
hooks = {
"PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60},
}
fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp"))
assert fmt == "json"
assert "hooks" in fragment
assert "PostToolUse" in fragment["hooks"]
entry_group = fragment["hooks"]["PostToolUse"][0]
assert entry_group["matcher"] == "Edit|Write"
handler = entry_group["hooks"][0]
assert handler["type"] == "command"
assert handler["command"] == "python3"
assert "speckit.tdd.validate" in handler["args"]
assert "PostToolUse" in handler["args"]
assert handler["timeout"] == 60
def test_merge_into_empty_file(self, tmp_path):
adapter = ClaudeHookAdapter()
config_path = tmp_path / ".claude" / "settings.json"
config_path.parent.mkdir(parents=True)
hooks = {
"PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60},
}
fmt, fragment = adapter.generate_fragment(hooks, tmp_path)
adapter.merge_fragment(config_path, fragment, format=fmt)
data = json.loads(config_path.read_text())
assert "hooks" in data
assert "PostToolUse" in data["hooks"]
assert len(data["hooks"]["PostToolUse"]) == 1
def test_merge_preserves_user_keys(self, tmp_path):
adapter = ClaudeHookAdapter()
config_path = tmp_path / ".claude" / "settings.json"
config_path.parent.mkdir(parents=True)
config_path.write_text(json.dumps({
"model": "claude-sonnet-4-20250514",
"permissions": {"allow": ["Bash(git *)"]},
"hooks": {
"PostToolUse": [{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "/usr/bin/my-hook.sh"}],
}],
},
}))
hooks = {
"PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60},
}
fmt, fragment = adapter.generate_fragment(hooks, tmp_path)
adapter.merge_fragment(config_path, fragment, format=fmt)
data = json.loads(config_path.read_text())
assert data["model"] == "claude-sonnet-4-20250514"
assert data["permissions"]["allow"] == ["Bash(git *)"]
assert len(data["hooks"]["PostToolUse"]) == 2 # user hook + our hook
def test_remove_entries_preserves_user_hooks(self, tmp_path):
adapter = ClaudeHookAdapter()
config_path = tmp_path / ".claude" / "settings.json"
config_path.parent.mkdir(parents=True)
config_path.write_text(json.dumps({
"hooks": {
"PostToolUse": [
{"matcher": "Bash", "hooks": [{"type": "command", "command": "/usr/bin/my-hook.sh"}]},
{"matcher": "Edit|Write", "hooks": [{"type": "command", "command": "python3",
"args": [".specify/hooks/bridge.py", "speckit.tdd.validate", "PostToolUse"],
"__speckit_hook__": True}]},
],
},
}))
adapter.remove_entries(config_path)
data = json.loads(config_path.read_text())
assert len(data["hooks"]["PostToolUse"]) == 1
assert data["hooks"]["PostToolUse"][0]["matcher"] == "Bash"
# -- CursorHookAdapter ----------------------------------------------------
class TestCursorHookAdapter:
"""Test Cursor native config generation."""
def test_generate_fragment_camelcase(self):
adapter = CursorHookAdapter()
hooks = {
"PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60},
}
fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp"))
assert "postToolUse" in fragment["hooks"]
entry = fragment["hooks"]["postToolUse"][0]
assert "python3" in entry["command"]
assert entry["matcher"] == "Edit|Write"
# -- CodexHookAdapter -----------------------------------------------------
class TestCodexHookAdapter:
"""Test Codex TOML config generation."""
def test_generate_fragment_toml(self):
adapter = CodexHookAdapter()
hooks = {
"PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60},
}
fmt, fragment = adapter.generate_fragment(hooks, Path("/tmp"))
assert fmt == "toml"
assert "[[hooks.PostToolUse]]" in fragment
assert 'matcher = "Edit|Write"' in fragment
assert "speckit.tdd.validate" in fragment
assert "speckit_marker = true" in fragment
# -- OpencodeHookAdapter --------------------------------------------------
class TestOpencodeHookAdapter:
"""Test opencode TS plugin generation."""
def test_install_generates_plugin_and_merges_config(self, tmp_path):
adapter = OpencodeHookAdapter()
manifest = MagicMock(spec=IntegrationManifest)
manifest.files = {}
manifest.record_file = MagicMock()
manifest.record_existing = MagicMock()
hooks = {
"PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60},
}
created = adapter.install(tmp_path, manifest, hooks)
plugin_path = tmp_path / ".opencode/plugin/speckit-hooks.ts"
assert plugin_path.exists()
content = plugin_path.read_text()
assert "tool.execute.after" in content
assert "speckit.tdd.validate" in content
config_path = tmp_path / "opencode.json"
assert config_path.exists()
config = json.loads(config_path.read_text())
assert "./.opencode/plugin/speckit-hooks.ts" in config["plugin"]
def test_remove_removes_plugin_ref(self, tmp_path):
adapter = OpencodeHookAdapter()
config_path = tmp_path / "opencode.json"
config_path.write_text(json.dumps({
"plugin": ["./.opencode/plugin/speckit-hooks.ts", "./other.ts"],
}))
adapter.remove_entries(config_path)
config = json.loads(config_path.read_text())
assert "./.opencode/plugin/speckit-hooks.ts" not in config["plugin"]
assert "./other.ts" in config["plugin"]
# -- Helpers --------------------------------------------------------------
class TestHelpers:
def test_has_marker_true(self):
assert _has_marker({"__speckit_hook__": True, "command": "foo"})
def test_has_marker_false(self):
assert not _has_marker({"command": "foo"})
def test_to_camel_case(self):
assert _to_camel_case("PostToolUse") == "postToolUse"
assert _to_camel_case("PreToolUse") == "preToolUse"
assert _to_camel_case("Stop") == "stop"
assert _to_camel_case("") == ""
# -- hooks_stale_exclusions -----------------------------------------------
class TestStaleExclusions:
def test_claude_returns_settings_path(self):
exclusions = hooks_stale_exclusions("claude")
assert ".claude/settings.json" in exclusions
def test_opencode_returns_config_and_plugin(self):
exclusions = hooks_stale_exclusions("opencode")
assert "opencode.json" in exclusions
assert ".opencode/plugin/speckit-hooks.ts" in exclusions
def test_gemini_returns_settings_path(self):
exclusions = hooks_stale_exclusions("gemini")
assert ".gemini/settings.json" in exclusions
def test_copilot_returns_empty(self):
assert hooks_stale_exclusions("copilot") == set()
# -- Integration: install + remove round-trip -----------------------------
class TestInstallRemoveRoundTrip:
"""End-to-end install → remove for Claude adapter."""
def test_claude_install_then_remove(self, tmp_path):
from specify_cli.integrations._hooks import HOOK_BRIDGE_REL
manifest = IntegrationManifest("claude", tmp_path)
adapter = ClaudeHookAdapter()
hooks = {
"PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60},
}
created = adapter.install(tmp_path, manifest, hooks)
assert len(created) == 2 # bridge + settings.json
bridge = tmp_path / HOOK_BRIDGE_REL
assert bridge.exists()
settings = tmp_path / ".claude/settings.json"
assert settings.exists()
data = json.loads(settings.read_text())
assert "PostToolUse" in data["hooks"]
# Remove
adapter.remove(tmp_path, manifest)
data = json.loads(settings.read_text())
# Our entries removed, but file still exists
assert "hooks" not in data or "PostToolUse" not in data.get("hooks", {})
def test_group_b_no_crash(self, tmp_path):
"""Installing hooks for a Group B agent (no adapter) is a no-op."""
mock_integration = MagicMock()
mock_integration.key = "copilot"
mock_integration.config = {"hooks": {"PostToolUse": {"command": "speckit.tdd.validate"}}}
manifest = IntegrationManifest("gemini", tmp_path)
result = install_integration_hooks(mock_integration, tmp_path, manifest, None)
assert result == []
def test_upgrade_removes_stale_events(self, tmp_path):
"""Upgrading from hook set A to hook set B removes stale events."""
mock_integration = MagicMock()
mock_integration.key = "claude"
mock_integration.config = None
(tmp_path / ".specify").mkdir(parents=True)
# v1: install PostToolUse only
mock_integration.config = {"hooks": {
"PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60},
}}
manifest_v1 = IntegrationManifest("claude", tmp_path)
install_integration_hooks(mock_integration, tmp_path, manifest_v1, None)
settings = tmp_path / ".claude" / "settings.json"
data = json.loads(settings.read_text())
assert "PostToolUse" in data.get("hooks", {})
# v2: upgrade to PreToolUse + Stop (no PostToolUse)
mock_integration.config = {"hooks": {
"PreToolUse": {"command": "speckit.protected_paths", "matcher": "Edit|Write", "timeout": 10},
"Stop": {"command": "speckit.tdd.validate", "matcher": "*", "timeout": 30},
}}
manifest_v2 = IntegrationManifest("claude", tmp_path)
install_integration_hooks(mock_integration, tmp_path, manifest_v2, None)
data2 = json.loads(settings.read_text())
hooks_v2 = data2.get("hooks", {})
assert "PostToolUse" not in hooks_v2, "Stale PostToolUse should be removed on upgrade"
assert "PreToolUse" in hooks_v2
assert "Stop" in hooks_v2
def test_upgrade_preserves_user_hooks(self, tmp_path):
"""User-defined hooks survive upgrade."""
mock_integration = MagicMock()
mock_integration.key = "claude"
mock_integration.config = None
(tmp_path / ".specify").mkdir(parents=True)
# Pre-existing user hook
settings_dir = tmp_path / ".claude"
settings_dir.mkdir(parents=True)
(settings_dir / "settings.json").write_text(json.dumps({
"hooks": {
"PostToolUse": [{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "/usr/bin/my-linter.sh"}],
}],
},
}))
# Install our hook
mock_integration.config = {"hooks": {
"PostToolUse": {"command": "speckit.tdd.validate", "matcher": "Edit|Write", "timeout": 60},
}}
manifest = IntegrationManifest("claude", tmp_path)
install_integration_hooks(mock_integration, tmp_path, manifest, None)
data = json.loads((settings_dir / "settings.json").read_text())
post_hooks = data["hooks"]["PostToolUse"]
assert len(post_hooks) == 2 # user hook + our hook
# Upgrade: change our hook to PreToolUse only
mock_integration.config = {"hooks": {
"PreToolUse": {"command": "speckit.protected_paths", "matcher": "Edit|Write", "timeout": 10},
}}
manifest_v2 = IntegrationManifest("claude", tmp_path)
install_integration_hooks(mock_integration, tmp_path, manifest_v2, None)
data2 = json.loads((settings_dir / "settings.json").read_text())
hooks2 = data2.get("hooks", {})
# User's PostToolUse hook should survive
assert "PostToolUse" in hooks2
user_entry = hooks2["PostToolUse"][0]
assert user_entry["matcher"] == "Bash"
# Our new PreToolUse should be present
assert "PreToolUse" in hooks2