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
52 changes: 49 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,53 @@ 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 individual instructions that already have the note immediately
above them.
"""
note = _HOOK_COMMAND_NOTE.rstrip("\n")

def repl(m: re.Match[str]) -> str:
indent = m.group(1)
instruction = m.group(2)
previous_lines = content[:m.start()].splitlines()
if previous_lines and previous_lines[-1] == indent + note:
return m.group(0)
# ``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
+ note
+ eol
+ indent
+ instruction
+ eol
)

return re.sub(
r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
repl,
content,
)
Comment thread
puneetdixit200 marked this conversation as resolved.

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 +1546,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
13 changes: 8 additions & 5 deletions src/specify_cli/integrations/codex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,30 +76,33 @@ def _inject_hook_command_note(content: str) -> str:

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.
Skips individual instructions that already have the note immediately
above them.
"""
if "replace dots" in content:
return content
note = _HOOK_COMMAND_NOTE.rstrip("\n")

def repl(m: re.Match[str]) -> str:
indent = m.group(1)
instruction = m.group(2)
previous_lines = content[:m.start()].splitlines()
if previous_lines and previous_lines[-1] == indent + note:
return m.group(0)
# ``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")
+ note
+ eol
+ indent
+ instruction
+ eol
)

return re.sub(
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
repl,
content,
)
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)
33 changes: 33 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,39 @@ 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_hook_note_injected_for_each_instruction_independently(self):
"""Existing hook notes should not suppress later missing notes."""
content = (
"---\n"
"name: test\n"
"---\n\n"
"- When constructing slash commands from hook command names, "
"replace dots (`.`) with hyphens (`-`). "
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
"- For each executable hook, output the following first block:\n"
"\n"
"- For each executable hook, output the following second block:\n"
)

result = SkillsIntegration._inject_hook_command_note(content)

assert result.count("replace dots (`.`) with hyphens") == 2

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
Loading