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
49 changes: 46 additions & 3 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1391,15 +1397,50 @@ 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 _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`` 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")
+ eol
+ indent
+ instruction
+ eol
)

return re.sub(
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
repl,
content,
)
Comment on lines +1400 to +1432

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,
Expand Down Expand Up @@ -1502,6 +1543,8 @@ def _quote(v: str) -> str:
f"{processed_body}"
)

skill_content = self.post_process_skill_content(skill_content)

# Write speckit-<name>/SKILL.md
skill_dir = skills_dir / skill_name
skill_file = skill_dir / "SKILL.md"
Expand Down
49 changes: 4 additions & 45 deletions src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/specify_cli/integrations/codex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 5 additions & 4 deletions src/specify_cli/integrations/copilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,12 +255,13 @@ 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.<stem>`` before the closing ``---`` so
Copilot can associate the skill with its agent mode.
"""
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
Expand All @@ -274,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("'")
Expand All @@ -285,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] = []
Expand Down
28 changes: 3 additions & 25 deletions src/specify_cli/integrations/vibe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
15 changes: 15 additions & 0 deletions tests/integrations/test_integration_base_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,21 @@ def test_command_refs_use_hyphen_separator(self, tmp_path):
f"skills agents must use /speckit-<name>"
)

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)
Expand Down
35 changes: 20 additions & 15 deletions tests/integrations/test_integration_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -516,35 +516,40 @@ 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_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."""
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]
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):
Expand Down
21 changes: 17 additions & 4 deletions tests/integrations/test_integration_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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"
Expand Down
Loading