Skip to content

Commit 8fc2bd3

Browse files
authored
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.
1 parent b78a3cd commit 8fc2bd3

File tree

11 files changed

+257
-19
lines changed

11 files changed

+257
-19
lines changed

extensions/git/scripts/bash/auto-commit.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,4 @@ fi
137137
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
138138
_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
139139

140-
echo " Changes committed ${_phase} ${_command_name}" >&2
140+
echo "[OK] Changes committed ${_phase} ${_command_name}" >&2

extensions/git/scripts/powershell/auto-commit.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,4 @@ try {
146146
exit 1
147147
}
148148

149-
Write-Host " Changes committed $phase $commandName"
149+
Write-Host "[OK] Changes committed $phase $commandName"

src/specify_cli/agents.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -317,11 +317,6 @@ def build_skill_frontmatter(
317317
"source": source,
318318
},
319319
}
320-
if agent_name == "claude":
321-
# Claude skills should be user-invocable (accessible via /command)
322-
# and only run when explicitly invoked (not auto-triggered by the model).
323-
skill_frontmatter["user-invocable"] = True
324-
skill_frontmatter["disable-model-invocation"] = True
325320
return skill_frontmatter
326321

327322
@staticmethod

src/specify_cli/extensions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,7 @@ def _register_extension_skills(
850850

851851
from . import load_init_options
852852
from .agents import CommandRegistrar
853+
from .integrations import get_integration
853854
import yaml
854855

855856
written: List[str] = []
@@ -860,6 +861,7 @@ def _register_extension_skills(
860861
if not isinstance(selected_ai, str) or not selected_ai:
861862
return []
862863
registrar = CommandRegistrar()
864+
integration = get_integration(selected_ai)
863865

864866
for cmd_info in manifest.commands:
865867
cmd_name = cmd_info["name"]
@@ -939,6 +941,10 @@ def _register_extension_skills(
939941
f"# {title_name} Skill\n\n"
940942
f"{body}\n"
941943
)
944+
if integration is not None and hasattr(integration, "post_process_skill_content"):
945+
skill_content = integration.post_process_skill_content(
946+
skill_content
947+
)
942948

943949
skill_file.write_text(skill_content, encoding="utf-8")
944950
written.append(skill_name)

src/specify_cli/integrations/base.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,16 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str:
11021102
invocation = f"{invocation} {args}"
11031103
return invocation
11041104

1105+
def post_process_skill_content(self, content: str) -> str:
1106+
"""Post-process a SKILL.md file's content after generation.
1107+
1108+
Called by external skill generators (presets, extensions) to let
1109+
the integration inject agent-specific frontmatter or body
1110+
transformations. The default implementation returns *content*
1111+
unchanged. Subclasses may override — see ``ClaudeIntegration``.
1112+
"""
1113+
return content
1114+
11051115
def setup(
11061116
self,
11071117
project_root: Path,

src/specify_cli/integrations/claude/__init__.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,21 @@
55
from pathlib import Path
66
from typing import Any
77

8+
import re
9+
810
import yaml
911

1012
from ..base import SkillsIntegration
1113
from ..manifest import IntegrationManifest
1214

15+
# Note injected into hook sections so Claude maps dot-notation command
16+
# names (from extensions.yml) to the hyphenated skill names it uses.
17+
_HOOK_COMMAND_NOTE = (
18+
"- When constructing slash commands from hook command names, "
19+
"replace dots (`.`) with hyphens (`-`). "
20+
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
21+
)
22+
1323
# Mapping of command template stem → argument-hint text shown inline
1424
# when a user invokes the slash command in Claude Code.
1525
ARGUMENT_HINTS: dict[str, str] = {
@@ -148,14 +158,51 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str
148158
out.append(line)
149159
return "".join(out)
150160

161+
@staticmethod
162+
def _inject_hook_command_note(content: str) -> str:
163+
"""Insert a dot-to-hyphen note before each hook output instruction.
164+
165+
Targets the line ``- For each executable hook, output the following``
166+
and inserts the note on the line before it, matching its indentation.
167+
Skips if the note is already present.
168+
"""
169+
if "replace dots" in content:
170+
return content
171+
172+
def repl(m: re.Match[str]) -> str:
173+
indent = m.group(1)
174+
instruction = m.group(2)
175+
eol = m.group(3)
176+
return (
177+
indent
178+
+ _HOOK_COMMAND_NOTE.rstrip("\n")
179+
+ eol
180+
+ indent
181+
+ instruction
182+
+ eol
183+
)
184+
185+
return re.sub(
186+
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
187+
repl,
188+
content,
189+
)
190+
191+
def post_process_skill_content(self, content: str) -> str:
192+
"""Inject Claude-specific frontmatter flags and hook notes."""
193+
updated = self._inject_frontmatter_flag(content, "user-invocable")
194+
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
195+
updated = self._inject_hook_command_note(updated)
196+
return updated
197+
151198
def setup(
152199
self,
153200
project_root: Path,
154201
manifest: IntegrationManifest,
155202
parsed_options: dict[str, Any] | None = None,
156203
**opts: Any,
157204
) -> list[Path]:
158-
"""Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint."""
205+
"""Install Claude skills, then inject Claude-specific flags and argument-hints."""
159206
created = super().setup(project_root, manifest, parsed_options, **opts)
160207

161208
# Post-process generated skill files
@@ -173,11 +220,7 @@ def setup(
173220
content_bytes = path.read_bytes()
174221
content = content_bytes.decode("utf-8")
175222

176-
# Inject user-invocable: true (Claude skills are accessible via /command)
177-
updated = self._inject_frontmatter_flag(content, "user-invocable")
178-
179-
# Inject disable-model-invocation: true (Claude skills run only when invoked)
180-
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation")
223+
updated = self.post_process_skill_content(content)
181224

182225
# Inject argument-hint if available for this skill
183226
skill_dir_name = path.parent.name # e.g. "speckit-plan"

src/specify_cli/presets.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,7 @@ def _register_skills(
707707

708708
from . import SKILL_DESCRIPTIONS, load_init_options
709709
from .agents import CommandRegistrar
710+
from .integrations import get_integration
710711

711712
init_opts = load_init_options(self.project_root)
712713
if not isinstance(init_opts, dict):
@@ -716,6 +717,7 @@ def _register_skills(
716717
return []
717718
ai_skills_enabled = bool(init_opts.get("ai_skills"))
718719
registrar = CommandRegistrar()
720+
integration = get_integration(selected_ai)
719721
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
720722
# Native skill agents (e.g. codex/kimi/agy/trae) materialize brand-new
721723
# preset skills in _register_commands() because their detected agent
@@ -789,6 +791,10 @@ def _register_skills(
789791
f"# Speckit {skill_title} Skill\n\n"
790792
f"{body}\n"
791793
)
794+
if integration is not None and hasattr(integration, "post_process_skill_content"):
795+
skill_content = integration.post_process_skill_content(
796+
skill_content
797+
)
792798

793799
skill_file = skill_subdir / "SKILL.md"
794800
skill_file.write_text(skill_content, encoding="utf-8")
@@ -816,6 +822,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
816822

817823
from . import SKILL_DESCRIPTIONS, load_init_options
818824
from .agents import CommandRegistrar
825+
from .integrations import get_integration
819826

820827
# Locate core command templates from the project's installed templates
821828
core_templates_dir = self.project_root / ".specify" / "templates" / "commands"
@@ -824,6 +831,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
824831
init_opts = {}
825832
selected_ai = init_opts.get("ai")
826833
registrar = CommandRegistrar()
834+
integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None
827835
extension_restore_index = self._build_extension_skill_restore_index()
828836

829837
for skill_name in skill_names:
@@ -877,6 +885,10 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
877885
f"# Speckit {skill_title} Skill\n\n"
878886
f"{body}\n"
879887
)
888+
if integration is not None and hasattr(integration, "post_process_skill_content"):
889+
skill_content = integration.post_process_skill_content(
890+
skill_content
891+
)
880892
skill_file.write_text(skill_content, encoding="utf-8")
881893
continue
882894

@@ -906,6 +918,10 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
906918
f"# {title_name} Skill\n\n"
907919
f"{body}\n"
908920
)
921+
if integration is not None and hasattr(integration, "post_process_skill_content"):
922+
skill_content = integration.post_process_skill_content(
923+
skill_content
924+
)
909925
skill_file.write_text(skill_content, encoding="utf-8")
910926
else:
911927
# No core or extension template — remove the skill entirely

tests/extensions/git/test_git_extension.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,34 @@ def test_requires_event_name_argument(self, tmp_path: Path):
491491
result = _run_bash("auto-commit.sh", project)
492492
assert result.returncode != 0
493493

494+
def test_success_message_uses_ok_prefix(self, tmp_path: Path):
495+
"""auto-commit.sh success message uses [OK] (not Unicode)."""
496+
project = _setup_project(tmp_path)
497+
_write_config(project, (
498+
"auto_commit:\n"
499+
" default: false\n"
500+
" after_specify:\n"
501+
" enabled: true\n"
502+
))
503+
(project / "new-file.txt").write_text("content")
504+
result = _run_bash("auto-commit.sh", project, "after_specify")
505+
assert result.returncode == 0
506+
assert "[OK] Changes committed" in result.stderr
507+
508+
def test_success_message_no_unicode_checkmark(self, tmp_path: Path):
509+
"""auto-commit.sh must not use Unicode checkmark in output."""
510+
project = _setup_project(tmp_path)
511+
_write_config(project, (
512+
"auto_commit:\n"
513+
" default: false\n"
514+
" after_plan:\n"
515+
" enabled: true\n"
516+
))
517+
(project / "new-file.txt").write_text("content")
518+
result = _run_bash("auto-commit.sh", project, "after_plan")
519+
assert result.returncode == 0
520+
assert "\u2713" not in result.stderr, "Must not use Unicode checkmark"
521+
494522

495523
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
496524
class TestAutoCommitPowerShell:
@@ -523,6 +551,34 @@ def test_enabled_per_command(self, tmp_path: Path):
523551
)
524552
assert "ps commit" in log.stdout
525553

554+
def test_success_message_uses_ok_prefix(self, tmp_path: Path):
555+
"""auto-commit.ps1 success message uses [OK] (not Unicode)."""
556+
project = _setup_project(tmp_path)
557+
_write_config(project, (
558+
"auto_commit:\n"
559+
" default: false\n"
560+
" after_specify:\n"
561+
" enabled: true\n"
562+
))
563+
(project / "new-file.txt").write_text("content")
564+
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
565+
assert result.returncode == 0
566+
assert "[OK] Changes committed" in result.stdout
567+
568+
def test_success_message_no_unicode_checkmark(self, tmp_path: Path):
569+
"""auto-commit.ps1 must not use Unicode checkmark in output."""
570+
project = _setup_project(tmp_path)
571+
_write_config(project, (
572+
"auto_commit:\n"
573+
" default: false\n"
574+
" after_plan:\n"
575+
" enabled: true\n"
576+
))
577+
(project / "new-file.txt").write_text("content")
578+
result = _run_pwsh("auto-commit.ps1", project, "after_plan")
579+
assert result.returncode == 0
580+
assert "\u2713" not in result.stdout, "Must not use Unicode checkmark"
581+
526582

527583
# ── git-common.sh Tests ──────────────────────────────────────────────────────
528584

0 commit comments

Comments
 (0)