From d8bd788a0faa1d893ae0b2e02e3130396610c80f Mon Sep 17 00:00:00 2001 From: WOLIKIMCHENG <35391914+WOLIKIMCHENG@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:51:10 +0800 Subject: [PATCH 1/2] fix(events): preserve unowned Vibe hooks files Avoid rewriting or deleting hooks.toml when cleanup finds no Spec Kit-managed hooks to remove. Preserve existing cleanup behavior for managed entries. --- src/specify_cli/events.py | 4 ++ tests/integrations/test_events.py | 69 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 3632661711..d2c419df43 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -2251,6 +2251,8 @@ 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). + + Leaves an unowned file untouched when no Specify-marked hook was removed. """ if not dst.exists(): return False @@ -2272,6 +2274,8 @@ def _remove_vibe_toml_entries(dst: Path) -> bool: existing, flags=re.DOTALL, ) + if cleaned == existing: + return False # If only whitespace/comments remain, the file had no user content stripped = "\n".join( line for line in cleaned.splitlines() diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 3c32891288..eff175ff5b 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -1046,6 +1046,75 @@ def test_owned_only_config_still_deleted_on_teardown(self, tmp_path): assert not config_path.exists() +class TestVibeTomlNoOpRemoval: + """An unowned Vibe hooks.toml must survive no-op event cleanup intact.""" + + _FIXED_MTIME_NS = 1_700_000_000_123_456_789 + + def _user_hooks_file(self, tmp_path, content): + path = tmp_path / ".vibe" / "hooks.toml" + path.parent.mkdir(parents=True) + path.write_bytes(content) + os.utime(path, ns=(self._FIXED_MTIME_NS, self._FIXED_MTIME_NS)) + return path, path.stat().st_mtime_ns + + def _assert_untouched(self, path, original, original_mtime_ns): + assert path.exists() + assert path.read_bytes() == original + assert path.stat().st_mtime_ns == original_mtime_ns + + def test_empty_events_leave_user_crlf_file_untracked_and_untouched(self, tmp_path): + from specify_cli.integrations import get_integration + + integration = get_integration("vibe") + manifest = _claude_manifest(tmp_path) + original = b'user_option = "keep"\r\nsecond_option = true' + path, original_mtime_ns = self._user_hooks_file(tmp_path, original) + + install_integration_events(integration, tmp_path, manifest, {}) + + self._assert_untouched(path, original, original_mtime_ns) + manifest.record_existing.assert_not_called() + + def test_empty_events_preserve_comments_only_file(self, tmp_path): + from specify_cli.integrations import get_integration + + original = b"# maintained by the user\n# no hooks yet\n" + path, original_mtime_ns = self._user_hooks_file(tmp_path, original) + + install_integration_events( + get_integration("vibe"), tmp_path, _claude_manifest(tmp_path), {} + ) + + self._assert_untouched(path, original, original_mtime_ns) + + def test_empty_events_preserve_whitespace_only_file(self, tmp_path): + from specify_cli.integrations import get_integration + + original = b"\r\n \t\r\n" + path, original_mtime_ns = self._user_hooks_file(tmp_path, original) + + install_integration_events( + get_integration("vibe"), tmp_path, _claude_manifest(tmp_path), {} + ) + + self._assert_untouched(path, original, original_mtime_ns) + + def test_forced_teardown_preserves_unowned_file_with_manifest_claim(self, tmp_path): + from specify_cli.integrations import get_integration + + integration = get_integration("vibe") + original = b'user_option = "keep"\r\n' + path, original_mtime_ns = self._user_hooks_file(tmp_path, original) + manifest = IntegrationManifest(integration.key, tmp_path, version="test") + manifest.record_existing(".vibe/hooks.toml") + manifest.save() + + integration.teardown(tmp_path, manifest, force=True) + + self._assert_untouched(path, original, original_mtime_ns) + + # -- Opencode TS Plugin merging --------------------------------------------- class TestOpencodePluginMerging: From 1359d75efc1caf462ba68a567f977a2a778cabab Mon Sep 17 00:00:00 2001 From: WOLIKIMCHENG <35391914+WOLIKIMCHENG@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:30:49 +0800 Subject: [PATCH 2/2] fix(events): preserve Vibe hook Unicode and POSIX paths --- src/specify_cli/events/__init__.py | 2 +- src/specify_cli/integrations/vibe/__init__.py | 4 ++-- tests/integrations/test_integration_vibe.py | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/events/__init__.py b/src/specify_cli/events/__init__.py index 5fe6c14bc7..36e5d8f494 100644 --- a/src/specify_cli/events/__init__.py +++ b/src/specify_cli/events/__init__.py @@ -1511,7 +1511,7 @@ def install_integration_events( native_timeout=lambda seconds: _native_timeout(integration, seconds), ensure_safe_destination=_ensure_safe_destination, ): - rel = str(config_path.relative_to(project_root)) + rel = config_path.relative_to(project_root).as_posix() if rel not in manifest.files: manifest.record_existing(rel) created.append(config_path) diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py index 6c0e5bf8c1..6de58f95db 100644 --- a/src/specify_cli/integrations/vibe/__init__.py +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -6,7 +6,6 @@ from __future__ import annotations -import json import logging import os import re @@ -15,6 +14,7 @@ from ..base import IntegrationOption, SkillsIntegration from ..manifest import IntegrationManifest +from ..._toml_string import escape_toml_basic from ..._utils import dump_frontmatter # Per-command frontmatter overrides for skills that should run in a forked @@ -220,7 +220,7 @@ def _hook_target_os() -> str: @staticmethod def _toml_quote(value: str) -> str: """Render a TOML basic string without exposing Vibe syntax to events.""" - return json.dumps(value) + return escape_toml_basic(value) @staticmethod def _managed_hooks_pattern() -> re.Pattern[str]: diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py index 020ece178f..b46de417b6 100644 --- a/tests/integrations/test_integration_vibe.py +++ b/tests/integrations/test_integration_vibe.py @@ -171,6 +171,20 @@ def test_wildcard_matcher_omitted(self, tmp_path): (hook,) = self._parse(tmp_path)["hooks"] assert "match" not in hook + def test_non_bmp_matcher_round_trips_through_toml(self, tmp_path): + """Vibe hook strings retain non-BMP Unicode rather than TOML-invalid surrogates.""" + matcher = "Edit|😀" + self._install(tmp_path, { + "pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": matcher}], + }) + (hook,) = self._parse(tmp_path)["hooks"] + assert hook["match"] == f"re:{matcher}" + + def test_toml_quote_round_trips_special_characters(self): + value = 'quote: " backslash: \\ newline: \n carriage: \r tab: \t nul: \x00 unit: \x1f del: \x7f' + quoted = VibeIntegration._toml_quote(value) + assert tomllib.loads(f"value = {quoted}\n")["value"] == value + def test_unsupported_events_are_skipped(self, tmp_path, capsys): self._install(tmp_path, { "session_start": [{"command": "speckit.agent-context.update"}],