feat(vibe): migrate to SkillsIntegration from the old prompts-based MarkdownIntegration (#2336)

* feat(vibe): migrate to SkillsIntegration and inject user-invocable frontmatter
Switches VibeIntegration from the old prompts-based MarkdownIntegration to SkillsIntegration, adopting the .vibe/skills/speckit-<name>/SKILL.md layout required by Mistral Vibe v2.0.0+. Post-processes each generated SKILL.md to inject `user-invocable: true` so skills are directly callable by users, not  just by other agents.

* test(vibe): assert user- invocable: true is present in all generated SKILL.md files

* Update tests/integrations/test_integration_vibe.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Rodolphe Lefebvre
2026-04-24 19:37:05 +02:00
committed by GitHub
parent 9f14dfc6c6
commit 998f927576
2 changed files with 151 additions and 12 deletions

View File

@@ -1,21 +1,133 @@
"""Mistral Vibe CLI integration."""
"""
Mistral Vibe CLI integration — skills-based agent.
from ..base import MarkdownIntegration
Vibe uses ``.vibe/skills/speckit-<name>/SKILL.md`` layout (enforced since v2.0.0).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ..base import IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
class VibeIntegration(MarkdownIntegration):
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

View File

@@ -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
from .test_integration_base_skills import SkillsIntegrationTests
class TestVibeIntegration(MarkdownIntegrationTests):
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"
)