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:
Andrii Furmanets
2026-04-02 17:44:48 +03:00
committed by GitHub
parent d9ce7c1fc0
commit a858c1d6da
11 changed files with 633 additions and 79 deletions

View File

@@ -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}"

View File

@@ -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:

View File

@@ -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}"

View File

@@ -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

View File

@@ -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: