mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 22:32:13 +08:00
* claude: run /analyze in a forked subagent /analyze is explicitly read-only and produces a compact analysis report from heavy artefact reads (spec.md, plan.md, tasks.md). It matches the canonical use case for context: fork — bulk inputs that collapse to a short summary, no need for conversation history. Forking keeps the artefact contents out of the main conversation context, which is the concern raised in #752. Done as a per-command opt-in via FORK_CONTEXT_COMMANDS so other spec-kit commands (which are interactive or have side effects) are unaffected. Refs #752 * claude: apply per-command frontmatter on every skill-generation path argument-hint and fork context were injected only in setup(), so skills produced via post_process_skill_content() directly (presets, extensions) lost them - e.g. a preset overriding speckit-analyze dropped context: fork. Move the per-command injection into post_process_skill_content(), deriving the command stem from the frontmatter name, so all generation paths stay consistent. setup() now just calls post_process_skill_content(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * claude: drop redundant post-process loop from setup SkillsIntegration.setup() already runs post_process_skill_content() on every SKILL.md before writing it, and that method now applies the argument-hint and fork-context injection. The per-file re-process loop in ClaudeIntegration.setup() was therefore a no-op, so inherit the base setup() directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
202 lines
7.7 KiB
Python
202 lines
7.7 KiB
Python
"""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. Read-only analysis commands are good candidates: the
|
|
# heavy reads (spec/plan/tasks artefacts) collapse to a short summary,
|
|
# so isolating them keeps the main conversation context clean.
|
|
# See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent
|
|
FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = {
|
|
"analyze": {"context": "fork", "agent": "general-purpose"},
|
|
}
|
|
|
|
|
|
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 = 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
|