diff --git a/docs/integrations/vibe.md b/docs/integrations/vibe.md index c359bd54e5..35ca12e866 100644 --- a/docs/integrations/vibe.md +++ b/docs/integrations/vibe.md @@ -24,9 +24,40 @@ Skills are installed as `.vibe/skills/speckit-/SKILL.md`. Invoke them in the agent's chat, for example `/speckit-specify` or `/speckit-bug-assess` for the installed bug extension. -Generated skills are both user- and model-invocable. The optional +Generated skills are both user- and model-invocable by default. The optional `--integration-options="--skills"` flag restates the default. +## User-Invocable-Only Skills + +To stop Mistral Vibe invoking the core Spec Kit command skills automatically: + +```bash +specify init my-project --integration vibe --integration-options="--no-model-invocation" +``` + +This writes `disable-model-invocation: true` into the core command skills. +Per Mistral Vibe's [README](https://github.com/mistralai/mistral-vibe#skills-system), +such a skill is explicit-only: you can still invoke `/speckit-`, but +the model does not see or invoke it automatically. + +- Extension skills stay model-invocable, because mandatory extension hooks rely + on the model invoking them. If an extension's mandatory hook runs a core + command (its `command:` is a core `speckit.`), leave this option off: + the model cannot run that hook while the core skill is user-invocable only. +- The option is stored with the integration's other options and reapplied by + `specify integration upgrade vibe`. Passing `--integration-options` to + `upgrade` replaces all stored options; `--integration-options=""` clears + them. Re-running `specify init` likewise stores only the options passed to it. +- Core command skills that a preset overrides, or stops overriding, are reverted + to the default by commands that re-register preset skills without reinstalling + Mistral Vibe: commands that install, remove or update presets (including + `specify bundle` commands that carry presets), `specify integration use vibe`, + and `specify integration switch vibe` when Mistral Vibe is installed but not + the default. To reapply the option, run `specify integration upgrade vibe + --force` while Mistral Vibe is the default integration (`--force` is needed + because those commands modify installed skill files, and it overwrites local + edits to Spec Kit-managed files). + ## Runtime Events Native events use `.vibe/hooks.toml`. Vibe supports only `pre_tool_use`, diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py index 692e303da6..4a2a1d4bd4 100644 --- a/src/specify_cli/integrations/vibe/__init__.py +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -95,6 +95,14 @@ class VibeIntegration(SkillsIntegration): # still parse cleanly. events_context_envelope = {"*": "hook_specific_output"} + # Set by ``setup()`` from ``--no-model-invocation``; applies to core + # command skills only, since mandatory extension hooks rely on the model + # invoking the hook skill. Integrations are process-wide singletons, so + # preset overrides re-registered later in the same run see the same value. + # Commands that rewrite skills without calling ``setup()`` (e.g. + # ``preset add``, ``integration use``) get the default. + _disable_model_invocation: bool = False + @classmethod def options(cls) -> list[IntegrationOption]: opts = super().options() @@ -106,6 +114,17 @@ def options(cls) -> list[IntegrationOption]: help="Install as agent skills", ), ) + opts.append( + IntegrationOption( + "--no-model-invocation", + is_flag=True, + default=False, + help=( + "Install core command skills as user-invocable only " + "(disable-model-invocation: true)" + ), + ) + ) return opts def build_exec_args( @@ -204,6 +223,14 @@ def _skill_stem_from_content(content: str) -> str | None: Reads the ``name:`` field of the first frontmatter block and strips the ``speckit-`` prefix. Returns ``None`` when no name is present. """ + name = VibeIntegration._skill_name_from_content(content) + if name and name.startswith("speckit-"): + return name[len("speckit-"):] + return name + + @staticmethod + def _skill_name_from_content(content: str) -> str | None: + """Return the ``name:`` field of a skill's first frontmatter block.""" dash_count = 0 for line in content.splitlines(): stripped = line.rstrip("\r\n") @@ -214,21 +241,35 @@ def _skill_stem_from_content(content: str) -> str | None: 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 + @classmethod + def _is_core_command_skill(cls, content: str) -> bool: + from specify_cli.extensions import CORE_COMMAND_NAMES # lazy: avoids circular import + + name = cls._skill_name_from_content(content) + return bool(name) and name.startswith("speckit-") and ( + name.removeprefix("speckit-") in CORE_COMMAND_NAMES + ) + 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. + + ``disable-model-invocation`` is ``true`` for core command skills when + the last ``setup()`` in this process was given + ``--no-model-invocation``, else ``false``. """ 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") + disable = self._disable_model_invocation and self._is_core_command_skill(updated) + updated = self._inject_frontmatter_flag( + updated, "disable-model-invocation", "true" if disable else "false" + ) stem = self._skill_stem_from_content(updated) if stem: @@ -255,7 +296,16 @@ def setup( err=True, ) - return super().setup(project_root, manifest, parsed_options=parsed_options, **opts) + self._disable_model_invocation = ( + (parsed_options or {}).get("no_model_invocation") is True + ) + try: + return super().setup( + project_root, manifest, parsed_options=parsed_options, **opts + ) + except BaseException: + self._disable_model_invocation = False + raise @staticmethod def _hook_target_os() -> str: diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py index 5ac37c2ea0..c3349e7ceb 100644 --- a/tests/integrations/test_integration_vibe.py +++ b/tests/integrations/test_integration_vibe.py @@ -1,14 +1,16 @@ """Tests for VibeIntegration.""" +import json import os -from unittest.mock import MagicMock +from pathlib import Path +from unittest.mock import MagicMock, patch import pytest 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.base import IntegrationBase, SkillsIntegration from specify_cli.integrations.manifest import IntegrationManifest from specify_cli.integrations.vibe import VibeIntegration @@ -20,6 +22,24 @@ import tomli as tomllib # type: ignore +REPO_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture(autouse=True) +def _reset_vibe_model_invocation(): + """``setup()`` stores ``--no-model-invocation`` on the process-wide Vibe + singleton; keep it from leaking between tests.""" + vibe = get_integration("vibe") + vibe._disable_model_invocation = False + yield + vibe._disable_model_invocation = False + + +def _skill_frontmatter(skill_file: Path) -> dict: + content = skill_file.read_text(encoding="utf-8") + return yaml.safe_load(content.split("---", 2)[1]) + + def _vibe_manifest() -> MagicMock: manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} @@ -851,3 +871,230 @@ def test_preserves_crlf_line_endings(self): content, "user-invocable" ) assert result == "---\r\nname: x\r\nuser-invocable: true\r\n---\r\n" + + +class TestVibeNoModelInvocationOption: + """``--integration-options="--no-model-invocation"`` opts out of model invocation (#2654).""" + + def test_options_include_no_model_invocation_flag(self): + opts = get_integration("vibe").options() + matching = [o for o in opts if o.name == "--no-model-invocation"] + assert len(matching) == 1 + assert matching[0].is_flag is True + # Opt-in: skills stay model-invocable unless the flag is passed. + assert matching[0].default is False + + def test_setup_with_option_sets_disable_model_invocation_true(self, tmp_path): + i = get_integration("vibe") + m = IntegrationManifest("vibe", tmp_path) + created = i.setup( + tmp_path, m, parsed_options={"no_model_invocation": True}, 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: + fm = _skill_frontmatter(f) + assert fm["disable-model-invocation"] is True, f.parent.name + assert fm["user-invocable"] is True, f.parent.name + assert f.read_text(encoding="utf-8").count("disable-model-invocation:") == 1 + + def test_post_process_follows_option_from_setup(self, tmp_path): + """Preset overrides of core commands re-registered in the same run as + ``setup()`` (init, upgrade) get the same value.""" + i = get_integration("vibe") + i.setup( + tmp_path, + IntegrationManifest("vibe", tmp_path), + parsed_options={"no_model_invocation": True}, + script_type="sh", + ) + result = i.post_process_skill_content("---\nname: speckit-plan\n---\n\nBody\n") + assert "disable-model-invocation: true" in result + assert "disable-model-invocation: false" not in result + + def test_extension_skills_stay_model_invocable(self, tmp_path): + """Mandatory extension hooks rely on the model invoking the hook skill, + so the option never applies to extension commands.""" + i = get_integration("vibe") + i.setup( + tmp_path, + IntegrationManifest("vibe", tmp_path), + parsed_options={"no_model_invocation": True}, + script_type="sh", + ) + result = i.post_process_skill_content( + "---\nname: speckit-git-feature\n---\n\nBody\n" + ) + assert "disable-model-invocation: false" in result + assert "disable-model-invocation: true" not in result + + def test_option_requires_speckit_prefixed_core_name(self, tmp_path): + """A skill merely named like a core stem is not a core command skill.""" + i = get_integration("vibe") + i.setup( + tmp_path, + IntegrationManifest("vibe", tmp_path), + parsed_options={"no_model_invocation": True}, + script_type="sh", + ) + result = i.post_process_skill_content("---\nname: tasks\n---\n\nBody\n") + assert "disable-model-invocation: false" in result + result = i.post_process_skill_content("---\nname: speckit-tasks\n---\n\nBody\n") + assert "disable-model-invocation: true" in result + + def test_failed_setup_does_not_leave_option_set(self, tmp_path): + i = get_integration("vibe") + with patch.object(SkillsIntegration, "setup", side_effect=RuntimeError("boom")): + with pytest.raises(RuntimeError): + i.setup( + tmp_path, + IntegrationManifest("vibe", tmp_path), + parsed_options={"no_model_invocation": True}, + script_type="sh", + ) + result = i.post_process_skill_content("---\nname: speckit-plan\n---\n\nBody\n") + assert "disable-model-invocation: false" in result + + def test_setup_without_option_resets_to_false(self, tmp_path): + i = get_integration("vibe") + first = tmp_path / "first" + first.mkdir() + i.setup( + first, + IntegrationManifest("vibe", first), + parsed_options={"no_model_invocation": True}, + script_type="sh", + ) + second = tmp_path / "second" + second.mkdir() + created = i.setup(second, IntegrationManifest("vibe", second), 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: + assert _skill_frontmatter(f)["disable-model-invocation"] is False, f.parent.name + result = i.post_process_skill_content("---\nname: speckit-plan\n---\n\nBody\n") + assert "disable-model-invocation: false" in result + + def test_cli_rejects_value_for_flag(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "vibe-bad-option" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke( + app, + [ + "init", + "--here", + "--integration", + "vibe", + "--integration-options", + "--no-model-invocation=yes", + "--script", + "sh", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code != 0 + assert "does not accept a value" in result.output + assert not (project / ".vibe" / "skills").exists() + + def test_option_persists_across_upgrade_and_can_be_cleared(self, tmp_path): + from tests.specify_cli.integrations._helpers import _init_project, _run_in_project + + project = _init_project(tmp_path, "vibe", "--no-model-invocation") + skills_dir = project / ".vibe" / "skills" + skill_files = sorted(skills_dir.glob("*/SKILL.md")) + assert len(skill_files) > 0 + for f in skill_files: + assert _skill_frontmatter(f)["disable-model-invocation"] is True, f.parent.name + + state = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + settings = state["integration_settings"]["vibe"] + assert settings["raw_options"] == "--no-model-invocation" + assert settings["parsed_options"] == {"no_model_invocation": True} + + # A later ``specify integration upgrade`` is a new process: drop the + # in-memory value so only the persisted options can supply it. + get_integration("vibe")._disable_model_invocation = False + result = _run_in_project(project, ["integration", "upgrade", "vibe", "--force"]) + assert result.exit_code == 0, result.output + for f in skill_files: + assert _skill_frontmatter(f)["disable-model-invocation"] is True, f.parent.name + + # Passing replacement options (here: none) clears it. + result = _run_in_project( + project, + ["integration", "upgrade", "vibe", "--force", "--integration-options", ""], + ) + assert result.exit_code == 0, result.output + for f in skill_files: + assert _skill_frontmatter(f)["disable-model-invocation"] is False, f.parent.name + state = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert "parsed_options" not in state["integration_settings"]["vibe"] + + def test_upgrade_reapplies_option_to_preset_overrides(self, tmp_path): + """``preset add`` runs without ``setup()`` (documented limitation); + ``integration upgrade vibe --force`` reapplies the option to the + core-command skills a preset overrides.""" + from tests.specify_cli.integrations._helpers import _init_project, _run_in_project + + project = _init_project(tmp_path, "vibe", "--no-model-invocation") + skill_file = project / ".vibe" / "skills" / "speckit-plan" / "SKILL.md" + assert _skill_frontmatter(skill_file)["disable-model-invocation"] is True + + get_integration("vibe")._disable_model_invocation = False + result = _run_in_project( + project, ["preset", "add", "--dev", str(REPO_ROOT / "presets" / "lean")] + ) + assert result.exit_code == 0, result.output + fm = _skill_frontmatter(skill_file) + assert fm["metadata"]["source"] == "preset:lean" + # Documented in docs/integrations/vibe.md; update the docs if + # preset commands start honouring the option. + assert fm["disable-model-invocation"] is False + + # The docs say ``--force`` is needed here: preset add modified + # installed skill files, so a plain upgrade refuses. + result = _run_in_project(project, ["integration", "upgrade", "vibe"]) + assert result.exit_code != 0 + assert "modified since installation" in result.output + assert _skill_frontmatter(skill_file)["disable-model-invocation"] is False + + get_integration("vibe")._disable_model_invocation = False + result = _run_in_project(project, ["integration", "upgrade", "vibe", "--force"]) + assert result.exit_code == 0, result.output + fm = _skill_frontmatter(skill_file) + assert fm["metadata"]["source"] == "preset:lean" + assert fm["disable-model-invocation"] is True + + def test_extension_hook_skills_stay_model_invocable_after_upgrade(self, tmp_path): + """``integration upgrade`` re-registers extension skills in the same + process as ``setup()``; they must keep ``false`` so mandatory hooks + (e.g. ``speckit.git.feature``) can still be invoked by the model.""" + from tests.specify_cli.integrations._helpers import _init_project, _run_in_project + + project = _init_project(tmp_path, "vibe", "--no-model-invocation") + result = _run_in_project( + project, ["extension", "add", "--dev", str(REPO_ROOT / "extensions" / "git")] + ) + assert result.exit_code == 0, result.output + get_integration("vibe")._disable_model_invocation = False + result = _run_in_project(project, ["integration", "upgrade", "vibe", "--force"]) + assert result.exit_code == 0, result.output + + skills_dir = project / ".vibe" / "skills" + git_skills = sorted(skills_dir.glob("speckit-git-*/SKILL.md")) + assert git_skills, "git extension skills were not generated" + for f in git_skills: + assert _skill_frontmatter(f)["disable-model-invocation"] is False, f.parent.name + assert _skill_frontmatter(skills_dir / "speckit-specify" / "SKILL.md")[ + "disable-model-invocation" + ] is True