diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 3469115d6e..dafd29bed4 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -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). diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py index 136dec8674..4412239301 100644 --- a/src/specify_cli/integrations/vibe/__init__.py +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -11,9 +11,25 @@ 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 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str 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( diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py index 20ff3c0304..55f410c088 100644 --- a/tests/integrations/test_integration_vibe.py +++ b/tests/integrations/test_integration_vibe.py @@ -1,12 +1,29 @@ """Tests for VibeIntegration.""" +from unittest.mock import MagicMock + import yaml +from specify_cli.events import install_integration_events, remove_integration_events from specify_cli.integrations import get_integration +from specify_cli.integrations.base import IntegrationBase from specify_cli.integrations.manifest import IntegrationManifest from .test_integration_base_skills import SkillsIntegrationTests +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python < 3.11 + import tomli as tomllib # type: ignore + + +def _vibe_manifest() -> MagicMock: + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + return manifest + class TestVibeIntegration(SkillsIntegrationTests): KEY = "vibe" @@ -14,6 +31,274 @@ class TestVibeIntegration(SkillsIntegrationTests): COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".vibe/skills" + def test_is_base_integration(self): + assert isinstance(get_integration("vibe"), IntegrationBase) + + def test_multi_install_safe(self): + integration = get_integration("vibe") + assert integration.multi_install_safe is True + + def test_canonical_to_native_events(self): + """Vibe supports exactly three hook types: pre_tool, post_tool, post_agent.""" + integration = get_integration("vibe") + assert integration.CANONICAL_TO_NATIVE == { + "pre_tool_use": "pre_tool", + "post_tool_use": "post_tool", + "stop": "post_agent", + } + + def test_events_config(self): + integration = get_integration("vibe") + assert integration.events_config_file == ".vibe/hooks.toml" + assert integration.events_format == "toml-vibe" + + def test_setup_creates_skill_files(self, tmp_path): + integration = get_integration("vibe") + manifest = IntegrationManifest("vibe", tmp_path) + created = integration.setup(tmp_path, manifest, script_type="sh") + + skill_files = [path for path in created if path.name == "SKILL.md"] + assert skill_files + + skills_dir = tmp_path / ".vibe" / "skills" + assert skills_dir.is_dir() + + plan_skill = skills_dir / "speckit-plan" / "SKILL.md" + assert plan_skill.exists() + + content = plan_skill.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content + assert "{ARGS}" not in content + assert "__AGENT__" not in content + assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__" + assert "/speckit." not in content, "skills agent must use /speckit- not /speckit." + + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed["name"] == "speckit-plan" + assert parsed["user-invocable"] is True + assert parsed["disable-model-invocation"] is False + assert parsed["metadata"]["source"] == "templates/commands/plan.md" + + def test_render_skill_unicode(self): + """Test rendering a skill preserves non-ASCII characters.""" + integration = get_integration("vibe") + rendered = integration._render_skill( + "constitution", + {"description": "Prüfe Konformität der Implementierung"}, + "Body", + ) + assert "Prüfe Konformität" in rendered + + def test_setup_does_not_write_context_section(self, tmp_path): + """The CLI no longer manages the agent context file — that is owned by + the opt-in agent-context extension. Setup must not create or touch it.""" + integration = get_integration("vibe") + manifest = IntegrationManifest("vibe", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text + + def test_teardown_does_not_touch_existing_context_file(self, tmp_path): + """A user-authored context file is left intact on teardown.""" + integration = get_integration("vibe") + ctx_path = tmp_path / "AGENTS.md" + original = "# AGENTS.md\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") + + manifest = IntegrationManifest("vibe", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + integration.teardown(tmp_path, manifest) + + assert ctx_path.read_text(encoding="utf-8") == original + + def test_skills_do_not_have_argument_hint(self, tmp_path): + """Vibe does not support argument-hint in skill frontmatter, so it must not be injected.""" + integration = get_integration("vibe") + manifest = IntegrationManifest("vibe", tmp_path) + created = integration.setup(tmp_path, manifest, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + assert skill_files + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert "argument-hint:" not in content, ( + f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" + ) + + +class TestVibeTomlMerging: + """Behavioral tests for the toml-vibe hooks.toml generation and cleanup.""" + + def _install(self, tmp_path, events): + integration = get_integration("vibe") + manifest = _vibe_manifest() + install_integration_events(integration, tmp_path, manifest, events) + return integration, manifest + + def _parse(self, tmp_path): + return tomllib.loads((tmp_path / ".vibe" / "hooks.toml").read_text(encoding="utf-8")) + + def test_generated_toml_is_valid_and_schema_conformant(self, tmp_path): + self._install(tmp_path, { + "pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit|Write"}], + "stop": [{"command": "speckit.session.finish"}], + }) + data = self._parse(tmp_path) + hooks = data["hooks"] + assert len(hooks) == 2 + by_type = {h["type"]: h for h in hooks} + assert set(by_type) == {"pre_tool", "post_agent"} + for h in hooks: + assert h["name"].startswith("speckit-") + assert isinstance(h["command"], str) and h["command"] + assert isinstance(h["timeout"], int) + # Canonical Claude-style regex matcher lands in Vibe's `match` + # field with the `re:` escape — never in a `matcher` field. + assert by_type["pre_tool"]["match"] == "re:Edit|Write" + assert "matcher" not in by_type["pre_tool"] + # HookConfig rejects `match` on post_agent hooks. + assert "match" not in by_type["post_agent"] + + def test_wildcard_matcher_omitted(self, tmp_path): + self._install(tmp_path, { + "pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "*"}], + }) + (hook,) = self._parse(tmp_path)["hooks"] + assert "match" not in hook + + def test_unsupported_events_are_skipped(self, tmp_path, capsys): + self._install(tmp_path, { + "session_start": [{"command": "speckit.agent-context.update"}], + "pre_tool_use": [{"command": "speckit.tdd.validate"}], + }) + hooks = self._parse(tmp_path)["hooks"] + assert [h["type"] for h in hooks] == ["pre_tool"] + assert "does not support 'session_start'" in capsys.readouterr().err + + def test_multiple_handlers_get_unique_names(self, tmp_path): + """Vibe drops duplicate hook names, so shared command stems must not collide.""" + self._install(tmp_path, { + "pre_tool_use": [ + {"command": "speckit.tdd.validate"}, + {"command": "speckit.other.validate"}, + ], + }) + hooks = self._parse(tmp_path)["hooks"] + assert len(hooks) == 2 + names = [h["name"] for h in hooks] + assert len(set(names)) == 2 + commands = " ".join(h["command"] for h in hooks) + assert "speckit.tdd.validate" in commands + assert "speckit.other.validate" in commands + + def test_reinstall_is_idempotent(self, tmp_path): + events = { + "pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Bash"}], + "stop": [{"command": "speckit.session.finish"}], + } + self._install(tmp_path, events) + first = self._parse(tmp_path)["hooks"] + self._install(tmp_path, events) + second = self._parse(tmp_path)["hooks"] + assert second == first + + def test_merge_and_teardown_preserve_user_hooks(self, tmp_path): + config_path = tmp_path / ".vibe" / "hooks.toml" + config_path.parent.mkdir(parents=True) + user_block = ( + '[[hooks]]\n' + 'name = "deny-rm-rf"\n' + 'type = "pre_tool"\n' + 'match = "bash"\n' + 'command = "guard-bash"\n' + ) + config_path.write_text(user_block, encoding="utf-8") + + integration, manifest = self._install(tmp_path, { + "pre_tool_use": [{"command": "speckit.tdd.validate"}], + }) + merged = self._parse(tmp_path)["hooks"] + assert len(merged) == 2 + assert any(h["name"] == "deny-rm-rf" for h in merged) + + remove_integration_events(integration, tmp_path, manifest) + remaining = self._parse(tmp_path)["hooks"] + assert [h["name"] for h in remaining] == ["deny-rm-rf"] + + def test_commands_carry_structured_output_envelope(self, tmp_path): + """Vibe parses non-empty hook stdout as JSON (HookStructuredResponse); + plain text is reported as a hook failure. Every generated hook command + must therefore pass the hook_specific_output envelope to the dispatcher.""" + self._install(tmp_path, { + "pre_tool_use": [{"command": "speckit.tdd.validate"}], + "stop": [{"command": "speckit.session.finish"}], + }) + for hook in self._parse(tmp_path)["hooks"]: + assert hook["command"].endswith(" hook_specific_output"), hook["name"] + + def test_windows_host_uses_cmd_quoting(self, tmp_path, monkeypatch): + """Vibe runs hooks via create_subprocess_shell — cmd.exe on Windows, + where POSIX single quotes don't quote. A host interpreter path with + spaces must be double-quoted, never shlex-quoted.""" + import specify_cli.events as events_mod + + monkeypatch.setattr(events_mod, "_vibe_target_os", lambda: "cmd") + monkeypatch.setattr( + events_mod, "_resolve_interpreter", + lambda root: r"C:\Program Files\Python\python.exe", + ) + self._install(tmp_path, {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}) + (hook,) = self._parse(tmp_path)["hooks"] + assert hook["command"].startswith('"C:\\Program Files\\Python\\python.exe" ') + assert "'" not in hook["command"] + + def test_posix_host_keeps_shlex_quoting(self, tmp_path, monkeypatch): + import specify_cli.events as events_mod + + # Pin the target: on a Windows CI runner _vibe_target_os() would + # return "cmd" and this test asserts the POSIX-host quoting path. + monkeypatch.setattr(events_mod, "_vibe_target_os", lambda: "host") + monkeypatch.setattr( + events_mod, "_resolve_interpreter", + lambda root: "/opt/my venv/bin/python3", + ) + self._install(tmp_path, {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}) + (hook,) = self._parse(tmp_path)["hooks"] + assert hook["command"].startswith("'/opt/my venv/bin/python3' ") + + def test_envelope_resolution(self): + from specify_cli.events import _context_envelope_for + integration = get_integration("vibe") + for event in ("pre_tool_use", "post_tool_use", "stop"): + assert _context_envelope_for(integration, event) == "hook_specific_output" + + def test_emit_wraps_stdout_as_structured_response(self, capsys): + import json + + from specify_cli.events import _emit_event_stdout + + _emit_event_stdout("context line", "hook_specific_output") + data = json.loads(capsys.readouterr().out) + assert data == { + "decision": "allow", + "hook_specific_output": {"additional_context": "context line"}, + } + + # Empty stdout stays empty — Vibe treats it as "no response". + _emit_event_stdout("", "hook_specific_output") + assert capsys.readouterr().out == "" + + def test_teardown_deletes_file_without_user_content(self, tmp_path): + integration, manifest = self._install(tmp_path, { + "pre_tool_use": [{"command": "speckit.tdd.validate"}], + }) + assert (tmp_path / ".vibe" / "hooks.toml").is_file() + remove_integration_events(integration, tmp_path, manifest) + assert not (tmp_path / ".vibe" / "hooks.toml").exists() + class TestVibeUserInvocable: def test_all_skills_have_user_invocable(self, tmp_path): @@ -35,3 +320,17 @@ def test_all_skills_have_user_invocable(self, tmp_path): assert parsed.get("user-invocable") is True, ( f"{f.parent.name}/SKILL.md is missing user-invocable: true in frontmatter" ) + + def test_all_skills_have_disable_model_invocation(self, tmp_path): + i = get_integration("vibe") + m = IntegrationManifest("vibe", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + assert skill_files + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed.get("disable-model-invocation") is False, ( + f"{f.parent.name}/SKILL.md is missing disable-model-invocation: false in frontmatter" + )