mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
_merge_toml_fragment() and _remove_toml_entries() read the user's config.toml with bare read_text() calls, so a non-UTF-8 (or otherwise unreadable) file crashed install_integration_events() and remove_integration_events() with a raw UnicodeDecodeError — and the merge path regenerates the file from what it read, so it would have discarded the user's bytes had it not crashed first. Every JSON merge/remove path already goes through _load_user_json(), which skips on an unreadable file to preserve user content (#22). Abort the merge (returning False so the caller skips tracking, S5) and skip the teardown cleanup with a warning, leaving the user's bytes untouched in both directions. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2346 lines
100 KiB
Python
2346 lines
100 KiB
Python
"""Agent runtime events for integrations.
|
|
|
|
Provides:
|
|
- ``resolve_events`` — layered event resolution (CLI flag → YAML override → extension-declared → built-in).
|
|
- ``collect_extension_events`` — scan installed extension.yml files for ``events:``.
|
|
- ``install_integration_events`` / ``remove_integration_events`` — entry points called from ``IntegrationBase.setup()`` / ``teardown()``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import sys
|
|
import subprocess
|
|
import platform
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import yaml
|
|
|
|
if TYPE_CHECKING:
|
|
from .integrations.base import IntegrationBase
|
|
from .integrations.manifest import IntegrationManifest
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# -- Constants -------------------------------------------------------------
|
|
|
|
EVENTS_DISPATCHER_DIR = Path(".specify")
|
|
EVENTS_DISPATCHER_FILENAME = "events.py"
|
|
# POSIX-form (forward-slash) relative path so it matches manifest keys, which
|
|
# are always stored in POSIX form (record_file/record_existing normalize via
|
|
# .as_posix()). On Windows, str(Path(".specify")/"events.py") yields
|
|
# ".specify\\events.py", which never matched a manifest key, so the shared-
|
|
# dispatcher manifest-claim drop was skipped and uninstall(force=True) deleted
|
|
# the dispatcher another integration still depended on.
|
|
EVENTS_DISPATCHER_REL = (EVENTS_DISPATCHER_DIR / EVENTS_DISPATCHER_FILENAME).as_posix()
|
|
|
|
YAML_OVERRIDE_FILENAME = Path(".specify") / "integration-events.yml"
|
|
|
|
_SPECKIT_MARKER = "__speckit_event__"
|
|
|
|
# Buffer (seconds) added to the native hook timeout so the agent's outer cap
|
|
# fires after the dispatcher's inner subprocess timeout, letting the inner
|
|
# kill its child cleanly instead of being killed mid-flight (which orphans
|
|
# the grandchild script process). The dispatcher receives the raw seconds
|
|
# (no buffer); the native config field gets seconds + buffer (R2).
|
|
EVENT_TIMEOUT_BUFFER = 5
|
|
|
|
# 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.
|
|
|
|
Self-contained: it prefers `specify_cli` when the package is importable
|
|
(durable pip/pipx/uv-tool install) and falls back to an inline stdlib-only
|
|
resolver when Spec Kit is not installed at runtime — e.g. a one-time `uvx`
|
|
init whose environment is discarded after `specify init` finishes (R1). In
|
|
both cases it resolves the event's command template and runs its script
|
|
directly, without requiring a persistent `specify` executable on PATH.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def _find_command_template(command_name, project_root):
|
|
"""Locate the command's .md template. Returns (path, ext_id|None)."""
|
|
exts_dir = project_root / ".specify" / "extensions"
|
|
disabled_ids = set()
|
|
registry_file = exts_dir / ".registry"
|
|
if registry_file.is_file():
|
|
try:
|
|
reg_data = json.loads(registry_file.read_text(encoding="utf-8"))
|
|
for ext_id, meta in reg_data.get("extensions", {}).items():
|
|
if isinstance(meta, dict) and meta.get("enabled") is False:
|
|
disabled_ids.add(ext_id)
|
|
except Exception:
|
|
pass
|
|
|
|
stem = command_name.replace("speckit.", "").replace("spec.", "")
|
|
|
|
# 1. Manifest-driven resolution from extension.yml in enabled extensions (Suppressed #1)
|
|
if exts_dir.is_dir():
|
|
for ext_dir in sorted(exts_dir.iterdir()):
|
|
if not ext_dir.is_dir() or ext_dir.name in disabled_ids:
|
|
continue
|
|
ext_yml = ext_dir / "extension.yml"
|
|
if ext_yml.is_file():
|
|
try:
|
|
yml_text = ext_yml.read_text(encoding="utf-8")
|
|
cur_name = None
|
|
cur_file = None
|
|
in_provides = False
|
|
in_commands = False
|
|
for line in yml_text.splitlines():
|
|
stripped = line.strip()
|
|
if stripped == "provides:":
|
|
in_provides = True
|
|
continue
|
|
if in_provides and stripped == "commands:":
|
|
in_commands = True
|
|
continue
|
|
if in_commands and stripped and not line[0].isspace():
|
|
in_provides = False
|
|
in_commands = False
|
|
continue
|
|
if in_commands:
|
|
if "name:" in line:
|
|
cur_name = line.split("name:", 1)[1].strip().strip('"').strip("'")
|
|
if "file:" in line:
|
|
cur_file = line.split("file:", 1)[1].strip().strip('"').strip("'")
|
|
if cur_name and cur_file:
|
|
if cur_name == command_name:
|
|
candidate = ext_dir / cur_file
|
|
if candidate.exists():
|
|
return candidate, ext_dir.name
|
|
cur_name = None
|
|
cur_file = None
|
|
except Exception:
|
|
pass
|
|
|
|
# 2. On-disk extension commands by file stem (non-disabled extensions)
|
|
if exts_dir.is_dir():
|
|
for ext_dir in sorted(exts_dir.iterdir()):
|
|
if not ext_dir.is_dir() or ext_dir.name in disabled_ids:
|
|
continue
|
|
cmds_dir = ext_dir / "commands"
|
|
if cmds_dir.is_dir():
|
|
for f in cmds_dir.glob("*.md"):
|
|
if f.stem == command_name or f.stem == stem:
|
|
return f, ext_dir.name
|
|
|
|
# 3. Core templates in the project
|
|
core = project_root / ".specify" / "templates" / "commands"
|
|
if core.is_dir():
|
|
candidate = core / (stem + ".md")
|
|
if candidate.exists():
|
|
return candidate, None
|
|
return None, None
|
|
|
|
|
|
def _script_variant(project_root):
|
|
"""Return the project's persisted script type ('sh'|'ps'|'py')."""
|
|
default = "ps" if os.name == "nt" else "sh"
|
|
init_opts = project_root / ".specify" / "init-options.json"
|
|
try:
|
|
data = json.loads(init_opts.read_text(encoding="utf-8"))
|
|
script = data.get("script")
|
|
if script in ("sh", "ps", "py"):
|
|
return script
|
|
except Exception:
|
|
pass
|
|
return default
|
|
|
|
|
|
def _extract_scripts(template_path):
|
|
"""Parse the scripts: block from a command template's frontmatter."""
|
|
try:
|
|
content = template_path.read_text(encoding="utf-8")
|
|
except Exception:
|
|
return {}
|
|
m = re.match(r"^---\\n(.*?)\\n---", content, re.DOTALL)
|
|
if not m:
|
|
return {}
|
|
scripts = {}
|
|
in_scripts = False
|
|
for line in m.group(1).splitlines():
|
|
if line.rstrip() == "scripts:":
|
|
in_scripts = True
|
|
continue
|
|
if in_scripts and line and not line[0].isspace():
|
|
break
|
|
if in_scripts and ":" in line:
|
|
k, _, v = line.partition(":")
|
|
scripts[k.strip()] = v.strip()
|
|
return scripts
|
|
|
|
|
|
def _resolve_argv(template_path, project_root, ext_id):
|
|
"""Resolve the command's script to a runnable argv (stdlib only)."""
|
|
scripts = _extract_scripts(template_path)
|
|
if not scripts:
|
|
return None
|
|
requested = _script_variant(project_root)
|
|
order = (requested,) if requested in scripts else ()
|
|
fallbacks = (requested, "ps" if requested != "ps" else "sh", "py", "sh")
|
|
seen = set()
|
|
for cand in order + fallbacks:
|
|
if cand in seen:
|
|
continue
|
|
seen.add(cand)
|
|
if cand in scripts:
|
|
variant = cand
|
|
break
|
|
else:
|
|
return None
|
|
script_cmd = scripts.get(variant, "").strip()
|
|
if not script_cmd:
|
|
return None
|
|
|
|
base = (project_root / ".specify" / "extensions" / ext_id) if ext_id else (project_root / ".specify")
|
|
try:
|
|
tokens = shlex.split(script_cmd, posix=(os.name != "nt"))
|
|
except ValueError:
|
|
return None
|
|
if not tokens:
|
|
return None
|
|
script_abs = base / tokens[0]
|
|
if not script_abs.exists():
|
|
return None
|
|
rest = tokens[1:]
|
|
|
|
if variant == "py":
|
|
# .py files aren't directly executable; run under the dispatcher's own
|
|
# Python (sys.executable), which is always available here.
|
|
return [sys.executable or "python3", str(script_abs), *rest]
|
|
if variant == "ps":
|
|
launcher = shutil.which("pwsh") or shutil.which("powershell")
|
|
if not launcher:
|
|
return None
|
|
return [launcher, "-File", str(script_abs), *rest]
|
|
# sh: direct on POSIX; a bash/sh launcher on Windows.
|
|
if os.name == "nt":
|
|
launcher = shutil.which("bash") or shutil.which("sh")
|
|
if launcher:
|
|
return [launcher, str(script_abs), *rest]
|
|
return None
|
|
return [str(script_abs), *rest]
|
|
|
|
|
|
def _run_inline(command_name, payload, project_root, timeout, envelope="plain", native_event=""):
|
|
"""Resolve and run the event command with stdlib only (no specify_cli)."""
|
|
template_path, ext_id = _find_command_template(command_name, project_root)
|
|
if not template_path:
|
|
return 0 # command not found: fail open (no-op) for lifecycle events
|
|
argv = _resolve_argv(template_path, project_root, ext_id)
|
|
if not argv:
|
|
return 0
|
|
try:
|
|
result = subprocess.run(
|
|
argv,
|
|
input=payload,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
cwd=str(project_root),
|
|
)
|
|
if result.stdout:
|
|
_emit(result.stdout, envelope, native_event)
|
|
if result.returncode != 0:
|
|
if result.stderr:
|
|
sys.stderr.write(result.stderr)
|
|
return result.returncode
|
|
return 0
|
|
except subprocess.TimeoutExpired:
|
|
print(f"Event {command_name} timed out", file=sys.stderr)
|
|
return 2
|
|
except Exception as e:
|
|
print(f"Event {command_name} error: {e}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
def _emit(output, envelope, native_event=""):
|
|
"""Write handler output to stdout in the agent's context-injection shape.
|
|
|
|
Not every agent injects a hook's plain-text stdout as model context:
|
|
Gemini/Tabnine/Qwen/Devin are JSON-only protocols (plain text becomes
|
|
user-facing noise, never context), Copilot discards non-JSON stdout, and
|
|
Cursor parses stdout as JSON. The native hook command passes the envelope
|
|
as the dispatcher's 5th argument (see events_context_envelope on the
|
|
integration classes), and the native event name as the 6th argument so
|
|
hookSpecificOutput can include hookEventName:
|
|
|
|
hookSpecificOutput → {"hookSpecificOutput": {"hookEventName": ..., "additionalContext": ...}}
|
|
additionalContext → {"additionalContext": ...} (top-level, Copilot)
|
|
additional_context → {"additional_context": ...} (top-level, Cursor)
|
|
suppress → emit nothing (strict-JSON agents on events whose
|
|
output can't be used)
|
|
plain (default) → passthrough (Claude/Codex inject plain stdout)
|
|
|
|
Empty output emits nothing under any envelope (an empty additionalContext
|
|
is useless noise).
|
|
"""
|
|
if not output:
|
|
return
|
|
if envelope == "suppress":
|
|
return
|
|
if envelope == "hookSpecificOutput":
|
|
payload = {"additionalContext": output}
|
|
if native_event:
|
|
payload["hookEventName"] = native_event
|
|
sys.stdout.write(json.dumps({"hookSpecificOutput": payload}) + "\\n")
|
|
return
|
|
if envelope == "additionalContext":
|
|
sys.stdout.write(json.dumps({"additionalContext": output}) + "\\n")
|
|
return
|
|
if envelope == "additional_context":
|
|
sys.stdout.write(json.dumps({"additional_context": output}) + "\\n")
|
|
return
|
|
sys.stdout.write(output)
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 3:
|
|
sys.exit(0)
|
|
command_name = sys.argv[1]
|
|
# event_name is accepted for argv-compat with the native hook command but
|
|
# is not needed for resolution (the command template drives everything).
|
|
_event_name = sys.argv[2]
|
|
# Optional 4th arg: per-handler timeout in seconds (S4).
|
|
timeout = 120
|
|
if len(sys.argv) >= 4:
|
|
try:
|
|
timeout = int(sys.argv[3])
|
|
except (TypeError, ValueError):
|
|
timeout = 120
|
|
# Optional 5th arg: context-injection envelope for stdout (C13): plain
|
|
# (default), hookSpecificOutput, additionalContext, additional_context,
|
|
# or suppress. Unknown values fall back to plain passthrough.
|
|
envelope = sys.argv[4] if len(sys.argv) >= 5 else "plain"
|
|
if envelope not in ("plain", "hookSpecificOutput", "additionalContext", "additional_context", "suppress"):
|
|
envelope = "plain"
|
|
# Optional 6th arg: native event name for hookSpecificOutput's
|
|
# hookEventName field (required by Qwen's hooks spec; included by
|
|
# Gemini/Tabnine/Devin which derive from the same protocol).
|
|
native_event = sys.argv[5] if len(sys.argv) >= 6 else ""
|
|
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
|
|
project_root = Path(__file__).parent.parent.resolve()
|
|
|
|
# Preferred path: specify_cli is importable (durable install) — delegate to
|
|
# the full resolver, which also handles extension manifests whose file stem
|
|
# differs from the command name and the project's custom script selection.
|
|
try:
|
|
from specify_cli.events import resolve_and_run_event_command
|
|
sys.exit(
|
|
resolve_and_run_event_command(
|
|
command_name, _event_name, payload, project_root, timeout=timeout, envelope=envelope, native_event=native_event
|
|
)
|
|
)
|
|
except (ImportError, TypeError):
|
|
pass
|
|
|
|
# Fallback: self-contained stdlib resolver (one-time/temporary installs).
|
|
sys.exit(_run_inline(command_name, payload, project_root, timeout, envelope, native_event))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
'''
|
|
|
|
# -- TS plugin template (opencode) ----------------------------------------
|
|
|
|
_TS_PLUGIN_TEMPLATE = '''import {{ execFileSync }} from 'child_process';
|
|
import * as path from 'path';
|
|
|
|
// The dispatcher + interpreter are resolved per-project at plugin load from
|
|
// the `directory` OpenCode passes to the plugin factory (C8), not
|
|
// process.cwd() — OpenCode may be launched from a parent directory or host
|
|
// another workspace, in which case process.cwd() points at the wrong project.
|
|
let DISPATCHER = '';
|
|
let INTERPRETER = '';
|
|
|
|
function canImportSpecifyCli(py: string): boolean {{
|
|
// R2: a project-local venv commonly lacks Spec Kit (installed globally or
|
|
// via uv tool). Probe the interpreter can import specify_cli before
|
|
// selecting it, so an unrelated venv doesn't shadow the PATH fallback.
|
|
try {{
|
|
execFileSync(py, ['-c', 'import specify_cli'], {{
|
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
timeout: 10000,
|
|
}});
|
|
return true;
|
|
}} catch (e) {{
|
|
return false;
|
|
}}
|
|
}}
|
|
|
|
function resolveDispatcher(directory: string): void {{
|
|
DISPATCHER = path.join(directory, '.specify', 'events.py');
|
|
// Prefer a project-local venv interpreter that can import specify_cli (R2),
|
|
// then fall back to a platform-appropriate PATH interpreter (S2: python on
|
|
// Windows, where python3 is commonly absent; python3 on POSIX).
|
|
const venvPy = path.join(directory, '.venv', 'bin', 'python');
|
|
const venvWin = path.join(directory, '.venv', 'Scripts', 'python.exe');
|
|
INTERPRETER = (
|
|
(require('fs').existsSync(venvPy) && canImportSpecifyCli(venvPy) && venvPy) ||
|
|
(require('fs').existsSync(venvWin) && canImportSpecifyCli(venvWin) && venvWin) ||
|
|
(process.platform === 'win32' ? 'python' : 'python3')
|
|
) as string;
|
|
}}
|
|
|
|
function runEvent(command: string, event: string, input: any, output: any, timeoutSec: number): string {{
|
|
if (!DISPATCHER) return '';
|
|
try {{
|
|
// execFileSync with an argv array invokes the interpreter directly — no
|
|
// shell — so command/event strings with metacharacters can't break out
|
|
// of the dispatcher argument (C9). The dispatcher arg is seconds; the
|
|
// execFileSync timeout is ms with a buffer so the outer cap fires after
|
|
// the dispatcher's inner subprocess (S3). stdout is captured and
|
|
// returned so context-injection hooks (experimental.chat.system.transform,
|
|
// chat.message) can push it into their outputs; stderr stays inherited so
|
|
// dispatcher errors remain visible (C11).
|
|
return execFileSync(INTERPRETER, [DISPATCHER, command, event, String(timeoutSec)], {{
|
|
input: JSON.stringify({{ input, output }}),
|
|
stdio: ['pipe', 'pipe', 'inherit'],
|
|
encoding: 'utf-8',
|
|
timeout: (timeoutSec + {buffer}) * 1000,
|
|
}});
|
|
}} catch (e) {{
|
|
// Propagate to OpenCode's hook machinery so only this hook is rejected,
|
|
// not the entire host process. process.exit() would kill the agent.
|
|
throw new Error(`specify event ${{command}} (${{event}}) failed: ${{(e as Error).message}}`);
|
|
}}
|
|
}}
|
|
|
|
// Cache session_start handler output per sessionID so non-idempotent
|
|
// handlers (setup, telemetry, file-mutating scripts) run once per session
|
|
// instead of on every LLM request (experimental.chat.system.transform
|
|
// fires per LLM turn). Evicted on session.deleted.
|
|
const sessionStartCache = new Map<string, string>();
|
|
|
|
{event_entries}
|
|
|
|
export default (async ({{ client, project, directory, $ }}) => {{
|
|
resolveDispatcher(directory);
|
|
return {{
|
|
{plugin_returns}
|
|
}};
|
|
}});
|
|
'''
|
|
|
|
|
|
# -- Command runner logic (core) --------------------------------------------
|
|
|
|
def _find_command_template(command_name: str, project_root: Path) -> tuple[Path | None, str | None]:
|
|
# 1. Resolve via installed extension manifests (authoritative). The
|
|
# registry stores per-agent ``registered_commands`` name-lists, not a
|
|
# ``{name, file}`` map, so the command→file mapping lives only in each
|
|
# extension's ``extension.yml`` ``provides.commands`` (S8). Match the
|
|
# command name to its declared ``file`` so commands whose file stem
|
|
# differs from the command name (e.g. ``speckit.selftest.extension`` →
|
|
# ``commands/selftest.md``) resolve correctly.
|
|
exts_dir = project_root / ".specify" / "extensions"
|
|
# S1: build the set of explicitly-disabled extension IDs so dispatch skips
|
|
# disabled extensions (a stale hook would otherwise keep executing a
|
|
# disabled extension's command). Applied to both the manifest loop and the
|
|
# on-disk fallback below.
|
|
disabled_ids = _disabled_extension_ids(project_root)
|
|
try:
|
|
from .extensions import ExtensionManager
|
|
manager = ExtensionManager(project_root)
|
|
for ext_id in sorted(manager.registry.keys()):
|
|
if ext_id in disabled_ids:
|
|
continue
|
|
manifest = manager.get_extension(ext_id)
|
|
if manifest is None:
|
|
continue
|
|
for cmd in manifest.commands:
|
|
if not isinstance(cmd, dict):
|
|
continue
|
|
if cmd.get("name") == command_name and cmd.get("file"):
|
|
candidate = exts_dir / ext_id / cmd["file"]
|
|
if candidate.exists():
|
|
return candidate, ext_id
|
|
except Exception:
|
|
# Fall through to the on-disk scan if the registry/manifests can't be
|
|
# read; event dispatch should degrade gracefully, not crash.
|
|
pass
|
|
|
|
# 2. Scan extension directories by file stem (covers extensions present on
|
|
# disk but not resolvable via the manifest above). S1: skip disabled
|
|
# extensions here too so the disk fallback can't re-enable them.
|
|
if exts_dir.is_dir():
|
|
for ext_dir in sorted(exts_dir.iterdir()):
|
|
if ext_dir.name in disabled_ids:
|
|
continue
|
|
cmds_dir = ext_dir / "commands"
|
|
if cmds_dir.is_dir():
|
|
for f in cmds_dir.glob("*.md"):
|
|
if f.stem == command_name:
|
|
return f, ext_dir.name
|
|
|
|
# 3. Check core templates in the project
|
|
core = project_root / ".specify" / "templates" / "commands"
|
|
if core.is_dir():
|
|
stem = command_name.replace("speckit.", "").replace("spec.", "")
|
|
candidate = core / f"{stem}.md"
|
|
if candidate.exists():
|
|
return candidate, None
|
|
|
|
# 4. Fallback to package-bundled templates via the canonical asset
|
|
# resolvers (wheel: core_pack/commands; source: repo-root
|
|
# templates/commands). The previous bespoke inspect.getfile() math
|
|
# pointed at core_pack/templates/commands, which never exists in a
|
|
# wheel build (force-include maps templates/commands -> core_pack/commands).
|
|
from ._assets import _locate_core_pack, _repo_root
|
|
core_pack = _locate_core_pack()
|
|
candidate_dirs = [
|
|
core_pack / "commands" if core_pack is not None else None,
|
|
_repo_root() / "templates" / "commands",
|
|
]
|
|
stem = command_name.replace("speckit.", "").replace("spec.", "")
|
|
for candidate_dir in candidate_dirs:
|
|
if candidate_dir is None or not candidate_dir.is_dir():
|
|
continue
|
|
candidate = candidate_dir / f"{stem}.md"
|
|
if candidate.exists():
|
|
return candidate, None
|
|
|
|
return None, None
|
|
|
|
|
|
def _resolve_event_command_argv(
|
|
template_path: Path, project_root: Path, ext_id: str | None
|
|
) -> list[str] | None:
|
|
"""Resolve a command template's ``scripts:`` entry to a runnable argv.
|
|
|
|
``scripts:`` values are command strings (e.g. ``scripts/bash/setup-plan.sh --json``),
|
|
not bare paths, so joining the whole value into a ``Path`` made ``exists()``
|
|
false and real commands silently no-op'd. This resolves the stored variant
|
|
(honoring the project's sh/ps/py selection), splits the command string
|
|
safely into argv, and prepends the appropriate interpreter (Python for
|
|
``.py``, the platform shell otherwise). Returns ``None`` if no runnable
|
|
script is declared.
|
|
"""
|
|
from .integrations.base import IntegrationBase
|
|
|
|
content = template_path.read_text(encoding="utf-8")
|
|
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
|
if not m:
|
|
return None
|
|
fm = m.group(1)
|
|
try:
|
|
fm_data = yaml.safe_load(fm) or {}
|
|
except Exception:
|
|
return None
|
|
if not isinstance(fm_data, dict):
|
|
return None
|
|
scripts = fm_data.get("scripts", {})
|
|
if not isinstance(scripts, dict):
|
|
return None
|
|
# Determine the requested variant from the project's persisted selection,
|
|
# falling back to the platform default — same logic MarkdownIntegration
|
|
# uses for command scaffolding.
|
|
requested = _load_project_script_type(project_root)
|
|
try:
|
|
variant = IntegrationBase.select_script_variant(requested, scripts)
|
|
except ValueError:
|
|
return None
|
|
script_cmd = scripts.get(variant)
|
|
if not isinstance(script_cmd, str) or not script_cmd.strip():
|
|
return None
|
|
|
|
# Base under which the script's leading path component is anchored —
|
|
# .specify/ (core) or .specify/extensions/<id>/ (extension). All variants
|
|
# share this anchoring so a `scripts/...` token resolves correctly (S2:
|
|
# the py branch previously invoked build_python_invocation() on the raw
|
|
# command string, leaving `scripts/...` anchored at the project root).
|
|
if ext_id:
|
|
base = project_root / ".specify" / "extensions" / ext_id
|
|
else:
|
|
base = project_root / ".specify"
|
|
|
|
try:
|
|
tokens = shlex.split(script_cmd, posix=(os.name != "nt"))
|
|
except ValueError:
|
|
# Mirror the generated dispatcher's _resolve_argv: a scripts: value
|
|
# shlex cannot tokenize (e.g. an unclosed quote) declares no runnable
|
|
# script, so degrade to "no argv" instead of raising.
|
|
return None
|
|
if not tokens:
|
|
return None
|
|
script_abs = base / tokens[0]
|
|
if not script_abs.exists():
|
|
return None
|
|
rest_args = tokens[1:]
|
|
|
|
if variant == "py":
|
|
# .py files aren't directly executable on Windows; prefix the resolved
|
|
# interpreter. argv is passed to subprocess.run(shell=False), so no
|
|
# shell quoting is needed.
|
|
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
|
|
return [interpreter, str(script_abs), *rest_args]
|
|
|
|
if variant == "ps":
|
|
# PowerShell scripts cannot be executed directly by
|
|
# subprocess.run(shell=False); invoke via `pwsh -File` (PowerShell 7+),
|
|
# falling back to `powershell -File` (Windows PowerShell) when pwsh is
|
|
# absent (S6). The default Windows script type would otherwise fail.
|
|
launcher = shutil.which("pwsh") or shutil.which("powershell") or "pwsh"
|
|
return [launcher, "-File", str(script_abs), *rest_args]
|
|
|
|
# sh: the script is chmod'd executable during install on POSIX. On Windows
|
|
# subprocess.run(shell=False) can't execute a .sh directly, so prefix a
|
|
# bash/sh launcher when one is available (mirroring the ps branch's
|
|
# pwsh -File handling, S5).
|
|
if os.name == "nt":
|
|
launcher = shutil.which("bash") or shutil.which("sh")
|
|
if launcher:
|
|
return [launcher, str(script_abs), *rest_args]
|
|
return [str(script_abs), *rest_args]
|
|
|
|
|
|
def _load_project_script_type(project_root: Path) -> str:
|
|
"""Return the project's persisted script type ('sh'|'ps'|'py').
|
|
|
|
Falls back to the platform default when init-options are absent or
|
|
unreadable so event dispatch still works in a partially-initialized
|
|
project.
|
|
"""
|
|
default = "ps" if platform.system().lower().startswith("win") else "sh"
|
|
try:
|
|
from ._init_options import load_init_options
|
|
opts = load_init_options(project_root)
|
|
if isinstance(opts, dict):
|
|
script = opts.get("script")
|
|
if isinstance(script, str) and script in ("sh", "ps", "py"):
|
|
return script
|
|
except Exception:
|
|
pass
|
|
return default
|
|
|
|
|
|
def resolve_and_run_event_command(
|
|
command_name: str,
|
|
event_name: str,
|
|
payload: str,
|
|
project_root: Path,
|
|
*,
|
|
timeout: int = 120,
|
|
envelope: str = "plain",
|
|
native_event: str = "",
|
|
) -> int:
|
|
"""Core entry point to resolve and execute an event-driven command.
|
|
|
|
*timeout* is the per-handler timeout in seconds, passed through from the
|
|
native hook config via the dispatcher (S4) so a handler configured above
|
|
the previous fixed 120s cap can run for its full duration.
|
|
|
|
*envelope* selects how the handler's stdout is emitted for the agent's
|
|
context-injection protocol (C13): ``plain`` passthrough (Claude/Codex
|
|
inject plain stdout), ``hookSpecificOutput``/``additionalContext``/
|
|
``additional_context`` JSON wrappers (Gemini/Tabnine/Qwen/Devin, Copilot,
|
|
Cursor respectively), or ``suppress`` (strict-JSON agents on events whose
|
|
output can't be used).
|
|
|
|
*native_event* is the agent's native hookEventName (e.g. ``"SessionStart"``),
|
|
required inside ``hookSpecificOutput`` by Qwen's hooks spec (and included
|
|
by the Claude Code hooks spec Gemini/Tabnine/Devin derive from). Only
|
|
used when *envelope* is ``hookSpecificOutput``.
|
|
"""
|
|
template_path, ext_id = _find_command_template(command_name, project_root)
|
|
if not template_path:
|
|
logger.warning("Event command '%s' not found", command_name)
|
|
return 0
|
|
argv = _resolve_event_command_argv(template_path, project_root, ext_id)
|
|
if not argv:
|
|
logger.warning("No script found for event command '%s'", command_name)
|
|
return 0
|
|
try:
|
|
result = subprocess.run(
|
|
argv,
|
|
input=payload,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
cwd=str(project_root),
|
|
)
|
|
if result.stdout:
|
|
_emit_event_stdout(result.stdout, envelope, native_event)
|
|
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
|
|
|
|
|
|
def _emit_event_stdout(output: str, envelope: str, native_event: str = "") -> None:
|
|
"""Write handler stdout in the agent's context-injection shape (C13).
|
|
|
|
Mirrors the ``_emit`` helper inside the generated dispatcher template;
|
|
keep both in sync. Empty output emits nothing under any envelope.
|
|
|
|
*native_event* is the agent's native hookEventName, required inside
|
|
``hookSpecificOutput`` by Qwen's hooks spec (and included by the
|
|
Claude Code hooks spec Gemini/Tabnine/Devin derive from).
|
|
"""
|
|
if not output:
|
|
return
|
|
if envelope == "suppress":
|
|
return
|
|
if envelope == "hookSpecificOutput":
|
|
payload = {"additionalContext": output}
|
|
if native_event:
|
|
payload["hookEventName"] = native_event
|
|
sys.stdout.write(json.dumps({"hookSpecificOutput": payload}) + "\n")
|
|
return
|
|
if envelope == "additionalContext":
|
|
sys.stdout.write(json.dumps({"additionalContext": output}) + "\n")
|
|
return
|
|
if envelope == "additional_context":
|
|
sys.stdout.write(json.dumps({"additional_context": output}) + "\n")
|
|
return
|
|
sys.stdout.write(output)
|
|
|
|
|
|
# -- Sourcing events map (CLI/Orchestration domain) -------------------------
|
|
|
|
# Resolved events map: each canonical event name maps to an *ordered list* of
|
|
# handler configs. Built-in defaults and per-extension declarations both
|
|
# contribute, so two extensions declaring ``session_start`` both run (finding
|
|
# #2) instead of the last one silently winning.
|
|
ResolvedEvents = dict[str, list[dict[str, Any]]]
|
|
|
|
|
|
def _normalize_handlers(value: Any) -> list[dict[str, Any]]:
|
|
"""Coerce a single handler config or a list of them into a validated list.
|
|
|
|
Accepts both the legacy single-mapping shape (``{command: ...}``) and the
|
|
explicit list shape (``[{command: ...}, ...]``). Drops any entry that is
|
|
not a mapping or lacks a ``command`` with a warning, so a malformed user
|
|
override never reaches installation and crashes on ``cfg.get(...)`` (#21).
|
|
"""
|
|
if isinstance(value, dict):
|
|
value = [value]
|
|
if not isinstance(value, list):
|
|
return []
|
|
handlers: list[dict[str, Any]] = []
|
|
for entry in value:
|
|
if not isinstance(entry, dict):
|
|
logger.warning("Skipping malformed event handler (expected a mapping): %r", entry)
|
|
continue
|
|
handlers.append(entry)
|
|
return handlers
|
|
|
|
|
|
def _validate_resolved_event(event_name: str, handlers: list[dict[str, Any]]) -> None:
|
|
"""Validate a resolved event's handlers, raising a user-facing error.
|
|
|
|
Raised for structural problems the user must fix (unknown event name,
|
|
handler missing a ``command``, or ``command`` not a non-empty string per
|
|
#17). Malformed-but-skipable entries are already dropped by
|
|
``_normalize_handlers``.
|
|
"""
|
|
from .extensions import ValidationError
|
|
|
|
if event_name not in CANONICAL_EVENTS:
|
|
raise ValidationError(
|
|
f"Unknown event '{event_name}': must be one of {sorted(CANONICAL_EVENTS)}"
|
|
)
|
|
for handler in handlers:
|
|
command = handler.get("command")
|
|
if not isinstance(command, str) or not command.strip():
|
|
raise ValidationError(
|
|
f"Event '{event_name}' handler missing required non-empty 'command' string"
|
|
)
|
|
# C10: matcher must be a string (or absent). A non-string matcher such
|
|
# as `matcher: []` passes extension validation but later crashes
|
|
# by_matcher.setdefault(matcher, ...) with TypeError: unhashable type.
|
|
matcher = handler.get("matcher")
|
|
if matcher is not None and not isinstance(matcher, str):
|
|
raise ValidationError(
|
|
f"Event '{event_name}' handler has invalid 'matcher': "
|
|
"must be a string"
|
|
)
|
|
timeout = handler.get("timeout")
|
|
if timeout is not None:
|
|
if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout <= 0:
|
|
raise ValidationError(
|
|
f"Event '{event_name}' handler has invalid 'timeout': must be a positive integer"
|
|
)
|
|
|
|
|
|
def resolve_events(
|
|
integration_key: str,
|
|
integration_config: dict[str, Any] | None,
|
|
project_root: Path,
|
|
parsed_options: dict[str, Any] | None,
|
|
) -> ResolvedEvents:
|
|
"""Resolve the final event set for an integration.
|
|
|
|
Returns a mapping of canonical event name → ordered list of handler
|
|
configs. Layers (lowest → highest precedence):
|
|
|
|
1. CLI gate ``--events false`` → empty map (caller still removes prior
|
|
native hooks; see ``install_integration_events``).
|
|
2. Built-in defaults from ``integration_config["events"]`` (single-config
|
|
per event, wrapped as one-element lists).
|
|
3. Extension-declared ``events:`` — appended per extension so multiple
|
|
extensions can declare the same event (#2).
|
|
4. User YAML override (``.specify/integration-events.yml``) — replaces the
|
|
accumulated set entirely when the integration key is present. Validated
|
|
(#21) before returning; a malformed override is warned about and
|
|
ignored rather than crashing downstream.
|
|
"""
|
|
# Layer 1: CLI flag gate
|
|
if parsed_options:
|
|
events_flag = str(parsed_options.get("events", "true")).lower()
|
|
if events_flag in ("false", "0", "no", "off"):
|
|
return {}
|
|
|
|
events: ResolvedEvents = {}
|
|
|
|
# Layer 2: built-in defaults from integration config
|
|
if integration_config and isinstance(integration_config.get("events"), dict):
|
|
for ev, cfg in integration_config["events"].items():
|
|
handlers = _normalize_handlers(cfg)
|
|
if handlers:
|
|
events.setdefault(ev, []).extend(handlers)
|
|
|
|
# Layer 3: extension-declared events (accumulated, not overwriting)
|
|
for ev, handlers in collect_extension_events(project_root).items():
|
|
events.setdefault(ev, []).extend(handlers)
|
|
|
|
# Layer 4: user YAML override (replaces entirely if key present)
|
|
override_file = project_root / YAML_OVERRIDE_FILENAME
|
|
if override_file.exists():
|
|
try:
|
|
override = yaml.safe_load(override_file.read_text(encoding="utf-8")) or {}
|
|
except (OSError, UnicodeError, yaml.YAMLError):
|
|
logger.warning(
|
|
"Could not read or parse %s; ignoring override", override_file
|
|
)
|
|
override = {}
|
|
integrations = override.get("integrations", {}) if isinstance(override, dict) else {}
|
|
if isinstance(integrations, dict) and integration_key in integrations:
|
|
key_data = integrations[integration_key]
|
|
if not isinstance(key_data, dict):
|
|
# C6: a non-mapping integration entry (e.g. `claude: bad`) must
|
|
# not be treated as a valid explicit disable. Warn and abandon
|
|
# the override, keeping the accumulated built-in + extension
|
|
# layers. Only an explicitly present, mapping-valued `events`
|
|
# field replaces the prior layers.
|
|
logger.warning(
|
|
"Override %s: entry for '%s' is not a mapping; ignoring override",
|
|
override_file, integration_key,
|
|
)
|
|
else:
|
|
key_events = key_data.get("events", {})
|
|
if not isinstance(key_events, dict):
|
|
logger.warning(
|
|
"Override %s: 'events' for '%s' is not a mapping; ignoring override",
|
|
override_file, integration_key,
|
|
)
|
|
else:
|
|
# Validate every entry before adopting the override. A single
|
|
# invalid entry abandons the whole override and keeps the
|
|
# accumulated built-in + extension layers (#10): previously a
|
|
# typo reset resolved_override to {} and then assigned that
|
|
# empty map to events, silently disabling all hooks despite
|
|
# the "ignored" warning. Only a fully-valid override (including
|
|
# an explicit `events: {}`) replaces the prior layers.
|
|
resolved_override: ResolvedEvents = {}
|
|
override_valid = True
|
|
for ev, raw in key_events.items():
|
|
handlers = _normalize_handlers(raw)
|
|
if not handlers:
|
|
# C4: a malformed handler (e.g. `stop: []` or
|
|
# `stop: bad-value`) normalizes to no handlers.
|
|
# Abandon the whole override (keep prior layers)
|
|
# rather than skipping the entry — otherwise an
|
|
# override whose only entry is malformed silently
|
|
# disabled every built-in and extension hook. An
|
|
# explicit `events: {}` (no entries) remains a
|
|
# valid disable.
|
|
logger.warning(
|
|
"Override %s: event '%s' has no valid handler; ignoring entire override",
|
|
override_file, ev,
|
|
)
|
|
override_valid = False
|
|
break
|
|
try:
|
|
_validate_resolved_event(ev, handlers)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Override %s: invalid event '%s': %s; ignoring entire override",
|
|
override_file, ev, exc,
|
|
)
|
|
override_valid = False
|
|
break
|
|
resolved_override[ev] = handlers
|
|
if override_valid:
|
|
events = resolved_override
|
|
# else: keep the accumulated built-in + extension layers.
|
|
|
|
return events
|
|
|
|
|
|
def _disabled_extension_ids(project_root: Path) -> set[str]:
|
|
"""Return the set of explicitly-disabled extension IDs.
|
|
|
|
Extensions not tracked in the registry are treated as enabled (backward
|
|
compat). Used by ``collect_extension_events`` and ``_find_command_template``
|
|
so a disabled extension's events and commands are never emitted or
|
|
executed (S1) — otherwise a stale native hook would keep running a
|
|
disabled extension after its config file was preserved (e.g. a JSONC
|
|
parse failure that skipped native cleanup).
|
|
"""
|
|
from .extensions import ExtensionRegistry
|
|
|
|
exts_dir = project_root / ".specify" / "extensions"
|
|
disabled_ids: set[str] = set()
|
|
if not exts_dir.is_dir():
|
|
return disabled_ids
|
|
try:
|
|
registry = ExtensionRegistry(exts_dir)
|
|
for ext_id, meta in registry.list_by_priority(include_disabled=True):
|
|
if not isinstance(meta, dict) or not meta.get("enabled", True):
|
|
disabled_ids.add(ext_id)
|
|
except Exception:
|
|
pass
|
|
return disabled_ids
|
|
|
|
|
|
def collect_extension_events(project_root: Path) -> ResolvedEvents:
|
|
"""Scan all installed extensions for ``events:`` declarations.
|
|
|
|
Returns a mapping of event name → list of handler configs. Multiple
|
|
extensions declaring the same event each contribute a handler (in
|
|
extension-directory sort order), so callers can emit all of them (#2).
|
|
|
|
Honors the extension registry's ``enabled`` flag (#1): an explicitly
|
|
disabled extension's events are skipped so disabling an extension actually
|
|
deactivates its runtime hooks. Extensions absent from the registry (e.g.
|
|
a partially-staged install) are still included to preserve the on-disk
|
|
scan behavior.
|
|
|
|
Events are read from a validated ``ExtensionManifest`` (R1) rather than
|
|
the raw ``extension.yml`` YAML, so the command-reference canonicalization
|
|
applied during install validation (C11, e.g. ``my-ext.boot`` →
|
|
``speckit.my-ext.boot``) is reflected — otherwise refresh would emit the
|
|
obsolete name and ``_find_command_template`` could not match it, leaving
|
|
the hook silently inert.
|
|
"""
|
|
from .extensions import ExtensionManager
|
|
|
|
events: ResolvedEvents = {}
|
|
exts_dir = project_root / ".specify" / "extensions"
|
|
if not exts_dir.is_dir():
|
|
return events
|
|
|
|
manager = ExtensionManager(project_root)
|
|
|
|
# Build the set of explicitly-disabled extension IDs. Extensions not
|
|
# tracked in the registry are treated as enabled (backward compat).
|
|
disabled_ids = _disabled_extension_ids(project_root)
|
|
|
|
# Union of extension IDs to consider: registry-tracked IDs (validated
|
|
# manifests, canonicalized refs) plus on-disk dirs not yet in the registry
|
|
# (partially-staged installs). The latter fall back to the raw YAML since
|
|
# no validated manifest is available, preserving the on-disk scan behavior.
|
|
registry_ids = set()
|
|
try:
|
|
registry_ids = set(manager.registry.keys())
|
|
except Exception:
|
|
pass
|
|
on_disk_ids = {
|
|
d.name for d in exts_dir.iterdir() if d.is_dir() and (d / "extension.yml").exists()
|
|
}
|
|
for ext_id in sorted(registry_ids | on_disk_ids):
|
|
if ext_id in disabled_ids:
|
|
continue
|
|
# Prefer the validated manifest (canonicalized command refs, R1);
|
|
# fall back to the raw YAML for an on-disk extension not yet
|
|
# registered (a malformed extension shouldn't abort collection).
|
|
runtime: dict[str, Any] = {}
|
|
if ext_id in registry_ids:
|
|
try:
|
|
manifest = manager.get_extension(ext_id)
|
|
except Exception:
|
|
manifest = None
|
|
if manifest is not None:
|
|
runtime = manifest.data.get("events", {}) or {}
|
|
if not runtime:
|
|
ext_yml = exts_dir / ext_id / "extension.yml"
|
|
if not ext_yml.exists():
|
|
continue
|
|
try:
|
|
data = yaml.safe_load(ext_yml.read_text(encoding="utf-8")) or {}
|
|
except (UnicodeDecodeError, yaml.YAMLError):
|
|
continue
|
|
if not isinstance(data, dict):
|
|
continue
|
|
runtime = data.get("events", {}) or {}
|
|
if not isinstance(runtime, dict):
|
|
continue
|
|
for event, config in runtime.items():
|
|
handlers = _normalize_handlers(config)
|
|
if handlers:
|
|
events.setdefault(event, []).extend(handlers)
|
|
return events
|
|
|
|
|
|
# -- Writing/Merging Config (Integration domain) ---------------------------
|
|
|
|
def _resolve_interpreter(project_root: Path) -> str:
|
|
"""Resolve a portable Python interpreter for native hook commands (#16).
|
|
|
|
Delegates to ``IntegrationBase.resolve_python_interpreter`` so generated
|
|
commands honor the project venv and never hard-code ``python3`` (which is
|
|
commonly absent on Windows even when ``py.exe``/``python.exe`` exist).
|
|
"""
|
|
from .integrations.base import IntegrationBase
|
|
return IntegrationBase.resolve_python_interpreter(project_root)
|
|
|
|
|
|
def _resolve_interpreter_for_target(target_os: str) -> str:
|
|
"""Resolve a Python interpreter for a target OS, independent of the host (#S4).
|
|
|
|
Copilot's native config carries both a ``bash`` (POSIX) and a
|
|
``powershell`` (Windows) variant in the same checked-in file. Resolving
|
|
both with the *host* interpreter writes a Linux venv path into the
|
|
PowerShell hook (or vice-versa), so the config fails on the other OS.
|
|
Each variant instead gets a portable interpreter for its target shell;
|
|
the dispatcher script's own ``_find_specify()`` does per-OS venv
|
|
resolution at runtime.
|
|
"""
|
|
if target_os == "windows":
|
|
# Windows: ``python`` is the most portable on PATH; the py launcher
|
|
# (``py -3``) is the recommended fallback when ``python`` is absent.
|
|
return "python"
|
|
# POSIX (bash): ``python3`` is universally available.
|
|
return "python3"
|
|
|
|
|
|
def _native_timeout(integration: IntegrationBase, timeout_seconds: Any) -> int:
|
|
"""Return the timeout in the unit the integration's native config expects.
|
|
|
|
Claude/Cursor/Codex/Copilot measure timeouts in seconds; Gemini measures
|
|
in milliseconds (#7). An integration declares its unit via
|
|
``events_timeout_unit`` (``"s"`` default, ``"ms"`` for Gemini).
|
|
"""
|
|
try:
|
|
seconds = int(timeout_seconds)
|
|
except (TypeError, ValueError):
|
|
seconds = 60
|
|
if getattr(integration, "events_timeout_unit", "s") == "ms":
|
|
return seconds * 1000
|
|
return seconds
|
|
|
|
|
|
def _shell_quote(value: str, target_os: str) -> str:
|
|
"""Quote *value* as one argument for the target shell (R2).
|
|
|
|
``host`` and ``posix`` targets use ``shlex.quote`` (POSIX shells). Safe
|
|
tokens — ``python3``, ``speckit.ext.cmd`` — pass through bare, so a
|
|
single-``command``-string hook (Claude/Gemini/etc.) stays invocable on
|
|
every platform. ``windows`` targets use a PowerShell single-quoted literal
|
|
with embedded quotes doubled, for Copilot's dedicated ``powershell`` field.
|
|
|
|
Prevents a component containing spaces (e.g. a venv interpreter path under
|
|
a directory with spaces) or shell metacharacters (a malformed
|
|
extension/override ``command``) from breaking the hook or being
|
|
interpreted by the native shell instead of passed as one dispatcher
|
|
argument.
|
|
"""
|
|
if target_os == "windows":
|
|
return "'" + value.replace("'", "''") + "'"
|
|
# "host" and "posix" both use POSIX quoting. On Windows the single-
|
|
# command-string formats (Claude/Gemini/Qwen/Devin/Tabnine) are run via
|
|
# Git Bash or the agent's POSIX-ish shell, so POSIX quoting is correct and
|
|
# avoids emitting 'python' (which PowerShell wouldn't invoke without &).
|
|
return shlex.quote(value)
|
|
|
|
|
|
def _dispatcher_command(
|
|
integration: IntegrationBase,
|
|
project_root: Path,
|
|
command_name: str,
|
|
event_name: str,
|
|
*,
|
|
target_os: str = "host",
|
|
timeout_seconds: Any = None,
|
|
) -> str:
|
|
"""Build the single shell command string that invokes the dispatcher (#6).
|
|
|
|
Claude/Gemini/Qwen/Devin/Tabnine accept one ``command`` string (not a
|
|
``command``+``args`` split), so each adapter renders a complete invocation:
|
|
``<interpreter> <dispatcher> <command> <event> [<timeout>]``. The
|
|
interpreter is resolved portably (#16); Claude's dispatcher path is
|
|
prefixed with ``${CLAUDE_PROJECT_DIR}/`` (Claude expands it before shell
|
|
execution).
|
|
|
|
``target_os`` selects an OS-appropriate interpreter for adapters that emit
|
|
both POSIX and Windows variants into one checked-in file (Copilot): ``host``
|
|
uses the host-resolved interpreter (venv-aware), while ``posix``/``windows``
|
|
emit portable interpreters so the config works on either OS (#S4).
|
|
|
|
Each component is shell-quoted for the target shell (R2) so an interpreter
|
|
path with spaces or a command/event containing shell metacharacters is
|
|
passed as a single argument rather than reinterpreted by the native shell.
|
|
The Claude dispatcher is double-quoted (``"${CLAUDE_PROJECT_DIR}/..."``) so
|
|
the variable still expands but a project path with spaces doesn't
|
|
word-split (C2). For the explicit ``windows`` target (Copilot's
|
|
powershell field) the quoted interpreter is prefixed with PowerShell's
|
|
call operator ``&`` so the quoted command is actually invoked (C1).
|
|
|
|
When *timeout_seconds* is given, the resolved timeout (in the
|
|
integration's native unit) is appended as a 4th argument so the dispatcher
|
|
and inner runner honor the per-handler timeout instead of a fixed 120s cap
|
|
that would kill a handler configured for longer (S4). When omitted, a
|
|
default of 60s is emitted so the positional argument order
|
|
(command event timeout envelope native_event) stays aligned — otherwise
|
|
the envelope would land in the timeout slot and the dispatcher would
|
|
silently fall back to plain stdout.
|
|
|
|
When the integration declares a context-injection envelope for this
|
|
canonical event (``events_context_envelope``, C13), the envelope token is
|
|
appended as a 5th argument so the dispatcher wraps stdout in the JSON
|
|
shape the agent's hook protocol requires. Plain-passthrough agents
|
|
(Claude/Codex) declare no envelope and get no extra argument.
|
|
"""
|
|
if target_os == "host":
|
|
interpreter = _resolve_interpreter(project_root)
|
|
else:
|
|
interpreter = _resolve_interpreter_for_target(target_os)
|
|
q_interp = _shell_quote(interpreter, target_os)
|
|
q_command = _shell_quote(command_name, target_os)
|
|
q_event = _shell_quote(event_name, target_os)
|
|
if integration.key == "claude":
|
|
# C2: double-quote so ${CLAUDE_PROJECT_DIR} still expands (double
|
|
# quotes allow variable expansion in POSIX shells) but a project path
|
|
# containing spaces doesn't word-split.
|
|
dispatcher = '"${CLAUDE_PROJECT_DIR}/' + EVENTS_DISPATCHER_REL + '"'
|
|
else:
|
|
dispatcher = _shell_quote(EVENTS_DISPATCHER_REL, target_os)
|
|
# C1: PowerShell won't invoke a single-quoted command without the call
|
|
# operator. Prefix & for the explicit windows target only.
|
|
prefix = "& " if target_os == "windows" else ""
|
|
base = f"{prefix}{q_interp} {dispatcher} {q_command} {q_event}"
|
|
# Always emit the timeout (4th positional arg) so the dispatcher's argv
|
|
# parsing stays aligned when an envelope (5th) or native_event (6th)
|
|
# follows. Without it the envelope would land in the timeout slot and
|
|
# the dispatcher would fall back to plain stdout (R3).
|
|
resolved_timeout = 60 if timeout_seconds is None else int(timeout_seconds)
|
|
# R2: the dispatcher interprets this argument as seconds, so pass the
|
|
# raw seconds — NOT _native_timeout(...) (which converts to ms for
|
|
# Gemini/Qwen/Tabnine and would yield 60000 seconds). The buffer is
|
|
# applied to the native hook timeout field (in the adapter formatters)
|
|
# so the agent's outer cap fires after the inner subprocess timeout.
|
|
base += f" {_shell_quote(str(resolved_timeout), target_os)}"
|
|
envelope = _context_envelope_for(integration, event_name)
|
|
if envelope:
|
|
base += f" {_shell_quote(envelope, target_os)}"
|
|
# hookSpecificOutput requires the native hookEventName inside the
|
|
# envelope (Qwen's hooks spec marks it mandatory; the Claude Code
|
|
# hooks spec that Gemini/Tabnine/Devin derive from includes it).
|
|
# Append the native event name as a 6th dispatcher argument so the
|
|
# dispatcher can populate hookEventName in the JSON output.
|
|
if envelope == "hookSpecificOutput":
|
|
native_event = getattr(integration, "CANONICAL_TO_NATIVE", {}).get(event_name, "")
|
|
if native_event:
|
|
base += f" {_shell_quote(native_event, target_os)}"
|
|
return base
|
|
|
|
|
|
def _context_envelope_for(integration: IntegrationBase, canonical_event: str) -> str | None:
|
|
"""Resolve the context-injection envelope for an integration + event (C13).
|
|
|
|
The event key wins; ``"*"`` is the fallback. Returns ``None`` when the
|
|
integration declares no envelope for the event (plain stdout passthrough).
|
|
"""
|
|
mapping = getattr(integration, "events_context_envelope", None) or {}
|
|
if canonical_event in mapping:
|
|
return mapping[canonical_event]
|
|
return mapping.get("*")
|
|
|
|
|
|
def install_integration_events(
|
|
integration: IntegrationBase,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
events: ResolvedEvents,
|
|
) -> list[Path]:
|
|
"""Generate dispatcher, merge native config, return created files.
|
|
|
|
``events`` maps each canonical event to an ordered list of handler configs
|
|
(#2); every handler is emitted as a separate native hook entry so two
|
|
extensions declaring ``session_start`` both run.
|
|
"""
|
|
canonical_to_native = getattr(integration, "CANONICAL_TO_NATIVE", {})
|
|
if not canonical_to_native:
|
|
return []
|
|
|
|
# Filter to only supported events, preserving all handlers per event.
|
|
filtered: ResolvedEvents = {}
|
|
for ev, handlers in events.items():
|
|
if not isinstance(handlers, list):
|
|
continue
|
|
if ev in canonical_to_native:
|
|
filtered[ev] = handlers
|
|
else:
|
|
print(
|
|
f"\u26a0\ufe0f {integration.key} does not support '{ev}' events; skipping",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
# #3: an empty resolved map (--events false, or override disabling events)
|
|
# must still strip prior Specify hooks from this integration's native
|
|
# config rather than leaving them active. S3: also run the shared-
|
|
# dispatcher refcount cleanup so an --events false upgrade of the last
|
|
# event integration doesn't orphan .specify/events.py permanently (the
|
|
# new manifest no longer claims it and stale cleanup excludes it).
|
|
if not filtered:
|
|
_remove_native_event_hooks(integration, project_root, manifest)
|
|
_cleanup_shared_dispatcher(integration, project_root, manifest)
|
|
return []
|
|
|
|
created: list[Path] = []
|
|
|
|
# 1. Generate events.py dispatcher script (#12: validate destination first)
|
|
dispatcher_dir = project_root / EVENTS_DISPATCHER_DIR
|
|
dispatcher_path = dispatcher_dir / EVENTS_DISPATCHER_FILENAME
|
|
_ensure_safe_destination(dispatcher_path)
|
|
dispatcher_dir.mkdir(parents=True, exist_ok=True)
|
|
dispatcher_path.write_text(_EVENTS_DISPATCHER_TEMPLATE, encoding="utf-8")
|
|
dispatcher_path.chmod(0o755)
|
|
manifest.record_file(
|
|
str(dispatcher_path.relative_to(project_root)),
|
|
dispatcher_path.read_bytes(),
|
|
)
|
|
created.append(dispatcher_path)
|
|
|
|
# 2. Format-specific merge/write
|
|
fmt = getattr(integration, "events_format", "json-nested")
|
|
config_file = getattr(integration, "events_config_file", None)
|
|
if not config_file:
|
|
return created
|
|
|
|
config_path = project_root / config_file
|
|
|
|
if fmt == "ts-plugin":
|
|
# Opencode TS plugin custom merge
|
|
plugin_rel = ".opencode/plugin/speckit-events.ts"
|
|
plugin_path = project_root / plugin_rel
|
|
_ensure_safe_destination(plugin_path)
|
|
plugin_path.parent.mkdir(parents=True, exist_ok=True)
|
|
plugin_path.write_text(
|
|
_build_opencode_plugin(filtered, canonical_to_native),
|
|
encoding="utf-8",
|
|
)
|
|
manifest.record_file(
|
|
plugin_rel,
|
|
plugin_path.read_bytes(),
|
|
)
|
|
created.append(plugin_path)
|
|
|
|
# Merge plugin path into opencode.json. S5: only track the config
|
|
# file when the merge actually wrote; a skipped merge (JSONC/malformed)
|
|
# must not be tracked or manifest.uninstall() would later delete the
|
|
# user's untouched file.
|
|
if _merge_opencode_plugin_ref(config_path, f"./{plugin_rel}"):
|
|
rel = str(config_path.relative_to(project_root))
|
|
if rel not in manifest.files:
|
|
manifest.record_existing(rel)
|
|
created.append(config_path)
|
|
|
|
elif fmt == "copilot-json":
|
|
# Copilot dedicated .github/hooks/speckit.json. Each handler becomes
|
|
# its own entry in the native event's list (#2). The bash and
|
|
# powershell variants get independent OS-targeted interpreters (#S4)
|
|
# so a config generated on Linux doesn't write a POSIX venv path into
|
|
# the PowerShell hook (and vice-versa). Entries carry the ownership
|
|
# marker so a pre-existing user-authored file is merged (owned entries
|
|
# replaced) rather than overwritten (#8), and teardown removes only
|
|
# owned entries.
|
|
copilot_hooks: dict[str, list[dict[str, Any]]] = {}
|
|
for ev, handlers in filtered.items():
|
|
native = canonical_to_native[ev]
|
|
entries: list[dict[str, Any]] = []
|
|
for cfg in handlers:
|
|
command = cfg.get("command", "")
|
|
bash_cmd = _dispatcher_command(
|
|
integration, project_root, command, ev, target_os="posix",
|
|
timeout_seconds=cfg.get("timeout", 60),
|
|
)
|
|
ps_cmd = _dispatcher_command(
|
|
integration, project_root, command, ev, target_os="windows",
|
|
timeout_seconds=cfg.get("timeout", 60),
|
|
)
|
|
entries.append(
|
|
{
|
|
"type": "command",
|
|
"bash": bash_cmd,
|
|
"powershell": ps_cmd,
|
|
"timeoutSec": _native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER),
|
|
_SPECKIT_MARKER: True,
|
|
}
|
|
)
|
|
copilot_hooks[native] = entries
|
|
# S5: only track when the merge wrote (skips on JSONC/malformed).
|
|
if _merge_copilot_json(config_path, copilot_hooks):
|
|
rel = str(config_path.relative_to(project_root))
|
|
if rel not in manifest.files:
|
|
manifest.record_existing(rel)
|
|
created.append(config_path)
|
|
|
|
elif fmt == "toml":
|
|
# Codex config.toml custom merge. One [[hooks.<native>.hooks]] block
|
|
# per handler so multiple handlers per event all emit (#2).
|
|
lines: list[str] = []
|
|
for ev, handlers in filtered.items():
|
|
native = canonical_to_native[ev]
|
|
for cfg in handlers:
|
|
command = cfg.get("command", "")
|
|
dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev, timeout_seconds=cfg.get("timeout", 60))
|
|
lines.append(f'[[hooks.{native}]]')
|
|
lines.append(f'matcher = {_toml_quote(str(cfg.get("matcher", "*")))}')
|
|
lines.append('')
|
|
lines.append(f'[[hooks.{native}.hooks]]')
|
|
lines.append('type = "command"')
|
|
lines.append(f'command = {_toml_quote(dispatcher_cmd)}')
|
|
lines.append(f'timeout = {_native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER)}')
|
|
lines.append('speckit_marker = true')
|
|
lines.append('')
|
|
# S5: only track when the merge wrote (skips on unreadable file).
|
|
if _merge_toml_fragment(config_path, "\n".join(lines)):
|
|
rel = str(config_path.relative_to(project_root))
|
|
if rel not in manifest.files:
|
|
manifest.record_existing(rel)
|
|
created.append(config_path)
|
|
|
|
elif fmt == "json-flat":
|
|
# Cursor hooks.json custom merge. Flat command-string entries, one
|
|
# per handler (#2), single resolved command string (#6/#16).
|
|
cursor_hooks: dict[str, list[dict[str, Any]]] = {}
|
|
for ev, handlers in filtered.items():
|
|
native = canonical_to_native[ev]
|
|
entries: list[dict[str, Any]] = []
|
|
for cfg in handlers:
|
|
command = cfg.get("command", "")
|
|
dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev, timeout_seconds=cfg.get("timeout", 60))
|
|
entries.append(
|
|
{
|
|
"command": dispatcher_cmd,
|
|
"type": "command",
|
|
"timeout": _native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER),
|
|
"matcher": cfg.get("matcher", "*"),
|
|
_SPECKIT_MARKER: True,
|
|
}
|
|
)
|
|
cursor_hooks[native] = entries
|
|
# #7: Cursor's .cursor/hooks.json schema requires top-level
|
|
# "version": 1; ensure it (preserving a user's value if present).
|
|
# S5: only track when the merge wrote (skips on JSONC/malformed).
|
|
if _merge_json_fragment(config_path, cursor_hooks, version=1):
|
|
rel = str(config_path.relative_to(project_root))
|
|
if rel not in manifest.files:
|
|
manifest.record_existing(rel)
|
|
created.append(config_path)
|
|
|
|
elif fmt == "json-nested":
|
|
# Claude/Qwen/Gemini/Devin/Tabnine nested config JSON merge.
|
|
# Native schema is a single ``command`` string per hook (not
|
|
# command+args), so each handler renders one complete dispatcher
|
|
# invocation (#6). Gemini timeouts are converted to ms (#7).
|
|
# Handlers are grouped by distinct matcher so each matcher gets its
|
|
# own matcher-group (S3); previously all handlers were placed under
|
|
# the first handler's matcher, so two extensions registering the same
|
|
# event with different matchers both ran for the first matcher and
|
|
# neither for the later.
|
|
nested_hooks: dict[str, list[dict[str, Any]]] = {}
|
|
for ev, handlers in filtered.items():
|
|
native = canonical_to_native[ev]
|
|
by_matcher: dict[str, list[dict[str, Any]]] = {}
|
|
for cfg in handlers:
|
|
matcher = cfg.get("matcher", "*")
|
|
command = cfg.get("command", "")
|
|
dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev, timeout_seconds=cfg.get("timeout", 60))
|
|
by_matcher.setdefault(matcher, []).append(
|
|
{
|
|
"type": "command",
|
|
"command": dispatcher_cmd,
|
|
"timeout": _native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER),
|
|
_SPECKIT_MARKER: True,
|
|
}
|
|
)
|
|
nested_hooks[native] = [
|
|
{"matcher": matcher, "hooks": inner}
|
|
for matcher, inner in by_matcher.items()
|
|
]
|
|
# S5: only track when the merge wrote (skips on JSONC/malformed).
|
|
if _merge_json_fragment(config_path, nested_hooks):
|
|
rel = str(config_path.relative_to(project_root))
|
|
if rel not in manifest.files:
|
|
manifest.record_existing(rel)
|
|
created.append(config_path)
|
|
|
|
elif fmt == "json-root-nested":
|
|
# Devin hooks.v1.json: a root event map ({"PreToolUse": [...]}) with
|
|
# no top-level "hooks" wrapper (U2). Same matcher-grouping and single
|
|
# command string as json-nested, but written to the root.
|
|
root_hooks: dict[str, list[dict[str, Any]]] = {}
|
|
for ev, handlers in filtered.items():
|
|
native = canonical_to_native[ev]
|
|
by_matcher: dict[str, list[dict[str, Any]]] = {}
|
|
for cfg in handlers:
|
|
matcher = cfg.get("matcher", "*")
|
|
command = cfg.get("command", "")
|
|
dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev, timeout_seconds=cfg.get("timeout", 60))
|
|
by_matcher.setdefault(matcher, []).append(
|
|
{
|
|
"type": "command",
|
|
"command": dispatcher_cmd,
|
|
"timeout": _native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER),
|
|
_SPECKIT_MARKER: True,
|
|
}
|
|
)
|
|
root_hooks[native] = [
|
|
{"matcher": matcher, "hooks": inner}
|
|
for matcher, inner in by_matcher.items()
|
|
]
|
|
if _merge_json_root(config_path, root_hooks):
|
|
rel = str(config_path.relative_to(project_root))
|
|
if rel not in manifest.files:
|
|
manifest.record_existing(rel)
|
|
created.append(config_path)
|
|
|
|
return created
|
|
|
|
|
|
def _remove_native_event_hooks(
|
|
integration: IntegrationBase,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
) -> None:
|
|
"""Remove Specify-authored hooks from *this* integration's native config.
|
|
|
|
Used both by full teardown and by the empty-resolved-map install path (#3).
|
|
Does NOT touch the shared dispatcher (another integration may still
|
|
reference it — #10).
|
|
"""
|
|
fmt = getattr(integration, "events_format", None)
|
|
config_file = getattr(integration, "events_config_file", None)
|
|
if not config_file:
|
|
return
|
|
config_path = project_root / config_file
|
|
if not config_path.exists():
|
|
return
|
|
_ensure_safe_destination(config_path)
|
|
if fmt == "copilot-json":
|
|
_remove_copilot_entries(config_path)
|
|
elif fmt == "toml":
|
|
_remove_toml_entries(config_path)
|
|
elif fmt in ("json-nested", "json-flat"):
|
|
_remove_json_entries(config_path)
|
|
elif fmt == "json-root-nested":
|
|
_remove_json_root_entries(config_path)
|
|
elif fmt == "ts-plugin":
|
|
_remove_opencode_entries(config_path)
|
|
# Always drop this integration's manifest claim on the native config,
|
|
# whether the file was deleted or retained with user content (S9). If we
|
|
# kept a retained file tracked, teardown()'s manifest.uninstall(force=True)
|
|
# would delete the entire user-owned settings file. After cleanup the file
|
|
# is either gone or contains only user content, so this integration must
|
|
# no longer claim it for teardown purposes.
|
|
manifest.remove(config_file)
|
|
|
|
|
|
def _other_event_integrations_reference_dispatcher(
|
|
project_root: Path, excluding_key: str
|
|
) -> bool:
|
|
"""Return True if another installed event-capable integration still
|
|
references the shared ``.specify/events.py`` dispatcher (#10).
|
|
|
|
Inspects each installed integration's manifest (excluding *excluding_key*)
|
|
for the dispatcher path so uninstalling one multi-install event-capable
|
|
integration doesn't delete the dispatcher the others still rely on.
|
|
"""
|
|
from .integrations._helpers import _read_integration_json
|
|
from .integrations.manifest import IntegrationManifest
|
|
from .integration_state import installed_integration_keys
|
|
|
|
state = _read_integration_json(project_root)
|
|
for key in installed_integration_keys(state):
|
|
if key == excluding_key:
|
|
continue
|
|
try:
|
|
manifest = IntegrationManifest.load(key, project_root)
|
|
except Exception:
|
|
continue
|
|
if EVENTS_DISPATCHER_REL in manifest.files:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _cleanup_shared_dispatcher(
|
|
integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest
|
|
) -> None:
|
|
"""Drop this integration's manifest claim on the shared dispatcher and
|
|
delete the file only when no other installed event-capable integration
|
|
still references it (#10, S3).
|
|
|
|
The manifest.remove() runs in both branches (S1): if we retain the file
|
|
but leave it tracked, the subsequent manifest.uninstall() in teardown()
|
|
sees the matching hash and deletes the file another integration still
|
|
depends on. Used by full teardown and by the empty-resolved-map install
|
|
path so an ``--events false`` upgrade of the last event integration
|
|
doesn't orphan ``.specify/events.py`` permanently (S3).
|
|
"""
|
|
dispatcher_rel = EVENTS_DISPATCHER_REL
|
|
# Drop this integration's manifest claim if present. The remove() is
|
|
# conditional (S1): an upgrade passes a *fresh* manifest that may never
|
|
# have claimed the dispatcher, so the key may be absent — that's a no-op.
|
|
if dispatcher_rel in manifest.files:
|
|
manifest.remove(dispatcher_rel)
|
|
# S2: run the no-other-references deletion independently of whether the
|
|
# new manifest currently contains the key. An ``integration upgrade
|
|
# --events false`` passes a fresh manifest that never recorded the
|
|
# dispatcher, so gating the deletion on its presence orphans the file the
|
|
# old on-disk manifest owned — and stale cleanup excludes it (C3). If no
|
|
# other installed event-capable integration references the dispatcher,
|
|
# delete it; otherwise leave it for them.
|
|
if not _other_event_integrations_reference_dispatcher(project_root, integration.key):
|
|
dispatcher_path = project_root / dispatcher_rel
|
|
if dispatcher_path.exists():
|
|
_ensure_safe_destination(dispatcher_path)
|
|
dispatcher_path.unlink(missing_ok=True)
|
|
|
|
|
|
def remove_integration_events(
|
|
integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest
|
|
) -> None:
|
|
"""Remove Specify-authored event entries from native config.
|
|
|
|
The shared ``.specify/events.py`` dispatcher is deleted only when no other
|
|
installed event-capable integration still references it (#10); otherwise
|
|
it is left in place so multi-install setups don't lose the dispatcher
|
|
mid-stream.
|
|
"""
|
|
_remove_native_event_hooks(integration, project_root, manifest)
|
|
_cleanup_shared_dispatcher(integration, project_root, manifest)
|
|
|
|
# Clean up opencode TS plugin (owned solely by the opencode integration).
|
|
if integration.key == "opencode":
|
|
plugin_rel = ".opencode/plugin/speckit-events.ts"
|
|
if plugin_rel in manifest.files:
|
|
plugin_path = project_root / plugin_rel
|
|
if plugin_path.exists():
|
|
_ensure_safe_destination(plugin_path)
|
|
plugin_path.unlink(missing_ok=True)
|
|
manifest.remove(plugin_rel)
|
|
|
|
|
|
def events_stale_exclusions(integration_key: str) -> set[str]:
|
|
"""Return project-relative paths to protect from stale cleanup."""
|
|
from .integrations import get_integration
|
|
integration = get_integration(integration_key)
|
|
if not integration:
|
|
return set()
|
|
exclusions = set()
|
|
config_file = getattr(integration, "events_config_file", None)
|
|
if config_file:
|
|
exclusions.add(config_file)
|
|
if integration_key == "opencode":
|
|
exclusions.add(".opencode/plugin/speckit-events.ts")
|
|
# C3: the shared dispatcher is written into every event-capable
|
|
# integration's manifest but is reference-counted across them. An upgrade
|
|
# with --events false omits events.py from the new manifest, so the generic
|
|
# stale pass would delete it without the refcount check, breaking any other
|
|
# installed event-capable integration. Protect it here; its deletion is
|
|
# left to remove_integration_events(), which checks the refcount.
|
|
exclusions.add(EVENTS_DISPATCHER_REL)
|
|
return exclusions
|
|
|
|
|
|
class EventRefreshError(RuntimeError):
|
|
"""Raised when refreshing one or more integrations' event config failed.
|
|
|
|
Aggregates per-integration failures so a lifecycle command
|
|
(extension add/remove/enable/disable) can surface that an extension was
|
|
not fully deactivated — a stale native hook may still be active (R3).
|
|
"""
|
|
|
|
def __init__(self, failures: list[tuple[str, str]]) -> None:
|
|
self.failures = failures
|
|
details = "; ".join(f"{key}: {detail}" for key, detail in failures)
|
|
super().__init__(
|
|
f"event refresh failed for {len(failures)} integration(s): {details}"
|
|
)
|
|
|
|
|
|
def refresh_integration_events(project_root: Path) -> None:
|
|
"""Re-resolve and re-emit native event config for every installed
|
|
event-capable integration (#1).
|
|
|
|
Called after extension state changes (install/uninstall/enable/disable)
|
|
so that extension-declared events are regenerated in each installed
|
|
integration's native config — otherwise the documented install-after-
|
|
``specify init`` flow is inert and disabled/removed extension events stay
|
|
active. Each integration is refreshed independently; a failure for one
|
|
is logged and accumulated but does not abort the others. If any
|
|
integration failed, :class:`EventRefreshError` is raised at the end so
|
|
the lifecycle command can't claim the extension was fully deactivated
|
|
while a stale native hook may still be active (R3).
|
|
"""
|
|
from .integrations import get_integration
|
|
from .integrations._helpers import _read_integration_json, _resolve_integration_options
|
|
from .integrations.manifest import IntegrationManifest
|
|
from .integration_state import installed_integration_keys
|
|
|
|
state = _read_integration_json(project_root)
|
|
failures: list[tuple[str, str]] = []
|
|
for key in installed_integration_keys(state):
|
|
integration = get_integration(key)
|
|
if integration is None or not integration.supports_events():
|
|
continue
|
|
try:
|
|
manifest = IntegrationManifest.load(key, project_root)
|
|
except Exception as exc:
|
|
logger.warning("Could not load manifest for '%s'; skipping event refresh: %s", key, exc)
|
|
failures.append((key, f"manifest load: {exc}"))
|
|
continue
|
|
try:
|
|
# C12: resolve first, then call install_integration_events once.
|
|
# The previous flow ran _remove_native_event_hooks *before*
|
|
# resolution, so any later failure (invalid destination, write
|
|
# error, formatter error) destroyed the working native config
|
|
# before the new one was written. install_integration_events
|
|
# already removes stale Specify-marked entries and handles an
|
|
# empty map (stripping prior hooks), so the destructive pre-step
|
|
# is both unsafe and redundant.
|
|
# S7: resolve this integration's persisted parsed_options so a
|
|
# stored --events false is honored across extension lifecycle
|
|
# changes; passing None would re-enable events the user disabled.
|
|
_, parsed_options = _resolve_integration_options(integration, state, key, None)
|
|
events_map = resolve_events(
|
|
key, integration.config, project_root, parsed_options
|
|
)
|
|
# install_integration_events handles both the populated case
|
|
# (writes new config, stripping stale owned entries) and the empty
|
|
# case (strips prior hooks for --events false / disabled override).
|
|
install_integration_events(integration, project_root, manifest, events_map)
|
|
manifest.save()
|
|
except Exception as exc:
|
|
logger.warning("Failed to refresh events for '%s': %s", key, exc)
|
|
failures.append((key, str(exc)))
|
|
|
|
if failures:
|
|
raise EventRefreshError(failures)
|
|
|
|
|
|
# -- Manifest validation ---------------------------------------------------
|
|
|
|
def validate_events(data: dict[str, Any]) -> None:
|
|
"""Validate ``events`` field in extension manifest data."""
|
|
from .extensions import ValidationError
|
|
|
|
events = data.get("events")
|
|
if "events" in data and not isinstance(events, dict):
|
|
raise ValidationError("Invalid events: expected a mapping")
|
|
if events:
|
|
for event_name, event_config in events.items():
|
|
if not isinstance(event_config, dict):
|
|
raise ValidationError(
|
|
f"Invalid event '{event_name}': expected a mapping"
|
|
)
|
|
command = event_config.get("command")
|
|
# #17: command must be a non-empty string. A truthy non-string
|
|
# (e.g. command: [foo]) would pass a bare truthiness check and
|
|
# later render into invalid native configuration.
|
|
if not isinstance(command, str) or not command.strip():
|
|
raise ValidationError(
|
|
f"Event '{event_name}' missing required 'command' string"
|
|
)
|
|
if event_name not in CANONICAL_EVENTS:
|
|
raise ValidationError(
|
|
f"Unknown event '{event_name}': "
|
|
f"must be one of {sorted(CANONICAL_EVENTS)}"
|
|
)
|
|
# C10: matcher must be a string (or absent). A non-string matcher
|
|
# such as `matcher: []` would later crash by_matcher.setdefault.
|
|
matcher = event_config.get("matcher")
|
|
if matcher is not None and not isinstance(matcher, str):
|
|
raise ValidationError(
|
|
f"Event '{event_name}' has invalid 'matcher': must be a string"
|
|
)
|
|
timeout = event_config.get("timeout")
|
|
if timeout is not None:
|
|
if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout <= 0:
|
|
raise ValidationError(
|
|
f"Event '{event_name}' has invalid 'timeout': must be a positive integer"
|
|
)
|
|
|
|
|
|
def has_events(data: dict[str, Any]) -> bool:
|
|
"""Return True if ``events`` is present and non-empty."""
|
|
return bool(data.get("events"))
|
|
|
|
|
|
# -- Helper merging functions ----------------------------------------------
|
|
|
|
def _toml_quote(value: str) -> str:
|
|
"""Render *value* as a TOML basic string via the shared escaper."""
|
|
from ._toml_string import escape_toml_basic
|
|
return escape_toml_basic(value)
|
|
|
|
|
|
def _build_opencode_plugin(
|
|
filtered_events: ResolvedEvents,
|
|
canonical_to_native: dict[str, str],
|
|
) -> str:
|
|
"""Render the opencode TS plugin for the resolved event set.
|
|
|
|
Each canonical event may carry multiple handlers (#2); all handlers for a
|
|
native event are invoked from one generated function. The dispatcher and
|
|
interpreter are resolved per-project at plugin load from the ``directory``
|
|
OpenCode passes (C8); the dispatcher is launched with ``execFileSync`` and
|
|
an argv array (C9). Both the ``input`` and ``output`` callback arguments
|
|
are forwarded to ``runEvent`` (C7) so pre_tool_use can inspect tool
|
|
arguments and post_tool_use can inspect the result.
|
|
|
|
Context-injection natives get dedicated hook bodies: for
|
|
``experimental.chat.system.transform`` the handlers' concatenated stdout
|
|
is pushed into ``output.system`` (system-prompt injection, re-applied per
|
|
LLM request so the context survives compaction); for ``chat.message`` it
|
|
is pushed as a synthetic text part on the user message (C11). Other
|
|
natives keep their side-effect behavior (tool.execute.* args mutation;
|
|
session.* lifecycle events via the generic ``event`` hook).
|
|
"""
|
|
event_entries: list[str] = []
|
|
plugin_returns: list[str] = []
|
|
event_handlers: list[str] = []
|
|
|
|
for ev, handlers in filtered_events.items():
|
|
native = canonical_to_native[ev]
|
|
# S1: serialize every interpolated value as a JSON string literal so a
|
|
# quote/backslash/backtick in a command or matcher can't break the
|
|
# generated TypeScript or inject code. json.dumps produces a valid
|
|
# TS/JS string literal (double-quoted, fully escaped).
|
|
ev_lit = json.dumps(ev)
|
|
native_lit = json.dumps(native)
|
|
|
|
is_injection = native in ("experimental.chat.system.transform", "chat.message")
|
|
|
|
# Build the body: one runEvent() call per handler wrapped in try/catch,
|
|
# forwarding both input and output (C7). An optional tool-name matcher
|
|
# guard applies to tool.execute.* hooks. All handlers execute before
|
|
# any aggregate error is thrown. Injection hooks additionally collect
|
|
# each handler's stdout and return the concatenation.
|
|
body_lines: list[str] = [" const errors: string[] = [];"]
|
|
if is_injection:
|
|
body_lines.append(" const contexts: string[] = [];")
|
|
for cfg in handlers:
|
|
command = str(cfg.get("command", ""))
|
|
command_lit = json.dumps(command)
|
|
matcher = cfg.get("matcher", "*")
|
|
# S3: thread the per-handler timeout (seconds) to runEvent so the
|
|
# execFileSync cap and dispatcher arg match the configuration
|
|
# instead of a fixed 60000ms / 120s.
|
|
timeout_sec = int(cfg.get("timeout", 60))
|
|
if native.startswith("tool.execute."):
|
|
if matcher and matcher != "*":
|
|
tools = [t.strip().strip('"') for t in matcher.split("|")]
|
|
checks = " || ".join(
|
|
f"input.tool === {json.dumps(t.lower())}" for t in tools
|
|
)
|
|
body_lines.append(
|
|
f" try {{ if ({checks}) {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} }} catch (e) {{ errors.push((e as Error).message); }}"
|
|
)
|
|
else:
|
|
body_lines.append(
|
|
f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
|
|
)
|
|
elif is_injection:
|
|
body_lines.append(
|
|
f" try {{ const ctx = runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); if (ctx) contexts.push(ctx); }} catch (e) {{ errors.push((e as Error).message); }}"
|
|
)
|
|
else:
|
|
body_lines.append(
|
|
f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
|
|
)
|
|
body_lines.append(" if (errors.length > 0) { throw new Error(errors.join('; ')); }")
|
|
|
|
if native.startswith("tool.execute."):
|
|
ts_hook = native
|
|
event_entries.append(
|
|
f"function _{ev}(input: any, output: any) {{\n"
|
|
+ "\n".join(body_lines) + "\n"
|
|
" }"
|
|
)
|
|
plugin_returns.append(
|
|
f" {json.dumps(ts_hook)}: async (input: any, output: any) => {{\n"
|
|
f" _{ev}(input, output);\n"
|
|
f" }},"
|
|
)
|
|
elif native == "experimental.chat.system.transform":
|
|
body_lines.append(' return contexts.join("\\n\\n");')
|
|
event_entries.append(
|
|
f"function _{ev}(input: any, output: any): string {{\n"
|
|
+ "\n".join(body_lines) + "\n"
|
|
" }"
|
|
)
|
|
# OpenCode fires experimental.chat.system.transform for non-session
|
|
# operations (e.g. agent generation) with no sessionID. Guard so
|
|
# canonical session_start handlers only run when a session is
|
|
# present, preventing their output from being injected into
|
|
# internal prompts. Cache the handler output per sessionID so
|
|
# non-idempotent handlers (setup, telemetry, file-mutating
|
|
# scripts) execute once per session instead of on every LLM
|
|
# request; the cache is evicted on session.deleted.
|
|
plugin_returns.append(
|
|
f" {native_lit}: async (input: any, output: any) => {{\n"
|
|
f" if (!input.sessionID) return;\n"
|
|
f" let ctx = sessionStartCache.get(input.sessionID);\n"
|
|
f" if (ctx === undefined) {{\n"
|
|
f" ctx = _{ev}(input, output);\n"
|
|
f" sessionStartCache.set(input.sessionID, ctx ?? \"\");\n"
|
|
f" }}\n"
|
|
f" if (ctx) output.system.push(ctx);\n"
|
|
f" }},"
|
|
)
|
|
elif native == "chat.message":
|
|
body_lines.append(' return contexts.join("\\n\\n");')
|
|
event_entries.append(
|
|
f"function _{ev}(input: any, output: any): string {{\n"
|
|
+ "\n".join(body_lines) + "\n"
|
|
" }"
|
|
)
|
|
# Part id must start with "prt" (opencode's Identifier brand): an
|
|
# invalid id fails the user-part schema validation and crashes the
|
|
# whole session (C12). Derive it from the last existing part so the
|
|
# brand survives an opencode prefix change, falling back to "prt_"
|
|
# when output.parts is empty.
|
|
plugin_returns.append(
|
|
f" {native_lit}: async (input: any, output: any) => {{\n"
|
|
f" const ctx = _{ev}(input, output);\n"
|
|
f" if (!ctx) return;\n"
|
|
f" const base = output.parts[output.parts.length - 1]?.id ?? \"prt_\" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);\n"
|
|
f" output.parts.push({{ id: base + \".speckit\" + Math.random().toString(36).slice(2, 8), sessionID: input.sessionID, messageID: output.message.id, type: \"text\", text: ctx, synthetic: true }});\n"
|
|
f" }},"
|
|
)
|
|
else:
|
|
event_entries.append(
|
|
f"function _{ev}(input: any, output: any) {{\n"
|
|
+ "\n".join(body_lines) + "\n"
|
|
" }"
|
|
)
|
|
# Evict the sessionStartCache when the session is deleted so the
|
|
# cache doesn't grow unbounded across sessions.
|
|
eviction = ""
|
|
if native == "session.deleted":
|
|
eviction = "if (event.sessionID) sessionStartCache.delete(event.sessionID); "
|
|
event_handlers.append(
|
|
f" if (event.type === {native_lit}) {{ {eviction}_{ev}(event, event); }}"
|
|
)
|
|
|
|
if event_handlers:
|
|
plugin_returns.append(
|
|
" event: async ({ event }) => {\n"
|
|
+ "\n".join(event_handlers) + "\n"
|
|
" },"
|
|
)
|
|
|
|
return _TS_PLUGIN_TEMPLATE.format(
|
|
buffer=EVENT_TIMEOUT_BUFFER,
|
|
event_entries="\n\n".join(event_entries),
|
|
plugin_returns="\n".join(plugin_returns),
|
|
)
|
|
|
|
|
|
def _merge_opencode_plugin_ref(config_path: Path, ref: str) -> bool:
|
|
"""Merge the speckit-events plugin ref into opencode.json.
|
|
|
|
Aborts with a warning (#23) when the file cannot be parsed (e.g. JSONC or
|
|
malformed JSON) instead of resetting user configuration to ``{}``. Returns
|
|
False when skipped so callers avoid tracking the untouched file (S5).
|
|
"""
|
|
existing = _load_user_json(config_path)
|
|
if existing is None:
|
|
return False
|
|
plugins = existing.get("plugin", [])
|
|
if not isinstance(plugins, list):
|
|
plugins = []
|
|
if ref not in plugins:
|
|
plugins.append(ref)
|
|
existing["plugin"] = plugins
|
|
_safe_write_json(config_path, existing)
|
|
return True
|
|
|
|
|
|
def _remove_opencode_entries(config_path: Path) -> bool:
|
|
"""Remove the speckit-events plugin ref from opencode.json (#23).
|
|
|
|
Returns True if the file was deleted (now empty of user content), False
|
|
otherwise. Aborts without writing when the file cannot be parsed.
|
|
"""
|
|
_ensure_safe_destination(config_path)
|
|
existing = _load_user_json(config_path)
|
|
if existing is None:
|
|
return False
|
|
plugins = existing.get("plugin", [])
|
|
if isinstance(plugins, list):
|
|
ref = "./.opencode/plugin/speckit-events.ts"
|
|
plugins = [p for p in plugins if p != ref]
|
|
if plugins:
|
|
existing["plugin"] = plugins
|
|
else:
|
|
existing.pop("plugin", None)
|
|
if not existing:
|
|
config_path.unlink(missing_ok=True)
|
|
return True
|
|
_safe_write_json(config_path, existing)
|
|
return False
|
|
|
|
|
|
def _merge_toml_fragment(dst: Path, fragment: str) -> bool:
|
|
"""Merge Specify-owned TOML entries into *dst*, regenerating the file.
|
|
|
|
An unreadable or undecodable pre-existing file aborts the merge instead
|
|
of discarding the user's bytes, mirroring ``_load_user_json`` (#22).
|
|
Returns False when skipped so callers avoid tracking the untouched file
|
|
(S5).
|
|
"""
|
|
_ensure_safe_destination(dst)
|
|
existing = ""
|
|
if dst.exists():
|
|
try:
|
|
existing = dst.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError) as exc:
|
|
logger.warning(
|
|
"Could not read %s (it may be unreadable or not UTF-8); "
|
|
"skipping event-config merge to preserve user content.",
|
|
dst,
|
|
)
|
|
logger.debug("Read error detail: %s", exc)
|
|
return False
|
|
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")
|
|
return True
|
|
|
|
|
|
def _remove_toml_entries(dst: Path) -> bool:
|
|
"""Remove Specify-marked TOML entries; delete the file if now empty (#14).
|
|
|
|
Returns True if the file was deleted (no user content remained).
|
|
"""
|
|
if not dst.exists():
|
|
return False
|
|
# R3: validate the destination before reading/writing so a symlink swap of
|
|
# the config after install can't make teardown overwrite a file outside
|
|
# the project (the merge/write path already validates; teardown must too).
|
|
_ensure_safe_destination(dst)
|
|
try:
|
|
existing = dst.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError) as exc:
|
|
# An unreadable or undecodable file is left untouched rather than
|
|
# crashing teardown — it contains only user content as far as we can
|
|
# tell, and the caller drops the manifest claim either way (S9).
|
|
logger.warning(
|
|
"Could not read %s (it may be unreadable or not UTF-8); "
|
|
"skipping event-config cleanup to preserve user content.",
|
|
dst,
|
|
)
|
|
logger.debug("Read error detail: %s", exc)
|
|
return False
|
|
cleaned = re.sub(
|
|
r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*',
|
|
"",
|
|
existing,
|
|
flags=re.DOTALL,
|
|
)
|
|
# If only whitespace/comments remain, the file had no user content —
|
|
# delete it rather than leaving an empty stub that confuses uninstall.
|
|
stripped = "\n".join(
|
|
line for line in cleaned.splitlines()
|
|
if line.strip() and not line.strip().startswith("#")
|
|
)
|
|
if not stripped:
|
|
dst.unlink(missing_ok=True)
|
|
return True
|
|
dst.write_text(cleaned, encoding="utf-8")
|
|
return False
|
|
|
|
|
|
def _merge_copilot_json(dst: Path, new_hooks: dict[str, list]) -> bool:
|
|
"""Merge Specify-owned hooks into Copilot's dedicated hooks JSON (#8).
|
|
|
|
A pre-existing user-authored ``.github/hooks/speckit.json`` is merged
|
|
(owned entries replaced via markers) rather than overwritten, and a
|
|
parse failure aborts instead of resetting user content (#22). Returns
|
|
False when skipped so callers avoid tracking the untouched file (S5).
|
|
"""
|
|
existing = _load_user_json(dst)
|
|
if existing is None:
|
|
return False
|
|
if not isinstance(existing, dict):
|
|
existing = {}
|
|
existing.setdefault("version", 1)
|
|
existing_hooks = existing.get("hooks", {})
|
|
if not isinstance(existing_hooks, dict):
|
|
existing_hooks = {}
|
|
# #11: strip ALL Specify-marked entries from every event first.
|
|
cleaned_hooks: dict[str, list] = {}
|
|
for event, entries in existing_hooks.items():
|
|
if not isinstance(entries, list):
|
|
continue
|
|
kept_entries = _drop_marked_entries(entries)
|
|
if kept_entries:
|
|
cleaned_hooks[event] = kept_entries
|
|
for event, entries in new_hooks.items():
|
|
cleaned_hooks.setdefault(event, []).extend(entries)
|
|
if cleaned_hooks:
|
|
existing["hooks"] = cleaned_hooks
|
|
else:
|
|
existing.pop("hooks", None)
|
|
_safe_write_json(dst, existing)
|
|
return True
|
|
|
|
|
|
def _remove_copilot_entries(dst: Path) -> bool:
|
|
"""Remove Specify-owned hooks from Copilot's hooks JSON (#8, #14).
|
|
|
|
Deletes the file when no user-authored hooks remain; otherwise keeps the
|
|
file with user content. Aborts (no write) on parse failure (#22).
|
|
"""
|
|
_ensure_safe_destination(dst)
|
|
existing = _load_user_json(dst)
|
|
if existing is None:
|
|
return False
|
|
if not isinstance(existing, dict):
|
|
return False
|
|
hooks = existing.get("hooks", {})
|
|
if not isinstance(hooks, dict):
|
|
hooks = {}
|
|
cleaned: dict[str, list] = {}
|
|
for event, entries in hooks.items():
|
|
if not isinstance(entries, list):
|
|
continue
|
|
kept_entries = _drop_marked_entries(entries)
|
|
if kept_entries:
|
|
cleaned[event] = kept_entries
|
|
if cleaned:
|
|
existing["hooks"] = cleaned
|
|
else:
|
|
existing.pop("hooks", None)
|
|
# Dedicated Spec-Kit file: delete when only the (Spec-Kit-invented)
|
|
# ``version`` key would remain — no user content to preserve.
|
|
user_keys = {k for k in existing if k != "version"}
|
|
if not user_keys:
|
|
dst.unlink(missing_ok=True)
|
|
return True
|
|
_safe_write_json(dst, existing)
|
|
return False
|
|
|
|
|
|
def _merge_json_fragment(dst: Path, new_hooks: dict, *, version: int | None = None) -> bool:
|
|
"""Merge Specify-authored hook entries into a native JSON config.
|
|
|
|
Idempotent: removes ALL prior Specify-marked entries from every event in
|
|
the existing config first (#11), so an override that drops an event (e.g.
|
|
``pre_tool_use`` → ``stop``) doesn't leave stale marked entries behind.
|
|
Marker detection recurses into nested ``hooks`` arrays (#9) so a
|
|
matcher-group containing Specify-owned inner hooks is recognized and
|
|
replaced rather than duplicated on every upgrade.
|
|
|
|
Aborts with a warning (no write) when the existing file cannot be parsed
|
|
(#22) — e.g. JSONC with comments — instead of resetting user content to
|
|
``{}``. Returns False when the merge was skipped so callers avoid tracking
|
|
the untouched file (S5: otherwise manifest.uninstall() later deletes the
|
|
user's JSONC/malformed file).
|
|
|
|
When *version* is given, the top-level ``version`` field is ensured
|
|
(preserving a user's value if present) so formats that require it — e.g.
|
|
Cursor's ``.cursor/hooks.json`` schema (``version: 1``) — stay valid on a
|
|
freshly generated file (#7).
|
|
"""
|
|
existing = _load_user_json(dst)
|
|
if existing is None:
|
|
return False
|
|
if not isinstance(existing, dict):
|
|
existing = {}
|
|
|
|
if version is not None:
|
|
existing.setdefault("version", version)
|
|
|
|
hooks_key = "hooks"
|
|
existing_hooks = existing.get(hooks_key, {})
|
|
if not isinstance(existing_hooks, dict):
|
|
existing_hooks = {}
|
|
|
|
# #11: strip ALL Specify-marked entries from every event first.
|
|
cleaned_hooks: dict[str, list] = {}
|
|
for event, entries in existing_hooks.items():
|
|
if not isinstance(entries, list):
|
|
continue
|
|
kept_entries = _drop_marked_entries(entries)
|
|
if kept_entries:
|
|
cleaned_hooks[event] = kept_entries
|
|
|
|
# Then add the newly resolved set.
|
|
for event, entries in new_hooks.items():
|
|
cleaned_hooks.setdefault(event, []).extend(entries)
|
|
|
|
if cleaned_hooks:
|
|
existing[hooks_key] = cleaned_hooks
|
|
else:
|
|
existing.pop(hooks_key, None)
|
|
_safe_write_json(dst, existing)
|
|
return True
|
|
|
|
|
|
def _merge_json_root(dst: Path, new_hooks: dict) -> bool:
|
|
"""Merge Specify-authored hooks into a root-nested JSON config (Devin U2).
|
|
|
|
Devin's ``.devin/hooks.v1.json`` is a root event map
|
|
(``{"PreToolUse": [...]}``) with no ``hooks`` wrapper, so the event keys
|
|
are top-level. Same idempotent strip-all-marked-then-add semantics and
|
|
JSONC-abort behavior as ``_merge_json_fragment``.
|
|
"""
|
|
existing = _load_user_json(dst)
|
|
if existing is None:
|
|
return False
|
|
if not isinstance(existing, dict):
|
|
existing = {}
|
|
|
|
# #11: strip ALL Specify-marked entries from every root event first.
|
|
cleaned: dict[str, list] = {}
|
|
for event, entries in existing.items():
|
|
if not isinstance(entries, list):
|
|
# Preserve non-list user fields at the root (Devin has none, but
|
|
# be defensive against a mixed user file).
|
|
cleaned[event] = entries # type: ignore[assignment]
|
|
continue
|
|
kept_entries = _drop_marked_entries(entries)
|
|
if kept_entries:
|
|
cleaned[event] = kept_entries
|
|
|
|
# Then add the newly resolved set (list values only).
|
|
for event, entries in new_hooks.items():
|
|
cleaned.setdefault(event, []).extend(entries)
|
|
|
|
if cleaned:
|
|
existing = cleaned
|
|
else:
|
|
existing = {}
|
|
if not existing:
|
|
dst.unlink(missing_ok=True)
|
|
return True
|
|
_safe_write_json(dst, existing)
|
|
return True
|
|
|
|
|
|
def _remove_json_root_entries(dst: Path) -> bool:
|
|
"""Remove Specify-authored entries from a root-nested JSON config (Devin U2).
|
|
|
|
Deletes the file when no user content remains (C5/#14 mirror).
|
|
"""
|
|
_ensure_safe_destination(dst)
|
|
existing = _load_user_json(dst)
|
|
if existing is None:
|
|
return False
|
|
if not isinstance(existing, dict):
|
|
return False
|
|
cleaned: dict[str, Any] = {}
|
|
for event, entries in existing.items():
|
|
if not isinstance(entries, list):
|
|
cleaned[event] = entries
|
|
continue
|
|
kept_entries = _drop_marked_entries(entries)
|
|
if kept_entries:
|
|
cleaned[event] = kept_entries
|
|
if not cleaned:
|
|
dst.unlink(missing_ok=True)
|
|
return True
|
|
_safe_write_json(dst, cleaned)
|
|
return False
|
|
|
|
|
|
def _drop_marked_entries(entries: list) -> list:
|
|
"""Return *entries* with Specify-marked hooks removed, preserving user hooks.
|
|
|
|
Handles both flat entries (marker on the entry itself) and nested entries
|
|
(marker on inner ``hooks`` elements). A nested matcher-group whose inner
|
|
hooks are all Specify-owned is dropped; one with surviving user inner
|
|
hooks is kept with only the user hooks retained (#9).
|
|
"""
|
|
kept: list = []
|
|
for entry in entries:
|
|
if not isinstance(entry, dict):
|
|
kept.append(entry)
|
|
continue
|
|
inner = entry.get("hooks")
|
|
if isinstance(inner, list):
|
|
kept_inner = [h for h in inner if not _has_marker(h)]
|
|
if kept_inner:
|
|
entry["hooks"] = kept_inner
|
|
kept.append(entry)
|
|
# else: outer group was entirely Specify-owned → drop
|
|
elif _has_marker(entry):
|
|
pass # flat Specify-owned entry → drop
|
|
else:
|
|
kept.append(entry)
|
|
return kept
|
|
|
|
|
|
def _load_user_json(path: Path) -> dict | None:
|
|
"""Load a user-owned JSON file, aborting (None) on read/parse failure (#22/#23).
|
|
|
|
Returns the parsed dict, or ``None`` when the file is missing or cannot be
|
|
read or parsed (e.g. JSONC with comments, a temporarily malformed JSON
|
|
document, or an unreadable path). Callers must skip the merge rather than
|
|
resetting user content to ``{}``.
|
|
"""
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError, ValueError) as exc:
|
|
logger.warning(
|
|
"Could not read or parse %s (it may be unreadable, contain JSONC "
|
|
"comments, or be malformed); "
|
|
"skipping event-config merge to preserve user content.",
|
|
path,
|
|
)
|
|
logger.debug("Read/parse error detail: %s", exc)
|
|
return None
|
|
if not isinstance(data, dict):
|
|
logger.warning("%s is not a JSON object; skipping event-config merge.", path)
|
|
return None
|
|
return data
|
|
|
|
|
|
def _safe_write_json(dst: Path, data: dict) -> None:
|
|
"""Write *data* as JSON to *dst* after validating the destination (#12)."""
|
|
_ensure_safe_destination(dst)
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
dst.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def _ensure_safe_destination(dst: Path) -> None:
|
|
"""Validate a write target is a regular path inside the project (#12).
|
|
|
|
Walks each path component and rejects symlinks (which could escape the
|
|
project — e.g. a symlinked ``.claude`` or ``.specify`` directory pointing
|
|
outside the repo would redirect writes to external files). Then validates
|
|
lexical containment so ``..`` traversal is also rejected.
|
|
"""
|
|
from .agents import CommandRegistrar
|
|
|
|
# Walk each component so a symlinked ancestor (e.g. ``.claude`` → outside)
|
|
# cannot be silently followed. Mirrors IntegrationManifest.record_existing.
|
|
walked = dst.anchor and Path(dst.anchor) or Path("/")
|
|
for part in dst.relative_to(dst.anchor).parts if dst.anchor else dst.parts:
|
|
walked = walked / part
|
|
if walked.is_symlink():
|
|
raise ValueError(
|
|
f"Refusing to write event config through a symlink: {walked}"
|
|
)
|
|
|
|
# Containment check against the nearest existing ancestor directory.
|
|
base = dst.parent
|
|
while not base.exists() and base != base.parent:
|
|
base = base.parent
|
|
CommandRegistrar._ensure_inside(dst, base)
|
|
|
|
|
|
def _remove_json_entries(dst: Path) -> bool:
|
|
"""Remove Specify-authored entries; delete the file if now empty (#14).
|
|
|
|
Returns True if the file was deleted (Spec Kit created it and no user
|
|
content remains), False otherwise.
|
|
"""
|
|
_ensure_safe_destination(dst)
|
|
existing = _load_user_json(dst)
|
|
if existing is None:
|
|
return False
|
|
hooks = existing.get("hooks", {})
|
|
if not isinstance(hooks, dict):
|
|
return False
|
|
cleaned: dict[str, list] = {}
|
|
for event, entries in hooks.items():
|
|
if not isinstance(entries, list):
|
|
continue
|
|
kept_entries = _drop_marked_entries(entries)
|
|
if kept_entries:
|
|
cleaned[event] = kept_entries
|
|
if cleaned:
|
|
existing["hooks"] = cleaned
|
|
else:
|
|
existing.pop("hooks", None)
|
|
# #14/C5: if the config is now empty of user content, delete the file
|
|
# rather than leaving a stub that confuses manifest.uninstall(). A
|
|
# Spec-Kit-created Cursor file retains {"version": 1} after all owned
|
|
# hooks are removed (we added the version field); treat the version-only
|
|
# case as empty too, mirroring _remove_copilot_entries, so clean teardown
|
|
# doesn't leave a generated stub behind.
|
|
user_keys = {k for k in existing if k != "version"}
|
|
if not user_keys:
|
|
dst.unlink(missing_ok=True)
|
|
return True
|
|
_safe_write_json(dst, existing)
|
|
return False
|
|
|
|
|
|
def _has_marker(entry: Any) -> bool:
|
|
"""Return True if *entry* (or any nested inner hook) is Specify-marked (#9).
|
|
|
|
Flat entries carry the marker directly; nested matcher-groups carry it on
|
|
their inner ``hooks`` elements, so detection recurses one level to
|
|
recognize groups that are (wholly or partly) Specify-owned.
|
|
"""
|
|
if not isinstance(entry, dict):
|
|
return False
|
|
if entry.get(_SPECKIT_MARKER, False) is True:
|
|
return True
|
|
inner = entry.get("hooks")
|
|
if isinstance(inner, list):
|
|
return any(_has_marker(h) for h in inner)
|
|
return False
|