Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion docs/integrations/vibe.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,40 @@ Skills are installed as `.vibe/skills/speckit-<command>/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-<command>`, 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.<command>`), 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`,
Expand Down
58 changes: 54 additions & 4 deletions src/specify_cli/integrations/vibe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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(
Expand Down Expand Up @@ -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")
Expand All @@ -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:
Expand All @@ -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:
Expand Down
Loading
Loading