diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 482572007..9eb321461 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -10,6 +10,20 @@ from ..base import SkillsIntegration from ..manifest import IntegrationManifest +# Mapping of command template stem → argument-hint text shown inline +# when a user invokes the slash command in Claude Code. +ARGUMENT_HINTS: dict[str, str] = { + "specify": "Describe the feature you want to specify", + "plan": "Optional guidance for the planning phase", + "tasks": "Optional task generation constraints", + "implement": "Optional implementation guidance or task filter", + "analyze": "Optional focus areas for analysis", + "clarify": "Optional areas to clarify in the spec", + "constitution": "Principles or values for the project constitution", + "checklist": "Domain or focus area for the checklist", + "taskstoissues": "Optional filter or label for GitHub issues", +} + class ClaudeIntegration(SkillsIntegration): """Integration for Claude Code skills.""" @@ -30,10 +44,53 @@ class ClaudeIntegration(SkillsIntegration): } context_file = "CLAUDE.md" - def command_filename(self, template_name: str) -> str: - """Claude skills live at .claude/skills//SKILL.md.""" - skill_name = f"speckit-{template_name.replace('.', '-')}" - return f"{skill_name}/SKILL.md" + @staticmethod + def inject_argument_hint(content: str, hint: str) -> str: + """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter. + + Skips injection if ``argument-hint:`` already exists in the + frontmatter to avoid duplicate keys. + """ + lines = content.splitlines(keepends=True) + + # Pre-scan: bail out if argument-hint already present in frontmatter + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith("argument-hint:"): + return content # already present + + out: list[str] = [] + in_fm = False + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + in_fm = dash_count == 1 + out.append(line) + continue + if in_fm and not injected and stripped.startswith("description:"): + out.append(line) + # Preserve the exact line-ending style (\r\n vs \n) + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + escaped = hint.replace("\\", "\\\\").replace('"', '\\"') + out.append(f'argument-hint: "{escaped}"{eol}') + injected = True + continue + out.append(line) + return "".join(out) def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str: """Render a processed command template as a Claude skill.""" @@ -54,6 +111,38 @@ def _build_skill_fm(self, name: str, description: str, source: str) -> dict: self.key, name, description, source ) + @staticmethod + def _inject_disable_model_invocation(content: str) -> str: + """Insert ``disable-model-invocation: true`` before the closing ``---``.""" + lines = content.splitlines(keepends=True) + + # Pre-scan: bail out if already present in frontmatter + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith("disable-model-invocation:"): + return content + + # Inject before the closing --- of frontmatter + out: list[str] = [] + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2 and not injected: + eol = "\r\n" if line.endswith("\r\n") else "\n" + out.append(f"disable-model-invocation: true{eol}") + injected = True + out.append(line) + return "".join(out) + def setup( self, project_root: Path, @@ -61,49 +150,38 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Install Claude skills into .claude/skills.""" - templates = self.list_command_templates() - if not templates: - return [] - - project_root_resolved = project_root.resolve() - if manifest.project_root != project_root_resolved: - raise ValueError( - f"manifest.project_root ({manifest.project_root}) does not match " - f"project_root ({project_root_resolved})" - ) - - dest = self.skills_dest(project_root).resolve() - try: - dest.relative_to(project_root_resolved) - except ValueError as exc: - raise ValueError( - f"Integration destination {dest} escapes " - f"project root {project_root_resolved}" - ) from exc - dest.mkdir(parents=True, exist_ok=True) - - script_type = opts.get("script_type", "sh") - arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") - from specify_cli.agents import CommandRegistrar - registrar = CommandRegistrar() - created: list[Path] = [] - - for src_file in templates: - raw = src_file.read_text(encoding="utf-8") - processed = self.process_template(raw, self.key, script_type, arg_placeholder) - frontmatter, body = registrar.parse_frontmatter(processed) - if not isinstance(frontmatter, dict): - frontmatter = {} - - rendered = self._render_skill(src_file.stem, frontmatter, body) - dst_file = self.write_file_and_record( - rendered, - dest / self.command_filename(src_file.stem), - project_root, - manifest, - ) - created.append(dst_file) - - created.extend(self.install_scripts(project_root, manifest)) + """Install Claude skills, then inject argument-hint and disable-model-invocation.""" + created = super().setup(project_root, manifest, parsed_options, **opts) + + # Post-process generated skill files + skills_dir = self.skills_dest(project_root).resolve() + + for path in created: + # Only touch SKILL.md files under the skills directory + try: + path.resolve().relative_to(skills_dir) + except ValueError: + continue + if path.name != "SKILL.md": + continue + + content_bytes = path.read_bytes() + content = content_bytes.decode("utf-8") + + # Inject disable-model-invocation: true (Claude skills run only when invoked) + updated = self._inject_disable_model_invocation(content) + + # Inject argument-hint if available for this skill + skill_dir_name = path.parent.name # e.g. "speckit-plan" + stem = skill_dir_name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + hint = ARGUMENT_HINTS.get(stem, "") + if hint: + updated = self.inject_argument_hint(updated, hint) + + if updated != content: + path.write_bytes(updated.encode("utf-8")) + self.record_file_in_manifest(path, project_root, manifest) + return created diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 998485469..fe50eecc7 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -8,6 +8,7 @@ from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration from specify_cli.integrations.base import IntegrationBase +from specify_cli.integrations.claude import ARGUMENT_HINTS from specify_cli.integrations.manifest import IntegrationManifest @@ -279,3 +280,119 @@ def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path): metadata = manager.registry.get("claude-skill-command") assert "speckit-research" in metadata.get("registered_skills", []) + + +class TestClaudeArgumentHints: + """Verify that argument-hint frontmatter is injected for Claude skills.""" + + def test_all_skills_have_hints(self, tmp_path): + """Every generated SKILL.md must contain an argument-hint line.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert "argument-hint:" in content, ( + f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter" + ) + + def test_hints_match_expected_values(self, tmp_path): + """Each skill's argument-hint must match the expected text.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + # Extract stem: speckit-plan -> plan + stem = f.parent.name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + expected_hint = ARGUMENT_HINTS.get(stem) + assert expected_hint is not None, ( + f"No expected hint defined for skill '{stem}'" + ) + content = f.read_text(encoding="utf-8") + assert f'argument-hint: "{expected_hint}"' in content, ( + f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found" + ) + + def test_hint_is_inside_frontmatter(self, tmp_path): + """argument-hint must appear between the --- delimiters, not in the body.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md" + frontmatter = parts[1] + body = parts[2] + assert "argument-hint:" in frontmatter, ( + f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section" + ) + assert "argument-hint:" not in body, ( + f"{f.parent.name}/SKILL.md: argument-hint leaked into body" + ) + + def test_hint_appears_after_description(self, tmp_path): + """argument-hint must immediately follow the description line.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + content = f.read_text(encoding="utf-8") + lines = content.splitlines() + found_description = False + for idx, line in enumerate(lines): + if line.startswith("description:"): + found_description = True + assert idx + 1 < len(lines), ( + f"{f.parent.name}/SKILL.md: description is last line" + ) + assert lines[idx + 1].startswith("argument-hint:"), ( + f"{f.parent.name}/SKILL.md: argument-hint does not follow description" + ) + break + assert found_description, ( + f"{f.parent.name}/SKILL.md: no description: line found in output" + ) + + def test_inject_argument_hint_only_in_frontmatter(self): + """inject_argument_hint must not modify description: lines in the body.""" + from specify_cli.integrations.claude import ClaudeIntegration + + content = ( + "---\n" + "description: My command\n" + "---\n" + "\n" + "description: this is body text\n" + ) + result = ClaudeIntegration.inject_argument_hint(content, "Test hint") + lines = result.splitlines() + hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) + assert hint_count == 1, ( + f"Expected exactly 1 argument-hint line, found {hint_count}" + ) + + def test_inject_argument_hint_skips_if_already_present(self): + """inject_argument_hint must not duplicate if argument-hint already exists.""" + from specify_cli.integrations.claude import ClaudeIntegration + + content = ( + "---\n" + "description: My command\n" + 'argument-hint: "Existing hint"\n' + "---\n" + "\n" + "Body text\n" + ) + result = ClaudeIntegration.inject_argument_hint(content, "New hint") + assert result == content, "Content should be unchanged when hint already exists" + lines = result.splitlines() + hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) + assert hint_count == 1