mirror of
https://github.com/github/spec-kit.git
synced 2026-07-08 07:04:44 +08:00
Install Claude Code as native skills and align preset/integration flows (#2051)
* Use Claude skills for generated commands * Fix Claude integration and preset skill flows * Group Claude tests in integration suite * Align Claude skill frontmatter across generators * Fix native skill preset cleanup * Keep legacy AI skills test on legacy path * Move Claude here-mode test to CLI suite
This commit is contained in:
@@ -1640,6 +1640,8 @@ def install_ai_skills(
|
||||
``True`` if at least one skill was installed or all skills were
|
||||
already present (idempotent re-run), ``False`` otherwise.
|
||||
"""
|
||||
from .agents import CommandRegistrar
|
||||
|
||||
# Locate command templates in the agent's extracted commands directory.
|
||||
# download_and_extract_template() already placed the .md files here.
|
||||
agent_config = AGENT_CONFIG.get(selected_ai, {})
|
||||
@@ -1741,15 +1743,12 @@ def install_ai_skills(
|
||||
if source_name.endswith(".agent.md"):
|
||||
source_name = source_name[:-len(".agent.md")] + ".md"
|
||||
|
||||
frontmatter_data = {
|
||||
"name": skill_name,
|
||||
"description": enhanced_desc,
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
"metadata": {
|
||||
"author": "github-spec-kit",
|
||||
"source": f"templates/commands/{source_name}",
|
||||
},
|
||||
}
|
||||
frontmatter_data = CommandRegistrar.build_skill_frontmatter(
|
||||
selected_ai,
|
||||
skill_name,
|
||||
enhanced_desc,
|
||||
f"templates/commands/{source_name}",
|
||||
)
|
||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
@@ -1859,6 +1858,23 @@ def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]:
|
||||
|
||||
|
||||
AGENT_SKILLS_MIGRATIONS = {
|
||||
"claude": {
|
||||
"error": (
|
||||
"Claude Code now installs spec-kit as agent skills; "
|
||||
"legacy .claude/commands projects are kept for backwards compatibility."
|
||||
),
|
||||
"usage": "specify init <project> --ai claude",
|
||||
"interactive_note": (
|
||||
"'claude' was selected interactively; enabling [cyan]--ai-skills[/cyan] "
|
||||
"automatically so spec-kit is installed to [cyan].claude/skills[/cyan]."
|
||||
),
|
||||
"explicit_note": (
|
||||
"'claude' now installs spec-kit as agent skills; enabling "
|
||||
"[cyan]--ai-skills[/cyan] automatically so commands are written to "
|
||||
"[cyan].claude/skills[/cyan]."
|
||||
),
|
||||
"auto_enable_explicit": True,
|
||||
},
|
||||
"agy": {
|
||||
"error": "Explicit command support was deprecated in Antigravity version 1.20.5.",
|
||||
"usage": "specify init <project> --ai agy --ai-skills",
|
||||
@@ -1943,7 +1959,7 @@ def init(
|
||||
specify init --here --ai vibe # Initialize with Mistral Vibe support
|
||||
specify init --here
|
||||
specify init --here --force # Skip confirmation when current directory not empty
|
||||
specify init my-project --ai claude --ai-skills # Install agent skills
|
||||
specify init my-project --ai claude # Claude installs skills by default
|
||||
specify init --here --ai gemini --ai-skills
|
||||
specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent
|
||||
specify init my-project --offline # Use bundled assets (no network access)
|
||||
@@ -1977,6 +1993,7 @@ def init(
|
||||
|
||||
# Auto-promote: --ai <key> → integration path with a nudge (if registered)
|
||||
use_integration = False
|
||||
resolved_integration = None
|
||||
if integration:
|
||||
from .integrations import INTEGRATION_REGISTRY, get_integration
|
||||
resolved_integration = get_integration(integration)
|
||||
@@ -2098,11 +2115,13 @@ def init(
|
||||
# If selected interactively (no --ai provided), automatically enable
|
||||
# ai_skills so the agent remains usable without requiring an extra flag.
|
||||
# Preserve fail-fast behavior only for explicit '--ai <agent>' without skills.
|
||||
if ai_assistant:
|
||||
migration = AGENT_SKILLS_MIGRATIONS[selected_ai]
|
||||
if ai_assistant and not migration.get("auto_enable_explicit", False):
|
||||
_handle_agent_skills_migration(console, selected_ai)
|
||||
else:
|
||||
ai_skills = True
|
||||
console.print(f"\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}")
|
||||
note_key = "explicit_note" if ai_assistant else "interactive_note"
|
||||
console.print(f"\n[yellow]Note:[/yellow] {migration[note_key]}")
|
||||
|
||||
# Validate --ai-commands-dir usage.
|
||||
# Skip validation when --integration-options is provided — the integration
|
||||
@@ -2540,27 +2559,33 @@ def init(
|
||||
step_num = 2
|
||||
|
||||
# Determine skill display mode for the next-steps panel.
|
||||
# Skills integrations (codex, kimi, agy) should show skill invocation syntax
|
||||
# regardless of whether --ai-skills was explicitly passed.
|
||||
# Skills integrations (codex, claude, kimi, agy) should show skill
|
||||
# invocation syntax regardless of whether --ai-skills was explicitly passed.
|
||||
_is_skills_integration = False
|
||||
if use_integration:
|
||||
from .integrations.base import SkillsIntegration as _SkillsInt
|
||||
_is_skills_integration = isinstance(resolved_integration, _SkillsInt)
|
||||
|
||||
codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
|
||||
claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration)
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
|
||||
native_skill_mode = codex_skill_mode or kimi_skill_mode or agy_skill_mode
|
||||
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode
|
||||
|
||||
if codex_skill_mode and not ai_skills:
|
||||
# Integration path installed skills; show the helpful notice
|
||||
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
|
||||
step_num += 1
|
||||
if claude_skill_mode and not ai_skills:
|
||||
steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]")
|
||||
step_num += 1
|
||||
usage_label = "skills" if native_skill_mode else "slash commands"
|
||||
|
||||
def _display_cmd(name: str) -> str:
|
||||
if codex_skill_mode or agy_skill_mode:
|
||||
return f"$speckit-{name}"
|
||||
if claude_skill_mode:
|
||||
return f"/speckit-{name}"
|
||||
if kimi_skill_mode:
|
||||
return f"/skill:speckit-{name}"
|
||||
return f"/speckit.{name}"
|
||||
|
||||
@@ -370,16 +370,35 @@ class CommandRegistrar:
|
||||
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
|
||||
|
||||
description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}")
|
||||
skill_frontmatter = self.build_skill_frontmatter(
|
||||
agent_name,
|
||||
skill_name,
|
||||
description,
|
||||
f"{source_id}:{source_file}",
|
||||
)
|
||||
return self.render_frontmatter(skill_frontmatter) + "\n" + body
|
||||
|
||||
@staticmethod
|
||||
def build_skill_frontmatter(
|
||||
agent_name: str,
|
||||
skill_name: str,
|
||||
description: str,
|
||||
source: str,
|
||||
) -> dict:
|
||||
"""Build consistent SKILL.md frontmatter across all skill generators."""
|
||||
skill_frontmatter = {
|
||||
"name": skill_name,
|
||||
"description": description,
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
"metadata": {
|
||||
"author": "github-spec-kit",
|
||||
"source": f"{source_id}:{source_file}",
|
||||
"source": source,
|
||||
},
|
||||
}
|
||||
return self.render_frontmatter(skill_frontmatter) + "\n" + body
|
||||
if agent_name == "claude":
|
||||
# Claude skills should only run when explicitly invoked.
|
||||
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:
|
||||
|
||||
@@ -801,15 +801,12 @@ class ExtensionManager:
|
||||
original_desc = frontmatter.get("description", "")
|
||||
description = original_desc or f"Extension command: {cmd_name}"
|
||||
|
||||
frontmatter_data = {
|
||||
"name": skill_name,
|
||||
"description": description,
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
"metadata": {
|
||||
"author": "github-spec-kit",
|
||||
"source": f"extension:{manifest.id}",
|
||||
},
|
||||
}
|
||||
frontmatter_data = registrar.build_skill_frontmatter(
|
||||
selected_ai,
|
||||
skill_name,
|
||||
description,
|
||||
f"extension:{manifest.id}",
|
||||
)
|
||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
||||
|
||||
# Derive a human-friendly title from the command name
|
||||
@@ -2138,11 +2135,14 @@ class HookExecutor:
|
||||
init_options = self._load_init_options()
|
||||
selected_ai = init_options.get("ai")
|
||||
codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills"))
|
||||
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
|
||||
skill_name = self._skill_name_from_command(command_id)
|
||||
if codex_skill_mode and skill_name:
|
||||
return f"${skill_name}"
|
||||
if claude_skill_mode and skill_name:
|
||||
return f"/{skill_name}"
|
||||
if kimi_skill_mode and skill_name:
|
||||
return f"/skill:{skill_name}"
|
||||
|
||||
|
||||
@@ -1,21 +1,106 @@
|
||||
"""Claude Code integration."""
|
||||
|
||||
from ..base import MarkdownIntegration
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from ...agents import CommandRegistrar
|
||||
from ..base import SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
class ClaudeIntegration(MarkdownIntegration):
|
||||
class ClaudeIntegration(SkillsIntegration):
|
||||
"""Integration for Claude Code skills."""
|
||||
|
||||
key = "claude"
|
||||
config = {
|
||||
"name": "Claude Code",
|
||||
"folder": ".claude/",
|
||||
"commands_subdir": "commands",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": "https://docs.anthropic.com/en/docs/claude-code/setup",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".claude/commands",
|
||||
"dir": ".claude/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = "CLAUDE.md"
|
||||
|
||||
def command_filename(self, template_name: str) -> str:
|
||||
"""Claude skills live at .claude/skills/<name>/SKILL.md."""
|
||||
skill_name = f"speckit-{template_name.replace('.', '-')}"
|
||||
return f"{skill_name}/SKILL.md"
|
||||
|
||||
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 = CommandRegistrar.build_skill_frontmatter(
|
||||
self.key,
|
||||
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 setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install Claude skills into .claude/skills."""
|
||||
templates = self.list_command_templates()
|
||||
if not templates:
|
||||
return []
|
||||
|
||||
project_root_resolved = project_root.resolve()
|
||||
if manifest.project_root != project_root_resolved:
|
||||
raise ValueError(
|
||||
f"manifest.project_root ({manifest.project_root}) does not match "
|
||||
f"project_root ({project_root_resolved})"
|
||||
)
|
||||
|
||||
dest = self.skills_dest(project_root).resolve()
|
||||
try:
|
||||
dest.relative_to(project_root_resolved)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Integration destination {dest} escapes "
|
||||
f"project root {project_root_resolved}"
|
||||
) from exc
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
script_type = opts.get("script_type", "sh")
|
||||
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS")
|
||||
registrar = CommandRegistrar()
|
||||
created: list[Path] = []
|
||||
|
||||
for src_file in templates:
|
||||
raw = src_file.read_text(encoding="utf-8")
|
||||
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
|
||||
frontmatter, body = registrar.parse_frontmatter(processed)
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
|
||||
rendered = self._render_skill(src_file.stem, frontmatter, body)
|
||||
dst_file = self.write_file_and_record(
|
||||
rendered,
|
||||
dest / self.command_filename(src_file.stem),
|
||||
project_root,
|
||||
manifest,
|
||||
)
|
||||
created.append(dst_file)
|
||||
|
||||
created.extend(self.install_scripts(project_root, manifest))
|
||||
return created
|
||||
|
||||
@@ -714,7 +714,14 @@ class PresetManager:
|
||||
selected_ai = init_opts.get("ai")
|
||||
if not isinstance(selected_ai, str):
|
||||
return []
|
||||
ai_skills_enabled = bool(init_opts.get("ai_skills"))
|
||||
registrar = CommandRegistrar()
|
||||
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
|
||||
# Native skill agents (e.g. codex/kimi/agy) materialize brand-new
|
||||
# preset skills in _register_commands() because their detected agent
|
||||
# directory is already the skills directory. This flag is only for
|
||||
# command-backed agents that also mirror commands into skills.
|
||||
create_missing_skills = ai_skills_enabled and agent_config.get("extension") != "/SKILL.md"
|
||||
|
||||
written: List[str] = []
|
||||
|
||||
@@ -741,6 +748,10 @@ class PresetManager:
|
||||
target_skill_names.append(skill_name)
|
||||
if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir():
|
||||
target_skill_names.append(legacy_skill_name)
|
||||
if not target_skill_names and create_missing_skills:
|
||||
missing_skill_dir = skills_dir / skill_name
|
||||
if not missing_skill_dir.exists():
|
||||
target_skill_names.append(skill_name)
|
||||
if not target_skill_names:
|
||||
continue
|
||||
|
||||
@@ -760,15 +771,16 @@ class PresetManager:
|
||||
)
|
||||
|
||||
for target_skill_name in target_skill_names:
|
||||
frontmatter_data = {
|
||||
"name": target_skill_name,
|
||||
"description": enhanced_desc,
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
"metadata": {
|
||||
"author": "github-spec-kit",
|
||||
"source": f"preset:{manifest.id}",
|
||||
},
|
||||
}
|
||||
skill_subdir = skills_dir / target_skill_name
|
||||
if skill_subdir.exists() and not skill_subdir.is_dir():
|
||||
continue
|
||||
skill_subdir.mkdir(parents=True, exist_ok=True)
|
||||
frontmatter_data = registrar.build_skill_frontmatter(
|
||||
selected_ai,
|
||||
target_skill_name,
|
||||
enhanced_desc,
|
||||
f"preset:{manifest.id}",
|
||||
)
|
||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
@@ -778,7 +790,7 @@ class PresetManager:
|
||||
f"{body}\n"
|
||||
)
|
||||
|
||||
skill_file = skills_dir / target_skill_name / "SKILL.md"
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
written.append(target_skill_name)
|
||||
|
||||
@@ -850,15 +862,12 @@ class PresetManager:
|
||||
original_desc or f"Spec-kit workflow command: {short_name}",
|
||||
)
|
||||
|
||||
frontmatter_data = {
|
||||
"name": skill_name,
|
||||
"description": enhanced_desc,
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
"metadata": {
|
||||
"author": "github-spec-kit",
|
||||
"source": f"templates/commands/{short_name}.md",
|
||||
},
|
||||
}
|
||||
frontmatter_data = registrar.build_skill_frontmatter(
|
||||
selected_ai if isinstance(selected_ai, str) else "",
|
||||
skill_name,
|
||||
enhanced_desc,
|
||||
f"templates/commands/{short_name}.md",
|
||||
)
|
||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
||||
skill_title = self._skill_title_from_command(short_name)
|
||||
skill_content = (
|
||||
@@ -883,15 +892,12 @@ class PresetManager:
|
||||
command_name = extension_restore["command_name"]
|
||||
title_name = self._skill_title_from_command(command_name)
|
||||
|
||||
frontmatter_data = {
|
||||
"name": skill_name,
|
||||
"description": frontmatter.get("description", f"Extension command: {command_name}"),
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
"metadata": {
|
||||
"author": "github-spec-kit",
|
||||
"source": extension_restore["source"],
|
||||
},
|
||||
}
|
||||
frontmatter_data = registrar.build_skill_frontmatter(
|
||||
selected_ai if isinstance(selected_ai, str) else "",
|
||||
skill_name,
|
||||
frontmatter.get("description", f"Extension command: {command_name}"),
|
||||
extension_restore["source"],
|
||||
)
|
||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
@@ -1040,14 +1046,15 @@ class PresetManager:
|
||||
if registered_skills:
|
||||
self._unregister_skills(registered_skills, pack_dir)
|
||||
try:
|
||||
from . import NATIVE_SKILLS_AGENTS
|
||||
from .agents import CommandRegistrar
|
||||
except ImportError:
|
||||
NATIVE_SKILLS_AGENTS = set()
|
||||
registered_commands = {
|
||||
agent_name: cmd_names
|
||||
for agent_name, cmd_names in registered_commands.items()
|
||||
if agent_name not in NATIVE_SKILLS_AGENTS
|
||||
}
|
||||
CommandRegistrar = None
|
||||
if CommandRegistrar is not None:
|
||||
registered_commands = {
|
||||
agent_name: cmd_names
|
||||
for agent_name, cmd_names in registered_commands.items()
|
||||
if CommandRegistrar.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md"
|
||||
}
|
||||
|
||||
# Unregister non-skill command files from AI agents.
|
||||
if registered_commands:
|
||||
|
||||
Reference in New Issue
Block a user