Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 171 additions & 5 deletions src/specify_cli/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,10 @@ def _emit(output, envelope, native_event=""):
hookSpecificOutput → {"hookSpecificOutput": {"hookEventName": ..., "additionalContext": ...}}
additionalContext → {"additionalContext": ...} (top-level, Copilot)
additional_context → {"additional_context": ...} (top-level, Cursor)
hook_specific_output → {"decision": "allow", "hook_specific_output":
{"additional_context": ...}} (Vibe: any non-empty
stdout must parse as a HookStructuredResponse or
the hook is reported failed and output dropped)
suppress → emit nothing (strict-JSON agents on events whose
output can't be used)
plain (default) → passthrough (Claude/Codex inject plain stdout)
Expand All @@ -320,6 +324,9 @@ def _emit(output, envelope, native_event=""):
if envelope == "additional_context":
sys.stdout.write(json.dumps({"additional_context": output}) + "\\n")
return
if envelope == "hook_specific_output":
sys.stdout.write(json.dumps({"decision": "allow", "hook_specific_output": {"additional_context": output}}) + "\\n")
return
sys.stdout.write(output)


Expand All @@ -339,9 +346,10 @@ def main():
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.
# hook_specific_output, 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"):
if envelope not in ("plain", "hookSpecificOutput", "additionalContext", "additional_context", "hook_specific_output", "suppress"):
envelope = "plain"
# Optional 6th arg: native event name for hookSpecificOutput's
# hookEventName field (required by Qwen's hooks spec; included by
Expand Down Expand Up @@ -672,8 +680,10 @@ def resolve_and_run_event_command(
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).
Cursor respectively), ``hook_specific_output`` (Vibe's
HookStructuredResponse — any non-empty stdout that isn't valid JSON is
reported as a hook failure and dropped), 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
Expand Down Expand Up @@ -738,6 +748,13 @@ def _emit_event_stdout(output: str, envelope: str, native_event: str = "") -> No
if envelope == "additional_context":
sys.stdout.write(json.dumps({"additional_context": output}) + "\n")
return
if envelope == "hook_specific_output":
# Vibe parses any non-empty hook stdout as a HookStructuredResponse;
# plain text would be reported as a hook failure. Wrap it as an
# explicit allow with additional_context (injected on post_tool,
# harmlessly ignored on pre_tool/post_agent).
sys.stdout.write(json.dumps({"decision": "allow", "hook_specific_output": {"additional_context": output}}) + "\n")
return
sys.stdout.write(output)


Expand Down Expand Up @@ -1093,13 +1110,33 @@ def _shell_quote(value: str, target_os: str) -> str:
"""
if target_os == "windows":
return "'" + value.replace("'", "''") + "'"
if target_os == "cmd":
# cmd.exe (Vibe launches hooks via create_subprocess_shell, which is
# %COMSPEC% on Windows): single quotes are not quoting there, so a
# POSIX-quoted path with spaces would break apart. Double-quote only
# when needed; embedded double quotes are doubled (MSVCRT argv
# parsing treats "" inside a quoted string as a literal quote).
if re.fullmatch(r"[A-Za-z0-9_.\-\\/:]+", value):
return value
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 _vibe_target_os() -> str:
"""Quoting target for Vibe hook commands.

Vibe launches hooks with ``asyncio.create_subprocess_shell`` — the host's
native shell: POSIX ``sh`` on Unix, ``cmd.exe`` (%COMSPEC%) on Windows,
where POSIX single-quoting is not quoting at all and an interpreter or
dispatcher path containing spaces would split.
"""
return "cmd" if os.name == "nt" else "host"


def _dispatcher_command(
integration: IntegrationBase,
project_root: Path,
Expand All @@ -1122,6 +1159,8 @@ def _dispatcher_command(
both POSIX and Windows variants into one checked-in file (Copilot): ``host``
uses the host-resolved interpreter (venv-aware), while ``posix``/``windows``
emit portable interpreters so the config works on either OS (#S4).
``cmd`` also uses the host-resolved interpreter but quotes for cmd.exe —
for agents that launch hooks through the native Windows shell (Vibe).

Each component is shell-quoted for the target shell (R2) so an interpreter
path with spaces or a command/event containing shell metacharacters is
Expand All @@ -1147,7 +1186,10 @@ def _dispatcher_command(
shape the agent's hook protocol requires. Plain-passthrough agents
(Claude/Codex) declare no envelope and get no extra argument.
"""
if target_os == "host":
if target_os in ("host", "cmd"):
# "cmd" is host-resolved too (venv-aware): it is selected only when
# generating on a Windows host for an agent that runs hooks through
# cmd.exe (Vibe), and differs from "host" purely in quoting style.
interpreter = _resolve_interpreter(project_root)
else:
interpreter = _resolve_interpreter_for_target(target_os)
Expand Down Expand Up @@ -1357,6 +1399,55 @@ def install_integration_events(
manifest.record_existing(rel)
created.append(config_path)

elif fmt == "toml-vibe":
# Vibe hooks.toml custom merge. Flat [[hooks]] array; Vibe's
# HookConfig schema is name/type/command/match/timeout, with type
# limited to "pre_tool" | "post_tool" | "post_agent". Hook names must
# be unique (Vibe silently drops duplicates by name), so a per-file
# counter suffix disambiguates handlers whose commands share a final
# segment (e.g. speckit.a.validate vs speckit.b.validate).
lines: list[str] = []
used_names: set[str] = set()
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,
target_os=_vibe_target_os(),
timeout_seconds=cfg.get("timeout", 60),
)
command_stem = command.split('.')[-1] if command else "unknown"
command_stem = re.sub(r'[^A-Za-z0-9_-]+', '-', command_stem) or "unknown"
base_name = f"speckit-{native}-{command_stem}"
hook_name = base_name
suffix = 2
while hook_name in used_names:
hook_name = f"{base_name}-{suffix}"
suffix += 1
used_names.add(hook_name)
lines.append("[[hooks]]")
lines.append(f'name = {_toml_quote(hook_name)}')
lines.append(f'type = {_toml_quote(native)}')
# Vibe's field is `match` (fnmatch glob, or `re:`-prefixed
# regex, case-insensitive) and it is only valid on tool
# hooks — HookConfig rejects `match` on post_agent. Canonical
# matchers are Claude-style regexes ("Edit|Write"), so
# non-wildcard matchers are emitted as `re:` patterns.
matcher = cfg.get("matcher", "*")
if matcher and matcher != "*" and native in ("pre_tool", "post_tool"):
lines.append(f'match = {_toml_quote("re:" + matcher)}')
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_vibe_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).
Expand Down Expand Up @@ -1479,6 +1570,8 @@ def _remove_native_event_hooks(
_remove_copilot_entries(config_path)
elif fmt == "toml":
_remove_toml_entries(config_path)
elif fmt == "toml-vibe":
_remove_vibe_toml_entries(config_path)
elif fmt in ("json-nested", "json-flat"):
_remove_json_entries(config_path)
elif fmt == "json-root-nested":
Expand Down Expand Up @@ -1973,6 +2066,42 @@ def _merge_toml_fragment(dst: Path, fragment: str) -> bool:
return True


def _merge_vibe_toml_fragment(dst: Path, fragment: str) -> bool:
"""Merge Specify-owned Vibe TOML hook entries into *dst*, regenerating the file.

Vibe uses a flat [[hooks]] array with type/matcher/command fields.
This removes any existing Specify-marked hooks and appends the new fragment.
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
# Remove existing Specify-marked [[hooks]] blocks
# Match [[hooks]] ... speckit_marker = true (with any content in between)
existing = re.sub(
r'\[\[hooks\]\]\n(?:(?!\[\[hooks\]\]).)*?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).

Expand Down Expand Up @@ -2016,6 +2145,43 @@ def _remove_toml_entries(dst: Path) -> bool:
return False


def _remove_vibe_toml_entries(dst: Path) -> bool:
"""Remove Specify-marked Vibe TOML hook entries; delete the file if now empty.

Returns True if the file was deleted (no user content remained).
"""
if not dst.exists():
return False
_ensure_safe_destination(dst)
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 cleanup to preserve user content.",
dst,
)
logger.debug("Read error detail: %s", exc)
return False
# Remove Specify-marked [[hooks]] blocks
cleaned = re.sub(
r'\[\[hooks\]\]\n(?:(?!\[\[hooks\]\]).)*?speckit_marker = true\n*',
"",
existing,
flags=re.DOTALL,
)
# If only whitespace/comments remain, the file had no user content
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).

Expand Down
Loading