diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py index dcc4a60dda..f5ad63bdc2 100644 --- a/src/specify_cli/integrations/vibe/__init__.py +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -1,21 +1,133 @@ -"""Mistral Vibe CLI integration.""" +""" +Mistral Vibe CLI integration — skills-based agent. -from ..base import MarkdownIntegration +Vibe uses ``.vibe/skills/speckit-/SKILL.md`` layout (enforced since v2.0.0). +""" +from __future__ import annotations -class VibeIntegration(MarkdownIntegration): +from pathlib import Path +from typing import Any + +from ..base import IntegrationOption, SkillsIntegration +from ..manifest import IntegrationManifest + + +class VibeIntegration(SkillsIntegration): key = "vibe" config = { "name": "Mistral Vibe", "folder": ".vibe/", - "commands_subdir": "prompts", + "commands_subdir": "skills", "install_url": "https://github.com/mistralai/mistral-vibe", "requires_cli": True, } registrar_config = { - "dir": ".vibe/prompts", + "dir": ".vibe/skills", "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", + "extension": "/SKILL.md", } - context_file = ".vibe/agents/specify-agents.md" + context_file = "AGENTS.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills", + ), + ] + + @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 + """ + lines = content.splitlines(keepends=True) + + # Pre-scan: bail out if 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(f"{key}:"): + return content + + # Inject before the closing --- of frontmatter + out: list[str] = [] + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2 and not injected: + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + out.append(f"{key}: {value}{eol}") + injected = True + out.append(line) + return "".join(out) + + + def post_process_skill_content(self, content: str) -> str: + """ + Inject Vibe-specific frontmatter flags: + - user-invocable: allows the skill to be invoked by the user (not just other agents) + """ + updated = self._inject_frontmatter_flag(content, "user-invocable") + return updated + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install Vibe skills then inject Vibe-specific flags""" + import click + + click.secho( + "Warning: The .vibe/skills layout requires Mistral Vibe v2.0.0 or newer. " + "Please ensure your installation is up to date.", + fg="yellow", + err=True, + ) + + created = super().setup(project_root, manifest, parsed_options=parsed_options, **opts) + + # Post-process generated skill files + skills_dir = self.skills_dest(project_root).resolve() + + for path in created: + # Only touch SKILL.md files under the skills directory + try: + path.resolve().relative_to(skills_dir) + except ValueError: + continue + if path.name != "SKILL.md": + continue + + content_bytes = path.read_bytes() + content = content_bytes.decode("utf-8") + + updated = self.post_process_skill_content(content) + + if updated != content: + path.write_bytes(updated.encode("utf-8")) + self.record_file_in_manifest(path, project_root, manifest) + + return created diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py index ea6dc85a88..bab4539f1e 100644 --- a/tests/integrations/test_integration_vibe.py +++ b/tests/integrations/test_integration_vibe.py @@ -1,11 +1,38 @@ """Tests for VibeIntegration.""" -from .test_integration_base_markdown import MarkdownIntegrationTests +import yaml +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest -class TestVibeIntegration(MarkdownIntegrationTests): +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestVibeIntegration(SkillsIntegrationTests): KEY = "vibe" FOLDER = ".vibe/" - COMMANDS_SUBDIR = "prompts" - REGISTRAR_DIR = ".vibe/prompts" - CONTEXT_FILE = ".vibe/agents/specify-agents.md" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".vibe/skills" + CONTEXT_FILE = "AGENTS.md" + + +class TestVibeUserInvocable: + def test_all_skills_have_user_invocable(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") + assert content.startswith("---"), ( + f"{f.parent.name}/SKILL.md is missing the opening frontmatter delimiter '---'" + ) + parts = content.split("---", 2) + assert len(parts) >= 3, ( + f"{f.parent.name}/SKILL.md has malformed frontmatter; expected a '--- ... ---' block" + ) + parsed = yaml.safe_load(parts[1]) + assert parsed.get("user-invocable") is True, ( + f"{f.parent.name}/SKILL.md is missing user-invocable: true in frontmatter" + ) \ No newline at end of file