From 372c6f25ff60cfb761279c88695b9dca6f7a1093 Mon Sep 17 00:00:00 2001 From: 0x677A70 <457616+0x677A70@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:26:06 +0200 Subject: [PATCH 1/3] 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) --- src/specify_cli/integrations/vibe/__init__.py | 170 ++++++++++- tests/integrations/test_integration_vibe.py | 275 +++++++++++++++++- 2 files changed, 434 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py index 136dec8674..de865de982 100644 --- a/src/specify_cli/integrations/vibe/__init__.py +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -11,6 +11,25 @@ from ..base import IntegrationOption, SkillsIntegration from ..manifest import IntegrationManifest +from ..._utils import dump_frontmatter + +# Mapping of command template stem → argument-hint text shown inline +# when a user invokes the slash command in Mistral Vibe. +ARGUMENT_HINTS: dict[str, str] = { + "specify": "Describe the feature you want to specify", + "plan": "Optional guidance for the planning phase", + "tasks": "Optional task generation constraints", + "implement": "Optional implementation guidance or task filter", + "analyze": "Optional focus areas for analysis", + "clarify": "Optional areas to clarify in the spec", + "constitution": "Principles or values for the project constitution", + "checklist": "Domain or focus area for the checklist", + "taskstoissues": "Optional filter or label for GitHub issues", +} + +# Per-command frontmatter overrides for skills that should run in a forked +# subagent context. Currently empty - no commands opt into forked execution. +FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = {} class VibeIntegration(SkillsIntegration): @@ -28,17 +47,103 @@ class VibeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } + multi_install_safe = True + + CANONICAL_TO_NATIVE = { + "session_start": "SessionStart", + "pre_tool_use": "PreToolUse", + "post_tool_use": "PostToolUse", + "session_end": "SessionEnd", + "user_prompt_submit": "UserPromptSubmit", + "stop": "Stop", + } + events_config_file = ".vibe/settings.json" + events_format = "json-nested" @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 + + @staticmethod + def inject_argument_hint(content: str, hint: str) -> str: + """Insert ``argument-hint`` after the ``description:`` scalar in YAML frontmatter. + + A long ``description`` gets folded by the YAML dumper across + indented continuation lines (plain or quoted), and an embedded + paragraph break can add unindented blank lines inside a quoted + scalar. Inserting the new line right after the *first* line of + that scalar — instead of after the whole scalar — either produces + invalid YAML or gets silently absorbed into the description + string, so every continuation line (indented, or blank) is skipped + first. + + Skips injection if ``argument-hint:`` already exists in the + frontmatter to avoid duplicate keys. + """ + lines = content.splitlines(keepends=True) + + # Pre-scan: bail out if argument-hint already present in frontmatter + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith("argument-hint:"): + return content + + out: list[str] = [] + in_fm = False + dash_count = 0 + injected = False + i = 0 + n = len(lines) + while i < n: + line = lines[i] + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + in_fm = dash_count == 1 + out.append(line) + i += 1 + continue + if in_fm and not injected and stripped.startswith("description:"): + out.append(line) + i += 1 + # Skip past folded/quoted continuation lines of the scalar + # before inserting, so the new key lands after it ends. + # Blank lines count too: PyYAML emits unindented blank + # lines for embedded "\n\n" inside a quoted scalar. + while i < n and ( + lines[i][:1] in (" ", "\t") or lines[i].rstrip("\r\n") == "" + ): + out.append(lines[i]) + i += 1 + # Preserve the exact line-ending style (\r\n vs \n) + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + escaped = hint.replace("\\", "\\\\").replace('"', '\\"') + out.append(f'argument-hint: "{escaped}"{eol}') + injected = True + continue + out.append(line) + i += 1 + return "".join(out) @staticmethod def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str: @@ -80,13 +185,68 @@ 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 _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 + ) + + def post_process_skill_content(self, content: str) -> str: + """Inject Vibe-specific frontmatter flags, hook notes, and any + per-command frontmatter. + + Applied by every skill-generation path (setup, presets, extensions), + so command-specific frontmatter (argument-hint, fork context) 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: + hint = ARGUMENT_HINTS.get(stem, "") + if hint: + updated = self.inject_argument_hint(updated, hint) + 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..c61baf4a7a 100644 --- a/tests/integrations/test_integration_vibe.py +++ b/tests/integrations/test_integration_vibe.py @@ -1,18 +1,267 @@ """Tests for VibeIntegration.""" +import json +import os +from pathlib import Path +from unittest.mock import patch + import yaml -from specify_cli.integrations import get_integration +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import IntegrationBase, SkillsIntegration from specify_cli.integrations.manifest import IntegrationManifest +from specify_cli.integrations.vibe import ARGUMENT_HINTS, FORK_CONTEXT_COMMANDS from .test_integration_base_skills import SkillsIntegrationTests -class TestVibeIntegration(SkillsIntegrationTests): - KEY = "vibe" - FOLDER = ".vibe/" - COMMANDS_SUBDIR = "skills" - REGISTRAR_DIR = ".vibe/skills" +class TestVibeIntegration: + def test_registered(self): + assert "vibe" in INTEGRATION_REGISTRY + assert get_integration("vibe") is not None + + def test_is_base_integration(self): + assert isinstance(get_integration("vibe"), IntegrationBase) + + def test_is_skills_integration(self): + assert isinstance(get_integration("vibe"), SkillsIntegration) + + def test_config_uses_skills(self): + integration = get_integration("vibe") + assert integration.config["folder"] == ".vibe/" + assert integration.config["commands_subdir"] == "skills" + + def test_registrar_config_uses_skill_layout(self): + integration = get_integration("vibe") + assert integration.registrar_config["dir"] == ".vibe/skills" + assert integration.registrar_config["format"] == "markdown" + assert integration.registrar_config["args"] == "$ARGUMENTS" + assert integration.registrar_config["extension"] == "/SKILL.md" + + def test_multi_install_safe(self): + integration = get_integration("vibe") + assert integration.multi_install_safe is True + + def test_canonical_to_native_events(self): + integration = get_integration("vibe") + assert integration.CANONICAL_TO_NATIVE is not None + assert integration.CANONICAL_TO_NATIVE.get("session_start") == "SessionStart" + assert integration.CANONICAL_TO_NATIVE.get("pre_tool_use") == "PreToolUse" + assert integration.CANONICAL_TO_NATIVE.get("post_tool_use") == "PostToolUse" + assert integration.CANONICAL_TO_NATIVE.get("session_end") == "SessionEnd" + assert integration.CANONICAL_TO_NATIVE.get("user_prompt_submit") == "UserPromptSubmit" + assert integration.CANONICAL_TO_NATIVE.get("stop") == "Stop" + + def test_events_config(self): + integration = get_integration("vibe") + assert integration.events_config_file == ".vibe/settings.json" + assert integration.events_format == "json-nested" + + 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 + + +class TestVibeArgumentHints: + """Verify that argument-hint frontmatter is injected for Vibe skills.""" + + def test_converge_has_no_argument_hint(self): + """Converge should not advertise unsupported feature-name arguments.""" + assert "converge" not in ARGUMENT_HINTS + + def test_all_skills_have_hints(self, tmp_path): + """Every skill with a configured hint must contain an argument-hint line.""" + 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 len(skill_files) > 0 + for f in skill_files: + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + content = f.read_text(encoding="utf-8") + if stem in ARGUMENT_HINTS: + assert "argument-hint:" in content, ( + f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter" + ) + else: + assert "argument-hint:" not in content, ( + f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" + ) + + def test_hints_match_expected_values(self, tmp_path): + """Each skill's argument-hint must match the expected text.""" + 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"] + for f in skill_files: + # Extract stem: speckit-plan -> plan + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + expected_hint = ARGUMENT_HINTS.get(stem) + content = f.read_text(encoding="utf-8") + if expected_hint is None: + assert "argument-hint:" not in content, ( + f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" + ) + else: + assert f'argument-hint: "{expected_hint}"' in content, ( + f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found" + ) + + def test_hint_is_inside_frontmatter(self, tmp_path): + """argument-hint must appear between the --- delimiters, not in the body.""" + 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"] + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md" + frontmatter = parts[1] + body = parts[2] + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + if stem in ARGUMENT_HINTS: + assert "argument-hint:" in frontmatter, ( + f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section" + ) + assert "argument-hint:" not in body, ( + f"{f.parent.name}/SKILL.md: argument-hint leaked into body" + ) + else: + assert "argument-hint:" not in content, ( + f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" + ) + + def test_hint_appears_after_description(self, tmp_path): + """argument-hint must immediately follow the description line.""" + 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"] + for f in skill_files: + content = f.read_text(encoding="utf-8") + lines = content.splitlines() + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + if stem not in ARGUMENT_HINTS: + assert "argument-hint:" not in content, ( + f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" + ) + continue + found_description = False + for idx, line in enumerate(lines): + if line.startswith("description:"): + found_description = True + assert idx + 1 < len(lines), ( + f"{f.parent.name}/SKILL.md: description is last line" + ) + assert lines[idx + 1].startswith("argument-hint:"), ( + f"{f.parent.name}/SKILL.md: argument-hint does not follow description" + ) + break + assert found_description, ( + f"{f.parent.name}/SKILL.md: no description: line found in output" + ) + + def test_inject_argument_hint_only_in_frontmatter(self): + """inject_argument_hint must not modify description: lines in the body.""" + from specify_cli.integrations.vibe import VibeIntegration + + content = ( + "---\n" + "description: My command\n" + "---\n" + "\n" + "description: this is body text\n" + ) + result = VibeIntegration.inject_argument_hint(content, "Test hint") + lines = result.splitlines() + hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) + assert hint_count == 1, ( + f"Expected exactly 1 argument-hint line, found {hint_count}" + ) + + def test_inject_argument_hint_skips_if_already_present(self): + """inject_argument_hint must not duplicate if argument-hint already exists.""" + from specify_cli.integrations.vibe import VibeIntegration + + content = ( + "---\n" + "description: My command\n" + 'argument-hint: "Existing hint"\n' + "---\n" + "\n" + "Body text\n" + ) + result = VibeIntegration.inject_argument_hint(content, "New hint") + assert result == content, "Content should be unchanged when hint already exists" class TestVibeUserInvocable: @@ -35,3 +284,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" + ) From de6c89a00b65120ca93d74b5daeb25eba1c59592 Mon Sep 17 00:00:00 2001 From: 0x677A70 <457616+0x677A70@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:35:46 +0200 Subject: [PATCH 2/3] 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 --- src/specify_cli/events.py | 100 +++++++++ src/specify_cli/integrations/vibe/__init__.py | 164 ++++----------- tests/integrations/test_integration_vibe.py | 196 ++---------------- 3 files changed, 164 insertions(+), 296 deletions(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 3469115d6e..7d8aef5955 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -1357,6 +1357,31 @@ def install_integration_events( manifest.record_existing(rel) created.append(config_path) + elif fmt == "toml-vibe": + # Vibe hooks.toml custom merge. Flat [[hooks]] array with type field. + # Vibe expects type = "pre_tool" | "post_tool" | "post_agent" (and others). + 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("[[hooks]]") + lines.append(f'type = {_toml_quote(native)}') + matcher = cfg.get("matcher", "*") + if matcher != "*": + lines.append(f'matcher = {_toml_quote(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 +1504,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 +2000,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(?:(?!\\[\}[^:]*\]).)*?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 +2079,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(?:(?!\\[\}[^:]*\]).)*?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 de865de982..4dfd5df64f 100644 --- a/src/specify_cli/integrations/vibe/__init__.py +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -13,26 +13,23 @@ from ..manifest import IntegrationManifest from ..._utils import dump_frontmatter -# Mapping of command template stem → argument-hint text shown inline -# when a user invokes the slash command in Mistral Vibe. -ARGUMENT_HINTS: dict[str, str] = { - "specify": "Describe the feature you want to specify", - "plan": "Optional guidance for the planning phase", - "tasks": "Optional task generation constraints", - "implement": "Optional implementation guidance or task filter", - "analyze": "Optional focus areas for analysis", - "clarify": "Optional areas to clarify in the spec", - "constitution": "Principles or values for the project constitution", - "checklist": "Domain or focus area for the checklist", - "taskstoissues": "Optional filter or label for GitHub issues", -} - # Per-command frontmatter overrides for skills that should run in a forked -# subagent context. Currently empty - no commands opt into forked execution. +# 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", @@ -50,15 +47,15 @@ class VibeIntegration(SkillsIntegration): multi_install_safe = True CANONICAL_TO_NATIVE = { - "session_start": "SessionStart", - "pre_tool_use": "PreToolUse", - "post_tool_use": "PostToolUse", - "session_end": "SessionEnd", - "user_prompt_submit": "UserPromptSubmit", - "stop": "Stop", + "session_start": "session_start", + "pre_tool_use": "pre_tool", + "post_tool_use": "post_tool", + "session_end": "session_end", + "user_prompt_submit": "user_prompt_submit", + "stop": "post_agent", } - events_config_file = ".vibe/settings.json" - events_format = "json-nested" + events_config_file = ".vibe/hooks.toml" + events_format = "toml-vibe" @classmethod def options(cls) -> list[IntegrationOption]: @@ -73,84 +70,28 @@ def options(cls) -> list[IntegrationOption]: ) return opts - @staticmethod - def inject_argument_hint(content: str, hint: str) -> str: - """Insert ``argument-hint`` after the ``description:`` scalar in YAML frontmatter. - - A long ``description`` gets folded by the YAML dumper across - indented continuation lines (plain or quoted), and an embedded - paragraph break can add unindented blank lines inside a quoted - scalar. Inserting the new line right after the *first* line of - that scalar — instead of after the whole scalar — either produces - invalid YAML or gets silently absorbed into the description - string, so every continuation line (indented, or blank) is skipped - first. - - Skips injection if ``argument-hint:`` already exists in the - frontmatter to avoid duplicate keys. - """ - lines = content.splitlines(keepends=True) - - # Pre-scan: bail out if argument-hint already present in frontmatter - dash_count = 0 - for line in lines: - stripped = line.rstrip("\n\r") - if stripped == "---": - dash_count += 1 - if dash_count == 2: - break - continue - if dash_count == 1 and stripped.startswith("argument-hint:"): - return content + 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" - out: list[str] = [] - in_fm = False - dash_count = 0 - injected = False - i = 0 - n = len(lines) - while i < n: - line = lines[i] - stripped = line.rstrip("\n\r") - if stripped == "---": - dash_count += 1 - in_fm = dash_count == 1 - out.append(line) - i += 1 - continue - if in_fm and not injected and stripped.startswith("description:"): - out.append(line) - i += 1 - # Skip past folded/quoted continuation lines of the scalar - # before inserting, so the new key lands after it ends. - # Blank lines count too: PyYAML emits unindented blank - # lines for embedded "\n\n" inside a quoted scalar. - while i < n and ( - lines[i][:1] in (" ", "\t") or lines[i].rstrip("\r\n") == "" - ): - out.append(lines[i]) - i += 1 - # Preserve the exact line-ending style (\r\n vs \n) - if line.endswith("\r\n"): - eol = "\r\n" - elif line.endswith("\n"): - eol = "\n" - else: - eol = "" - escaped = hint.replace("\\", "\\\\").replace('"', '\\"') - out.append(f'argument-hint: "{escaped}"{eol}') - injected = True - continue - out.append(line) - i += 1 - return "".join(out) + 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 @@ -207,32 +148,12 @@ def _skill_stem_from_content(content: str) -> str | None: return name or None return None - 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 - ) - def post_process_skill_content(self, content: str) -> str: - """Inject Vibe-specific frontmatter flags, hook notes, and any - per-command frontmatter. + """Inject Vibe-specific frontmatter flags. Applied by every skill-generation path (setup, presets, extensions), - so command-specific frontmatter (argument-hint, fork context) stays - consistent however the SKILL.md was produced. + 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") @@ -240,9 +161,6 @@ def post_process_skill_content(self, content: str) -> str: stem = self._skill_stem_from_content(updated) if stem: - hint = ARGUMENT_HINTS.get(stem, "") - if hint: - updated = self.inject_argument_hint(updated, hint) fork_config = FORK_CONTEXT_COMMANDS.get(stem) if fork_config: for key, value in fork_config.items(): diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py index c61baf4a7a..4e17410fdf 100644 --- a/tests/integrations/test_integration_vibe.py +++ b/tests/integrations/test_integration_vibe.py @@ -1,43 +1,23 @@ """Tests for VibeIntegration.""" -import json -import os -from pathlib import Path -from unittest.mock import patch - import yaml -from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration -from specify_cli.integrations.base import IntegrationBase, SkillsIntegration +from specify_cli.integrations import get_integration +from specify_cli.integrations.base import IntegrationBase from specify_cli.integrations.manifest import IntegrationManifest -from specify_cli.integrations.vibe import ARGUMENT_HINTS, FORK_CONTEXT_COMMANDS from .test_integration_base_skills import SkillsIntegrationTests -class TestVibeIntegration: - def test_registered(self): - assert "vibe" in INTEGRATION_REGISTRY - assert get_integration("vibe") is not None +class TestVibeIntegration(SkillsIntegrationTests): + KEY = "vibe" + FOLDER = ".vibe/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".vibe/skills" def test_is_base_integration(self): assert isinstance(get_integration("vibe"), IntegrationBase) - def test_is_skills_integration(self): - assert isinstance(get_integration("vibe"), SkillsIntegration) - - def test_config_uses_skills(self): - integration = get_integration("vibe") - assert integration.config["folder"] == ".vibe/" - assert integration.config["commands_subdir"] == "skills" - - def test_registrar_config_uses_skill_layout(self): - integration = get_integration("vibe") - assert integration.registrar_config["dir"] == ".vibe/skills" - assert integration.registrar_config["format"] == "markdown" - assert integration.registrar_config["args"] == "$ARGUMENTS" - assert integration.registrar_config["extension"] == "/SKILL.md" - def test_multi_install_safe(self): integration = get_integration("vibe") assert integration.multi_install_safe is True @@ -45,17 +25,17 @@ def test_multi_install_safe(self): def test_canonical_to_native_events(self): integration = get_integration("vibe") assert integration.CANONICAL_TO_NATIVE is not None - assert integration.CANONICAL_TO_NATIVE.get("session_start") == "SessionStart" - assert integration.CANONICAL_TO_NATIVE.get("pre_tool_use") == "PreToolUse" - assert integration.CANONICAL_TO_NATIVE.get("post_tool_use") == "PostToolUse" - assert integration.CANONICAL_TO_NATIVE.get("session_end") == "SessionEnd" - assert integration.CANONICAL_TO_NATIVE.get("user_prompt_submit") == "UserPromptSubmit" - assert integration.CANONICAL_TO_NATIVE.get("stop") == "Stop" + assert integration.CANONICAL_TO_NATIVE.get("session_start") == "session_start" + assert integration.CANONICAL_TO_NATIVE.get("pre_tool_use") == "pre_tool" + assert integration.CANONICAL_TO_NATIVE.get("post_tool_use") == "post_tool" + assert integration.CANONICAL_TO_NATIVE.get("session_end") == "session_end" + assert integration.CANONICAL_TO_NATIVE.get("user_prompt_submit") == "user_prompt_submit" + assert integration.CANONICAL_TO_NATIVE.get("stop") == "post_agent" def test_events_config(self): integration = get_integration("vibe") - assert integration.events_config_file == ".vibe/settings.json" - assert integration.events_format == "json-nested" + 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") @@ -120,149 +100,19 @@ def test_teardown_does_not_touch_existing_context_file(self, tmp_path): assert ctx_path.read_text(encoding="utf-8") == original - -class TestVibeArgumentHints: - """Verify that argument-hint frontmatter is injected for Vibe skills.""" - - def test_converge_has_no_argument_hint(self): - """Converge should not advertise unsupported feature-name arguments.""" - assert "converge" not in ARGUMENT_HINTS - - def test_all_skills_have_hints(self, tmp_path): - """Every skill with a configured hint must contain an argument-hint line.""" - 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 len(skill_files) > 0 - for f in skill_files: - stem = f.parent.name - if stem.startswith("speckit-"): - stem = stem[len("speckit-"):] - content = f.read_text(encoding="utf-8") - if stem in ARGUMENT_HINTS: - assert "argument-hint:" in content, ( - f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter" - ) - else: - assert "argument-hint:" not in content, ( - f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" - ) - - def test_hints_match_expected_values(self, tmp_path): - """Each skill's argument-hint must match the expected text.""" - 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"] - for f in skill_files: - # Extract stem: speckit-plan -> plan - stem = f.parent.name - if stem.startswith("speckit-"): - stem = stem[len("speckit-"):] - expected_hint = ARGUMENT_HINTS.get(stem) - content = f.read_text(encoding="utf-8") - if expected_hint is None: - assert "argument-hint:" not in content, ( - f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" - ) - else: - assert f'argument-hint: "{expected_hint}"' in content, ( - f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found" - ) - - def test_hint_is_inside_frontmatter(self, tmp_path): - """argument-hint must appear between the --- delimiters, not in the body.""" - 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"] - for f in skill_files: - content = f.read_text(encoding="utf-8") - parts = content.split("---", 2) - assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md" - frontmatter = parts[1] - body = parts[2] - stem = f.parent.name - if stem.startswith("speckit-"): - stem = stem[len("speckit-"):] - if stem in ARGUMENT_HINTS: - assert "argument-hint:" in frontmatter, ( - f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section" - ) - assert "argument-hint:" not in body, ( - f"{f.parent.name}/SKILL.md: argument-hint leaked into body" - ) - else: - assert "argument-hint:" not in content, ( - f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" - ) - - def test_hint_appears_after_description(self, tmp_path): - """argument-hint must immediately follow the description line.""" - i = get_integration("vibe") - m = IntegrationManifest("vibe", tmp_path) - created = i.setup(tmp_path, m, script_type="sh") + 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") - lines = content.splitlines() - stem = f.parent.name - if stem.startswith("speckit-"): - stem = stem[len("speckit-"):] - if stem not in ARGUMENT_HINTS: - assert "argument-hint:" not in content, ( - f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" - ) - continue - found_description = False - for idx, line in enumerate(lines): - if line.startswith("description:"): - found_description = True - assert idx + 1 < len(lines), ( - f"{f.parent.name}/SKILL.md: description is last line" - ) - assert lines[idx + 1].startswith("argument-hint:"), ( - f"{f.parent.name}/SKILL.md: argument-hint does not follow description" - ) - break - assert found_description, ( - f"{f.parent.name}/SKILL.md: no description: line found in output" + assert "argument-hint:" not in content, ( + f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" ) - def test_inject_argument_hint_only_in_frontmatter(self): - """inject_argument_hint must not modify description: lines in the body.""" - from specify_cli.integrations.vibe import VibeIntegration - - content = ( - "---\n" - "description: My command\n" - "---\n" - "\n" - "description: this is body text\n" - ) - result = VibeIntegration.inject_argument_hint(content, "Test hint") - lines = result.splitlines() - hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) - assert hint_count == 1, ( - f"Expected exactly 1 argument-hint line, found {hint_count}" - ) - - def test_inject_argument_hint_skips_if_already_present(self): - """inject_argument_hint must not duplicate if argument-hint already exists.""" - from specify_cli.integrations.vibe import VibeIntegration - - content = ( - "---\n" - "description: My command\n" - 'argument-hint: "Existing hint"\n' - "---\n" - "\n" - "Body text\n" - ) - result = VibeIntegration.inject_argument_hint(content, "New hint") - assert result == content, "Content should be unchanged when hint already exists" - class TestVibeUserInvocable: def test_all_skills_have_user_invocable(self, tmp_path): From d954d1a41c234106077b37782123100b36f5d057 Mon Sep 17 00:00:00 2001 From: 0x677A70 <457616+0x677A70@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:04:26 +0200 Subject: [PATCH 3/3] 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 --- .devcontainer/devcontainer-lock.json | 24 ++++++++++++++++++++++++ src/specify_cli/events.py | 8 ++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 .devcontainer/devcontainer-lock.json diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000000..c9cd36a2ca --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,24 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "version": "2.5.9", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a" + }, + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "2.5.0", + "resolved": "ghcr.io/devcontainers/features/dotnet@sha256:0fc16547ed4db6d7ff2a9f5981d2b93eb314e568affb9958029ad794f1f9a093", + "integrity": "sha256:0fc16547ed4db6d7ff2a9f5981d2b93eb314e568affb9958029ad794f1f9a093" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "1.3.8", + "resolved": "ghcr.io/devcontainers/features/git@sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2", + "integrity": "sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2" + }, + "ghcr.io/devcontainers/features/node": { + "version": "2.1.0", + "resolved": "ghcr.io/devcontainers/features/node@sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857", + "integrity": "sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857" + } + } +} diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 7d8aef5955..d8a427e2b1 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -1366,7 +1366,11 @@ def install_integration_events( for cfg in handlers: command = cfg.get("command", "") dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev, timeout_seconds=cfg.get("timeout", 60)) + # Vibe requires a name field for each hook + command_stem = command.split('.')[-1] if command else "unknown" + hook_name = f"speckit-{native}-{command_stem}" lines.append("[[hooks]]") + lines.append(f'name = {_toml_quote(hook_name)}') lines.append(f'type = {_toml_quote(native)}') matcher = cfg.get("matcher", "*") if matcher != "*": @@ -2026,7 +2030,7 @@ def _merge_vibe_toml_fragment(dst: Path, fragment: str) -> bool: # Remove existing Specify-marked [[hooks]] blocks # Match [[hooks]] ... speckit_marker = true (with any content in between) existing = re.sub( - r'\[\[hooks\]\]\n(?:(?!\\[\}[^:]*\]).)*?speckit_marker = true\n*', + r'\[\[hooks\]\]\n(?:(?!\[\[hooks\]\]).)*?speckit_marker = true\n*', "", existing, flags=re.DOTALL, @@ -2099,7 +2103,7 @@ def _remove_vibe_toml_entries(dst: Path) -> bool: return False # Remove Specify-marked [[hooks]] blocks cleaned = re.sub( - r'\[\[hooks\]\]\n(?:(?!\\[\}[^:]*\]).)*?speckit_marker = true\n*', + r'\[\[hooks\]\]\n(?:(?!\[\[hooks\]\]).)*?speckit_marker = true\n*', "", existing, flags=re.DOTALL,