mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 14:01:01 +08:00
* support controlled multi-install integrations * fix: harden multi-install integration state * refactor: isolate integration runtime helpers * fix: address copilot review feedback * fix: address follow-up copilot feedback * fix: tighten integration switch semantics * fix: address final copilot review feedback * fix: harden integration manifest read errors * fix: refuse symlinked shared infra paths * test: filter expected self-test preset warning * test: address copilot review nits * refactor: centralize safe shared infra writes * fix: use no-follow writes for shared infra * fix: keep default integration atomic on template refresh * fix: harden shared infra error paths * fix: preflight shared infra and future state schemas * fix: support nested shared scripts during preflight * test: tolerate wrapped schema error output * fix: use safe default mode for shared text writes * fix: use posix paths in shared skip output * fix: share project guard for integration use * fix: centralize spec-kit project guards * fix: use posix project paths in cli output * fix: harden shared manifest and upgrade refresh
240 lines
8.5 KiB
Python
240 lines
8.5 KiB
Python
"""Claude Code integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import re
|
|
|
|
import yaml
|
|
|
|
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."""
|
|
|
|
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",
|
|
}
|
|
context_file = "CLAUDE.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 = yaml.safe_dump(skill_frontmatter, sort_keys=False).strip()
|
|
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 _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 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,
|
|
manifest: IntegrationManifest,
|
|
parsed_options: dict[str, Any] | None = None,
|
|
**opts: Any,
|
|
) -> list[Path]:
|
|
"""Install Claude skills, then inject Claude-specific flags and argument-hints."""
|
|
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")
|
|
|
|
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)
|
|
|
|
if updated != content:
|
|
path.write_bytes(updated.encode("utf-8"))
|
|
self.record_file_in_manifest(path, project_root, manifest)
|
|
|
|
return created
|