diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000000..c9cd36a2ca --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -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" + } + } +} diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 3469115d6e..d8a427e2b1 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -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). @@ -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": @@ -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). @@ -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). diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py index 136dec8674..4dfd5df64f 100644 --- a/src/specify_cli/integrations/vibe/__init__.py +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -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", @@ -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 @@ -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( diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py index 20ff3c0304..4e17410fdf 100644 --- a/tests/integrations/test_integration_vibe.py +++ b/tests/integrations/test_integration_vibe.py @@ -3,6 +3,7 @@ import yaml from specify_cli.integrations import get_integration +from specify_cli.integrations.base import IntegrationBase from specify_cli.integrations.manifest import IntegrationManifest from .test_integration_base_skills import SkillsIntegrationTests @@ -14,6 +15,104 @@ class TestVibeIntegration(SkillsIntegrationTests): COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".vibe/skills" + def test_is_base_integration(self): + assert isinstance(get_integration("vibe"), IntegrationBase) + + def test_multi_install_safe(self): + integration = get_integration("vibe") + assert integration.multi_install_safe is True + + def test_canonical_to_native_events(self): + integration = get_integration("vibe") + assert integration.CANONICAL_TO_NATIVE is not None + assert integration.CANONICAL_TO_NATIVE.get("session_start") == "session_start" + assert integration.CANONICAL_TO_NATIVE.get("pre_tool_use") == "pre_tool" + assert integration.CANONICAL_TO_NATIVE.get("post_tool_use") == "post_tool" + assert integration.CANONICAL_TO_NATIVE.get("session_end") == "session_end" + assert integration.CANONICAL_TO_NATIVE.get("user_prompt_submit") == "user_prompt_submit" + assert integration.CANONICAL_TO_NATIVE.get("stop") == "post_agent" + + def test_events_config(self): + integration = get_integration("vibe") + assert integration.events_config_file == ".vibe/hooks.toml" + assert integration.events_format == "toml-vibe" + + def test_setup_creates_skill_files(self, tmp_path): + integration = get_integration("vibe") + manifest = IntegrationManifest("vibe", tmp_path) + created = integration.setup(tmp_path, manifest, script_type="sh") + + skill_files = [path for path in created if path.name == "SKILL.md"] + assert skill_files + + skills_dir = tmp_path / ".vibe" / "skills" + assert skills_dir.is_dir() + + plan_skill = skills_dir / "speckit-plan" / "SKILL.md" + assert plan_skill.exists() + + content = plan_skill.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content + assert "{ARGS}" not in content + assert "__AGENT__" not in content + assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__" + assert "/speckit." not in content, "skills agent must use /speckit- not /speckit." + + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed["name"] == "speckit-plan" + assert parsed["user-invocable"] is True + assert parsed["disable-model-invocation"] is False + assert parsed["metadata"]["source"] == "templates/commands/plan.md" + + def test_render_skill_unicode(self): + """Test rendering a skill preserves non-ASCII characters.""" + integration = get_integration("vibe") + rendered = integration._render_skill( + "constitution", + {"description": "Prüfe Konformität der Implementierung"}, + "Body", + ) + assert "Prüfe Konformität" in rendered + + def test_setup_does_not_write_context_section(self, tmp_path): + """The CLI no longer manages the agent context file — that is owned by + the opt-in agent-context extension. Setup must not create or touch it.""" + integration = get_integration("vibe") + manifest = IntegrationManifest("vibe", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text + + def test_teardown_does_not_touch_existing_context_file(self, tmp_path): + """A user-authored context file is left intact on teardown.""" + integration = get_integration("vibe") + ctx_path = tmp_path / "AGENTS.md" + original = "# AGENTS.md\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") + + manifest = IntegrationManifest("vibe", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + integration.teardown(tmp_path, manifest) + + assert ctx_path.read_text(encoding="utf-8") == original + + def test_skills_do_not_have_argument_hint(self, tmp_path): + """Vibe does not support argument-hint in skill frontmatter, so it must not be injected.""" + integration = get_integration("vibe") + manifest = IntegrationManifest("vibe", tmp_path) + created = integration.setup(tmp_path, manifest, 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 "argument-hint:" not in content, ( + f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter" + ) + class TestVibeUserInvocable: def test_all_skills_have_user_invocable(self, tmp_path): @@ -35,3 +134,17 @@ def test_all_skills_have_user_invocable(self, tmp_path): assert parsed.get("user-invocable") is True, ( f"{f.parent.name}/SKILL.md is missing user-invocable: true in frontmatter" ) + + def test_all_skills_have_disable_model_invocation(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") + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed.get("disable-model-invocation") is False, ( + f"{f.parent.name}/SKILL.md is missing disable-model-invocation: false in frontmatter" + )