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>
This commit is contained in:
0x677A70
2026-08-13 11:56:24 -05:00
committed by GitHub
co-authored by Claude Fable 5 Mistral Vibe
parent 13c3c942fc
commit 54f8b2cdf0
3 changed files with 566 additions and 14 deletions
+171 -5
View File
@@ -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)
@@ -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)
@@ -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
@@ -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
@@ -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)
@@ -1093,6 +1110,15 @@ 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
@@ -1100,6 +1126,17 @@ def _shell_quote(value: str, target_os: str) -> str:
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,
@@ -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
@@ -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)
@@ -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).
@@ -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":
@@ -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).
@@ -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).
+96 -9
View File
@@ -11,9 +11,25 @@ from typing import Any
from ..base import IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
from ..._utils import dump_frontmatter
# Per-command frontmatter overrides for skills that should run in a forked
# subagent context.
#
# This is intentionally empty. ``analyze`` was previously forked (added in
# #2511) on the assumption that its heavy reads collapse to a short summary,
# but in practice ``/speckit-analyze`` returns a 300-500 line report that is
# injected back into the main conversation. In long sessions each subsequent
# fork inherits that growing context, compounding overhead until the chat
# freezes (#3185). Until a command genuinely returns a compact result, no
# command opts into ``context: fork``. The injection mechanism below stays in
# place so a future command can be added here when that holds true.
FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = {}
class VibeIntegration(SkillsIntegration):
"""Integration for Mistral Vibe skills."""
key = "vibe"
config = {
"name": "Mistral Vibe",
@@ -28,24 +44,63 @@ class VibeIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
multi_install_safe = True
# Vibe's hooks schema supports exactly three hook types (HookConfig
# rejects anything else): pre_tool, post_tool, post_agent. Unsupported
# canonical events (session_start/session_end/user_prompt_submit) are
# intentionally absent so install_integration_events skips them with a
# warning instead of writing entries Vibe would refuse to load.
CANONICAL_TO_NATIVE = {
"pre_tool_use": "pre_tool",
"post_tool_use": "post_tool",
"stop": "post_agent",
}
events_config_file = ".vibe/hooks.toml"
events_format = "toml-vibe"
# Vibe parses any non-empty hook stdout as a JSON HookStructuredResponse;
# plain text is reported as a hook failure and its output dropped. The
# dispatcher therefore wraps handler stdout as {"decision": "allow",
# "hook_specific_output": {"additional_context": ...}} for every event:
# post_tool injects additional_context, pre_tool/post_agent ignore it but
# still parse cleanly.
events_context_envelope = {"*": "hook_specific_output"}
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
opts = super().options()
opts.append(
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills",
),
]
)
return opts
def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str:
"""Render a processed command template as a Vibe skill."""
skill_name = f"speckit-{template_name.replace('.', '-')}"
description = frontmatter.get(
"description",
f"Spec-kit workflow command: {template_name}",
)
skill_frontmatter = self._build_skill_fm(
skill_name, description, f"templates/commands/{template_name}.md"
)
frontmatter_text = dump_frontmatter(skill_frontmatter)
return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n"
def _build_skill_fm(self, name: str, description: str, source: str) -> dict:
from specify_cli.agents import CommandRegistrar
return CommandRegistrar.build_skill_frontmatter(
self.key, name, description, source
)
@staticmethod
def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
"""
Insert ``key: value`` before the closing ``---`` if not already present.
Value: true by default
"""
"""Insert ``key: value`` before the closing ``---`` if not already present."""
lines = content.splitlines(keepends=True)
# Pre-scan: bail out if already present in frontmatter
@@ -80,13 +135,45 @@ class VibeIntegration(SkillsIntegration):
out.append(line)
return "".join(out)
def post_process_skill_content(self, content: str) -> str:
@staticmethod
def _skill_stem_from_content(content: str) -> str | None:
"""Derive the command stem (e.g. ``analyze``) from a skill's frontmatter.
Reads the ``name:`` field of the first frontmatter block and strips
the ``speckit-`` prefix. Returns ``None`` when no name is present.
"""
Inject shared hook guidance and Vibe-specific frontmatter flags:
- user-invocable: allows the skill to be invoked by the user (not just other agents)
dash_count = 0
for line in content.splitlines():
stripped = line.rstrip("\r\n")
if stripped == "---":
dash_count += 1
if dash_count == 2:
break
continue
if dash_count == 1 and stripped.startswith("name:"):
name = stripped[len("name:"):].strip().strip('"').strip("'")
if name.startswith("speckit-"):
return name[len("speckit-"):]
return name or None
return None
def post_process_skill_content(self, content: str) -> str:
"""Inject Vibe-specific frontmatter flags.
Applied by every skill-generation path (setup, presets, extensions),
so Vibe-specific frontmatter stays consistent however the SKILL.md
was produced.
"""
updated = super().post_process_skill_content(content)
updated = self._inject_frontmatter_flag(updated, "user-invocable")
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
stem = self._skill_stem_from_content(updated)
if stem:
fork_config = FORK_CONTEXT_COMMANDS.get(stem)
if fork_config:
for key, value in fork_config.items():
updated = self._inject_frontmatter_flag(updated, key, value)
return updated
def setup(