mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 14:01:01 +08:00
* Remove Template Version and Released from version output Templates are now bundled with the CLI, so showing them as separate artifacts with their own version and release date is no longer accurate. This also removes the GitHub API call that fetched the latest release, making the version command faster and eliminating a network dependency. * Remove unused datetime import * fix: inject user-invocable: true into Claude skill frontmatter The SkillsIntegration.setup() builds frontmatter manually without user-invocable. Add post-processing injection in ClaudeIntegration.setup(), matching the existing pattern for disable-model-invocation. * refactor: address review feedback - Factor _inject_user_invocable and _inject_disable_model_invocation into a shared _inject_frontmatter_flag(key, value) helper - Remove unused httpx, ssl, truststore imports and globals - Remove unused _github_token and _github_auth_headers helpers - Update setup() docstring to mention user-invocable * chore: remove httpx and truststore from dependencies Both are no longer used after removing the GitHub API call from the version command. Removes from PEP 723 script header and pyproject.toml. * fix: match EOL detection style in _inject_frontmatter_flag Handle \r\n, \n, and no-newline cases consistently with inject_argument_hint's pattern.
196 lines
7.1 KiB
Python
196 lines
7.1 KiB
Python
"""Claude Code integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
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."""
|
|
|
|
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"
|
|
|
|
@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)
|
|
|
|
def setup(
|
|
self,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
parsed_options: dict[str, Any] | None = None,
|
|
**opts: Any,
|
|
) -> list[Path]:
|
|
"""Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint."""
|
|
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 user-invocable: true (Claude skills are accessible via /command)
|
|
updated = self._inject_frontmatter_flag(content, "user-invocable")
|
|
|
|
# Inject disable-model-invocation: true (Claude skills run only when invoked)
|
|
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation")
|
|
|
|
# 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
|