Skip to content

Commit ffa6946

Browse files
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>
1 parent e8d0a95 commit ffa6946

File tree

2 files changed

+193
-0
lines changed

2 files changed

+193
-0
lines changed

src/specify_cli/integrations/claude/__init__.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,30 @@
11
"""Claude Code integration."""
22

3+
from __future__ import annotations
4+
5+
import re
6+
from pathlib import Path
7+
from typing import Any, TYPE_CHECKING
8+
39
from ..base import MarkdownIntegration
410

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

629
class ClaudeIntegration(MarkdownIntegration):
730
key = "claude"
@@ -19,3 +42,78 @@ class ClaudeIntegration(MarkdownIntegration):
1942
"extension": ".md",
2043
}
2144
context_file = "CLAUDE.md"
45+
46+
@staticmethod
47+
def inject_argument_hint(content: str, hint: str) -> str:
48+
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter."""
49+
lines = content.splitlines(keepends=True)
50+
out: list[str] = []
51+
in_fm = False
52+
dash_count = 0
53+
injected = False
54+
for line in lines:
55+
stripped = line.rstrip("\n\r")
56+
if stripped == "---":
57+
dash_count += 1
58+
in_fm = dash_count == 1
59+
out.append(line)
60+
continue
61+
if in_fm and not injected and stripped.startswith("description:"):
62+
out.append(line)
63+
# Preserve the line-ending style of the file
64+
eol = "\n" if line.endswith("\n") else ""
65+
out.append(f"argument-hint: {hint}{eol}")
66+
injected = True
67+
continue
68+
out.append(line)
69+
return "".join(out)
70+
71+
def setup(
72+
self,
73+
project_root: Path,
74+
manifest: IntegrationManifest,
75+
parsed_options: dict[str, Any] | None = None,
76+
**opts: Any,
77+
) -> list[Path]:
78+
templates = self.list_command_templates()
79+
if not templates:
80+
return []
81+
82+
project_root_resolved = project_root.resolve()
83+
if manifest.project_root != project_root_resolved:
84+
raise ValueError(
85+
f"manifest.project_root ({manifest.project_root}) does not match "
86+
f"project_root ({project_root_resolved})"
87+
)
88+
89+
dest = self.commands_dest(project_root).resolve()
90+
try:
91+
dest.relative_to(project_root_resolved)
92+
except ValueError as exc:
93+
raise ValueError(
94+
f"Integration destination {dest} escapes "
95+
f"project root {project_root_resolved}"
96+
) from exc
97+
dest.mkdir(parents=True, exist_ok=True)
98+
99+
script_type = opts.get("script_type", "sh")
100+
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") if self.registrar_config else "$ARGUMENTS"
101+
created: list[Path] = []
102+
103+
for src_file in templates:
104+
raw = src_file.read_text(encoding="utf-8")
105+
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
106+
107+
# Inject argument-hint for Claude Code commands
108+
hint = ARGUMENT_HINTS.get(src_file.stem, "")
109+
if hint:
110+
processed = self.inject_argument_hint(processed, hint)
111+
112+
dst_name = self.command_filename(src_file.stem)
113+
dst_file = self.write_file_and_record(
114+
processed, dest / dst_name, project_root, manifest
115+
)
116+
created.append(dst_file)
117+
118+
created.extend(self.install_scripts(project_root, manifest))
119+
return created

tests/integrations/test_integration_claude.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
"""Tests for ClaudeIntegration."""
22

3+
from specify_cli.integrations import get_integration
4+
from specify_cli.integrations.claude import ARGUMENT_HINTS
5+
from specify_cli.integrations.manifest import IntegrationManifest
6+
37
from .test_integration_base_markdown import MarkdownIntegrationTests
48

59

@@ -9,3 +13,94 @@ class TestClaudeIntegration(MarkdownIntegrationTests):
913
COMMANDS_SUBDIR = "commands"
1014
REGISTRAR_DIR = ".claude/commands"
1115
CONTEXT_FILE = "CLAUDE.md"
16+
17+
18+
class TestClaudeArgumentHints:
19+
"""Verify that argument-hint frontmatter is injected for Claude commands."""
20+
21+
def test_all_commands_have_hints(self, tmp_path):
22+
"""Every generated command file must contain an argument-hint line."""
23+
i = get_integration("claude")
24+
m = IntegrationManifest("claude", tmp_path)
25+
created = i.setup(tmp_path, m)
26+
cmd_files = [f for f in created if "scripts" not in f.parts]
27+
assert len(cmd_files) > 0
28+
for f in cmd_files:
29+
content = f.read_text(encoding="utf-8")
30+
assert "argument-hint:" in content, (
31+
f"{f.name} is missing argument-hint frontmatter"
32+
)
33+
34+
def test_hints_match_expected_values(self, tmp_path):
35+
"""Each command's argument-hint must match the expected text."""
36+
i = get_integration("claude")
37+
m = IntegrationManifest("claude", tmp_path)
38+
created = i.setup(tmp_path, m)
39+
cmd_files = [f for f in created if "scripts" not in f.parts]
40+
for f in cmd_files:
41+
# Extract stem: speckit.plan.md -> plan
42+
stem = f.name.replace("speckit.", "").replace(".md", "")
43+
expected_hint = ARGUMENT_HINTS.get(stem)
44+
assert expected_hint is not None, (
45+
f"No expected hint defined for command '{stem}'"
46+
)
47+
content = f.read_text(encoding="utf-8")
48+
assert f"argument-hint: {expected_hint}" in content, (
49+
f"{f.name}: expected hint '{expected_hint}' not found"
50+
)
51+
52+
def test_hint_is_inside_frontmatter(self, tmp_path):
53+
"""argument-hint must appear between the --- delimiters, not in the body."""
54+
i = get_integration("claude")
55+
m = IntegrationManifest("claude", tmp_path)
56+
created = i.setup(tmp_path, m)
57+
cmd_files = [f for f in created if "scripts" not in f.parts]
58+
for f in cmd_files:
59+
content = f.read_text(encoding="utf-8")
60+
parts = content.split("---", 2)
61+
assert len(parts) >= 3, f"No frontmatter in {f.name}"
62+
frontmatter = parts[1]
63+
body = parts[2]
64+
assert "argument-hint:" in frontmatter, (
65+
f"{f.name}: argument-hint not in frontmatter section"
66+
)
67+
assert "argument-hint:" not in body, (
68+
f"{f.name}: argument-hint leaked into body"
69+
)
70+
71+
def test_hint_appears_after_description(self, tmp_path):
72+
"""argument-hint must immediately follow the description line."""
73+
i = get_integration("claude")
74+
m = IntegrationManifest("claude", tmp_path)
75+
created = i.setup(tmp_path, m)
76+
cmd_files = [f for f in created if "scripts" not in f.parts]
77+
for f in cmd_files:
78+
content = f.read_text(encoding="utf-8")
79+
lines = content.splitlines()
80+
for idx, line in enumerate(lines):
81+
if line.startswith("description:"):
82+
assert idx + 1 < len(lines), (
83+
f"{f.name}: description is last line"
84+
)
85+
assert lines[idx + 1].startswith("argument-hint:"), (
86+
f"{f.name}: argument-hint does not follow description"
87+
)
88+
break
89+
90+
def test_inject_argument_hint_only_in_frontmatter(self):
91+
"""inject_argument_hint must not modify description: lines in the body."""
92+
from specify_cli.integrations.claude import ClaudeIntegration
93+
94+
content = (
95+
"---\n"
96+
"description: My command\n"
97+
"---\n"
98+
"\n"
99+
"description: this is body text\n"
100+
)
101+
result = ClaudeIntegration.inject_argument_hint(content, "Test hint")
102+
lines = result.splitlines()
103+
hint_count = sum(1 for l in lines if l.startswith("argument-hint:"))
104+
assert hint_count == 1, (
105+
f"Expected exactly 1 argument-hint line, found {hint_count}"
106+
)

0 commit comments

Comments
 (0)