Skip to content

Commit 10be484

Browse files
feat: add argument-hint frontmatter to Claude Code commands (#1951) (#2059)
* feat: add argument-hint frontmatter to Claude Code commands (#1951) Inject argument-hint into YAML frontmatter for Claude agent only during release package generation. Templates remain agent-agnostic; hints are added on the fly in generate_commands() when agent is "claude". Closes #1951 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: scope argument-hint injection to YAML frontmatter only Addresses Copilot review: the awk/regex matched description: anywhere in the file. Now both bash and PowerShell track frontmatter boundaries (--- delimiters) and only inject argument-hint after the first description: inside the frontmatter block. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add argument-hint to Claude integration + tests - Override setup() in ClaudeIntegration to inject argument-hint into YAML frontmatter after description: line, scoped to frontmatter only - Add ARGUMENT_HINTS mapping for all 9 commands - Add tests: hint presence, correct values, frontmatter scoping, ordering after description, and body-safety check Addresses maintainer feedback to cover the new integrations system in src/specify_cli/integrations/claude/__init__.py with tests in tests/integrations/test_integration_claude.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address Copilot review feedback on Claude integration - Remove unused `import re` - Skip injection if argument-hint already exists in frontmatter - Add found_description assertion to test_hint_appears_after_description - Add test_inject_argument_hint_skips_if_already_present test Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: delegate to super().setup() and post-process for hints - Eliminates setup() duplication by calling super().setup() then post-processing command files to inject argument-hint - Fixes EOL preservation to correctly detect \r\n vs \n - No drift risk if MarkdownIntegration.setup() changes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use read_bytes/write_bytes for platform-stable EOL handling Address Copilot review: avoid platform newline translation by using read_bytes()/write_bytes() instead of read_text()/write_text() when post-processing SKILL.md files for argument-hint injection. * fix: re-record manifest hash after hint injection, quote hint values - Re-record file hash in manifest after writing argument-hint so check_modified()/uninstall stays in sync - Double-quote argument-hint values to match SKILL.md frontmatter style - Update tests to expect quoted hint values * fix: inject disable-model-invocation into Claude skill frontmatter --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 48b84cc commit 10be484

2 files changed

Lines changed: 244 additions & 49 deletions

File tree

src/specify_cli/integrations/claude/__init__.py

Lines changed: 127 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@
1010
from ..base import SkillsIntegration
1111
from ..manifest import IntegrationManifest
1212

13+
# Mapping of command template stem → argument-hint text shown inline
14+
# when a user invokes the slash command in Claude Code.
15+
ARGUMENT_HINTS: dict[str, str] = {
16+
"specify": "Describe the feature you want to specify",
17+
"plan": "Optional guidance for the planning phase",
18+
"tasks": "Optional task generation constraints",
19+
"implement": "Optional implementation guidance or task filter",
20+
"analyze": "Optional focus areas for analysis",
21+
"clarify": "Optional areas to clarify in the spec",
22+
"constitution": "Principles or values for the project constitution",
23+
"checklist": "Domain or focus area for the checklist",
24+
"taskstoissues": "Optional filter or label for GitHub issues",
25+
}
26+
1327

1428
class ClaudeIntegration(SkillsIntegration):
1529
"""Integration for Claude Code skills."""
@@ -30,10 +44,53 @@ class ClaudeIntegration(SkillsIntegration):
3044
}
3145
context_file = "CLAUDE.md"
3246

33-
def command_filename(self, template_name: str) -> str:
34-
"""Claude skills live at .claude/skills/<name>/SKILL.md."""
35-
skill_name = f"speckit-{template_name.replace('.', '-')}"
36-
return f"{skill_name}/SKILL.md"
47+
@staticmethod
48+
def inject_argument_hint(content: str, hint: str) -> str:
49+
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
50+
51+
Skips injection if ``argument-hint:`` already exists in the
52+
frontmatter to avoid duplicate keys.
53+
"""
54+
lines = content.splitlines(keepends=True)
55+
56+
# Pre-scan: bail out if argument-hint already present in frontmatter
57+
dash_count = 0
58+
for line in lines:
59+
stripped = line.rstrip("\n\r")
60+
if stripped == "---":
61+
dash_count += 1
62+
if dash_count == 2:
63+
break
64+
continue
65+
if dash_count == 1 and stripped.startswith("argument-hint:"):
66+
return content # already present
67+
68+
out: list[str] = []
69+
in_fm = False
70+
dash_count = 0
71+
injected = False
72+
for line in lines:
73+
stripped = line.rstrip("\n\r")
74+
if stripped == "---":
75+
dash_count += 1
76+
in_fm = dash_count == 1
77+
out.append(line)
78+
continue
79+
if in_fm and not injected and stripped.startswith("description:"):
80+
out.append(line)
81+
# Preserve the exact line-ending style (\r\n vs \n)
82+
if line.endswith("\r\n"):
83+
eol = "\r\n"
84+
elif line.endswith("\n"):
85+
eol = "\n"
86+
else:
87+
eol = ""
88+
escaped = hint.replace("\\", "\\\\").replace('"', '\\"')
89+
out.append(f'argument-hint: "{escaped}"{eol}')
90+
injected = True
91+
continue
92+
out.append(line)
93+
return "".join(out)
3794

3895
def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str:
3996
"""Render a processed command template as a Claude skill."""
@@ -54,56 +111,77 @@ def _build_skill_fm(self, name: str, description: str, source: str) -> dict:
54111
self.key, name, description, source
55112
)
56113

114+
@staticmethod
115+
def _inject_disable_model_invocation(content: str) -> str:
116+
"""Insert ``disable-model-invocation: true`` before the closing ``---``."""
117+
lines = content.splitlines(keepends=True)
118+
119+
# Pre-scan: bail out if already present in frontmatter
120+
dash_count = 0
121+
for line in lines:
122+
stripped = line.rstrip("\n\r")
123+
if stripped == "---":
124+
dash_count += 1
125+
if dash_count == 2:
126+
break
127+
continue
128+
if dash_count == 1 and stripped.startswith("disable-model-invocation:"):
129+
return content
130+
131+
# Inject before the closing --- of frontmatter
132+
out: list[str] = []
133+
dash_count = 0
134+
injected = False
135+
for line in lines:
136+
stripped = line.rstrip("\n\r")
137+
if stripped == "---":
138+
dash_count += 1
139+
if dash_count == 2 and not injected:
140+
eol = "\r\n" if line.endswith("\r\n") else "\n"
141+
out.append(f"disable-model-invocation: true{eol}")
142+
injected = True
143+
out.append(line)
144+
return "".join(out)
145+
57146
def setup(
58147
self,
59148
project_root: Path,
60149
manifest: IntegrationManifest,
61150
parsed_options: dict[str, Any] | None = None,
62151
**opts: Any,
63152
) -> list[Path]:
64-
"""Install Claude skills into .claude/skills."""
65-
templates = self.list_command_templates()
66-
if not templates:
67-
return []
68-
69-
project_root_resolved = project_root.resolve()
70-
if manifest.project_root != project_root_resolved:
71-
raise ValueError(
72-
f"manifest.project_root ({manifest.project_root}) does not match "
73-
f"project_root ({project_root_resolved})"
74-
)
75-
76-
dest = self.skills_dest(project_root).resolve()
77-
try:
78-
dest.relative_to(project_root_resolved)
79-
except ValueError as exc:
80-
raise ValueError(
81-
f"Integration destination {dest} escapes "
82-
f"project root {project_root_resolved}"
83-
) from exc
84-
dest.mkdir(parents=True, exist_ok=True)
85-
86-
script_type = opts.get("script_type", "sh")
87-
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS")
88-
from specify_cli.agents import CommandRegistrar
89-
registrar = CommandRegistrar()
90-
created: list[Path] = []
91-
92-
for src_file in templates:
93-
raw = src_file.read_text(encoding="utf-8")
94-
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
95-
frontmatter, body = registrar.parse_frontmatter(processed)
96-
if not isinstance(frontmatter, dict):
97-
frontmatter = {}
98-
99-
rendered = self._render_skill(src_file.stem, frontmatter, body)
100-
dst_file = self.write_file_and_record(
101-
rendered,
102-
dest / self.command_filename(src_file.stem),
103-
project_root,
104-
manifest,
105-
)
106-
created.append(dst_file)
107-
108-
created.extend(self.install_scripts(project_root, manifest))
153+
"""Install Claude skills, then inject argument-hint and disable-model-invocation."""
154+
created = super().setup(project_root, manifest, parsed_options, **opts)
155+
156+
# Post-process generated skill files
157+
skills_dir = self.skills_dest(project_root).resolve()
158+
159+
for path in created:
160+
# Only touch SKILL.md files under the skills directory
161+
try:
162+
path.resolve().relative_to(skills_dir)
163+
except ValueError:
164+
continue
165+
if path.name != "SKILL.md":
166+
continue
167+
168+
content_bytes = path.read_bytes()
169+
content = content_bytes.decode("utf-8")
170+
171+
# Inject disable-model-invocation: true (Claude skills run only when invoked)
172+
updated = self._inject_disable_model_invocation(content)
173+
174+
# Inject argument-hint if available for this skill
175+
skill_dir_name = path.parent.name # e.g. "speckit-plan"
176+
stem = skill_dir_name
177+
if stem.startswith("speckit-"):
178+
stem = stem[len("speckit-"):]
179+
hint = ARGUMENT_HINTS.get(stem, "")
180+
if hint:
181+
updated = self.inject_argument_hint(updated, hint)
182+
183+
if updated != content:
184+
path.write_bytes(updated.encode("utf-8"))
185+
self.record_file_in_manifest(path, project_root, manifest)
186+
109187
return created

tests/integrations/test_integration_claude.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
1010
from specify_cli.integrations.base import IntegrationBase
11+
from specify_cli.integrations.claude import ARGUMENT_HINTS
1112
from specify_cli.integrations.manifest import IntegrationManifest
1213

1314

@@ -279,3 +280,119 @@ def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path):
279280

280281
metadata = manager.registry.get("claude-skill-command")
281282
assert "speckit-research" in metadata.get("registered_skills", [])
283+
284+
285+
class TestClaudeArgumentHints:
286+
"""Verify that argument-hint frontmatter is injected for Claude skills."""
287+
288+
def test_all_skills_have_hints(self, tmp_path):
289+
"""Every generated SKILL.md must contain an argument-hint line."""
290+
i = get_integration("claude")
291+
m = IntegrationManifest("claude", tmp_path)
292+
created = i.setup(tmp_path, m, script_type="sh")
293+
skill_files = [f for f in created if f.name == "SKILL.md"]
294+
assert len(skill_files) > 0
295+
for f in skill_files:
296+
content = f.read_text(encoding="utf-8")
297+
assert "argument-hint:" in content, (
298+
f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter"
299+
)
300+
301+
def test_hints_match_expected_values(self, tmp_path):
302+
"""Each skill's argument-hint must match the expected text."""
303+
i = get_integration("claude")
304+
m = IntegrationManifest("claude", tmp_path)
305+
created = i.setup(tmp_path, m, script_type="sh")
306+
skill_files = [f for f in created if f.name == "SKILL.md"]
307+
for f in skill_files:
308+
# Extract stem: speckit-plan -> plan
309+
stem = f.parent.name
310+
if stem.startswith("speckit-"):
311+
stem = stem[len("speckit-"):]
312+
expected_hint = ARGUMENT_HINTS.get(stem)
313+
assert expected_hint is not None, (
314+
f"No expected hint defined for skill '{stem}'"
315+
)
316+
content = f.read_text(encoding="utf-8")
317+
assert f'argument-hint: "{expected_hint}"' in content, (
318+
f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found"
319+
)
320+
321+
def test_hint_is_inside_frontmatter(self, tmp_path):
322+
"""argument-hint must appear between the --- delimiters, not in the body."""
323+
i = get_integration("claude")
324+
m = IntegrationManifest("claude", tmp_path)
325+
created = i.setup(tmp_path, m, script_type="sh")
326+
skill_files = [f for f in created if f.name == "SKILL.md"]
327+
for f in skill_files:
328+
content = f.read_text(encoding="utf-8")
329+
parts = content.split("---", 2)
330+
assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md"
331+
frontmatter = parts[1]
332+
body = parts[2]
333+
assert "argument-hint:" in frontmatter, (
334+
f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section"
335+
)
336+
assert "argument-hint:" not in body, (
337+
f"{f.parent.name}/SKILL.md: argument-hint leaked into body"
338+
)
339+
340+
def test_hint_appears_after_description(self, tmp_path):
341+
"""argument-hint must immediately follow the description line."""
342+
i = get_integration("claude")
343+
m = IntegrationManifest("claude", tmp_path)
344+
created = i.setup(tmp_path, m, script_type="sh")
345+
skill_files = [f for f in created if f.name == "SKILL.md"]
346+
for f in skill_files:
347+
content = f.read_text(encoding="utf-8")
348+
lines = content.splitlines()
349+
found_description = False
350+
for idx, line in enumerate(lines):
351+
if line.startswith("description:"):
352+
found_description = True
353+
assert idx + 1 < len(lines), (
354+
f"{f.parent.name}/SKILL.md: description is last line"
355+
)
356+
assert lines[idx + 1].startswith("argument-hint:"), (
357+
f"{f.parent.name}/SKILL.md: argument-hint does not follow description"
358+
)
359+
break
360+
assert found_description, (
361+
f"{f.parent.name}/SKILL.md: no description: line found in output"
362+
)
363+
364+
def test_inject_argument_hint_only_in_frontmatter(self):
365+
"""inject_argument_hint must not modify description: lines in the body."""
366+
from specify_cli.integrations.claude import ClaudeIntegration
367+
368+
content = (
369+
"---\n"
370+
"description: My command\n"
371+
"---\n"
372+
"\n"
373+
"description: this is body text\n"
374+
)
375+
result = ClaudeIntegration.inject_argument_hint(content, "Test hint")
376+
lines = result.splitlines()
377+
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
378+
assert hint_count == 1, (
379+
f"Expected exactly 1 argument-hint line, found {hint_count}"
380+
)
381+
382+
def test_inject_argument_hint_skips_if_already_present(self):
383+
"""inject_argument_hint must not duplicate if argument-hint already exists."""
384+
from specify_cli.integrations.claude import ClaudeIntegration
385+
386+
content = (
387+
"---\n"
388+
"description: My command\n"
389+
'argument-hint: "Existing hint"\n'
390+
"---\n"
391+
"\n"
392+
"Body text\n"
393+
)
394+
result = ClaudeIntegration.inject_argument_hint(content, "New hint")
395+
assert result == content, "Content should be unchanged when hint already exists"
396+
lines = result.splitlines()
397+
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
398+
assert hint_count == 1

0 commit comments

Comments
 (0)