Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 33 additions & 151 deletions src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,19 @@
from pathlib import Path
from typing import Any

import re

import yaml

from .skill_postprocess import (
ARGUMENT_HINTS,
apply_claude_skill_postprocess,
inject_argument_hint,
inject_frontmatter_flag,
inject_hook_command_note,
set_frontmatter_key,
)
Comment on lines +10 to +17
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ARGUMENT_HINTS is imported but not used in this module anymore (hint resolution moved into skill_postprocess.apply_claude_skill_postprocess). Consider dropping the unused import to keep the module tidy.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

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."""
Expand All @@ -56,51 +40,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."""
Expand All @@ -121,95 +72,35 @@ 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,
content,
)

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)
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 render_skill_postprocess(cls, content: str, skill_path: Path) -> str:
"""Run Claude-specific skill post-processing pipeline."""
return apply_claude_skill_postprocess(content, skill_path)

def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
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:
Expand All @@ -220,16 +111,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"))
Expand Down
117 changes: 117 additions & 0 deletions src/specify_cli/integrations/claude/question_transformer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Question block transformer for Claude Code integration."""

from __future__ import annotations
Comment on lines +1 to +3
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description calls out a new core module at src/specify_cli/core/question_transformer.py, but the implementation is currently located under src/specify_cli/integrations/claude/question_transformer.py. Either update the PR description (and any docs) or move/alias the module to match, to avoid confusion and broken links in follow-up work.

Copilot uses AI. Check for mistakes.

import json
import re

_FENCE_RE = re.compile(
r"<!-- speckit:question-render:begin -->\s*\n(.*?)\n\s*<!-- speckit:question-render:end -->",
re.DOTALL,
Comment thread
Rohan-1920 marked this conversation as resolved.
)
_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."""
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 |."""
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
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_clarify() drops any additional rows whose description matches the Recommended pattern after the first one (they're neither used as recommended nor appended to options). This can silently remove valid choices if more than one row is marked recommended. Consider appending subsequent recommended rows to options once recommended is already set (or supporting multiple recommended rows explicitly).

Suggested change
recommended = entry
recommended = entry
else:
options.append(entry)

Copilot uses AI. Check for mistakes.
else:
options.append(entry)
Comment thread
Rohan-1920 marked this conversation as resolved.

if recommended:
options.insert(0, recommended)

return options


def parse_checklist(block: str) -> list[dict]:
"""Parse checklist.md schema: | Option | Candidate | Why It Matters |."""
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."""
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,
}

raw = json.dumps(payload, ensure_ascii=False, indent=2)
json.loads(raw)
return f"```json\n{raw}\n```"


def transform_question_block(content: str) -> str:
"""Replace fenced question blocks with AskUserQuestion JSON payloads."""

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)
Loading