Skip to content

Commit b07f4fd

Browse files
committed
refactor: extension-based skill renderer (registry + Claude modules)
Made-with: Cursor
1 parent 1990cf2 commit b07f4fd

4 files changed

Lines changed: 270 additions & 155 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Extension registry for skill markdown post-processing.
2+
3+
The core applies registered :class:`RendererExtension` instances in order.
4+
Concrete transforms (e.g. Claude-specific) live in separate modules outside
5+
this package's knowledge.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from dataclasses import dataclass
11+
from pathlib import Path
12+
from typing import Protocol, runtime_checkable
13+
14+
15+
@dataclass(frozen=True)
16+
class SkillRenderContext:
17+
"""Per-file context passed to each extension."""
18+
19+
skill_path: Path
20+
21+
22+
@runtime_checkable
23+
class RendererExtension(Protocol):
24+
"""Post-process installed skill markdown."""
25+
26+
def transform(self, content: str, ctx: SkillRenderContext) -> str:
27+
"""Return *content* with this extension's transformation applied."""
28+
...
29+
30+
31+
class ExtensionRegistry:
32+
"""Ordered list of extensions applied sequentially."""
33+
34+
def __init__(self) -> None:
35+
self._extensions: list[RendererExtension] = []
36+
37+
def register(self, extension: RendererExtension) -> None:
38+
self._extensions.append(extension)
39+
40+
def apply(self, content: str, ctx: SkillRenderContext) -> str:
41+
out = content
42+
for ext in self._extensions:
43+
out = ext.transform(out, ctx)
44+
return out
45+
46+
47+
def apply_extensions(
48+
content: str, ctx: SkillRenderContext, registry: ExtensionRegistry
49+
) -> str:
50+
"""Apply *registry* to *content* (single entry point for callers)."""
51+
return registry.apply(content, ctx)
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Claude Code skill post-processing (frontmatter flags and argument hints)."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
7+
from . import RendererExtension, SkillRenderContext
8+
9+
# Note injected into hook sections so Claude maps dot-notation command
10+
# names (from extensions.yml) to the hyphenated skill names it uses.
11+
_HOOK_COMMAND_NOTE = (
12+
"- When constructing slash commands from hook command names, "
13+
"replace dots (`.`) with hyphens (`-`). "
14+
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
15+
)
16+
17+
# Mapping of command template stem → argument-hint text shown inline
18+
# when a user invokes the slash command in Claude Code.
19+
ARGUMENT_HINTS: dict[str, str] = {
20+
"specify": "Describe the feature you want to specify",
21+
"plan": "Optional guidance for the planning phase",
22+
"tasks": "Optional task generation constraints",
23+
"implement": "Optional implementation guidance or task filter",
24+
"analyze": "Optional focus areas for analysis",
25+
"clarify": "Optional areas to clarify in the spec",
26+
"constitution": "Principles or values for the project constitution",
27+
"checklist": "Domain or focus area for the checklist",
28+
"taskstoissues": "Optional filter or label for GitHub issues",
29+
}
30+
31+
32+
def inject_hook_command_note(content: str) -> str:
33+
"""Insert a dot-to-hyphen note before each hook output instruction.
34+
35+
Targets the line ``- For each executable hook, output the following``
36+
and inserts the note on the line before it, matching its indentation.
37+
Skips if the note is already present.
38+
"""
39+
if "replace dots" in content:
40+
return content
41+
42+
def repl(m: re.Match[str]) -> str:
43+
indent = m.group(1)
44+
instruction = m.group(2)
45+
eol = m.group(3)
46+
return (
47+
indent
48+
+ _HOOK_COMMAND_NOTE.rstrip("\n")
49+
+ eol
50+
+ indent
51+
+ instruction
52+
+ eol
53+
)
54+
55+
return re.sub(
56+
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
57+
repl,
58+
content,
59+
)
60+
61+
62+
def inject_argument_hint(content: str, hint: str) -> str:
63+
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
64+
65+
Skips injection if ``argument-hint:`` already exists in the
66+
frontmatter to avoid duplicate keys.
67+
"""
68+
lines = content.splitlines(keepends=True)
69+
70+
dash_count = 0
71+
for line in lines:
72+
stripped = line.rstrip("\n\r")
73+
if stripped == "---":
74+
dash_count += 1
75+
if dash_count == 2:
76+
break
77+
continue
78+
if dash_count == 1 and stripped.startswith("argument-hint:"):
79+
return content
80+
81+
out: list[str] = []
82+
in_fm = False
83+
dash_count = 0
84+
injected = False
85+
for line in lines:
86+
stripped = line.rstrip("\n\r")
87+
if stripped == "---":
88+
dash_count += 1
89+
in_fm = dash_count == 1
90+
out.append(line)
91+
continue
92+
if in_fm and not injected and stripped.startswith("description:"):
93+
out.append(line)
94+
if line.endswith("\r\n"):
95+
eol = "\r\n"
96+
elif line.endswith("\n"):
97+
eol = "\n"
98+
else:
99+
eol = ""
100+
escaped = hint.replace("\\", "\\\\").replace('"', '\\"')
101+
out.append(f'argument-hint: "{escaped}"{eol}')
102+
injected = True
103+
continue
104+
out.append(line)
105+
return "".join(out)
106+
107+
108+
def inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
109+
"""Insert ``key: value`` before the closing ``---`` if not already present."""
110+
lines = content.splitlines(keepends=True)
111+
112+
dash_count = 0
113+
for line in lines:
114+
stripped = line.rstrip("\n\r")
115+
if stripped == "---":
116+
dash_count += 1
117+
if dash_count == 2:
118+
break
119+
continue
120+
if dash_count == 1 and stripped.startswith(f"{key}:"):
121+
return content
122+
123+
out: list[str] = []
124+
dash_count = 0
125+
injected = False
126+
for line in lines:
127+
stripped = line.rstrip("\n\r")
128+
if stripped == "---":
129+
dash_count += 1
130+
if dash_count == 2 and not injected:
131+
if line.endswith("\r\n"):
132+
eol = "\r\n"
133+
elif line.endswith("\n"):
134+
eol = "\n"
135+
else:
136+
eol = ""
137+
out.append(f"{key}: {value}{eol}")
138+
injected = True
139+
out.append(line)
140+
return "".join(out)
141+
142+
143+
class ClaudeSkillTransformExtension:
144+
"""Inject Claude skill frontmatter flags, hook note, and optional argument-hint."""
145+
146+
def transform(self, content: str, ctx: SkillRenderContext) -> str:
147+
updated = inject_frontmatter_flag(content, "user-invocable")
148+
updated = inject_frontmatter_flag(
149+
updated, "disable-model-invocation", "false"
150+
)
151+
updated = inject_hook_command_note(updated)
152+
153+
skill_dir_name = ctx.skill_path.parent.name
154+
stem = skill_dir_name
155+
if stem.startswith("speckit-"):
156+
stem = stem[len("speckit-") :]
157+
hint = ARGUMENT_HINTS.get(stem, "")
158+
if hint:
159+
updated = inject_argument_hint(updated, hint)
160+
return updated
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Fenced ``speckit:question-render`` → AskUserQuestion JSON block."""
2+
3+
from __future__ import annotations
4+
5+
from ..question_transformer import transform_question_block
6+
from . import RendererExtension, SkillRenderContext
7+
8+
9+
class FencedQuestionRenderExtension:
10+
"""Replace question-render marker blocks with JSON payloads."""
11+
12+
def transform(self, content: str, ctx: SkillRenderContext) -> str:
13+
del ctx # path-independent
14+
return transform_question_block(content)

0 commit comments

Comments
 (0)