Skip to content
Open
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
24 changes: 24 additions & 0 deletions .devcontainer/devcontainer-lock.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
104 changes: 104 additions & 0 deletions src/specify_cli/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -1357,6 +1357,35 @@ 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))
# 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 != "*":
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).
Expand Down Expand Up @@ -1479,6 +1508,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":
Expand Down Expand Up @@ -1973,6 +2004,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(?:(?!\[\[hooks\]\]).)*?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).

Expand Down Expand Up @@ -2016,6 +2083,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(?:(?!\[\[hooks\]\]).)*?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).

Expand Down
96 changes: 87 additions & 9 deletions src/specify_cli/integrations/vibe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,25 @@

from ..base import IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
from ..._utils import dump_frontmatter

# Per-command frontmatter overrides for skills that should run in a forked
# 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",
Expand All @@ -28,24 +44,54 @@ class VibeIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
multi_install_safe = True

CANONICAL_TO_NATIVE = {
"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/hooks.toml"
events_format = "toml-vibe"

@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

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
)

@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
Expand Down Expand Up @@ -80,13 +126,45 @@ 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 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.
"""
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:
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(
Expand Down
Loading