diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 1a0e5a831..ddbb7b0d0 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -18,7 +18,6 @@ def _build_agent_configs() -> dict[str, Any]: """Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY.""" from specify_cli.integrations import INTEGRATION_REGISTRY - configs: dict[str, dict[str, Any]] = {} for key, integration in INTEGRATION_REGISTRY.items(): if key == "generic": @@ -76,7 +75,7 @@ def parse_frontmatter(content: str) -> tuple[dict, str]: return {}, content frontmatter_str = content[3:end_marker].strip() - body = content[end_marker + 3 :].strip() + body = content[end_marker + 3:].strip() try: frontmatter = yaml.safe_load(frontmatter_str) or {} @@ -101,9 +100,7 @@ def render_frontmatter(fm: dict) -> str: if not fm: return "" - yaml_str = yaml.dump( - fm, default_flow_style=False, sort_keys=False, allow_unicode=True - ) + yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False, allow_unicode=True) return f"---\n{yaml_str}---\n" def _adjust_script_paths(self, frontmatter: dict) -> dict: @@ -146,16 +143,16 @@ def rewrite_project_relative_paths(text: str) -> str: # ".specify/extensions//scripts/..." remain intact. text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text) text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text) - text = re.sub( - r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text - ) + text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text) - return text.replace(".specify/.specify/", ".specify/").replace( - ".specify.specify/", ".specify/" - ) + return text.replace(".specify/.specify/", ".specify/").replace(".specify.specify/", ".specify/") def render_markdown_command( - self, frontmatter: dict, body: str, source_id: str, context_note: str = None + self, + frontmatter: dict, + body: str, + source_id: str, + context_note: str = None ) -> str: """Render command in Markdown format. @@ -172,7 +169,12 @@ def render_markdown_command( context_note = f"\n\n" return self.render_frontmatter(frontmatter) + "\n" + context_note + body - def render_toml_command(self, frontmatter: dict, body: str, source_id: str) -> str: + def render_toml_command( + self, + frontmatter: dict, + body: str, + source_id: str + ) -> str: """Render command in TOML format. Args: @@ -187,7 +189,7 @@ def render_toml_command(self, frontmatter: dict, body: str, source_id: str) -> s if "description" in frontmatter: toml_lines.append( - f"description = {self._render_basic_toml_string(frontmatter['description'])}" + f'description = {self._render_basic_toml_string(frontmatter["description"])}' ) toml_lines.append("") @@ -221,41 +223,6 @@ def _render_basic_toml_string(value: str) -> str: ) return f'"{escaped}"' - def render_yaml_command( - self, - frontmatter: dict, - body: str, - source_id: str, - cmd_name: str = "", - ) -> str: - """Render command in YAML recipe format for Goose. - - Args: - frontmatter: Command frontmatter - body: Command body content - source_id: Source identifier (extension or preset ID) - cmd_name: Command name used as title fallback - - Returns: - Formatted YAML recipe file content - """ - from specify_cli.integrations.base import YamlIntegration - - title = frontmatter.get("title", "") or frontmatter.get("name", "") - if not isinstance(title, str): - title = str(title) if title is not None else "" - if not title and cmd_name: - title = YamlIntegration._human_title(cmd_name) - if not title and source_id: - title = YamlIntegration._human_title(Path(str(source_id)).stem) - if not title: - title = "Command" - - description = frontmatter.get("description", "") - if not isinstance(description, str): - description = str(description) if description is not None else "" - return YamlIntegration._render_yaml(title, description, body, source_id) - def render_skill_command( self, agent_name: str, @@ -282,13 +249,9 @@ def render_skill_command( frontmatter = {} if agent_name in {"codex", "kimi"}: - body = self.resolve_skill_placeholders( - agent_name, frontmatter, body, project_root - ) + body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root) - description = frontmatter.get( - "description", f"Spec-kit workflow command: {skill_name}" - ) + description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}") skill_frontmatter = self.build_skill_frontmatter( agent_name, skill_name, @@ -314,12 +277,15 @@ def build_skill_frontmatter( "source": source, }, } + if agent_name == "claude": + # Claude skills should be user-invocable (accessible via /command) + # and only run when explicitly invoked (not auto-triggered by the model). + skill_frontmatter["user-invocable"] = True + skill_frontmatter["disable-model-invocation"] = True return skill_frontmatter @staticmethod - def resolve_skill_placeholders( - agent_name: str, frontmatter: dict, body: str, project_root: Path - ) -> str: + def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, project_root: Path) -> str: """Resolve script placeholders for skills-backed agents.""" try: from . import load_init_options @@ -340,9 +306,7 @@ def resolve_skill_placeholders( script_variant = init_opts.get("script") if script_variant not in {"sh", "ps"}: fallback_order = [] - default_variant = ( - "ps" if platform.system().lower().startswith("win") else "sh" - ) + default_variant = "ps" if platform.system().lower().startswith("win") else "sh" secondary_variant = "sh" if default_variant == "ps" else "ps" if default_variant in scripts: @@ -361,6 +325,17 @@ def resolve_skill_placeholders( script_command = script_command.replace("{ARGS}", "$ARGUMENTS") body = body.replace("{SCRIPT}", script_command) + # Support agent-specific scripts (safe fallback) + agent_scripts = frontmatter.get("agent_scripts", {}) or {} + if not isinstance(agent_scripts, dict): + agent_scripts = {} + + agent_script_command = agent_scripts.get(script_variant) if script_variant else None + if agent_script_command: + agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS") + body = body.replace("{AGENT_SCRIPT}", agent_script_command) + + body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) # Resolve __CONTEXT_FILE__ from init-options @@ -369,9 +344,7 @@ def resolve_skill_placeholders( return CommandRegistrar.rewrite_project_relative_paths(body) - def _convert_argument_placeholder( - self, content: str, from_placeholder: str, to_placeholder: str - ) -> str: + def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str: """Convert argument placeholder format. Args: @@ -385,16 +358,14 @@ def _convert_argument_placeholder( return content.replace(from_placeholder, to_placeholder) @staticmethod - def _compute_output_name( - agent_name: str, cmd_name: str, agent_config: Dict[str, Any] - ) -> str: + def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str, Any]) -> str: """Compute the on-disk command or skill name for an agent.""" if agent_config["extension"] != "/SKILL.md": return cmd_name short_name = cmd_name if short_name.startswith("speckit."): - short_name = short_name[len("speckit.") :] + short_name = short_name[len("speckit."):] short_name = short_name.replace(".", "-") return f"speckit-{short_name}" @@ -406,7 +377,7 @@ def register_commands( source_id: str, source_dir: Path, project_root: Path, - context_note: str = None, + context_note: str = None ) -> List[str]: """Register commands for a specific agent. @@ -463,24 +434,12 @@ def register_commands( if agent_config["extension"] == "/SKILL.md": output = self.render_skill_command( - agent_name, - output_name, - frontmatter, - body, - source_id, - cmd_file, - project_root, + agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root ) elif agent_config["format"] == "markdown": - output = self.render_markdown_command( - frontmatter, body, source_id, context_note - ) + output = self.render_markdown_command(frontmatter, body, source_id, context_note) elif agent_config["format"] == "toml": output = self.render_toml_command(frontmatter, body, source_id) - elif agent_config["format"] == "yaml": - output = self.render_yaml_command( - frontmatter, body, source_id, cmd_name - ) else: raise ValueError(f"Unsupported format: {agent_config['format']}") @@ -494,68 +453,34 @@ def register_commands( registered.append(cmd_name) for alias in cmd_info.get("aliases", []): - alias_output_name = self._compute_output_name( - agent_name, alias, agent_config - ) + alias_output_name = self._compute_output_name(agent_name, alias, agent_config) # For agents with inject_name, render with alias-specific frontmatter if agent_config.get("inject_name"): alias_frontmatter = deepcopy(frontmatter) # Use custom name formatter if provided (e.g., Forge's hyphenated format) format_name = agent_config.get("format_name") - alias_frontmatter["name"] = ( - format_name(alias) if format_name else alias - ) + alias_frontmatter["name"] = format_name(alias) if format_name else alias if agent_config["extension"] == "/SKILL.md": alias_output = self.render_skill_command( - agent_name, - alias_output_name, - alias_frontmatter, - body, - source_id, - cmd_file, - project_root, + agent_name, alias_output_name, alias_frontmatter, body, source_id, cmd_file, project_root ) elif agent_config["format"] == "markdown": - alias_output = self.render_markdown_command( - alias_frontmatter, body, source_id, context_note - ) + alias_output = self.render_markdown_command(alias_frontmatter, body, source_id, context_note) elif agent_config["format"] == "toml": - alias_output = self.render_toml_command( - alias_frontmatter, body, source_id - ) - elif agent_config["format"] == "yaml": - alias_output = self.render_yaml_command( - alias_frontmatter, body, source_id, alias - ) + alias_output = self.render_toml_command(alias_frontmatter, body, source_id) else: - raise ValueError( - f"Unsupported format: {agent_config['format']}" - ) + raise ValueError(f"Unsupported format: {agent_config['format']}") else: # For other agents, reuse the primary output alias_output = output if agent_config["extension"] == "/SKILL.md": alias_output = self.render_skill_command( - agent_name, - alias_output_name, - frontmatter, - body, - source_id, - cmd_file, - project_root, + agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root ) - alias_file = ( - commands_dir / f"{alias_output_name}{agent_config['extension']}" - ) - try: - alias_file.resolve().relative_to(commands_dir.resolve()) - except ValueError: - raise ValueError( - f"Alias output path escapes commands directory: {alias_file!r}" - ) + alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}" alias_file.parent.mkdir(parents=True, exist_ok=True) alias_file.write_text(alias_output, encoding="utf-8") if agent_name == "copilot": @@ -583,7 +508,7 @@ def register_commands_for_all_agents( source_id: str, source_dir: Path, project_root: Path, - context_note: str = None, + context_note: str = None ) -> Dict[str, List[str]]: """Register commands for all detected agents in the project. @@ -606,12 +531,8 @@ def register_commands_for_all_agents( if agent_dir.exists(): try: registered = self.register_commands( - agent_name, - commands, - source_id, - source_dir, - project_root, - context_note=context_note, + agent_name, commands, source_id, source_dir, project_root, + context_note=context_note ) if registered: results[agent_name] = registered @@ -621,7 +542,9 @@ def register_commands_for_all_agents( return results def unregister_commands( - self, registered_commands: Dict[str, List[str]], project_root: Path + self, + registered_commands: Dict[str, List[str]], + project_root: Path ) -> None: """Remove previously registered command files from agent directories. @@ -638,26 +561,13 @@ def unregister_commands( commands_dir = project_root / agent_config["dir"] for cmd_name in cmd_names: - output_name = self._compute_output_name( - agent_name, cmd_name, agent_config - ) + output_name = self._compute_output_name(agent_name, cmd_name, agent_config) cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" if cmd_file.exists(): cmd_file.unlink() - # For SKILL.md agents each command lives in its own subdirectory - # (e.g. .agents/skills/speckit-ext-cmd/SKILL.md). Remove the - # parent dir when it becomes empty to avoid orphaned directories. - parent = cmd_file.parent - if parent != commands_dir and parent.exists(): - try: - parent.rmdir() # no-op if dir still has other files - except OSError: - pass if agent_name == "copilot": - prompt_file = ( - project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" - ) + prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" if prompt_file.exists(): prompt_file.unlink() diff --git a/src/specify_cli/core/__init__.py b/src/specify_cli/core/__init__.py new file mode 100644 index 000000000..09958cdc8 --- /dev/null +++ b/src/specify_cli/core/__init__.py @@ -0,0 +1 @@ +# Core utilities for specify-cli. diff --git a/src/specify_cli/core/question_transformer.py b/src/specify_cli/core/question_transformer.py new file mode 100644 index 000000000..3895dea4e --- /dev/null +++ b/src/specify_cli/core/question_transformer.py @@ -0,0 +1,137 @@ +"""Question block transformer for Claude Code integration.""" + +from __future__ import annotations + +import json +import re + +_FENCE_RE = re.compile( + r"\s*\n(.*?)\n\s*", + re.DOTALL, +) +_SEPARATOR_RE = re.compile(r"^\|[-| :]+\|$") + +# Markers that promote an option to the top of the list. +_RECOMMENDED_RE = re.compile(r"\bRecommended\b\s*[\u2014\-]", re.IGNORECASE) + + +def _parse_table_rows(block: str) -> list[list[str]]: + """Return data rows from a Markdown table, skipping header and separator. + + Handles leading indentation (as found in clarify.md / checklist.md). + Rows with pipe characters inside cell values are not supported by + standard Markdown tables, so no special handling is needed. + """ + rows: list[list[str]] = [] + header_seen = False + separator_seen = False + + for line in block.splitlines(): + stripped = line.strip() + if not stripped.startswith("|"): + continue + if not header_seen: + header_seen = True + continue + if not separator_seen: + if _SEPARATOR_RE.match(stripped): + separator_seen = True + continue + cells = [c.strip() for c in stripped.split("|")[1:-1]] + if cells: + rows.append(cells) + + return rows + + +def parse_clarify(block: str) -> list[dict]: + """Parse clarify.md schema: | Option | Description | + + - Rows matching ``Recommended —`` / ``Recommended -`` (case-insensitive) + are placed first. + - Duplicate labels are deduplicated (first occurrence wins). + """ + options: list[dict] = [] + recommended: dict | None = None + seen_labels: set[str] = set() + + for cells in _parse_table_rows(block): + if len(cells) < 2: + continue + label = cells[0] + description = cells[1] + if label in seen_labels: + continue + seen_labels.add(label) + entry = {"label": label, "description": description} + if _RECOMMENDED_RE.search(description): + if recommended is None: + recommended = entry + else: + options.append(entry) + + if recommended: + options.insert(0, recommended) + + return options + + +def parse_checklist(block: str) -> list[dict]: + """Parse checklist.md schema: | Option | Candidate | Why It Matters | + + Candidate → label, Why It Matters → description. + Duplicate labels are deduplicated (first occurrence wins). + """ + options: list[dict] = [] + seen_labels: set[str] = set() + + for cells in _parse_table_rows(block): + if len(cells) < 3: + continue + label = cells[1] + description = cells[2] + if label in seen_labels: + continue + seen_labels.add(label) + options.append({"label": label, "description": description}) + + return options + + +def _build_payload(options: list[dict]) -> str: + """Serialise options into a validated AskUserQuestion JSON code block.""" + # Append "Other" only if not already present. + if not any(o["label"].lower() == "other" for o in options): + options = options + [ + { + "label": "Other", + "description": "Provide my own short answer (\u226410 words)", + } + ] + + payload: dict = { + "question": "Please select an option:", + "multiSelect": False, + "options": options, + } + + # Validate round-trip before returning — raises ValueError on bad data. + raw = json.dumps(payload, ensure_ascii=False, indent=2) + json.loads(raw) # round-trip check + return f"```json\n{raw}\n```" + + +def transform_question_block(content: str) -> str: + """Replace fenced question blocks with AskUserQuestion JSON payloads. + + Content without markers is returned byte-identical — safe for all + non-Claude integrations. + """ + + def _replace(match: re.Match) -> str: + block = match.group(1) + is_checklist = "| Candidate |" in block or "|Candidate|" in block + options = parse_checklist(block) if is_checklist else parse_clarify(block) + return _build_payload(options) + + return _FENCE_RE.sub(_replace, content) diff --git a/src/specify_cli/core/renderer_extensions/__init__.py b/src/specify_cli/core/renderer_extensions/__init__.py new file mode 100644 index 000000000..2057efb3c --- /dev/null +++ b/src/specify_cli/core/renderer_extensions/__init__.py @@ -0,0 +1,51 @@ +"""Extension registry for skill markdown post-processing. + +The core applies registered :class:`RendererExtension` instances in order. +Concrete transforms (e.g. Claude-specific) live in separate modules outside +this package's knowledge. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol, runtime_checkable + + +@dataclass(frozen=True) +class SkillRenderContext: + """Per-file context passed to each extension.""" + + skill_path: Path + + +@runtime_checkable +class RendererExtension(Protocol): + """Post-process installed skill markdown.""" + + def transform(self, content: str, ctx: SkillRenderContext) -> str: + """Return *content* with this extension's transformation applied.""" + ... + + +class ExtensionRegistry: + """Ordered list of extensions applied sequentially.""" + + def __init__(self) -> None: + self._extensions: list[RendererExtension] = [] + + def register(self, extension: RendererExtension) -> None: + self._extensions.append(extension) + + def apply(self, content: str, ctx: SkillRenderContext) -> str: + out = content + for ext in self._extensions: + out = ext.transform(out, ctx) + return out + + +def apply_extensions( + content: str, ctx: SkillRenderContext, registry: ExtensionRegistry +) -> str: + """Apply *registry* to *content* (single entry point for callers).""" + return registry.apply(content, ctx) diff --git a/src/specify_cli/core/renderer_extensions/claude_skill.py b/src/specify_cli/core/renderer_extensions/claude_skill.py new file mode 100644 index 000000000..28a6b4aa6 --- /dev/null +++ b/src/specify_cli/core/renderer_extensions/claude_skill.py @@ -0,0 +1,181 @@ +"""Claude Code skill post-processing (frontmatter flags and argument hints).""" + +from __future__ import annotations + +import re + +from . import RendererExtension, SkillRenderContext + +# Note injected into hook sections so Claude maps dot-notation command +# names (from extensions.yml) to the hyphenated skill names it uses. +_HOOK_COMMAND_NOTE = ( + "- When constructing slash commands from hook command names, " + "replace dots (`.`) with hyphens (`-`). " + "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" +) + +# 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", +} + + +def inject_hook_command_note(content: str) -> str: + """Insert a dot-to-hyphen note before each hook output instruction. + + Targets the line ``- For each executable hook, output the following`` + and inserts the note on the line before it, matching its indentation. + Skips if the note is already present. + """ + if "replace dots" in content: + return content + + def repl(m: re.Match[str]) -> str: + indent = m.group(1) + instruction = m.group(2) + eol = m.group(3) + return ( + indent + + _HOOK_COMMAND_NOTE.rstrip("\n") + + eol + + indent + + instruction + + eol + ) + + return re.sub( + r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + repl, + content, + ) + + +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) + + 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 + + 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) + 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 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) + + 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 + + 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) + + +def set_frontmatter_key(content: str, key: str, value: str) -> str: + """Ensure ``key: value`` in the first frontmatter block; replace if *key* exists.""" + lines = content.splitlines(keepends=True) + dash_count = 0 + for i, line in enumerate(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}:"): + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + lines[i] = f"{key}: {value}{eol}" + return "".join(lines) + return inject_frontmatter_flag(content, key, value) + + +class ClaudeSkillTransformExtension: + """Inject Claude skill frontmatter flags, hook note, and optional argument-hint.""" + + def transform(self, content: str, ctx: SkillRenderContext) -> str: + updated = inject_frontmatter_flag(content, "user-invocable") + updated = set_frontmatter_key(updated, "disable-model-invocation", "false") + updated = inject_hook_command_note(updated) + + skill_dir_name = ctx.skill_path.parent.name + stem = skill_dir_name + if stem.startswith("speckit-"): + stem = stem[len("speckit-") :] + hint = ARGUMENT_HINTS.get(stem, "") + if hint: + updated = inject_argument_hint(updated, hint) + return updated diff --git a/src/specify_cli/core/renderer_extensions/question_render.py b/src/specify_cli/core/renderer_extensions/question_render.py new file mode 100644 index 000000000..61f183c57 --- /dev/null +++ b/src/specify_cli/core/renderer_extensions/question_render.py @@ -0,0 +1,14 @@ +"""Fenced ``speckit:question-render`` → AskUserQuestion JSON block.""" + +from __future__ import annotations + +from ..question_transformer import transform_question_block +from . import RendererExtension, SkillRenderContext + + +class FencedQuestionRenderExtension: + """Replace question-render marker blocks with JSON payloads.""" + + def transform(self, content: str, ctx: SkillRenderContext) -> str: + del ctx # path-independent + return transform_question_block(content) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 3e39db717..6ef4c0fdf 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -5,35 +5,21 @@ from pathlib import Path from typing import Any -import re - import yaml +from ...core.renderer_extensions import ExtensionRegistry, SkillRenderContext, apply_extensions +from ...core.renderer_extensions.claude_skill import ( + ARGUMENT_HINTS, + ClaudeSkillTransformExtension, + inject_argument_hint, + inject_frontmatter_flag, + inject_hook_command_note, + set_frontmatter_key, +) +from ...core.renderer_extensions.question_render import FencedQuestionRenderExtension from ..base import SkillsIntegration from ..manifest import IntegrationManifest -# Note injected into hook sections so Claude maps dot-notation command -# names (from extensions.yml) to the hyphenated skill names it uses. -_HOOK_COMMAND_NOTE = ( - "- When constructing slash commands from hook command names, " - "replace dots (`.`) with hyphens (`-`). " - "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" -) - -# 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.""" @@ -56,51 +42,18 @@ class ClaudeIntegration(SkillsIntegration): @staticmethod def inject_argument_hint(content: str, hint: str) -> str: - """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter. + """Delegate to shared Claude skill transform implementation.""" + return inject_argument_hint(content, hint) - 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) + @staticmethod + def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str: + """Delegate to shared Claude skill transform implementation.""" + return inject_frontmatter_flag(content, key, value) + + @staticmethod + def _inject_hook_command_note(content: str) -> str: + """Delegate to shared Claude skill transform implementation.""" + return inject_hook_command_note(content) def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str: """Render a processed command template as a Claude skill.""" @@ -121,80 +74,34 @@ def _build_skill_fm(self, name: str, description: str, source: str) -> dict: 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 _inject_hook_command_note(content: str) -> str: - """Insert a dot-to-hyphen note before each hook output instruction. + def post_process_skill_content(self, content: str) -> str: + """Inject Claude-specific frontmatter flags and hook notes (no argument-hint). - Targets the line ``- For each executable hook, output the following`` - and inserts the note on the line before it, matching its indentation. - Skips if the note is already present. + Used by preset/extension skill generators; matches flags applied during + ``setup()`` except for fenced question rendering and argument-hint lines. """ - if "replace dots" in content: - return content - - def repl(m: re.Match[str]) -> str: - indent = m.group(1) - instruction = m.group(2) - eol = m.group(3) - return ( - indent - + _HOOK_COMMAND_NOTE.rstrip("\n") - + eol - + indent - + instruction - + eol - ) - - return re.sub( - r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", - repl, + updated = inject_frontmatter_flag(content, "user-invocable") + updated = set_frontmatter_key(updated, "disable-model-invocation", "false") + updated = inject_hook_command_note(updated) + return updated + + @classmethod + def skill_postprocess_registry(cls) -> ExtensionRegistry: + """Extensions applied to each generated SKILL.md (order matters).""" + reg = ExtensionRegistry() + reg.register(FencedQuestionRenderExtension()) + reg.register(ClaudeSkillTransformExtension()) + return reg + + @classmethod + def render_skill_postprocess(cls, content: str, skill_path: Path) -> str: + """Core hook: run the extension registry only (no transform specifics here).""" + return apply_extensions( content, + SkillRenderContext(skill_path=skill_path), + cls.skill_postprocess_registry(), ) - def post_process_skill_content(self, content: str) -> str: - """Inject Claude-specific frontmatter flags and hook notes.""" - updated = self._inject_frontmatter_flag(content, "user-invocable") - updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false") - updated = self._inject_hook_command_note(updated) - return updated - def setup( self, project_root: Path, @@ -202,14 +109,12 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Install Claude skills, then inject Claude-specific flags and argument-hints.""" + """Install Claude skills, then run the skill post-process extension chain.""" 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: @@ -220,16 +125,7 @@ def setup( content_bytes = path.read_bytes() content = content_bytes.decode("utf-8") - updated = self.post_process_skill_content(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) + updated = self.render_skill_postprocess(content, path) if updated != content: path.write_bytes(updated.encode("utf-8")) diff --git a/templates/commands/checklist.md b/templates/commands/checklist.md index 533046566..98747155f 100644 --- a/templates/commands/checklist.md +++ b/templates/commands/checklist.md @@ -94,7 +94,15 @@ You **MUST** consider the user input before proceeding (if not empty). - Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?") Question formatting rules: - - If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters + - If presenting options, generate a compact table with columns: + + + | Option | Candidate | Why It Matters | + |--------|-----------|----------------| + | A | | | + | B | | | + + - Limit to A–E options maximum; omit table if a free-form answer is clearer - Never ask the user to restate what they already said - Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope." diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index d6d6bbe91..bbf0c4e50 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -145,12 +145,14 @@ Execution steps: - Format as: `**Recommended:** Option [X] - ` - Then render all options as a Markdown table: + | Option | Description | |--------|-------------| | A |