mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
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:
@@ -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,
|
||||||
|
|||||||
36
src/specify_cli/commands/event.py
Normal file
36
src/specify_cli/commands/event.py
Normal 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")
|
||||||
@@ -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
769
src/specify_cli/events.py
Normal 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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"))
|
|
||||||
@@ -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])
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
374
tests/integrations/test_events.py
Normal file
374
tests/integrations/test_events.py
Normal 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
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user