mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 06:35:06 +08:00
fix: allow Claude to chain skills for hook execution (#2227)
* fix: allow Claude to chain skills for hook execution (#2178) - Set disable-model-invocation to false so Claude can invoke extension skills (e.g. speckit-git-feature) from within workflow skills - Inject dot-to-hyphen normalization note into Claude SKILL.md hook sections so the model maps extension.yml command names to skill names - Replace Unicode checkmark with ASCII [OK] in auto-commit scripts to fix PowerShell encoding errors on Windows - Move Claude-specific frontmatter injection to ClaudeIntegration via post_process_skill_content() hook on SkillsIntegration, wired through presets and extensions managers - Add positive and negative tests for all changes Fixes #2178 * refactor: address PR review feedback - Preserve line-ending style (CRLF/LF) in _inject_hook_command_note instead of always inserting \n, matching the convention used by other injection helpers in the same module. - Extract duplicated _post_process_skill() from extensions.py and presets.py into a shared post_process_skill() function in agents.py. Both modules now import and call the shared helper. * fix: match full hook instruction line in regex The regex in _inject_hook_command_note only matched lines ending immediately after 'output the following', but the actual template lines continue with 'based on its `optional` flag:'. Use [^\r\n]* to capture the rest of the line before the EOL. * refactor: use integration object directly for post_process_skill_content Instead of a free function in agents.py that re-resolves the integration by key, callers in extensions.py and presets.py now resolve the integration once via get_integration() and call integration.post_process_skill_content() directly. The base identity method lives on SkillsIntegration.
This commit is contained in:
@@ -317,11 +317,6 @@ class CommandRegistrar:
|
||||
"source": source,
|
||||
},
|
||||
}
|
||||
if agent_name == "claude":
|
||||
# Claude skills should be user-invocable (accessible via /command)
|
||||
# and only run when explicitly invoked (not auto-triggered by the model).
|
||||
skill_frontmatter["user-invocable"] = True
|
||||
skill_frontmatter["disable-model-invocation"] = True
|
||||
return skill_frontmatter
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -850,6 +850,7 @@ class ExtensionManager:
|
||||
|
||||
from . import load_init_options
|
||||
from .agents import CommandRegistrar
|
||||
from .integrations import get_integration
|
||||
import yaml
|
||||
|
||||
written: List[str] = []
|
||||
@@ -860,6 +861,7 @@ class ExtensionManager:
|
||||
if not isinstance(selected_ai, str) or not selected_ai:
|
||||
return []
|
||||
registrar = CommandRegistrar()
|
||||
integration = get_integration(selected_ai)
|
||||
|
||||
for cmd_info in manifest.commands:
|
||||
cmd_name = cmd_info["name"]
|
||||
@@ -939,6 +941,10 @@ class ExtensionManager:
|
||||
f"# {title_name} Skill\n\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
if integration is not None and hasattr(integration, "post_process_skill_content"):
|
||||
skill_content = integration.post_process_skill_content(
|
||||
skill_content
|
||||
)
|
||||
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
written.append(skill_name)
|
||||
|
||||
@@ -1102,6 +1102,16 @@ class SkillsIntegration(IntegrationBase):
|
||||
invocation = f"{invocation} {args}"
|
||||
return invocation
|
||||
|
||||
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``.
|
||||
"""
|
||||
return content
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
|
||||
@@ -5,11 +5,21 @@ from __future__ import annotations
|
||||
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] = {
|
||||
@@ -148,6 +158,43 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
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 = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
|
||||
updated = self._inject_hook_command_note(updated)
|
||||
return updated
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
@@ -155,7 +202,7 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint."""
|
||||
"""Install Claude skills, then inject Claude-specific flags and argument-hints."""
|
||||
created = super().setup(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
# Post-process generated skill files
|
||||
@@ -173,11 +220,7 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
content_bytes = path.read_bytes()
|
||||
content = content_bytes.decode("utf-8")
|
||||
|
||||
# Inject user-invocable: true (Claude skills are accessible via /command)
|
||||
updated = self._inject_frontmatter_flag(content, "user-invocable")
|
||||
|
||||
# Inject disable-model-invocation: true (Claude skills run only when invoked)
|
||||
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation")
|
||||
updated = self.post_process_skill_content(content)
|
||||
|
||||
# Inject argument-hint if available for this skill
|
||||
skill_dir_name = path.parent.name # e.g. "speckit-plan"
|
||||
|
||||
@@ -707,6 +707,7 @@ class PresetManager:
|
||||
|
||||
from . import SKILL_DESCRIPTIONS, load_init_options
|
||||
from .agents import CommandRegistrar
|
||||
from .integrations import get_integration
|
||||
|
||||
init_opts = load_init_options(self.project_root)
|
||||
if not isinstance(init_opts, dict):
|
||||
@@ -716,6 +717,7 @@ class PresetManager:
|
||||
return []
|
||||
ai_skills_enabled = bool(init_opts.get("ai_skills"))
|
||||
registrar = CommandRegistrar()
|
||||
integration = get_integration(selected_ai)
|
||||
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
|
||||
# Native skill agents (e.g. codex/kimi/agy/trae) materialize brand-new
|
||||
# preset skills in _register_commands() because their detected agent
|
||||
@@ -789,6 +791,10 @@ class PresetManager:
|
||||
f"# Speckit {skill_title} Skill\n\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
if integration is not None and hasattr(integration, "post_process_skill_content"):
|
||||
skill_content = integration.post_process_skill_content(
|
||||
skill_content
|
||||
)
|
||||
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
@@ -816,6 +822,7 @@ class PresetManager:
|
||||
|
||||
from . import SKILL_DESCRIPTIONS, load_init_options
|
||||
from .agents import CommandRegistrar
|
||||
from .integrations import get_integration
|
||||
|
||||
# Locate core command templates from the project's installed templates
|
||||
core_templates_dir = self.project_root / ".specify" / "templates" / "commands"
|
||||
@@ -824,6 +831,7 @@ class PresetManager:
|
||||
init_opts = {}
|
||||
selected_ai = init_opts.get("ai")
|
||||
registrar = CommandRegistrar()
|
||||
integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None
|
||||
extension_restore_index = self._build_extension_skill_restore_index()
|
||||
|
||||
for skill_name in skill_names:
|
||||
@@ -877,6 +885,10 @@ class PresetManager:
|
||||
f"# Speckit {skill_title} Skill\n\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
if integration is not None and hasattr(integration, "post_process_skill_content"):
|
||||
skill_content = integration.post_process_skill_content(
|
||||
skill_content
|
||||
)
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
continue
|
||||
|
||||
@@ -906,6 +918,10 @@ class PresetManager:
|
||||
f"# {title_name} Skill\n\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
if integration is not None and hasattr(integration, "post_process_skill_content"):
|
||||
skill_content = integration.post_process_skill_content(
|
||||
skill_content
|
||||
)
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
else:
|
||||
# No core or extension template — remove the skill entirely
|
||||
|
||||
Reference in New Issue
Block a user