Skip to content

Commit 54f8b2c

Browse files
0x677A70mistral-vibeclaude
authored
feat: add Mistral Vibe integration with Claude parity (#4075)
* feat: add Mistral Vibe integration with Claude parity - Add VibeIntegration class with ARGUMENT_HINTS, user-invocable, disable-model-invocation - Add comprehensive test suite matching Claude integration - Support all Spec Kit workflows (py/sh/ps script types) * fix: address Vibe integration issues and test cleanup - Fix Vibe to use .vibe/hooks.toml with toml-vibe format instead of ignored .vibe/settings.json, adding toml-vibe event handler - Remove unsupported argument-hint injection (Vibe schema doesn't support it) - Restructure test file to inherit from SkillsIntegrationTests mixin - Remove all unused imports to pass Ruff F401 checks Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai> * fix: add name field to Vibe hooks and fix toml regex patterns - Add required 'name' field for each Vibe hook in hooks.toml - Fix regex patterns in _merge_vibe_toml_fragment and _remove_vibe_toml_entries to correctly match [[hooks]] blocks instead of [} characters Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai> * fix: align Vibe hooks with HookConfig schema and drop stray devcontainer lock - use Vibe's 'match' field (re:-prefixed regex translation) instead of unsupported 'matcher'; emit only on tool hooks (rejected on post_agent) - limit CANONICAL_TO_NATIVE to Vibe's three hook types (pre_tool, post_tool, post_agent); unsupported events skip with a warning - deduplicate generated hook names (Vibe drops duplicates by name) - add behavioral tests for toml-vibe generation, merging, and teardown - remove accidentally committed .devcontainer/devcontainer-lock.json Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: wrap Vibe hook stdout in structured JSON response envelope Vibe parses any non-empty hook stdout as a JSON HookStructuredResponse; plain text is reported as a hook failure and its output dropped. Add a hook_specific_output envelope to the dispatcher (template and runtime) that emits {"decision": "allow", "hook_specific_output": {"additional_context": ...}} and declare it for all Vibe events: post_tool injects the context, pre_tool/post_agent parse cleanly and ignore it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: quote Vibe hook commands for cmd.exe on Windows hosts Vibe launches hooks via asyncio.create_subprocess_shell, which is %COMSPEC% (cmd.exe) on Windows — POSIX single-quoting is not quoting there, so an interpreter or dispatcher path containing spaces made every hook fail to start. Add a 'cmd' quoting target to _shell_quote (double-quote when needed, embedded quotes doubled per MSVCRT argv rules), resolve it host-side like 'host', and select it for Vibe when generating on a Windows host. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: pin POSIX quoting target in Vibe test for Windows CI runners test_posix_host_keeps_shlex_quoting asserts host (shlex) quoting, but on a Windows runner _vibe_target_os() resolves to 'cmd' and the command is double-quoted. Monkeypatch the target so the test exercises the POSIX path on every platform. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Mistral Vibe <vibe@mistral.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 13c3c94 commit 54f8b2c

3 files changed

Lines changed: 566 additions & 14 deletions

File tree

‎src/specify_cli/events.py‎

Lines changed: 171 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,10 @@ def _emit(output, envelope, native_event=""):
297297
hookSpecificOutput → {"hookSpecificOutput": {"hookEventName": ..., "additionalContext": ...}}
298298
additionalContext → {"additionalContext": ...} (top-level, Copilot)
299299
additional_context → {"additional_context": ...} (top-level, Cursor)
300+
hook_specific_output → {"decision": "allow", "hook_specific_output":
301+
{"additional_context": ...}} (Vibe: any non-empty
302+
stdout must parse as a HookStructuredResponse or
303+
the hook is reported failed and output dropped)
300304
suppress → emit nothing (strict-JSON agents on events whose
301305
output can't be used)
302306
plain (default) → passthrough (Claude/Codex inject plain stdout)
@@ -320,6 +324,9 @@ def _emit(output, envelope, native_event=""):
320324
if envelope == "additional_context":
321325
sys.stdout.write(json.dumps({"additional_context": output}) + "\\n")
322326
return
327+
if envelope == "hook_specific_output":
328+
sys.stdout.write(json.dumps({"decision": "allow", "hook_specific_output": {"additional_context": output}}) + "\\n")
329+
return
323330
sys.stdout.write(output)
324331
325332
@@ -339,9 +346,10 @@ def main():
339346
timeout = 120
340347
# Optional 5th arg: context-injection envelope for stdout (C13): plain
341348
# (default), hookSpecificOutput, additionalContext, additional_context,
342-
# or suppress. Unknown values fall back to plain passthrough.
349+
# hook_specific_output, or suppress. Unknown values fall back to plain
350+
# passthrough.
343351
envelope = sys.argv[4] if len(sys.argv) >= 5 else "plain"
344-
if envelope not in ("plain", "hookSpecificOutput", "additionalContext", "additional_context", "suppress"):
352+
if envelope not in ("plain", "hookSpecificOutput", "additionalContext", "additional_context", "hook_specific_output", "suppress"):
345353
envelope = "plain"
346354
# Optional 6th arg: native event name for hookSpecificOutput's
347355
# hookEventName field (required by Qwen's hooks spec; included by
@@ -672,8 +680,10 @@ def resolve_and_run_event_command(
672680
context-injection protocol (C13): ``plain`` passthrough (Claude/Codex
673681
inject plain stdout), ``hookSpecificOutput``/``additionalContext``/
674682
``additional_context`` JSON wrappers (Gemini/Tabnine/Qwen/Devin, Copilot,
675-
Cursor respectively), or ``suppress`` (strict-JSON agents on events whose
676-
output can't be used).
683+
Cursor respectively), ``hook_specific_output`` (Vibe's
684+
HookStructuredResponse — any non-empty stdout that isn't valid JSON is
685+
reported as a hook failure and dropped), or ``suppress`` (strict-JSON
686+
agents on events whose output can't be used).
677687
678688
*native_event* is the agent's native hookEventName (e.g. ``"SessionStart"``),
679689
required inside ``hookSpecificOutput`` by Qwen's hooks spec (and included
@@ -738,6 +748,13 @@ def _emit_event_stdout(output: str, envelope: str, native_event: str = "") -> No
738748
if envelope == "additional_context":
739749
sys.stdout.write(json.dumps({"additional_context": output}) + "\n")
740750
return
751+
if envelope == "hook_specific_output":
752+
# Vibe parses any non-empty hook stdout as a HookStructuredResponse;
753+
# plain text would be reported as a hook failure. Wrap it as an
754+
# explicit allow with additional_context (injected on post_tool,
755+
# harmlessly ignored on pre_tool/post_agent).
756+
sys.stdout.write(json.dumps({"decision": "allow", "hook_specific_output": {"additional_context": output}}) + "\n")
757+
return
741758
sys.stdout.write(output)
742759

743760

@@ -1093,13 +1110,33 @@ def _shell_quote(value: str, target_os: str) -> str:
10931110
"""
10941111
if target_os == "windows":
10951112
return "'" + value.replace("'", "''") + "'"
1113+
if target_os == "cmd":
1114+
# cmd.exe (Vibe launches hooks via create_subprocess_shell, which is
1115+
# %COMSPEC% on Windows): single quotes are not quoting there, so a
1116+
# POSIX-quoted path with spaces would break apart. Double-quote only
1117+
# when needed; embedded double quotes are doubled (MSVCRT argv
1118+
# parsing treats "" inside a quoted string as a literal quote).
1119+
if re.fullmatch(r"[A-Za-z0-9_.\-\\/:]+", value):
1120+
return value
1121+
return '"' + value.replace('"', '""') + '"'
10961122
# "host" and "posix" both use POSIX quoting. On Windows the single-
10971123
# command-string formats (Claude/Gemini/Qwen/Devin/Tabnine) are run via
10981124
# Git Bash or the agent's POSIX-ish shell, so POSIX quoting is correct and
10991125
# avoids emitting 'python' (which PowerShell wouldn't invoke without &).
11001126
return shlex.quote(value)
11011127

11021128

1129+
def _vibe_target_os() -> str:
1130+
"""Quoting target for Vibe hook commands.
1131+
1132+
Vibe launches hooks with ``asyncio.create_subprocess_shell`` — the host's
1133+
native shell: POSIX ``sh`` on Unix, ``cmd.exe`` (%COMSPEC%) on Windows,
1134+
where POSIX single-quoting is not quoting at all and an interpreter or
1135+
dispatcher path containing spaces would split.
1136+
"""
1137+
return "cmd" if os.name == "nt" else "host"
1138+
1139+
11031140
def _dispatcher_command(
11041141
integration: IntegrationBase,
11051142
project_root: Path,
@@ -1122,6 +1159,8 @@ def _dispatcher_command(
11221159
both POSIX and Windows variants into one checked-in file (Copilot): ``host``
11231160
uses the host-resolved interpreter (venv-aware), while ``posix``/``windows``
11241161
emit portable interpreters so the config works on either OS (#S4).
1162+
``cmd`` also uses the host-resolved interpreter but quotes for cmd.exe —
1163+
for agents that launch hooks through the native Windows shell (Vibe).
11251164
11261165
Each component is shell-quoted for the target shell (R2) so an interpreter
11271166
path with spaces or a command/event containing shell metacharacters is
@@ -1147,7 +1186,10 @@ def _dispatcher_command(
11471186
shape the agent's hook protocol requires. Plain-passthrough agents
11481187
(Claude/Codex) declare no envelope and get no extra argument.
11491188
"""
1150-
if target_os == "host":
1189+
if target_os in ("host", "cmd"):
1190+
# "cmd" is host-resolved too (venv-aware): it is selected only when
1191+
# generating on a Windows host for an agent that runs hooks through
1192+
# cmd.exe (Vibe), and differs from "host" purely in quoting style.
11511193
interpreter = _resolve_interpreter(project_root)
11521194
else:
11531195
interpreter = _resolve_interpreter_for_target(target_os)
@@ -1357,6 +1399,55 @@ def install_integration_events(
13571399
manifest.record_existing(rel)
13581400
created.append(config_path)
13591401

1402+
elif fmt == "toml-vibe":
1403+
# Vibe hooks.toml custom merge. Flat [[hooks]] array; Vibe's
1404+
# HookConfig schema is name/type/command/match/timeout, with type
1405+
# limited to "pre_tool" | "post_tool" | "post_agent". Hook names must
1406+
# be unique (Vibe silently drops duplicates by name), so a per-file
1407+
# counter suffix disambiguates handlers whose commands share a final
1408+
# segment (e.g. speckit.a.validate vs speckit.b.validate).
1409+
lines: list[str] = []
1410+
used_names: set[str] = set()
1411+
for ev, handlers in filtered.items():
1412+
native = canonical_to_native[ev]
1413+
for cfg in handlers:
1414+
command = cfg.get("command", "")
1415+
dispatcher_cmd = _dispatcher_command(
1416+
integration, project_root, command, ev,
1417+
target_os=_vibe_target_os(),
1418+
timeout_seconds=cfg.get("timeout", 60),
1419+
)
1420+
command_stem = command.split('.')[-1] if command else "unknown"
1421+
command_stem = re.sub(r'[^A-Za-z0-9_-]+', '-', command_stem) or "unknown"
1422+
base_name = f"speckit-{native}-{command_stem}"
1423+
hook_name = base_name
1424+
suffix = 2
1425+
while hook_name in used_names:
1426+
hook_name = f"{base_name}-{suffix}"
1427+
suffix += 1
1428+
used_names.add(hook_name)
1429+
lines.append("[[hooks]]")
1430+
lines.append(f'name = {_toml_quote(hook_name)}')
1431+
lines.append(f'type = {_toml_quote(native)}')
1432+
# Vibe's field is `match` (fnmatch glob, or `re:`-prefixed
1433+
# regex, case-insensitive) and it is only valid on tool
1434+
# hooks — HookConfig rejects `match` on post_agent. Canonical
1435+
# matchers are Claude-style regexes ("Edit|Write"), so
1436+
# non-wildcard matchers are emitted as `re:` patterns.
1437+
matcher = cfg.get("matcher", "*")
1438+
if matcher and matcher != "*" and native in ("pre_tool", "post_tool"):
1439+
lines.append(f'match = {_toml_quote("re:" + matcher)}')
1440+
lines.append(f'command = {_toml_quote(dispatcher_cmd)}')
1441+
lines.append(f'timeout = {_native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER)}')
1442+
lines.append('speckit_marker = true')
1443+
lines.append('')
1444+
# S5: only track when the merge wrote (skips on unreadable file).
1445+
if _merge_vibe_toml_fragment(config_path, "\n".join(lines)):
1446+
rel = str(config_path.relative_to(project_root))
1447+
if rel not in manifest.files:
1448+
manifest.record_existing(rel)
1449+
created.append(config_path)
1450+
13601451
elif fmt == "json-flat":
13611452
# Cursor hooks.json custom merge. Flat command-string entries, one
13621453
# per handler (#2), single resolved command string (#6/#16).
@@ -1479,6 +1570,8 @@ def _remove_native_event_hooks(
14791570
_remove_copilot_entries(config_path)
14801571
elif fmt == "toml":
14811572
_remove_toml_entries(config_path)
1573+
elif fmt == "toml-vibe":
1574+
_remove_vibe_toml_entries(config_path)
14821575
elif fmt in ("json-nested", "json-flat"):
14831576
_remove_json_entries(config_path)
14841577
elif fmt == "json-root-nested":
@@ -1973,6 +2066,42 @@ def _merge_toml_fragment(dst: Path, fragment: str) -> bool:
19732066
return True
19742067

19752068

2069+
def _merge_vibe_toml_fragment(dst: Path, fragment: str) -> bool:
2070+
"""Merge Specify-owned Vibe TOML hook entries into *dst*, regenerating the file.
2071+
2072+
Vibe uses a flat [[hooks]] array with type/matcher/command fields.
2073+
This removes any existing Specify-marked hooks and appends the new fragment.
2074+
An unreadable or undecodable pre-existing file aborts the merge instead
2075+
of discarding the user's bytes, mirroring ``_load_user_json`` (#22).
2076+
Returns False when skipped so callers avoid tracking the untouched file
2077+
(S5).
2078+
"""
2079+
_ensure_safe_destination(dst)
2080+
existing = ""
2081+
if dst.exists():
2082+
try:
2083+
existing = dst.read_text(encoding="utf-8")
2084+
except (OSError, UnicodeDecodeError) as exc:
2085+
logger.warning(
2086+
"Could not read %s (it may be unreadable or not UTF-8); "
2087+
"skipping event-config merge to preserve user content.",
2088+
dst,
2089+
)
2090+
logger.debug("Read error detail: %s", exc)
2091+
return False
2092+
# Remove existing Specify-marked [[hooks]] blocks
2093+
# Match [[hooks]] ... speckit_marker = true (with any content in between)
2094+
existing = re.sub(
2095+
r'\[\[hooks\]\]\n(?:(?!\[\[hooks\]\]).)*?speckit_marker = true\n*',
2096+
"",
2097+
existing,
2098+
flags=re.DOTALL,
2099+
)
2100+
dst.parent.mkdir(parents=True, exist_ok=True)
2101+
dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
2102+
return True
2103+
2104+
19762105
def _remove_toml_entries(dst: Path) -> bool:
19772106
"""Remove Specify-marked TOML entries; delete the file if now empty (#14).
19782107
@@ -2016,6 +2145,43 @@ def _remove_toml_entries(dst: Path) -> bool:
20162145
return False
20172146

20182147

2148+
def _remove_vibe_toml_entries(dst: Path) -> bool:
2149+
"""Remove Specify-marked Vibe TOML hook entries; delete the file if now empty.
2150+
2151+
Returns True if the file was deleted (no user content remained).
2152+
"""
2153+
if not dst.exists():
2154+
return False
2155+
_ensure_safe_destination(dst)
2156+
try:
2157+
existing = dst.read_text(encoding="utf-8")
2158+
except (OSError, UnicodeDecodeError) as exc:
2159+
logger.warning(
2160+
"Could not read %s (it may be unreadable or not UTF-8); "
2161+
"skipping event-config cleanup to preserve user content.",
2162+
dst,
2163+
)
2164+
logger.debug("Read error detail: %s", exc)
2165+
return False
2166+
# Remove Specify-marked [[hooks]] blocks
2167+
cleaned = re.sub(
2168+
r'\[\[hooks\]\]\n(?:(?!\[\[hooks\]\]).)*?speckit_marker = true\n*',
2169+
"",
2170+
existing,
2171+
flags=re.DOTALL,
2172+
)
2173+
# If only whitespace/comments remain, the file had no user content
2174+
stripped = "\n".join(
2175+
line for line in cleaned.splitlines()
2176+
if line.strip() and not line.strip().startswith("#")
2177+
)
2178+
if not stripped:
2179+
dst.unlink(missing_ok=True)
2180+
return True
2181+
dst.write_text(cleaned, encoding="utf-8")
2182+
return False
2183+
2184+
20192185
def _merge_copilot_json(dst: Path, new_hooks: dict[str, list]) -> bool:
20202186
"""Merge Specify-owned hooks into Copilot's dedicated hooks JSON (#8).
20212187

0 commit comments

Comments
 (0)