From a9edbdadf75e4ce6c3cff836f162a7bcca327091 Mon Sep 17 00:00:00 2001 From: Puneet Dixit <236133619+puneetdixit200@users.noreply.github.com> Date: Fri, 22 May 2026 23:42:01 +0530 Subject: [PATCH 1/2] fix(integrations): share skills hook note post-processing --- src/specify_cli/integrations/base.py | 48 ++++++++++++++++-- .../integrations/claude/__init__.py | 49 ++----------------- .../integrations/copilot/__init__.py | 3 +- src/specify_cli/integrations/vibe/__init__.py | 28 ++--------- .../test_integration_base_skills.py | 15 ++++++ tests/integrations/test_integration_claude.py | 20 +++----- .../integrations/test_integration_copilot.py | 24 ++++++++- 7 files changed, 99 insertions(+), 88 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 7ce107caec..98ddbe6034 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -25,6 +25,12 @@ if TYPE_CHECKING: from .manifest import IntegrationManifest +_HOOK_COMMAND_NOTE = ( + "- When constructing slash commands from hook command names, " + "replace dots (`.`) with hyphens (`-`). " + "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" +) + # --------------------------------------------------------------------------- # IntegrationOption @@ -1391,15 +1397,46 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str: invocation = f"{invocation} {args}" return invocation + @staticmethod + def _inject_hook_command_note(content: str) -> str: + """Insert a dot-to-hyphen note before each hook output instruction. + + Targets the line ``- For each executable hook, output the following`` + and inserts the note on the line before it, matching its indentation. + Skips if the note is already present. + """ + if "replace dots" in content: + return content + + def repl(m: re.Match[str]) -> str: + indent = m.group(1) + instruction = m.group(2) + eol = m.group(3) + return ( + indent + + _HOOK_COMMAND_NOTE.rstrip("\n") + + eol + + indent + + instruction + + eol + ) + + return re.sub( + r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + repl, + content, + ) + def post_process_skill_content(self, content: str) -> str: """Post-process a SKILL.md file's content after generation. Called by external skill generators (presets, extensions) to let the integration inject agent-specific frontmatter or body - transformations. The default implementation returns *content* - unchanged. Subclasses may override — see ``ClaudeIntegration``. + transformations. The base implementation injects shared skills + guidance for converting dotted hook command names to hyphenated + slash commands. Subclasses may override — see ``ClaudeIntegration``. """ - return content + return self._inject_hook_command_note(content) def setup( self, @@ -1508,6 +1545,11 @@ def _quote(v: str) -> str: dst = self.write_file_and_record( skill_content, skill_file, project_root, manifest ) + content = dst.read_text(encoding="utf-8") + updated = self.post_process_skill_content(content) + if updated != content: + dst.write_bytes(updated.encode("utf-8")) + self.record_file_in_manifest(dst, project_root, manifest) created.append(dst) # Upsert managed context section into the agent context file diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 88aef85285..57ecb354ad 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -5,21 +5,11 @@ from pathlib import Path from typing import Any -import re - import yaml from ..base import SkillsIntegration from ..manifest import IntegrationManifest -# Note injected into hook sections so Claude maps dot-notation command -# names (from extensions.yml) to the hyphenated skill names it uses. -_HOOK_COMMAND_NOTE = ( - "- When constructing slash commands from hook command names, " - "replace dots (`.`) with hyphens (`-`). " - "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" -) - # Mapping of command template stem → argument-hint text shown inline # when a user invokes the slash command in Claude Code. ARGUMENT_HINTS: dict[str, str] = { @@ -159,41 +149,11 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str out.append(line) return "".join(out) - @staticmethod - def _inject_hook_command_note(content: str) -> str: - """Insert a dot-to-hyphen note before each hook output instruction. - - Targets the line ``- For each executable hook, output the following`` - and inserts the note on the line before it, matching its indentation. - Skips if the note is already present. - """ - if "replace dots" in content: - return content - - def repl(m: re.Match[str]) -> str: - indent = m.group(1) - instruction = m.group(2) - eol = m.group(3) - return ( - indent - + _HOOK_COMMAND_NOTE.rstrip("\n") - + eol - + indent - + instruction - + eol - ) - - return re.sub( - r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", - repl, - content, - ) - def post_process_skill_content(self, content: str) -> str: """Inject Claude-specific frontmatter flags and hook notes.""" - updated = self._inject_frontmatter_flag(content, "user-invocable") + 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") - updated = self._inject_hook_command_note(updated) return updated def setup( @@ -203,10 +163,9 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Install Claude skills, then inject Claude-specific flags and argument-hints.""" + """Install Claude skills, then inject argument-hints.""" created = super().setup(project_root, manifest, parsed_options, **opts) - # Post-process generated skill files skills_dir = self.skills_dest(project_root).resolve() for path in created: @@ -221,7 +180,7 @@ def setup( content_bytes = path.read_bytes() content = content_bytes.decode("utf-8") - updated = self.post_process_skill_content(content) + updated = content # Inject argument-hint if available for this skill skill_dir_name = path.parent.name # e.g. "speckit-plan" diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index c7456ce7f0..a4e7601a14 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -255,11 +255,12 @@ def command_filename(self, template_name: str) -> str: return f"speckit.{template_name}.agent.md" def post_process_skill_content(self, content: str) -> str: - """Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter. + """Inject shared hook guidance and Copilot ``mode:`` frontmatter. Inserts ``mode: speckit.`` before the closing ``---`` so Copilot can associate the skill with its agent mode. """ + content = SkillsIntegration._inject_hook_command_note(content) lines = content.splitlines(keepends=True) # Extract skill name from frontmatter to derive the mode value diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py index f5ad63bdc2..d340224b3d 100644 --- a/src/specify_cli/integrations/vibe/__init__.py +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -87,7 +87,8 @@ def post_process_skill_content(self, content: str) -> str: Inject Vibe-specific frontmatter flags: - user-invocable: allows the skill to be invoked by the user (not just other agents) """ - updated = self._inject_frontmatter_flag(content, "user-invocable") + updated = super().post_process_skill_content(content) + updated = self._inject_frontmatter_flag(updated, "user-invocable") return updated def setup( @@ -107,27 +108,4 @@ def setup( err=True, ) - created = super().setup(project_root, manifest, parsed_options=parsed_options, **opts) - - # Post-process generated skill files - skills_dir = self.skills_dest(project_root).resolve() - - for path in created: - # Only touch SKILL.md files under the skills directory - try: - path.resolve().relative_to(skills_dir) - except ValueError: - continue - if path.name != "SKILL.md": - continue - - content_bytes = path.read_bytes() - content = content_bytes.decode("utf-8") - - updated = self.post_process_skill_content(content) - - if updated != content: - path.write_bytes(updated.encode("utf-8")) - self.record_file_in_manifest(path, project_root, manifest) - - return created + return super().setup(project_root, manifest, parsed_options=parsed_options, **opts) diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 89140de1c3..2f5ee98e8b 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -176,6 +176,21 @@ def test_command_refs_use_hyphen_separator(self, tmp_path): f"skills agents must use /speckit-" ) + def test_hook_sections_explain_dotted_command_conversion(self, tmp_path): + """Generated skills with hook sections must explain dotted command conversion.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + specify_skill = i.skills_dest(tmp_path) / "speckit-specify" / "SKILL.md" + assert specify_skill.exists() + content = specify_skill.read_text(encoding="utf-8") + assert "replace dots" in content, ( + "speckit-specify should explain dotted hook command conversion" + ) + assert content.count("replace dots") == content.count( + "- For each executable hook, output the following" + ) + def test_skill_body_has_content(self, tmp_path): """Each SKILL.md body should contain template content after the frontmatter.""" i = get_integration(self.KEY) diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 1498dad44f..012a82e9ca 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -8,7 +8,7 @@ import yaml from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration -from specify_cli.integrations.base import IntegrationBase +from specify_cli.integrations.base import IntegrationBase, SkillsIntegration from specify_cli.integrations.claude import ARGUMENT_HINTS from specify_cli.integrations.manifest import IntegrationManifest @@ -487,8 +487,8 @@ def test_non_claude_agents_lack_disable_model_invocation(self, tmp_path): assert "disable-model-invocation" not in fm assert "user-invocable" not in fm - def test_skills_default_post_process_is_identity(self, tmp_path): - """SkillsIntegration agents without an override leave content unchanged.""" + def test_skills_default_post_process_preserves_content_without_hooks(self, tmp_path): + """SkillsIntegration agents without an override preserve non-hook content.""" # ``agy`` is a plain SkillsIntegration with no post-process override, # so it stands in for the base-class default behavior. agy = get_integration("agy") @@ -516,33 +516,27 @@ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): def test_hook_note_not_in_skills_without_hooks(self, tmp_path): """Skills without hook sections should not get the note.""" - from specify_cli.integrations.claude import ClaudeIntegration - content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n" - result = ClaudeIntegration._inject_hook_command_note(content) + result = SkillsIntegration._inject_hook_command_note(content) assert "replace dots" not in result def test_hook_note_idempotent(self, tmp_path): """Injecting the note twice should not duplicate it.""" - from specify_cli.integrations.claude import ClaudeIntegration - content = ( "---\nname: test\n---\n\n" "- For each executable hook, output the following based on its flag:\n" ) - once = ClaudeIntegration._inject_hook_command_note(content) - twice = ClaudeIntegration._inject_hook_command_note(once) + once = SkillsIntegration._inject_hook_command_note(content) + twice = SkillsIntegration._inject_hook_command_note(once) assert once == twice, "Hook note injection should be idempotent" def test_hook_note_preserves_indentation(self, tmp_path): """The injected note should match the indentation of the target line.""" - from specify_cli.integrations.claude import ClaudeIntegration - content = ( "---\nname: test\n---\n\n" " - For each executable hook, output the following\n" ) - result = ClaudeIntegration._inject_hook_command_note(content) + result = SkillsIntegration._inject_hook_command_note(content) lines = result.splitlines() note_line = [l for l in lines if "replace dots" in l][0] assert note_line.startswith(" "), "Note should preserve indentation" diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index c6e9259b09..1c69a54bd1 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -404,6 +404,20 @@ def test_post_process_skill_content_injects_mode(self): updated = copilot.post_process_skill_content(content) assert "mode: speckit.plan" in updated + def test_post_process_skill_content_injects_hook_note(self): + """post_process_skill_content() should inject shared hook guidance.""" + copilot = self._make_copilot() + content = ( + "---\n" + 'name: "speckit-specify"\n' + 'description: "Specify workflow"\n' + "---\n" + "\n- For each executable hook, output the following\n" + ) + updated = copilot.post_process_skill_content(content) + assert "replace dots" in updated + assert "mode: speckit.specify" in updated + def test_post_process_idempotent(self): """post_process_skill_content() must be idempotent.""" copilot = self._make_copilot() @@ -434,6 +448,14 @@ def test_skills_have_mode_in_frontmatter(self, tmp_path): stem = skill_dir_name.removeprefix("speckit-") assert fm["mode"] == f"speckit.{stem}" + def test_skills_hook_sections_explain_dotted_command_conversion(self, tmp_path): + """Generated skills with hook sections should include shared hook guidance.""" + copilot = self._make_copilot() + self._setup_skills(copilot, tmp_path) + specify_skill = tmp_path / ".github" / "skills" / "speckit-specify" / "SKILL.md" + content = specify_skill.read_text(encoding="utf-8") + assert "replace dots" in content + # -- Template processing ---------------------------------------------- def test_skills_templates_are_processed(self, tmp_path): @@ -724,4 +746,4 @@ def test_init_skills_next_steps_show_skill_syntax(self, tmp_path): # Must NOT show the dotted /speckit.plan form assert "/speckit.plan" not in result.output, ( f"Should not show /speckit.plan in skills mode:\n{result.output}" - ) \ No newline at end of file + ) From ee96e39d86e208483e254a0bc7fb94407eaf3e2b Mon Sep 17 00:00:00 2001 From: Puneet Dixit <236133619+puneetdixit200@users.noreply.github.com> Date: Sat, 23 May 2026 02:11:44 +0530 Subject: [PATCH 2/2] fix(integrations): tighten skill post-processing Apply skill content post-processing before the initial write, use an exact hook-note sentinel for idempotence, and route Copilot skill post-processing through the shared helper before adding mode frontmatter. --- src/specify_cli/integrations/base.py | 15 ++++++------- .../integrations/codex/__init__.py | 2 +- .../integrations/copilot/__init__.py | 8 +++---- tests/integrations/test_integration_claude.py | 15 +++++++++++-- tests/integrations/test_integration_codex.py | 21 +++++++++++++++---- 5 files changed, 43 insertions(+), 18 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 98ddbe6034..4c0b61167c 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -1405,13 +1405,17 @@ def _inject_hook_command_note(content: str) -> str: and inserts the note on the line before it, matching its indentation. Skips if the note is already present. """ - if "replace dots" in content: + if _HOOK_COMMAND_NOTE.rstrip("\n") in content: return content def repl(m: re.Match[str]) -> str: indent = m.group(1) instruction = m.group(2) - eol = m.group(3) + # ``eol`` is empty when the regex matched via ``$`` because the + # instruction was the final line of a file with no trailing + # newline. Default to ``\n`` so the note never collapses onto + # the same line as the instruction. + eol = m.group(3) or "\n" return ( indent + _HOOK_COMMAND_NOTE.rstrip("\n") @@ -1539,17 +1543,14 @@ def _quote(v: str) -> str: f"{processed_body}" ) + skill_content = self.post_process_skill_content(skill_content) + # Write speckit-/SKILL.md skill_dir = skills_dir / skill_name skill_file = skill_dir / "SKILL.md" dst = self.write_file_and_record( skill_content, skill_file, project_root, manifest ) - content = dst.read_text(encoding="utf-8") - updated = self.post_process_skill_content(content) - if updated != content: - dst.write_bytes(updated.encode("utf-8")) - self.record_file_in_manifest(dst, project_root, manifest) created.append(dst) # Upsert managed context section into the agent context file diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index 5efdcf877a..41850032a8 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -78,7 +78,7 @@ def _inject_hook_command_note(content: str) -> str: and inserts the note on the line before it, matching its indentation. Skips if the note is already present. """ - if "replace dots" in content: + if _HOOK_COMMAND_NOTE.rstrip("\n") in content: return content def repl(m: re.Match[str]) -> str: diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index a4e7601a14..c054ab0d32 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -260,8 +260,8 @@ def post_process_skill_content(self, content: str) -> str: Inserts ``mode: speckit.`` before the closing ``---`` so Copilot can associate the skill with its agent mode. """ - content = SkillsIntegration._inject_hook_command_note(content) - lines = content.splitlines(keepends=True) + updated = _CopilotSkillsHelper().post_process_skill_content(content) + lines = updated.splitlines(keepends=True) # Extract skill name from frontmatter to derive the mode value dash_count = 0 @@ -275,7 +275,7 @@ def post_process_skill_content(self, content: str) -> str: continue if dash_count == 1: if stripped.startswith("mode:"): - return content # already present + return updated # already present if stripped.startswith("name:"): # Parse: name: "speckit-plan" → speckit.plan val = stripped.split(":", 1)[1].strip().strip('"').strip("'") @@ -286,7 +286,7 @@ def post_process_skill_content(self, content: str) -> str: skill_name = val if not skill_name: - return content + return updated # Inject mode: before the closing --- of frontmatter out: list[str] = [] diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 012a82e9ca..e3af8a67b7 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -505,7 +505,7 @@ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): """Skills that have hook sections should get the normalization note.""" i = get_integration("claude") m = IntegrationManifest("claude", tmp_path) - created = i.setup(tmp_path, m, script_type="sh") + i.setup(tmp_path, m, script_type="sh") specify_skill = tmp_path / ".claude/skills/speckit-specify/SKILL.md" assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") @@ -530,6 +530,17 @@ def test_hook_note_idempotent(self, tmp_path): twice = SkillsIntegration._inject_hook_command_note(once) assert once == twice, "Hook note injection should be idempotent" + def test_hook_note_not_suppressed_by_unrelated_phrase(self, tmp_path): + """Unrelated text should not trip the hook-note idempotence guard.""" + content = ( + "---\nname: test\n---\n\n" + "This paragraph says replace dots in a different context.\n" + "- For each executable hook, output the following based on its flag:\n" + ) + result = SkillsIntegration._inject_hook_command_note(content) + assert "This paragraph says replace dots in a different context." in result + assert result.count("replace dots (`.`) with hyphens") == 1 + def test_hook_note_preserves_indentation(self, tmp_path): """The injected note should match the indentation of the target line.""" content = ( @@ -538,7 +549,7 @@ def test_hook_note_preserves_indentation(self, tmp_path): ) result = SkillsIntegration._inject_hook_command_note(content) lines = result.splitlines() - note_line = [l for l in lines if "replace dots" in l][0] + note_line = [line for line in lines if "replace dots" in line][0] assert note_line.startswith(" "), "Note should preserve indentation" def test_post_process_injects_all_claude_flags(self): diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py index 415773da0d..2a5181a863 100644 --- a/tests/integrations/test_integration_codex.py +++ b/tests/integrations/test_integration_codex.py @@ -71,6 +71,19 @@ def test_hook_note_idempotent(self): twice = CodexIntegration._inject_hook_command_note(once) assert once == twice, "Hook note injection should be idempotent" + def test_hook_note_not_suppressed_by_unrelated_phrase(self): + """Unrelated text should not trip the hook-note idempotence guard.""" + from specify_cli.integrations.codex import CodexIntegration + + content = ( + "---\nname: test\n---\n\n" + "This paragraph says replace dots in a different context.\n" + "- For each executable hook, output the following based on its flag:\n" + ) + result = CodexIntegration._inject_hook_command_note(content) + assert "This paragraph says replace dots in a different context." in result + assert result.count("replace dots (`.`) with hyphens") == 1 + def test_hook_note_preserves_indentation(self): """The injected note should match the indentation of the target line.""" from specify_cli.integrations.codex import CodexIntegration @@ -81,7 +94,7 @@ def test_hook_note_preserves_indentation(self): ) result = CodexIntegration._inject_hook_command_note(content) lines = result.splitlines() - note_line = [l for l in lines if "replace dots" in l][0] + note_line = [line for line in lines if "replace dots" in line][0] assert note_line.startswith(" "), "Note should preserve indentation" def test_hook_note_when_instruction_is_final_line_without_newline(self): @@ -102,11 +115,11 @@ def test_hook_note_when_instruction_is_final_line_without_newline(self): result = CodexIntegration._inject_hook_command_note(content) lines = result.splitlines() note_line_idx = next( - i for i, l in enumerate(lines) if "replace dots" in l + i for i, line in enumerate(lines) if "replace dots" in line ) instruction_line_idx = next( - i for i, l in enumerate(lines) - if l.lstrip().startswith("- For each executable hook") + i for i, line in enumerate(lines) + if line.lstrip().startswith("- For each executable hook") ) assert note_line_idx < instruction_line_idx, ( "Note must appear before the instruction"