"""Claude Code integration.""" from __future__ import annotations from typing import Any from ..base import SkillsIntegration from ..._utils import dump_frontmatter # 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", } # Per-command frontmatter overrides for skills that should run in a forked # subagent context. See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent # # This is intentionally empty. ``analyze`` was previously forked (added in # #2511) on the assumption that its heavy reads collapse to a short summary, # but in practice ``/speckit-analyze`` returns a 300-500 line report that is # injected back into the main conversation. In long sessions each subsequent # fork inherits that growing context, compounding overhead until the chat # freezes (#3185). Until a command genuinely returns a compact result, no # command opts into ``context: fork``. The injection mechanism below stays in # place so a future command can be added here when that holds true. FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = {} class ClaudeIntegration(SkillsIntegration): """Integration for Claude Code skills.""" key = "claude" config = { "name": "Claude Code", "folder": ".claude/", "commands_subdir": "skills", "install_url": "https://docs.anthropic.com/en/docs/claude-code/setup", "requires_cli": True, } registrar_config = { "dir": ".claude/skills", "format": "markdown", "args": "$ARGUMENTS", "extension": "/SKILL.md", } multi_install_safe = True @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.""" skill_name = f"speckit-{template_name.replace('.', '-')}" description = frontmatter.get( "description", f"Spec-kit workflow command: {template_name}", ) skill_frontmatter = self._build_skill_fm( skill_name, description, f"templates/commands/{template_name}.md" ) frontmatter_text = dump_frontmatter(skill_frontmatter) return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n" def _build_skill_fm(self, name: str, description: str, source: str) -> dict: from specify_cli.agents import CommandRegistrar return CommandRegistrar.build_skill_frontmatter( self.key, name, description, source ) @staticmethod def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str: """Insert ``key: value`` before the closing ``---`` if not already present.""" 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(f"{key}:"): 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: if line.endswith("\r\n"): eol = "\r\n" elif line.endswith("\n"): eol = "\n" else: eol = "" out.append(f"{key}: {value}{eol}") injected = True out.append(line) return "".join(out) @staticmethod def _skill_stem_from_content(content: str) -> str | None: """Derive the command stem (e.g. ``analyze``) from a skill's frontmatter. Reads the ``name:`` field of the first frontmatter block and strips the ``speckit-`` prefix. Returns ``None`` when no name is present. """ dash_count = 0 for line in content.splitlines(): stripped = line.rstrip("\r\n") if stripped == "---": dash_count += 1 if dash_count == 2: break continue if dash_count == 1 and stripped.startswith("name:"): name = stripped[len("name:"):].strip().strip('"').strip("'") if name.startswith("speckit-"): return name[len("speckit-"):] return name or None return None def post_process_skill_content(self, content: str) -> str: """Inject Claude-specific frontmatter flags, hook notes, and any per-command frontmatter. Applied by every skill-generation path (setup, presets, extensions), so command-specific frontmatter (argument-hint, fork context) stays consistent however the SKILL.md was produced. """ updated = super().post_process_skill_content(content) updated = self._inject_frontmatter_flag(updated, "user-invocable") updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false") stem = self._skill_stem_from_content(updated) if stem: hint = ARGUMENT_HINTS.get(stem, "") if hint: updated = self.inject_argument_hint(updated, hint) fork_config = FORK_CONTEXT_COMMANDS.get(stem) if fork_config: for key, value in fork_config.items(): updated = self._inject_frontmatter_flag(updated, key, value) return updated