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

@@ -281,7 +281,7 @@ Community projects that extend, visualize, or build on Spec Kit:
| [Kiro CLI](https://kiro.dev/docs/cli/) | ✅ | Use `--ai kiro-cli` (alias: `--ai kiro`) |
| [Amp](https://ampcode.com/) | ✅ | |
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | |
| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | |
| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | Installs skills in `.claude/skills`; invoke spec-kit as `/speckit-constitution`, `/speckit-plan`, etc. |
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | |
| [Codex CLI](https://github.com/openai/codex) | ✅ | Requires `--ai-skills`. Codex recommends [skills](https://developers.openai.com/codex/skills) and treats [custom prompts](https://developers.openai.com/codex/custom-prompts) as deprecated. Spec-kit installs Codex skills into `.agents/skills` and invokes them as `$speckit-<command>`. |
| [Cursor](https://cursor.sh/) | ✅ | |
@@ -401,8 +401,8 @@ specify init my-project --ai claude --debug
# Use GitHub token for API requests (helpful for corporate environments)
specify init my-project --ai claude --github-token ghp_your_token_here
# Install agent skills with the project
specify init my-project --ai claude --ai-skills
# Claude Code installs skills with the project by default
specify init my-project --ai claude
# Initialize in current directory with agent skills
specify init --here --ai gemini --ai-skills
@@ -416,7 +416,11 @@ specify check
### Available Slash Commands
After running `specify init`, your AI coding agent will have access to these slash commands for structured development.
After running `specify init`, your AI coding agent will have access to these structured development commands.
Most agents expose the traditional dotted slash commands shown below, like `/speckit.plan`.
Claude Code installs spec-kit as skills and invokes them as `/speckit-constitution`, `/speckit-specify`, `/speckit-plan`, `/speckit-tasks`, and `/speckit-implement`.
For Codex CLI, `--ai-skills` installs spec-kit as agent skills instead of slash-command prompt files. In Codex skills mode, invoke spec-kit as `$speckit-constitution`, `$speckit-specify`, `$speckit-plan`, `$speckit-tasks`, and `$speckit-implement`.

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:

View File

@@ -76,6 +76,33 @@ class TestInitIntegrationFlag:
assert "--integration copilot" in result.output
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "claude-here-existing"
project.mkdir()
commands_dir = project / ".claude" / "commands"
commands_dir.mkdir(parents=True)
command_file = commands_dir / "speckit.specify.md"
command_file.write_text("# preexisting command\n", encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--force", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert "--integration claude" in result.output
assert command_file.exists()
assert command_file.read_text(encoding="utf-8") == "# preexisting command\n"
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_shared_infra_skips_existing_files(self, tmp_path):
"""Pre-existing shared files are not overwritten by _install_shared_infra."""
from typer.testing import CliRunner

View File

@@ -1,11 +1,290 @@
"""Tests for ClaudeIntegration."""
from .test_integration_base_markdown import MarkdownIntegrationTests
import json
import os
from unittest.mock import patch
import yaml
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import IntegrationBase
from specify_cli.integrations.manifest import IntegrationManifest
class TestClaudeIntegration(MarkdownIntegrationTests):
KEY = "claude"
FOLDER = ".claude/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".claude/commands"
CONTEXT_FILE = "CLAUDE.md"
class TestClaudeIntegration:
def test_registered(self):
assert "claude" in INTEGRATION_REGISTRY
assert get_integration("claude") is not None
def test_is_base_integration(self):
assert isinstance(get_integration("claude"), IntegrationBase)
def test_config_uses_skills(self):
integration = get_integration("claude")
assert integration.config["folder"] == ".claude/"
assert integration.config["commands_subdir"] == "skills"
def test_registrar_config_uses_skill_layout(self):
integration = get_integration("claude")
assert integration.registrar_config["dir"] == ".claude/skills"
assert integration.registrar_config["format"] == "markdown"
assert integration.registrar_config["args"] == "$ARGUMENTS"
assert integration.registrar_config["extension"] == "/SKILL.md"
def test_context_file(self):
integration = get_integration("claude")
assert integration.context_file == "CLAUDE.md"
def test_setup_creates_skill_files(self, tmp_path):
integration = get_integration("claude")
manifest = IntegrationManifest("claude", tmp_path)
created = integration.setup(tmp_path, manifest, script_type="sh")
skill_files = [path for path in created if path.name == "SKILL.md"]
assert skill_files
skills_dir = tmp_path / ".claude" / "skills"
assert skills_dir.is_dir()
plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
assert plan_skill.exists()
content = plan_skill.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content
assert "{ARGS}" not in content
assert "__AGENT__" not in content
parts = content.split("---", 2)
parsed = yaml.safe_load(parts[1])
assert parsed["name"] == "speckit-plan"
assert parsed["disable-model-invocation"] is True
assert parsed["metadata"]["source"] == "templates/commands/plan.md"
def test_setup_installs_update_context_scripts(self, tmp_path):
integration = get_integration("claude")
manifest = IntegrationManifest("claude", tmp_path)
created = integration.setup(tmp_path, manifest, script_type="sh")
scripts_dir = tmp_path / ".specify" / "integrations" / "claude" / "scripts"
assert scripts_dir.is_dir()
assert (scripts_dir / "update-context.sh").exists()
assert (scripts_dir / "update-context.ps1").exists()
tracked = {path.resolve().relative_to(tmp_path.resolve()).as_posix() for path in created}
assert ".specify/integrations/claude/scripts/update-context.sh" in tracked
assert ".specify/integrations/claude/scripts/update-context.ps1" in tracked
def test_ai_flag_auto_promotes_and_enables_skills(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "claude-promote"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--ai",
"claude",
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert "--integration claude" in result.output
assert ".claude/skills" in result.output
assert "/speckit-plan" in result.output
assert "/speckit.plan" not in result.output
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert not (project / ".claude" / "commands").exists()
init_options = json.loads(
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
)
assert init_options["ai"] == "claude"
assert init_options["ai_skills"] is True
assert init_options["integration"] == "claude"
def test_integration_flag_creates_skill_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "claude-integration"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
"claude",
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert (project / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists()
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
def test_interactive_claude_selection_uses_integration_path(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "claude-interactive"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
with patch("specify_cli.select_with_arrows", return_value="claude"):
result = runner.invoke(
app,
[
"init",
"--here",
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert (project / ".specify" / "integration.json").exists()
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
skill_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
assert skill_file.exists()
assert "disable-model-invocation: true" in skill_file.read_text(encoding="utf-8")
init_options = json.loads(
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
)
assert init_options["ai"] == "claude"
assert init_options["ai_skills"] is True
assert init_options["integration"] == "claude"
def test_claude_init_remains_usable_when_converter_fails(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "fail-proj"
with patch("specify_cli.ensure_executable_scripts"), \
patch("specify_cli.ensure_constitution_from_template"), \
patch("specify_cli.install_ai_skills", return_value=False), \
patch("specify_cli.is_git_repo", return_value=False), \
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
result = runner.invoke(
app,
["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"],
)
assert result.exit_code == 0
assert (target / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists()
assert not (target / ".claude" / "commands").exists()
def test_claude_hooks_render_skill_invocation(self, tmp_path):
from specify_cli.extensions import HookExecutor
project = tmp_path / "claude-hooks"
project.mkdir()
init_options = project / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "claude", "ai_skills": True}))
hook_executor = HookExecutor(project)
message = hook_executor.format_hook_message(
"before_plan",
[
{
"extension": "test-ext",
"command": "speckit.plan",
"optional": False,
}
],
)
assert "Executing: `/speckit-plan`" in message
assert "EXECUTE_COMMAND: speckit.plan" in message
assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message
def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path):
from specify_cli import save_init_options
from specify_cli.presets import PresetManager
project = tmp_path / "claude-preset-skill"
project.mkdir()
save_init_options(project, {"ai": "claude", "ai_skills": True, "script": "sh"})
skills_dir = project / ".claude" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
preset_dir = tmp_path / "claude-skill-command"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.research.md").write_text(
"---\n"
"description: Research workflow\n"
"---\n\n"
"preset:claude-skill-command\n"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "claude-skill-command",
"name": "Claude Skill Command",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.research",
"file": "commands/speckit.research.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-research" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
assert "preset:claude-skill-command" in content
assert "name: speckit-research" in content
assert "disable-model-invocation: true" in content
metadata = manager.registry.get("claude-skill-command")
assert "speckit-research" in metadata.get("registered_skills", [])

View File

@@ -756,7 +756,6 @@ class TestLegacyDownloadPath:
assert not (tmp_path / "evil.txt").exists()
# ===== Skip-If-Exists Tests =====
class TestSkipIfExists:
@@ -925,7 +924,7 @@ class TestCliValidation:
plain = strip_ansi(result.output)
assert "--ai-skills" in plain
assert "agent skills" in plain.lower()
assert "skills" in plain.lower()
def test_q_removed_from_agent_config(self):
"""Amazon Q legacy key should not remain in AGENT_CONFIG."""

View File

@@ -269,6 +269,7 @@ class TestExtensionSkillRegistration:
assert isinstance(parsed, dict)
assert parsed["name"] == "speckit-test-ext-hello"
assert "description" in parsed
assert parsed["disable-model-invocation"] is True
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
"""No skills should be created when ai_skills is false."""

View File

@@ -1975,6 +1975,7 @@ class TestPresetSkills:
assert skill_file.exists()
content = skill_file.read_text()
assert "preset:self-test" in content, "Skill should reference preset source"
assert "disable-model-invocation: true" in content
# Verify it was recorded in registry
metadata = manager.registry.get("self-test")
@@ -2060,6 +2061,7 @@ class TestPresetSkills:
content = skill_file.read_text()
assert "preset:self-test" not in content, "Preset content should be gone"
assert "templates/commands/specify.md" in content, "Should reference core template"
assert "disable-model-invocation: true" in content
def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir):
"""Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths."""
@@ -2350,6 +2352,55 @@ class TestPresetSkills:
metadata = manager.registry.get("self-test")
assert "speckit-specify" in metadata.get("registered_skills", [])
def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, temp_dir):
"""Kimi native skills should still receive brand-new preset commands."""
self._write_init_options(project_dir, ai="kimi", ai_skills=False)
skills_dir = project_dir / ".kimi" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
preset_dir = temp_dir / "kimi-new-skill"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.research.md").write_text(
"---\n"
"description: Kimi research workflow\n"
"---\n\n"
"preset:kimi-new-skill\n"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "kimi-new-skill",
"name": "Kimi New Skill",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.research",
"file": "commands/speckit.research.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-research" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "preset:kimi-new-skill" in content
assert "name: speckit-research" in content
metadata = manager.registry.get("kimi-new-skill")
assert "speckit-research" in metadata.get("registered_skills", [])
def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir):
"""Kimi preset skill overrides should resolve placeholders and rewrite project paths."""
self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh")
@@ -2402,6 +2453,63 @@ class TestPresetSkills:
assert ".specify/memory/constitution.md" in content
assert "for kimi" in content
def test_agy_skill_restored_on_preset_remove(self, project_dir, temp_dir):
"""Agy preset removal should restore native skills instead of deleting them."""
self._write_init_options(project_dir, ai="agy", ai_skills=True)
skills_dir = project_dir / ".agent" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="before override")
core_command = project_dir / ".specify" / "templates" / "commands" / "specify.md"
core_command.write_text(
"---\n"
"description: Restored core specify workflow\n"
"---\n\n"
"restored core body\n"
)
preset_dir = temp_dir / "agy-override"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.specify.md").write_text(
"---\n"
"description: Agy override\n"
"---\n\n"
"preset agy body\n"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "agy-override",
"name": "Agy Override",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.specify",
"file": "commands/speckit.specify.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
assert "preset agy body" in skill_file.read_text()
assert manager.remove("agy-override") is True
assert skill_file.exists()
restored = skill_file.read_text()
assert "restored core body" in restored
assert "name: speckit-specify" in restored
def test_preset_skill_registration_handles_non_dict_init_options(self, project_dir, temp_dir):
"""Non-dict init-options payloads should not crash preset install/remove flows."""
init_options = project_dir / ".specify" / "init-options.json"