Skip to content

Commit 60181f0

Browse files
committed
refactor: scope question rendering to Claude integration
Move question-render transformation and Claude skill post-processing into the Claude integration package so core/shared rendering stays generic while preserving current behavior. Made-with: Cursor
1 parent 85e00f6 commit 60181f0

File tree

4 files changed

+637
-151
lines changed

4 files changed

+637
-151
lines changed

src/specify_cli/integrations/claude/__init__.py

Lines changed: 33 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -5,35 +5,19 @@
55
from pathlib import Path
66
from typing import Any
77

8-
import re
9-
108
import yaml
119

10+
from .skill_postprocess import (
11+
ARGUMENT_HINTS,
12+
apply_claude_skill_postprocess,
13+
inject_argument_hint,
14+
inject_frontmatter_flag,
15+
inject_hook_command_note,
16+
set_frontmatter_key,
17+
)
1218
from ..base import SkillsIntegration
1319
from ..manifest import IntegrationManifest
1420

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-
23-
# Mapping of command template stem → argument-hint text shown inline
24-
# when a user invokes the slash command in Claude Code.
25-
ARGUMENT_HINTS: dict[str, str] = {
26-
"specify": "Describe the feature you want to specify",
27-
"plan": "Optional guidance for the planning phase",
28-
"tasks": "Optional task generation constraints",
29-
"implement": "Optional implementation guidance or task filter",
30-
"analyze": "Optional focus areas for analysis",
31-
"clarify": "Optional areas to clarify in the spec",
32-
"constitution": "Principles or values for the project constitution",
33-
"checklist": "Domain or focus area for the checklist",
34-
"taskstoissues": "Optional filter or label for GitHub issues",
35-
}
36-
3721

3822
class ClaudeIntegration(SkillsIntegration):
3923
"""Integration for Claude Code skills."""
@@ -56,51 +40,18 @@ class ClaudeIntegration(SkillsIntegration):
5640

5741
@staticmethod
5842
def inject_argument_hint(content: str, hint: str) -> str:
59-
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
43+
"""Delegate to shared Claude skill transform implementation."""
44+
return inject_argument_hint(content, hint)
6045

61-
Skips injection if ``argument-hint:`` already exists in the
62-
frontmatter to avoid duplicate keys.
63-
"""
64-
lines = content.splitlines(keepends=True)
65-
66-
# Pre-scan: bail out if argument-hint already present in frontmatter
67-
dash_count = 0
68-
for line in lines:
69-
stripped = line.rstrip("\n\r")
70-
if stripped == "---":
71-
dash_count += 1
72-
if dash_count == 2:
73-
break
74-
continue
75-
if dash_count == 1 and stripped.startswith("argument-hint:"):
76-
return content # already present
77-
78-
out: list[str] = []
79-
in_fm = False
80-
dash_count = 0
81-
injected = False
82-
for line in lines:
83-
stripped = line.rstrip("\n\r")
84-
if stripped == "---":
85-
dash_count += 1
86-
in_fm = dash_count == 1
87-
out.append(line)
88-
continue
89-
if in_fm and not injected and stripped.startswith("description:"):
90-
out.append(line)
91-
# Preserve the exact line-ending style (\r\n vs \n)
92-
if line.endswith("\r\n"):
93-
eol = "\r\n"
94-
elif line.endswith("\n"):
95-
eol = "\n"
96-
else:
97-
eol = ""
98-
escaped = hint.replace("\\", "\\\\").replace('"', '\\"')
99-
out.append(f'argument-hint: "{escaped}"{eol}')
100-
injected = True
101-
continue
102-
out.append(line)
103-
return "".join(out)
46+
@staticmethod
47+
def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
48+
"""Delegate to shared Claude skill transform implementation."""
49+
return inject_frontmatter_flag(content, key, value)
50+
51+
@staticmethod
52+
def _inject_hook_command_note(content: str) -> str:
53+
"""Delegate to shared Claude skill transform implementation."""
54+
return inject_hook_command_note(content)
10455

10556
def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str:
10657
"""Render a processed command template as a Claude skill."""
@@ -121,95 +72,35 @@ def _build_skill_fm(self, name: str, description: str, source: str) -> dict:
12172
self.key, name, description, source
12273
)
12374

124-
@staticmethod
125-
def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
126-
"""Insert ``key: value`` before the closing ``---`` if not already present."""
127-
lines = content.splitlines(keepends=True)
128-
129-
# Pre-scan: bail out if already present in frontmatter
130-
dash_count = 0
131-
for line in lines:
132-
stripped = line.rstrip("\n\r")
133-
if stripped == "---":
134-
dash_count += 1
135-
if dash_count == 2:
136-
break
137-
continue
138-
if dash_count == 1 and stripped.startswith(f"{key}:"):
139-
return content
140-
141-
# Inject before the closing --- of frontmatter
142-
out: list[str] = []
143-
dash_count = 0
144-
injected = False
145-
for line in lines:
146-
stripped = line.rstrip("\n\r")
147-
if stripped == "---":
148-
dash_count += 1
149-
if dash_count == 2 and not injected:
150-
if line.endswith("\r\n"):
151-
eol = "\r\n"
152-
elif line.endswith("\n"):
153-
eol = "\n"
154-
else:
155-
eol = ""
156-
out.append(f"{key}: {value}{eol}")
157-
injected = True
158-
out.append(line)
159-
return "".join(out)
160-
161-
@staticmethod
162-
def _inject_hook_command_note(content: str) -> str:
163-
"""Insert a dot-to-hyphen note before each hook output instruction.
75+
def post_process_skill_content(self, content: str) -> str:
76+
"""Inject Claude-specific frontmatter flags and hook notes (no argument-hint).
16477
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.
78+
Used by preset/extension skill generators; matches flags applied during
79+
``setup()`` except for fenced question rendering and argument-hint lines.
16880
"""
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)
81+
updated = inject_frontmatter_flag(content, "user-invocable")
82+
updated = set_frontmatter_key(updated, "disable-model-invocation", "false")
83+
updated = inject_hook_command_note(updated)
19684
return updated
19785

86+
@classmethod
87+
def render_skill_postprocess(cls, content: str, skill_path: Path) -> str:
88+
"""Run Claude-specific skill post-processing pipeline."""
89+
return apply_claude_skill_postprocess(content, skill_path)
90+
19891
def setup(
19992
self,
20093
project_root: Path,
20194
manifest: IntegrationManifest,
20295
parsed_options: dict[str, Any] | None = None,
20396
**opts: Any,
20497
) -> list[Path]:
205-
"""Install Claude skills, then inject Claude-specific flags and argument-hints."""
98+
"""Install Claude skills, then run the skill post-process extension chain."""
20699
created = super().setup(project_root, manifest, parsed_options, **opts)
207100

208-
# Post-process generated skill files
209101
skills_dir = self.skills_dest(project_root).resolve()
210102

211103
for path in created:
212-
# Only touch SKILL.md files under the skills directory
213104
try:
214105
path.resolve().relative_to(skills_dir)
215106
except ValueError:
@@ -220,16 +111,7 @@ def setup(
220111
content_bytes = path.read_bytes()
221112
content = content_bytes.decode("utf-8")
222113

223-
updated = self.post_process_skill_content(content)
224-
225-
# Inject argument-hint if available for this skill
226-
skill_dir_name = path.parent.name # e.g. "speckit-plan"
227-
stem = skill_dir_name
228-
if stem.startswith("speckit-"):
229-
stem = stem[len("speckit-"):]
230-
hint = ARGUMENT_HINTS.get(stem, "")
231-
if hint:
232-
updated = self.inject_argument_hint(updated, hint)
114+
updated = self.render_skill_postprocess(content, path)
233115

234116
if updated != content:
235117
path.write_bytes(updated.encode("utf-8"))
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Question block transformer for Claude Code integration."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import re
7+
8+
_FENCE_RE = re.compile(
9+
r"<!-- speckit:question-render:begin -->\s*\n(.*?)\n\s*<!-- speckit:question-render:end -->",
10+
re.DOTALL,
11+
)
12+
_SEPARATOR_RE = re.compile(r"^\|[-| :]+\|$")
13+
14+
# Markers that promote an option to the top of the list.
15+
_RECOMMENDED_RE = re.compile(r"\bRecommended\b\s*[\u2014\-]", re.IGNORECASE)
16+
17+
18+
def _parse_table_rows(block: str) -> list[list[str]]:
19+
"""Return data rows from a Markdown table, skipping header and separator."""
20+
rows: list[list[str]] = []
21+
header_seen = False
22+
separator_seen = False
23+
24+
for line in block.splitlines():
25+
stripped = line.strip()
26+
if not stripped.startswith("|"):
27+
continue
28+
if not header_seen:
29+
header_seen = True
30+
continue
31+
if not separator_seen:
32+
if _SEPARATOR_RE.match(stripped):
33+
separator_seen = True
34+
continue
35+
cells = [c.strip() for c in stripped.split("|")[1:-1]]
36+
if cells:
37+
rows.append(cells)
38+
39+
return rows
40+
41+
42+
def parse_clarify(block: str) -> list[dict]:
43+
"""Parse clarify.md schema: | Option | Description |."""
44+
options: list[dict] = []
45+
recommended: dict | None = None
46+
seen_labels: set[str] = set()
47+
48+
for cells in _parse_table_rows(block):
49+
if len(cells) < 2:
50+
continue
51+
label = cells[0]
52+
description = cells[1]
53+
if label in seen_labels:
54+
continue
55+
seen_labels.add(label)
56+
entry = {"label": label, "description": description}
57+
if _RECOMMENDED_RE.search(description):
58+
if recommended is None:
59+
recommended = entry
60+
else:
61+
options.append(entry)
62+
63+
if recommended:
64+
options.insert(0, recommended)
65+
66+
return options
67+
68+
69+
def parse_checklist(block: str) -> list[dict]:
70+
"""Parse checklist.md schema: | Option | Candidate | Why It Matters |."""
71+
options: list[dict] = []
72+
seen_labels: set[str] = set()
73+
74+
for cells in _parse_table_rows(block):
75+
if len(cells) < 3:
76+
continue
77+
label = cells[1]
78+
description = cells[2]
79+
if label in seen_labels:
80+
continue
81+
seen_labels.add(label)
82+
options.append({"label": label, "description": description})
83+
84+
return options
85+
86+
87+
def _build_payload(options: list[dict]) -> str:
88+
"""Serialise options into a validated AskUserQuestion JSON code block."""
89+
if not any(o["label"].lower() == "other" for o in options):
90+
options = options + [
91+
{
92+
"label": "Other",
93+
"description": "Provide my own short answer (\u226410 words)",
94+
}
95+
]
96+
97+
payload: dict = {
98+
"question": "Please select an option:",
99+
"multiSelect": False,
100+
"options": options,
101+
}
102+
103+
raw = json.dumps(payload, ensure_ascii=False, indent=2)
104+
json.loads(raw)
105+
return f"```json\n{raw}\n```"
106+
107+
108+
def transform_question_block(content: str) -> str:
109+
"""Replace fenced question blocks with AskUserQuestion JSON payloads."""
110+
111+
def _replace(match: re.Match) -> str:
112+
block = match.group(1)
113+
is_checklist = "| Candidate |" in block or "|Candidate|" in block
114+
options = parse_checklist(block) if is_checklist else parse_clarify(block)
115+
return _build_payload(options)
116+
117+
return _FENCE_RE.sub(_replace, content)

0 commit comments

Comments
 (0)