mirror of
https://github.com/github/spec-kit.git
synced 2026-03-19 20:03:07 +00:00
feat: migrate Codex/agy init to native skills workflow (#1906)
* feat: migrate codex and agy to native skills flow * fix: harden codex skill frontmatter and script fallback * fix: clarify skills separator default expansion * fix: rewrite agent_scripts paths for codex skills * fix: align kimi guidance and platform-aware codex fallback
This commit is contained in:
@@ -201,20 +201,22 @@ agent: $basename
|
||||
}
|
||||
}
|
||||
|
||||
# Create Kimi Code skills in .kimi/skills/<name>/SKILL.md format.
|
||||
# Kimi CLI discovers skills as directories containing a SKILL.md file,
|
||||
# invoked with /skill:<name> (e.g. /skill:speckit.specify).
|
||||
function New-KimiSkills {
|
||||
# Create skills in <skills_dir>\<name>\SKILL.md format.
|
||||
# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
|
||||
# current dotted-name exception (e.g. speckit.plan).
|
||||
function New-Skills {
|
||||
param(
|
||||
[string]$SkillsDir,
|
||||
[string]$ScriptVariant
|
||||
[string]$ScriptVariant,
|
||||
[string]$AgentName,
|
||||
[string]$Separator = '-'
|
||||
)
|
||||
|
||||
$templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue
|
||||
|
||||
foreach ($template in $templates) {
|
||||
$name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name)
|
||||
$skillName = "speckit.$name"
|
||||
$skillName = "speckit${Separator}$name"
|
||||
$skillDir = Join-Path $SkillsDir $skillName
|
||||
New-Item -ItemType Directory -Force -Path $skillDir | Out-Null
|
||||
|
||||
@@ -267,7 +269,7 @@ function New-KimiSkills {
|
||||
|
||||
$body = $outputLines -join "`n"
|
||||
$body = $body -replace '\{ARGS\}', '$ARGUMENTS'
|
||||
$body = $body -replace '__AGENT__', 'kimi'
|
||||
$body = $body -replace '__AGENT__', $AgentName
|
||||
$body = Rewrite-Paths -Content $body
|
||||
|
||||
# Strip existing frontmatter, keep only body
|
||||
@@ -396,8 +398,9 @@ function Build-Variant {
|
||||
Generate-Commands -Agent 'windsurf' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
}
|
||||
'codex' {
|
||||
$cmdDir = Join-Path $baseDir ".codex/prompts"
|
||||
Generate-Commands -Agent 'codex' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
$skillsDir = Join-Path $baseDir ".agents/skills"
|
||||
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
||||
New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'codex' -Separator '-'
|
||||
}
|
||||
'kilocode' {
|
||||
$cmdDir = Join-Path $baseDir ".kilocode/workflows"
|
||||
@@ -452,7 +455,7 @@ function Build-Variant {
|
||||
'kimi' {
|
||||
$skillsDir = Join-Path $baseDir ".kimi/skills"
|
||||
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
||||
New-KimiSkills -SkillsDir $skillsDir -ScriptVariant $Script
|
||||
New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi' -Separator '.'
|
||||
}
|
||||
'trae' {
|
||||
$rulesDir = Join-Path $baseDir ".trae/rules"
|
||||
|
||||
@@ -121,18 +121,20 @@ EOF
|
||||
done
|
||||
}
|
||||
|
||||
# Create Kimi Code skills in .kimi/skills/<name>/SKILL.md format.
|
||||
# Kimi CLI discovers skills as directories containing a SKILL.md file,
|
||||
# invoked with /skill:<name> (e.g. /skill:speckit.specify).
|
||||
create_kimi_skills() {
|
||||
# Create skills in <skills_dir>/<name>/SKILL.md format.
|
||||
# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
|
||||
# current dotted-name exception (e.g. speckit.plan).
|
||||
create_skills() {
|
||||
local skills_dir="$1"
|
||||
local script_variant="$2"
|
||||
local agent_name="$3"
|
||||
local separator="${4:-"-"}"
|
||||
|
||||
for template in templates/commands/*.md; do
|
||||
[[ -f "$template" ]] || continue
|
||||
local name
|
||||
name=$(basename "$template" .md)
|
||||
local skill_name="speckit.${name}"
|
||||
local skill_name="speckit${separator}${name}"
|
||||
local skill_dir="${skills_dir}/${skill_name}"
|
||||
mkdir -p "$skill_dir"
|
||||
|
||||
@@ -175,9 +177,9 @@ create_kimi_skills() {
|
||||
in_frontmatter && skip_scripts && /^[[:space:]]/ { next }
|
||||
{ print }
|
||||
')
|
||||
body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed 's/__AGENT__/kimi/g' | rewrite_paths)
|
||||
body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed "s/__AGENT__/$agent_name/g" | rewrite_paths)
|
||||
|
||||
# Strip existing frontmatter and prepend Kimi frontmatter
|
||||
# Strip existing frontmatter and prepend skills frontmatter.
|
||||
local template_body
|
||||
template_body=$(printf '%s\n' "$body" | awk '/^---/{p++; if(p==2){found=1; next}} found')
|
||||
|
||||
@@ -249,8 +251,8 @@ build_variant() {
|
||||
mkdir -p "$base_dir/.windsurf/workflows"
|
||||
generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;;
|
||||
codex)
|
||||
mkdir -p "$base_dir/.codex/prompts"
|
||||
generate_commands codex md "\$ARGUMENTS" "$base_dir/.codex/prompts" "$script" ;;
|
||||
mkdir -p "$base_dir/.agents/skills"
|
||||
create_skills "$base_dir/.agents/skills" "$script" "codex" "-" ;;
|
||||
kilocode)
|
||||
mkdir -p "$base_dir/.kilocode/workflows"
|
||||
generate_commands kilocode md "\$ARGUMENTS" "$base_dir/.kilocode/workflows" "$script" ;;
|
||||
@@ -290,7 +292,7 @@ build_variant() {
|
||||
generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;;
|
||||
kimi)
|
||||
mkdir -p "$base_dir/.kimi/skills"
|
||||
create_kimi_skills "$base_dir/.kimi/skills" "$script" ;;
|
||||
create_skills "$base_dir/.kimi/skills" "$script" "kimi" "." ;;
|
||||
trae)
|
||||
mkdir -p "$base_dir/.trae/rules"
|
||||
generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;;
|
||||
|
||||
15
README.md
15
README.md
@@ -99,7 +99,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai c
|
||||
|
||||
### 2. Establish project principles
|
||||
|
||||
Launch your AI assistant in the project directory. The `/speckit.*` commands are available in the assistant.
|
||||
Launch your AI assistant in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
|
||||
|
||||
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
|
||||
|
||||
@@ -173,7 +173,7 @@ See Spec-Driven Development in action across different scenarios with these comm
|
||||
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | |
|
||||
| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | |
|
||||
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | |
|
||||
| [Codex CLI](https://github.com/openai/codex) | ✅ | |
|
||||
| [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/) | ✅ | |
|
||||
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | |
|
||||
| [GitHub Copilot](https://code.visualstudio.com/) | ✅ | |
|
||||
@@ -258,6 +258,9 @@ specify init my-project --ai bob
|
||||
# Initialize with Pi Coding Agent support
|
||||
specify init my-project --ai pi
|
||||
|
||||
# Initialize with Codex CLI support
|
||||
specify init my-project --ai codex --ai-skills
|
||||
|
||||
# Initialize with Antigravity support
|
||||
specify init my-project --ai agy --ai-skills
|
||||
|
||||
@@ -298,7 +301,9 @@ 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 slash commands for structured development.
|
||||
|
||||
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`.
|
||||
|
||||
#### Core Commands
|
||||
|
||||
@@ -484,11 +489,11 @@ specify init <project_name> --ai copilot
|
||||
|
||||
# Or in current directory:
|
||||
specify init . --ai claude
|
||||
specify init . --ai codex
|
||||
specify init . --ai codex --ai-skills
|
||||
|
||||
# or use --here flag
|
||||
specify init --here --ai claude
|
||||
specify init --here --ai codex
|
||||
specify init --here --ai codex --ai-skills
|
||||
|
||||
# Force merge into a non-empty current directory
|
||||
specify init . --force --ai claude
|
||||
|
||||
@@ -31,7 +31,6 @@ import sys
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import shlex
|
||||
import json
|
||||
import json5
|
||||
import stat
|
||||
@@ -172,8 +171,8 @@ AGENT_CONFIG = {
|
||||
},
|
||||
"codex": {
|
||||
"name": "Codex CLI",
|
||||
"folder": ".codex/",
|
||||
"commands_subdir": "prompts", # Special: uses prompts/ not commands/
|
||||
"folder": ".agents/",
|
||||
"commands_subdir": "skills", # Codex now uses project skills directly
|
||||
"install_url": "https://github.com/openai/codex",
|
||||
"requires_cli": True,
|
||||
},
|
||||
@@ -1211,6 +1210,9 @@ AGENT_SKILLS_DIR_OVERRIDES = {
|
||||
# Default skills directory for agents not in AGENT_CONFIG
|
||||
DEFAULT_SKILLS_DIR = ".agents/skills"
|
||||
|
||||
# Agents whose downloaded template already contains skills in the final layout.
|
||||
NATIVE_SKILLS_AGENTS = {"codex", "kimi"}
|
||||
|
||||
# Enhanced descriptions for each spec-kit command skill
|
||||
SKILL_DESCRIPTIONS = {
|
||||
"specify": "Create or update feature specifications from natural language descriptions. Use when starting new features or refining requirements. Generates spec.md with user stories, functional requirements, and acceptance criteria following spec-driven development methodology.",
|
||||
@@ -1339,8 +1341,6 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
||||
command_name = command_name[len("speckit."):]
|
||||
if command_name.endswith(".agent"):
|
||||
command_name = command_name[:-len(".agent")]
|
||||
# Kimi CLI discovers skills by directory name and invokes them as
|
||||
# /skill:<name> — use dot separator to match packaging convention.
|
||||
if selected_ai == "kimi":
|
||||
skill_name = f"speckit.{command_name}"
|
||||
else:
|
||||
@@ -1416,20 +1416,45 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
||||
return installed_count > 0 or skipped_count > 0
|
||||
|
||||
|
||||
def _handle_agy_deprecation(console: Console) -> None:
|
||||
"""
|
||||
Print the deprecation error for the Antigravity (agy) agent and exit.
|
||||
def _has_bundled_skills(project_path: Path, selected_ai: str) -> bool:
|
||||
"""Return True when a native-skills agent has spec-kit bundled skills."""
|
||||
skills_dir = _get_skills_dir(project_path, selected_ai)
|
||||
if not skills_dir.is_dir():
|
||||
return False
|
||||
|
||||
Context for future AI reviewers (e.g., GitHub Copilot):
|
||||
- Prior to Antigravity v1.20.5, users could rely on explicit agent command definitions generated by this tool.
|
||||
- v1.20.5 removed this "command support" in favor of "Agent Skills".
|
||||
- `specify_cli` handles this by throwing a fail-fast deprecation error if `--ai agy` is
|
||||
invoked without the accompanying `--ai-skills` flag. Users are instructed to add
|
||||
the skills flag to generate agent skills templates instead.
|
||||
"""
|
||||
console.print("\n[red]Error:[/red] Explicit command support was deprecated in Antigravity version 1.20.5.")
|
||||
pattern = "speckit.*/SKILL.md" if selected_ai == "kimi" else "speckit-*/SKILL.md"
|
||||
return any(skills_dir.glob(pattern))
|
||||
|
||||
|
||||
AGENT_SKILLS_MIGRATIONS = {
|
||||
"agy": {
|
||||
"error": "Explicit command support was deprecated in Antigravity version 1.20.5.",
|
||||
"usage": "specify init <project> --ai agy --ai-skills",
|
||||
"interactive_note": (
|
||||
"'agy' was selected interactively; enabling [cyan]--ai-skills[/cyan] "
|
||||
"automatically for compatibility (explicit .agent/commands usage is deprecated)."
|
||||
),
|
||||
},
|
||||
"codex": {
|
||||
"error": (
|
||||
"Custom prompt-based spec-kit initialization is deprecated for Codex CLI; "
|
||||
"use agent skills instead."
|
||||
),
|
||||
"usage": "specify init <project> --ai codex --ai-skills",
|
||||
"interactive_note": (
|
||||
"'codex' was selected interactively; enabling [cyan]--ai-skills[/cyan] "
|
||||
"automatically for compatibility (.agents/skills is the recommended Codex layout)."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _handle_agent_skills_migration(console: Console, agent_key: str) -> None:
|
||||
"""Print a fail-fast migration error for agents that now require skills."""
|
||||
migration = AGENT_SKILLS_MIGRATIONS[agent_key]
|
||||
console.print(f"\n[red]Error:[/red] {migration['error']}")
|
||||
console.print("Please use [cyan]--ai-skills[/cyan] when initializing to install templates as agent skills instead.")
|
||||
console.print("[yellow]Usage:[/yellow] specify init <project> --ai agy --ai-skills")
|
||||
console.print(f"[yellow]Usage:[/yellow] {migration['usage']}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@app.command()
|
||||
@@ -1467,7 +1492,7 @@ def init(
|
||||
specify init . --ai claude # Initialize in current directory
|
||||
specify init . # Initialize in current directory (interactive AI selection)
|
||||
specify init --here --ai claude # Alternative syntax for current directory
|
||||
specify init --here --ai codex
|
||||
specify init --here --ai codex --ai-skills
|
||||
specify init --here --ai codebuddy
|
||||
specify init --here --ai vibe # Initialize with Mistral Vibe support
|
||||
specify init --here
|
||||
@@ -1557,24 +1582,16 @@ def init(
|
||||
"copilot"
|
||||
)
|
||||
|
||||
# [DEPRECATION NOTICE: Antigravity (agy)]
|
||||
# As of Antigravity v1.20.5, traditional CLI "command" support was fully removed
|
||||
# in favor of "Agent Skills" (SKILL.md files under <agent_folder>/skills/<skill_name>/).
|
||||
# Because 'specify_cli' historically populated .agent/commands/, we now must explicitly
|
||||
# enforce the `--ai-skills` flag for `agy` to ensure valid template generation.
|
||||
if selected_ai == "agy" and not ai_skills:
|
||||
# If agy was selected interactively (no --ai provided), automatically enable
|
||||
# Agents that have moved from explicit commands/prompts to agent skills.
|
||||
if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills:
|
||||
# If selected interactively (no --ai provided), automatically enable
|
||||
# ai_skills so the agent remains usable without requiring an extra flag.
|
||||
# Preserve deprecation behavior only for explicit '--ai agy' without skills.
|
||||
# Preserve fail-fast behavior only for explicit '--ai <agent>' without skills.
|
||||
if ai_assistant:
|
||||
_handle_agy_deprecation(console)
|
||||
_handle_agent_skills_migration(console, selected_ai)
|
||||
else:
|
||||
ai_skills = True
|
||||
console.print(
|
||||
"\n[yellow]Note:[/yellow] 'agy' was selected interactively; "
|
||||
"enabling [cyan]--ai-skills[/cyan] automatically for compatibility "
|
||||
"(explicit .agent/commands usage is deprecated)."
|
||||
)
|
||||
console.print(f"\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}")
|
||||
|
||||
# Validate --ai-commands-dir usage
|
||||
if selected_ai == "generic":
|
||||
@@ -1698,28 +1715,41 @@ def init(
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
|
||||
if ai_skills:
|
||||
skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker)
|
||||
if selected_ai in NATIVE_SKILLS_AGENTS:
|
||||
skills_dir = _get_skills_dir(project_path, selected_ai)
|
||||
if not _has_bundled_skills(project_path, selected_ai):
|
||||
raise RuntimeError(
|
||||
f"Expected bundled agent skills in {skills_dir.relative_to(project_path)}, "
|
||||
"but none were found. Re-run with an up-to-date template."
|
||||
)
|
||||
if tracker:
|
||||
tracker.start("ai-skills")
|
||||
tracker.complete("ai-skills", f"bundled skills → {skills_dir.relative_to(project_path)}")
|
||||
else:
|
||||
console.print(f"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/")
|
||||
else:
|
||||
skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker)
|
||||
|
||||
# When --ai-skills is used on a NEW project and skills were
|
||||
# successfully installed, remove the command files that the
|
||||
# template archive just created. Skills replace commands, so
|
||||
# keeping both would be confusing. For --here on an existing
|
||||
# repo we leave pre-existing commands untouched to avoid a
|
||||
# breaking change. We only delete AFTER skills succeed so the
|
||||
# project always has at least one of {commands, skills}.
|
||||
if skills_ok and not here:
|
||||
agent_cfg = AGENT_CONFIG.get(selected_ai, {})
|
||||
agent_folder = agent_cfg.get("folder", "")
|
||||
commands_subdir = agent_cfg.get("commands_subdir", "commands")
|
||||
if agent_folder:
|
||||
cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir
|
||||
if cmds_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(cmds_dir)
|
||||
except OSError:
|
||||
# Best-effort cleanup: skills are already installed,
|
||||
# so leaving stale commands is non-fatal.
|
||||
console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]")
|
||||
# When --ai-skills is used on a NEW project and skills were
|
||||
# successfully installed, remove the command files that the
|
||||
# template archive just created. Skills replace commands, so
|
||||
# keeping both would be confusing. For --here on an existing
|
||||
# repo we leave pre-existing commands untouched to avoid a
|
||||
# breaking change. We only delete AFTER skills succeed so the
|
||||
# project always has at least one of {commands, skills}.
|
||||
if skills_ok and not here:
|
||||
agent_cfg = AGENT_CONFIG.get(selected_ai, {})
|
||||
agent_folder = agent_cfg.get("folder", "")
|
||||
commands_subdir = agent_cfg.get("commands_subdir", "commands")
|
||||
if agent_folder:
|
||||
cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir
|
||||
if cmds_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(cmds_dir)
|
||||
except OSError:
|
||||
# Best-effort cleanup: skills are already installed,
|
||||
# so leaving stale commands is non-fatal.
|
||||
console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]")
|
||||
|
||||
if not no_git:
|
||||
tracker.start("git")
|
||||
@@ -1843,38 +1873,48 @@ def init(
|
||||
steps_lines.append("1. You're already in the project directory!")
|
||||
step_num = 2
|
||||
|
||||
# Add Codex-specific setup step if needed
|
||||
if selected_ai == "codex":
|
||||
codex_path = project_path / ".codex"
|
||||
quoted_path = shlex.quote(str(codex_path))
|
||||
if os.name == "nt": # Windows
|
||||
cmd = f"setx CODEX_HOME {quoted_path}"
|
||||
else: # Unix-like systems
|
||||
cmd = f"export CODEX_HOME={quoted_path}"
|
||||
|
||||
steps_lines.append(f"{step_num}. Set [cyan]CODEX_HOME[/cyan] environment variable before running Codex: [cyan]{cmd}[/cyan]")
|
||||
if selected_ai == "codex" and ai_skills:
|
||||
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
|
||||
|
||||
steps_lines.append(f"{step_num}. Start using slash commands with your AI agent:")
|
||||
codex_skill_mode = selected_ai == "codex" and ai_skills
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
native_skill_mode = codex_skill_mode or kimi_skill_mode
|
||||
usage_label = "skills" if native_skill_mode else "slash commands"
|
||||
|
||||
steps_lines.append(" 2.1 [cyan]/speckit.constitution[/] - Establish project principles")
|
||||
steps_lines.append(" 2.2 [cyan]/speckit.specify[/] - Create baseline specification")
|
||||
steps_lines.append(" 2.3 [cyan]/speckit.plan[/] - Create implementation plan")
|
||||
steps_lines.append(" 2.4 [cyan]/speckit.tasks[/] - Generate actionable tasks")
|
||||
steps_lines.append(" 2.5 [cyan]/speckit.implement[/] - Execute implementation")
|
||||
def _display_cmd(name: str) -> str:
|
||||
if codex_skill_mode:
|
||||
return f"$speckit-{name}"
|
||||
if kimi_skill_mode:
|
||||
return f"/skill:speckit.{name}"
|
||||
return f"/speckit.{name}"
|
||||
|
||||
steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:")
|
||||
|
||||
steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles")
|
||||
steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification")
|
||||
steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan")
|
||||
steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks")
|
||||
steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation")
|
||||
|
||||
steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2))
|
||||
console.print()
|
||||
console.print(steps_panel)
|
||||
|
||||
enhancement_intro = (
|
||||
"Optional skills that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
|
||||
if native_skill_mode
|
||||
else "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
|
||||
)
|
||||
enhancement_lines = [
|
||||
"Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]",
|
||||
enhancement_intro,
|
||||
"",
|
||||
"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)",
|
||||
"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])",
|
||||
"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])"
|
||||
f"○ [cyan]{_display_cmd('clarify')}[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]{_display_cmd('plan')}[/] if used)",
|
||||
f"○ [cyan]{_display_cmd('analyze')}[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]{_display_cmd('tasks')}[/], before [cyan]{_display_cmd('implement')}[/])",
|
||||
f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])"
|
||||
]
|
||||
enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2))
|
||||
enhancements_title = "Enhancement Skills" if native_skill_mode else "Enhancement Commands"
|
||||
enhancements_panel = Panel("\n".join(enhancement_lines), title=enhancements_title, border_style="cyan", padding=(1,2))
|
||||
console.print()
|
||||
console.print(enhancements_panel)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ command files into agent-specific directories in the correct format.
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any
|
||||
|
||||
import platform
|
||||
import yaml
|
||||
|
||||
|
||||
@@ -59,10 +60,10 @@ class CommandRegistrar:
|
||||
"extension": ".md"
|
||||
},
|
||||
"codex": {
|
||||
"dir": ".codex/prompts",
|
||||
"dir": ".agents/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
"extension": "/SKILL.md",
|
||||
},
|
||||
"windsurf": {
|
||||
"dir": ".windsurf/workflows",
|
||||
@@ -140,7 +141,7 @@ class CommandRegistrar:
|
||||
"dir": ".kimi/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md"
|
||||
"extension": "/SKILL.md",
|
||||
},
|
||||
"trae": {
|
||||
"dir": ".trae/rules",
|
||||
@@ -182,6 +183,9 @@ class CommandRegistrar:
|
||||
except yaml.YAMLError:
|
||||
frontmatter = {}
|
||||
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
|
||||
return frontmatter, body
|
||||
|
||||
@staticmethod
|
||||
@@ -209,11 +213,14 @@ class CommandRegistrar:
|
||||
Returns:
|
||||
Modified frontmatter with adjusted paths
|
||||
"""
|
||||
if "scripts" in frontmatter:
|
||||
for key in frontmatter["scripts"]:
|
||||
script_path = frontmatter["scripts"][key]
|
||||
if script_path.startswith("../../scripts/"):
|
||||
frontmatter["scripts"][key] = f".specify/scripts/{script_path[14:]}"
|
||||
for script_key in ("scripts", "agent_scripts"):
|
||||
scripts = frontmatter.get(script_key)
|
||||
if not isinstance(scripts, dict):
|
||||
continue
|
||||
|
||||
for key, script_path in scripts.items():
|
||||
if isinstance(script_path, str) and script_path.startswith("../../scripts/"):
|
||||
scripts[key] = f".specify/scripts/{script_path[14:]}"
|
||||
return frontmatter
|
||||
|
||||
def render_markdown_command(
|
||||
@@ -270,6 +277,95 @@ class CommandRegistrar:
|
||||
|
||||
return "\n".join(toml_lines)
|
||||
|
||||
def render_skill_command(
|
||||
self,
|
||||
agent_name: str,
|
||||
skill_name: str,
|
||||
frontmatter: dict,
|
||||
body: str,
|
||||
source_id: str,
|
||||
source_file: str,
|
||||
project_root: Path,
|
||||
) -> str:
|
||||
"""Render a command override as a SKILL.md file.
|
||||
|
||||
SKILL-target agents should receive the same skills-oriented
|
||||
frontmatter shape used elsewhere in the project instead of the
|
||||
original command frontmatter.
|
||||
"""
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
|
||||
if agent_name == "codex":
|
||||
body = self._resolve_codex_skill_placeholders(frontmatter, body, project_root)
|
||||
|
||||
description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}")
|
||||
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}",
|
||||
},
|
||||
}
|
||||
return self.render_frontmatter(skill_frontmatter) + "\n" + body
|
||||
|
||||
@staticmethod
|
||||
def _resolve_codex_skill_placeholders(frontmatter: dict, body: str, project_root: Path) -> str:
|
||||
"""Resolve script placeholders for Codex skill overrides.
|
||||
|
||||
This intentionally scopes the fix to Codex, which is the newly
|
||||
migrated runtime path in this PR. Existing Kimi behavior is left
|
||||
unchanged for now.
|
||||
"""
|
||||
try:
|
||||
from . import load_init_options
|
||||
except ImportError:
|
||||
return body
|
||||
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
|
||||
scripts = frontmatter.get("scripts", {}) or {}
|
||||
agent_scripts = frontmatter.get("agent_scripts", {}) or {}
|
||||
if not isinstance(scripts, dict):
|
||||
scripts = {}
|
||||
if not isinstance(agent_scripts, dict):
|
||||
agent_scripts = {}
|
||||
|
||||
script_variant = load_init_options(project_root).get("script")
|
||||
if script_variant not in {"sh", "ps"}:
|
||||
fallback_order = []
|
||||
default_variant = "ps" if platform.system().lower().startswith("win") else "sh"
|
||||
secondary_variant = "sh" if default_variant == "ps" else "ps"
|
||||
|
||||
if default_variant in scripts or default_variant in agent_scripts:
|
||||
fallback_order.append(default_variant)
|
||||
if secondary_variant in scripts or secondary_variant in agent_scripts:
|
||||
fallback_order.append(secondary_variant)
|
||||
|
||||
for key in scripts:
|
||||
if key not in fallback_order:
|
||||
fallback_order.append(key)
|
||||
for key in agent_scripts:
|
||||
if key not in fallback_order:
|
||||
fallback_order.append(key)
|
||||
|
||||
script_variant = fallback_order[0] if fallback_order else None
|
||||
|
||||
script_command = scripts.get(script_variant) if script_variant else None
|
||||
if script_command:
|
||||
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
|
||||
body = body.replace("{SCRIPT}", script_command)
|
||||
|
||||
agent_script_command = agent_scripts.get(script_variant) if script_variant else None
|
||||
if agent_script_command:
|
||||
agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS")
|
||||
body = body.replace("{AGENT_SCRIPT}", agent_script_command)
|
||||
|
||||
return body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", "codex")
|
||||
|
||||
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
|
||||
"""Convert argument placeholder format.
|
||||
|
||||
@@ -283,6 +379,18 @@ class CommandRegistrar:
|
||||
"""
|
||||
return content.replace(from_placeholder, to_placeholder)
|
||||
|
||||
@staticmethod
|
||||
def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str, Any]) -> str:
|
||||
"""Compute the on-disk command or skill name for an agent."""
|
||||
if agent_config["extension"] != "/SKILL.md":
|
||||
return cmd_name
|
||||
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
|
||||
return f"speckit.{short_name}" if agent_name == "kimi" else f"speckit-{short_name}"
|
||||
|
||||
def register_commands(
|
||||
self,
|
||||
agent_name: str,
|
||||
@@ -334,14 +442,20 @@ class CommandRegistrar:
|
||||
body, "$ARGUMENTS", agent_config["args"]
|
||||
)
|
||||
|
||||
if agent_config["format"] == "markdown":
|
||||
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
|
||||
|
||||
if agent_config["extension"] == "/SKILL.md":
|
||||
output = self.render_skill_command(
|
||||
agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root
|
||||
)
|
||||
elif agent_config["format"] == "markdown":
|
||||
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
|
||||
elif agent_config["format"] == "toml":
|
||||
output = self.render_toml_command(frontmatter, body, source_id)
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
||||
|
||||
dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest_file.write_text(output, encoding="utf-8")
|
||||
|
||||
@@ -351,9 +465,15 @@ class CommandRegistrar:
|
||||
registered.append(cmd_name)
|
||||
|
||||
for alias in cmd_info.get("aliases", []):
|
||||
alias_file = commands_dir / f"{alias}{agent_config['extension']}"
|
||||
alias_output_name = self._compute_output_name(agent_name, alias, agent_config)
|
||||
alias_output = output
|
||||
if agent_config["extension"] == "/SKILL.md":
|
||||
alias_output = self.render_skill_command(
|
||||
agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root
|
||||
)
|
||||
alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}"
|
||||
alias_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
alias_file.write_text(output, encoding="utf-8")
|
||||
alias_file.write_text(alias_output, encoding="utf-8")
|
||||
if agent_name == "copilot":
|
||||
self.write_copilot_prompt(project_root, alias)
|
||||
registered.append(alias)
|
||||
@@ -396,7 +516,7 @@ class CommandRegistrar:
|
||||
results = {}
|
||||
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
agent_dir = project_root / agent_config["dir"].split("/")[0]
|
||||
agent_dir = project_root / agent_config["dir"]
|
||||
|
||||
if agent_dir.exists():
|
||||
try:
|
||||
@@ -430,7 +550,8 @@ class CommandRegistrar:
|
||||
commands_dir = project_root / agent_config["dir"]
|
||||
|
||||
for cmd_name in cmd_names:
|
||||
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
|
||||
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
|
||||
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||
if cmd_file.exists():
|
||||
cmd_file.unlink()
|
||||
|
||||
|
||||
@@ -646,8 +646,6 @@ class PresetManager:
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
# Kimi CLI discovers skills by directory name and invokes them as
|
||||
# /skill:<name> — use dot separator to match packaging convention.
|
||||
if selected_ai == "kimi":
|
||||
skill_name = f"speckit.{short_name}"
|
||||
else:
|
||||
|
||||
@@ -29,11 +29,17 @@ class TestAgentConfigConsistency:
|
||||
assert "q" not in cfg
|
||||
|
||||
def test_extension_registrar_includes_codex(self):
|
||||
"""Extension command registrar should include codex targeting .codex/prompts."""
|
||||
"""Extension command registrar should include codex targeting .agents/skills."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "codex" in cfg
|
||||
assert cfg["codex"]["dir"] == ".codex/prompts"
|
||||
assert cfg["codex"]["dir"] == ".agents/skills"
|
||||
assert cfg["codex"]["extension"] == "/SKILL.md"
|
||||
|
||||
def test_runtime_codex_uses_native_skills(self):
|
||||
"""Codex runtime config should point at .agents/skills."""
|
||||
assert AGENT_CONFIG["codex"]["folder"] == ".agents/"
|
||||
assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills"
|
||||
|
||||
def test_release_agent_lists_include_kiro_cli_and_exclude_q(self):
|
||||
"""Bash and PowerShell release scripts should agree on agent key set for Kiro."""
|
||||
@@ -71,6 +77,16 @@ class TestAgentConfigConsistency:
|
||||
assert re.search(r"shai\)\s*\n.*?\.shai/commands", sh_text, re.S) is not None
|
||||
assert re.search(r"agy\)\s*\n.*?\.agent/commands", sh_text, re.S) is not None
|
||||
|
||||
def test_release_scripts_generate_codex_skills(self):
|
||||
"""Release scripts should generate Codex skills in .agents/skills."""
|
||||
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
||||
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
|
||||
|
||||
assert ".agents/skills" in sh_text
|
||||
assert ".agents/skills" in ps_text
|
||||
assert re.search(r"codex\)\s*\n.*?create_skills.*?\.agents/skills.*?\"-\"", sh_text, re.S) is not None
|
||||
assert re.search(r"'codex'\s*\{.*?\.agents/skills.*?New-Skills.*?-Separator '-'", ps_text, re.S) is not None
|
||||
|
||||
def test_init_ai_help_includes_roo_and_kiro_alias(self):
|
||||
"""CLI help text for --ai should stay in sync with agent config and alias guidance."""
|
||||
assert "roo" in AI_ASSISTANT_HELP
|
||||
|
||||
@@ -471,8 +471,7 @@ class TestInstallAiSkills:
|
||||
skills_dir = _get_skills_dir(proj, agent_key)
|
||||
assert skills_dir.exists()
|
||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
||||
# Kimi uses dot-separator (speckit.specify) to match /skill:speckit.* invocation;
|
||||
# all other agents use hyphen-separator (speckit-specify).
|
||||
# Kimi uses dotted skill names; other agents use hyphen-separated names.
|
||||
expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify"
|
||||
assert expected_skill_name in skill_dirs
|
||||
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
|
||||
@@ -694,6 +693,82 @@ class TestNewProjectCommandSkip:
|
||||
prompts_dir = target / ".kiro" / "prompts"
|
||||
assert not prompts_dir.exists()
|
||||
|
||||
def test_codex_native_skills_preserved_without_conversion(self, tmp_path):
|
||||
"""Codex should keep bundled .agents/skills and skip install_ai_skills conversion."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "new-codex-proj"
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
skill_dir = project_path / ".agents" / "skills" / "speckit-specify"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills") as mock_skills, \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_skills.assert_not_called()
|
||||
assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md").exists()
|
||||
|
||||
def test_codex_native_skills_missing_fails_clearly(self, tmp_path):
|
||||
"""Codex native skills init should fail if bundled skills are missing."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "missing-codex-skills"
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", lambda *args, **kwargs: None), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills") as mock_skills, \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
mock_skills.assert_not_called()
|
||||
assert "Expected bundled agent skills" in result.output
|
||||
|
||||
def test_codex_native_skills_ignores_non_speckit_skill_dirs(self, tmp_path):
|
||||
"""Non-spec-kit SKILL.md files should not satisfy Codex bundled-skills validation."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "foreign-codex-skills"
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
skill_dir = project_path / ".agents" / "skills" / "other-tool"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\ndescription: Foreign skill\n---\n\nBody.\n")
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills") as mock_skills, \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
mock_skills.assert_not_called()
|
||||
assert "Expected bundled agent skills" in result.output
|
||||
|
||||
def test_commands_preserved_when_skills_fail(self, tmp_path):
|
||||
"""If skills fail, commands should NOT be removed (safety net)."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -837,6 +912,17 @@ class TestCliValidation:
|
||||
assert "Explicit command support was deprecated in Antigravity version 1.20.5." in result.output
|
||||
assert "--ai-skills" in result.output
|
||||
|
||||
def test_codex_without_ai_skills_fails(self):
|
||||
"""--ai codex without --ai-skills should fail with exit code 1."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", "test-proj", "--ai", "codex"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" in result.output
|
||||
assert "--ai-skills" in result.output
|
||||
|
||||
def test_interactive_agy_without_ai_skills_prompts_skills(self, monkeypatch):
|
||||
"""Interactive selector returning agy without --ai-skills should automatically enable --ai-skills."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -879,6 +965,72 @@ class TestCliValidation:
|
||||
assert result.exit_code == 0
|
||||
assert "Explicit command support was deprecated" not in result.output
|
||||
|
||||
def test_interactive_codex_without_ai_skills_enables_skills(self, monkeypatch):
|
||||
"""Interactive selector returning codex without --ai-skills should automatically enable --ai-skills."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
def _fake_select_with_arrows(*args, **kwargs):
|
||||
options = kwargs.get("options")
|
||||
if options is None and len(args) >= 1:
|
||||
options = args[0]
|
||||
|
||||
if isinstance(options, dict) and "codex" in options:
|
||||
return "codex"
|
||||
if isinstance(options, (list, tuple)) and "codex" in options:
|
||||
return "codex"
|
||||
|
||||
if isinstance(options, dict) and options:
|
||||
return next(iter(options.keys()))
|
||||
if isinstance(options, (list, tuple)) and options:
|
||||
return options[0]
|
||||
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows)
|
||||
|
||||
def _fake_download(*args, **kwargs):
|
||||
project_path = Path(args[0])
|
||||
skill_dir = project_path / ".agents" / "skills" / "speckit-specify"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
||||
|
||||
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
|
||||
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(app, ["init", "test-proj", "--no-git", "--ignore-agent-tools"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output
|
||||
assert ".agents/skills" in result.output
|
||||
assert "$speckit-constitution" in result.output
|
||||
assert "/speckit.constitution" not in result.output
|
||||
assert "Optional skills that you can use for your specs" in result.output
|
||||
|
||||
def test_kimi_next_steps_show_skill_invocation(self, monkeypatch):
|
||||
"""Kimi next-steps guidance should display /skill:speckit.* usage."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
def _fake_download(*args, **kwargs):
|
||||
project_path = Path(args[0])
|
||||
skill_dir = project_path / ".kimi" / "skills" / "speckit.specify"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
||||
|
||||
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
|
||||
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", "test-proj", "--ai", "kimi", "--no-git", "--ignore-agent-tools"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "/skill:speckit.constitution" in result.output
|
||||
assert "/speckit.constitution" not in result.output
|
||||
assert "Optional skills that you can use for your specs" in result.output
|
||||
|
||||
def test_ai_skills_flag_appears_in_help(self):
|
||||
"""--ai-skills should appear in init --help output."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@@ -665,9 +665,10 @@ class TestCommandRegistrar:
|
||||
assert "q" not in CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
def test_codex_agent_config_present(self):
|
||||
"""Codex should be mapped to .codex/prompts."""
|
||||
"""Codex should be mapped to .agents/skills."""
|
||||
assert "codex" in CommandRegistrar.AGENT_CONFIGS
|
||||
assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts"
|
||||
assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".agents/skills"
|
||||
assert CommandRegistrar.AGENT_CONFIGS["codex"]["extension"] == "/SKILL.md"
|
||||
|
||||
def test_pi_agent_config_present(self):
|
||||
"""Pi should be mapped to .pi/prompts."""
|
||||
@@ -717,6 +718,21 @@ $ARGUMENTS
|
||||
assert frontmatter == {}
|
||||
assert body == content
|
||||
|
||||
def test_parse_frontmatter_non_mapping_returns_empty_dict(self):
|
||||
"""Non-mapping YAML frontmatter should not crash downstream renderers."""
|
||||
content = """---
|
||||
- item1
|
||||
- item2
|
||||
---
|
||||
|
||||
# Command body
|
||||
"""
|
||||
registrar = CommandRegistrar()
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
|
||||
assert frontmatter == {}
|
||||
assert "Command body" in body
|
||||
|
||||
def test_render_frontmatter(self):
|
||||
"""Test rendering frontmatter to YAML."""
|
||||
frontmatter = {
|
||||
@@ -808,6 +824,299 @@ $ARGUMENTS
|
||||
assert (claude_dir / "speckit.alias.cmd.md").exists()
|
||||
assert (claude_dir / "speckit.shortcut.md").exists()
|
||||
|
||||
def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir):
|
||||
"""Codex skill cleanup should use the same mapped names as registration."""
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
(skills_dir / "speckit-specify").mkdir(parents=True)
|
||||
(skills_dir / "speckit-specify" / "SKILL.md").write_text("body")
|
||||
(skills_dir / "speckit-shortcut").mkdir(parents=True)
|
||||
(skills_dir / "speckit-shortcut" / "SKILL.md").write_text("body")
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registrar.unregister_commands(
|
||||
{"codex": ["speckit.specify", "speckit.shortcut"]},
|
||||
project_dir,
|
||||
)
|
||||
|
||||
assert not (skills_dir / "speckit-specify" / "SKILL.md").exists()
|
||||
assert not (skills_dir / "speckit-shortcut" / "SKILL.md").exists()
|
||||
|
||||
def test_register_commands_for_all_agents_distinguishes_codex_from_amp(self, extension_dir, project_dir):
|
||||
"""A Codex project under .agents/skills should not implicitly activate Amp."""
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registered = registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
|
||||
|
||||
assert "codex" in registered
|
||||
assert "amp" not in registered
|
||||
assert not (project_dir / ".agents" / "commands").exists()
|
||||
|
||||
def test_codex_skill_registration_writes_skill_frontmatter(self, extension_dir, project_dir):
|
||||
"""Codex SKILL.md output should use skills-oriented frontmatter."""
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-test.hello" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
assert "name: speckit-test.hello" in content
|
||||
assert "description: Test hello command" in content
|
||||
assert "compatibility:" in content
|
||||
assert "metadata:" in content
|
||||
assert "source: test-ext:commands/hello.md" in content
|
||||
assert "<!-- Extension:" not in content
|
||||
|
||||
def test_codex_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):
|
||||
"""Codex SKILL.md overrides should resolve script placeholders."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / "ext-scripted"
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "ext-scripted",
|
||||
"name": "Scripted Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.test.plan",
|
||||
"file": "commands/plan.md",
|
||||
"description": "Scripted command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands" / "plan.md").write_text(
|
||||
"""---
|
||||
description: "Scripted command"
|
||||
scripts:
|
||||
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
|
||||
ps: ../../scripts/powershell/setup-plan.ps1 -Json
|
||||
agent_scripts:
|
||||
sh: ../../scripts/bash/update-agent-context.sh __AGENT__
|
||||
ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__
|
||||
---
|
||||
|
||||
Run {SCRIPT}
|
||||
Then {AGENT_SCRIPT}
|
||||
Agent __AGENT__
|
||||
"""
|
||||
)
|
||||
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text('{"ai":"codex","ai_skills":true,"script":"sh"}')
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-test.plan" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "{AGENT_SCRIPT}" not in content
|
||||
assert "__AGENT__" not in content
|
||||
assert "{ARGS}" not in content
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
|
||||
|
||||
def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir):
|
||||
"""Codex alias skills should render their own matching `name:` frontmatter."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / "ext-alias-skill"
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "ext-alias-skill",
|
||||
"name": "Alias Skill Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.alias.cmd",
|
||||
"file": "commands/cmd.md",
|
||||
"aliases": ["speckit.shortcut"],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Alias skill\n---\n\nBody\n")
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
primary = skills_dir / "speckit-alias.cmd" / "SKILL.md"
|
||||
alias = skills_dir / "speckit-shortcut" / "SKILL.md"
|
||||
|
||||
assert primary.exists()
|
||||
assert alias.exists()
|
||||
assert "name: speckit-alias.cmd" in primary.read_text()
|
||||
assert "name: speckit-shortcut" in alias.read_text()
|
||||
|
||||
def test_codex_skill_registration_uses_fallback_script_variant_without_init_options(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Codex placeholder substitution should still work without init-options.json."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / "ext-script-fallback"
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "ext-script-fallback",
|
||||
"name": "Script fallback",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.fallback.plan",
|
||||
"file": "commands/plan.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands" / "plan.md").write_text(
|
||||
"""---
|
||||
description: "Fallback scripted command"
|
||||
scripts:
|
||||
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
|
||||
ps: ../../scripts/powershell/setup-plan.ps1 -Json
|
||||
agent_scripts:
|
||||
sh: ../../scripts/bash/update-agent-context.sh __AGENT__
|
||||
---
|
||||
|
||||
Run {SCRIPT}
|
||||
Then {AGENT_SCRIPT}
|
||||
"""
|
||||
)
|
||||
|
||||
# Intentionally do NOT create .specify/init-options.json
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-fallback.plan" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "{AGENT_SCRIPT}" not in content
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
|
||||
|
||||
def test_codex_skill_registration_fallback_prefers_powershell_on_windows(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""Without init metadata, Windows fallback should prefer ps scripts over sh."""
|
||||
import yaml
|
||||
|
||||
monkeypatch.setattr("specify_cli.agents.platform.system", lambda: "Windows")
|
||||
|
||||
ext_dir = temp_dir / "ext-script-windows-fallback"
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "ext-script-windows-fallback",
|
||||
"name": "Script fallback windows",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.windows.plan",
|
||||
"file": "commands/plan.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands" / "plan.md").write_text(
|
||||
"""---
|
||||
description: "Windows fallback scripted command"
|
||||
scripts:
|
||||
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
|
||||
ps: ../../scripts/powershell/setup-plan.ps1 -Json
|
||||
agent_scripts:
|
||||
sh: ../../scripts/bash/update-agent-context.sh __AGENT__
|
||||
ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__
|
||||
---
|
||||
|
||||
Run {SCRIPT}
|
||||
Then {AGENT_SCRIPT}
|
||||
"""
|
||||
)
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-windows.plan" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content
|
||||
assert ".specify/scripts/powershell/update-agent-context.ps1 -AgentType codex" in content
|
||||
assert ".specify/scripts/bash/setup-plan.sh" not in content
|
||||
|
||||
def test_register_commands_for_copilot(self, extension_dir, project_dir):
|
||||
"""Test registering commands for Copilot agent with .agent.md extension."""
|
||||
# Create .github/agents directory (Copilot project)
|
||||
|
||||
Reference in New Issue
Block a user