mirror of
https://github.com/github/spec-kit.git
synced 2026-03-20 12:23:09 +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.
|
# Create skills in <skills_dir>\<name>\SKILL.md format.
|
||||||
# Kimi CLI discovers skills as directories containing a SKILL.md file,
|
# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
|
||||||
# invoked with /skill:<name> (e.g. /skill:speckit.specify).
|
# current dotted-name exception (e.g. speckit.plan).
|
||||||
function New-KimiSkills {
|
function New-Skills {
|
||||||
param(
|
param(
|
||||||
[string]$SkillsDir,
|
[string]$SkillsDir,
|
||||||
[string]$ScriptVariant
|
[string]$ScriptVariant,
|
||||||
|
[string]$AgentName,
|
||||||
|
[string]$Separator = '-'
|
||||||
)
|
)
|
||||||
|
|
||||||
$templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue
|
$templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
foreach ($template in $templates) {
|
foreach ($template in $templates) {
|
||||||
$name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name)
|
$name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name)
|
||||||
$skillName = "speckit.$name"
|
$skillName = "speckit${Separator}$name"
|
||||||
$skillDir = Join-Path $SkillsDir $skillName
|
$skillDir = Join-Path $SkillsDir $skillName
|
||||||
New-Item -ItemType Directory -Force -Path $skillDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $skillDir | Out-Null
|
||||||
|
|
||||||
@@ -267,7 +269,7 @@ function New-KimiSkills {
|
|||||||
|
|
||||||
$body = $outputLines -join "`n"
|
$body = $outputLines -join "`n"
|
||||||
$body = $body -replace '\{ARGS\}', '$ARGUMENTS'
|
$body = $body -replace '\{ARGS\}', '$ARGUMENTS'
|
||||||
$body = $body -replace '__AGENT__', 'kimi'
|
$body = $body -replace '__AGENT__', $AgentName
|
||||||
$body = Rewrite-Paths -Content $body
|
$body = Rewrite-Paths -Content $body
|
||||||
|
|
||||||
# Strip existing frontmatter, keep only 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
|
Generate-Commands -Agent 'windsurf' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||||
}
|
}
|
||||||
'codex' {
|
'codex' {
|
||||||
$cmdDir = Join-Path $baseDir ".codex/prompts"
|
$skillsDir = Join-Path $baseDir ".agents/skills"
|
||||||
Generate-Commands -Agent 'codex' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
||||||
|
New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'codex' -Separator '-'
|
||||||
}
|
}
|
||||||
'kilocode' {
|
'kilocode' {
|
||||||
$cmdDir = Join-Path $baseDir ".kilocode/workflows"
|
$cmdDir = Join-Path $baseDir ".kilocode/workflows"
|
||||||
@@ -452,7 +455,7 @@ function Build-Variant {
|
|||||||
'kimi' {
|
'kimi' {
|
||||||
$skillsDir = Join-Path $baseDir ".kimi/skills"
|
$skillsDir = Join-Path $baseDir ".kimi/skills"
|
||||||
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
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' {
|
'trae' {
|
||||||
$rulesDir = Join-Path $baseDir ".trae/rules"
|
$rulesDir = Join-Path $baseDir ".trae/rules"
|
||||||
|
|||||||
@@ -121,18 +121,20 @@ EOF
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create Kimi Code skills in .kimi/skills/<name>/SKILL.md format.
|
# Create skills in <skills_dir>/<name>/SKILL.md format.
|
||||||
# Kimi CLI discovers skills as directories containing a SKILL.md file,
|
# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
|
||||||
# invoked with /skill:<name> (e.g. /skill:speckit.specify).
|
# current dotted-name exception (e.g. speckit.plan).
|
||||||
create_kimi_skills() {
|
create_skills() {
|
||||||
local skills_dir="$1"
|
local skills_dir="$1"
|
||||||
local script_variant="$2"
|
local script_variant="$2"
|
||||||
|
local agent_name="$3"
|
||||||
|
local separator="${4:-"-"}"
|
||||||
|
|
||||||
for template in templates/commands/*.md; do
|
for template in templates/commands/*.md; do
|
||||||
[[ -f "$template" ]] || continue
|
[[ -f "$template" ]] || continue
|
||||||
local name
|
local name
|
||||||
name=$(basename "$template" .md)
|
name=$(basename "$template" .md)
|
||||||
local skill_name="speckit.${name}"
|
local skill_name="speckit${separator}${name}"
|
||||||
local skill_dir="${skills_dir}/${skill_name}"
|
local skill_dir="${skills_dir}/${skill_name}"
|
||||||
mkdir -p "$skill_dir"
|
mkdir -p "$skill_dir"
|
||||||
|
|
||||||
@@ -175,9 +177,9 @@ create_kimi_skills() {
|
|||||||
in_frontmatter && skip_scripts && /^[[:space:]]/ { next }
|
in_frontmatter && skip_scripts && /^[[:space:]]/ { next }
|
||||||
{ print }
|
{ 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
|
local template_body
|
||||||
template_body=$(printf '%s\n' "$body" | awk '/^---/{p++; if(p==2){found=1; next}} found')
|
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"
|
mkdir -p "$base_dir/.windsurf/workflows"
|
||||||
generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;;
|
generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;;
|
||||||
codex)
|
codex)
|
||||||
mkdir -p "$base_dir/.codex/prompts"
|
mkdir -p "$base_dir/.agents/skills"
|
||||||
generate_commands codex md "\$ARGUMENTS" "$base_dir/.codex/prompts" "$script" ;;
|
create_skills "$base_dir/.agents/skills" "$script" "codex" "-" ;;
|
||||||
kilocode)
|
kilocode)
|
||||||
mkdir -p "$base_dir/.kilocode/workflows"
|
mkdir -p "$base_dir/.kilocode/workflows"
|
||||||
generate_commands kilocode md "\$ARGUMENTS" "$base_dir/.kilocode/workflows" "$script" ;;
|
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" ;;
|
generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;;
|
||||||
kimi)
|
kimi)
|
||||||
mkdir -p "$base_dir/.kimi/skills"
|
mkdir -p "$base_dir/.kimi/skills"
|
||||||
create_kimi_skills "$base_dir/.kimi/skills" "$script" ;;
|
create_skills "$base_dir/.kimi/skills" "$script" "kimi" "." ;;
|
||||||
trae)
|
trae)
|
||||||
mkdir -p "$base_dir/.trae/rules"
|
mkdir -p "$base_dir/.trae/rules"
|
||||||
generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;;
|
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
|
### 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.
|
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) | ✅ | |
|
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | |
|
||||||
| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | |
|
| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | |
|
||||||
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | |
|
| [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/) | ✅ | |
|
| [Cursor](https://cursor.sh/) | ✅ | |
|
||||||
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | |
|
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | |
|
||||||
| [GitHub Copilot](https://code.visualstudio.com/) | ✅ | |
|
| [GitHub Copilot](https://code.visualstudio.com/) | ✅ | |
|
||||||
@@ -258,6 +258,9 @@ specify init my-project --ai bob
|
|||||||
# Initialize with Pi Coding Agent support
|
# Initialize with Pi Coding Agent support
|
||||||
specify init my-project --ai pi
|
specify init my-project --ai pi
|
||||||
|
|
||||||
|
# Initialize with Codex CLI support
|
||||||
|
specify init my-project --ai codex --ai-skills
|
||||||
|
|
||||||
# Initialize with Antigravity support
|
# Initialize with Antigravity support
|
||||||
specify init my-project --ai agy --ai-skills
|
specify init my-project --ai agy --ai-skills
|
||||||
|
|
||||||
@@ -298,7 +301,9 @@ specify check
|
|||||||
|
|
||||||
### Available Slash Commands
|
### 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
|
#### Core Commands
|
||||||
|
|
||||||
@@ -484,11 +489,11 @@ specify init <project_name> --ai copilot
|
|||||||
|
|
||||||
# Or in current directory:
|
# Or in current directory:
|
||||||
specify init . --ai claude
|
specify init . --ai claude
|
||||||
specify init . --ai codex
|
specify init . --ai codex --ai-skills
|
||||||
|
|
||||||
# or use --here flag
|
# or use --here flag
|
||||||
specify init --here --ai claude
|
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
|
# Force merge into a non-empty current directory
|
||||||
specify init . --force --ai claude
|
specify init . --force --ai claude
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import sys
|
|||||||
import zipfile
|
import zipfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
import shlex
|
|
||||||
import json
|
import json
|
||||||
import json5
|
import json5
|
||||||
import stat
|
import stat
|
||||||
@@ -172,8 +171,8 @@ AGENT_CONFIG = {
|
|||||||
},
|
},
|
||||||
"codex": {
|
"codex": {
|
||||||
"name": "Codex CLI",
|
"name": "Codex CLI",
|
||||||
"folder": ".codex/",
|
"folder": ".agents/",
|
||||||
"commands_subdir": "prompts", # Special: uses prompts/ not commands/
|
"commands_subdir": "skills", # Codex now uses project skills directly
|
||||||
"install_url": "https://github.com/openai/codex",
|
"install_url": "https://github.com/openai/codex",
|
||||||
"requires_cli": True,
|
"requires_cli": True,
|
||||||
},
|
},
|
||||||
@@ -1211,6 +1210,9 @@ AGENT_SKILLS_DIR_OVERRIDES = {
|
|||||||
# Default skills directory for agents not in AGENT_CONFIG
|
# Default skills directory for agents not in AGENT_CONFIG
|
||||||
DEFAULT_SKILLS_DIR = ".agents/skills"
|
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
|
# Enhanced descriptions for each spec-kit command skill
|
||||||
SKILL_DESCRIPTIONS = {
|
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.",
|
"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."):]
|
command_name = command_name[len("speckit."):]
|
||||||
if command_name.endswith(".agent"):
|
if command_name.endswith(".agent"):
|
||||||
command_name = command_name[:-len(".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":
|
if selected_ai == "kimi":
|
||||||
skill_name = f"speckit.{command_name}"
|
skill_name = f"speckit.{command_name}"
|
||||||
else:
|
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
|
return installed_count > 0 or skipped_count > 0
|
||||||
|
|
||||||
|
|
||||||
def _handle_agy_deprecation(console: Console) -> None:
|
def _has_bundled_skills(project_path: Path, selected_ai: str) -> bool:
|
||||||
"""
|
"""Return True when a native-skills agent has spec-kit bundled skills."""
|
||||||
Print the deprecation error for the Antigravity (agy) agent and exit.
|
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):
|
pattern = "speckit.*/SKILL.md" if selected_ai == "kimi" else "speckit-*/SKILL.md"
|
||||||
- Prior to Antigravity v1.20.5, users could rely on explicit agent command definitions generated by this tool.
|
return any(skills_dir.glob(pattern))
|
||||||
- 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
|
AGENT_SKILLS_MIGRATIONS = {
|
||||||
the skills flag to generate agent skills templates instead.
|
"agy": {
|
||||||
"""
|
"error": "Explicit command support was deprecated in Antigravity version 1.20.5.",
|
||||||
console.print("\n[red]Error:[/red] 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("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)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -1467,7 +1492,7 @@ def init(
|
|||||||
specify init . --ai claude # Initialize in current directory
|
specify init . --ai claude # Initialize in current directory
|
||||||
specify init . # Initialize in current directory (interactive AI selection)
|
specify init . # Initialize in current directory (interactive AI selection)
|
||||||
specify init --here --ai claude # Alternative syntax for current directory
|
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 codebuddy
|
||||||
specify init --here --ai vibe # Initialize with Mistral Vibe support
|
specify init --here --ai vibe # Initialize with Mistral Vibe support
|
||||||
specify init --here
|
specify init --here
|
||||||
@@ -1557,24 +1582,16 @@ def init(
|
|||||||
"copilot"
|
"copilot"
|
||||||
)
|
)
|
||||||
|
|
||||||
# [DEPRECATION NOTICE: Antigravity (agy)]
|
# Agents that have moved from explicit commands/prompts to agent skills.
|
||||||
# As of Antigravity v1.20.5, traditional CLI "command" support was fully removed
|
if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills:
|
||||||
# in favor of "Agent Skills" (SKILL.md files under <agent_folder>/skills/<skill_name>/).
|
# If selected interactively (no --ai provided), automatically enable
|
||||||
# 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
|
|
||||||
# ai_skills so the agent remains usable without requiring an extra flag.
|
# 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:
|
if ai_assistant:
|
||||||
_handle_agy_deprecation(console)
|
_handle_agent_skills_migration(console, selected_ai)
|
||||||
else:
|
else:
|
||||||
ai_skills = True
|
ai_skills = True
|
||||||
console.print(
|
console.print(f"\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}")
|
||||||
"\n[yellow]Note:[/yellow] 'agy' was selected interactively; "
|
|
||||||
"enabling [cyan]--ai-skills[/cyan] automatically for compatibility "
|
|
||||||
"(explicit .agent/commands usage is deprecated)."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate --ai-commands-dir usage
|
# Validate --ai-commands-dir usage
|
||||||
if selected_ai == "generic":
|
if selected_ai == "generic":
|
||||||
@@ -1698,28 +1715,41 @@ def init(
|
|||||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||||
|
|
||||||
if ai_skills:
|
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
|
# When --ai-skills is used on a NEW project and skills were
|
||||||
# successfully installed, remove the command files that the
|
# successfully installed, remove the command files that the
|
||||||
# template archive just created. Skills replace commands, so
|
# template archive just created. Skills replace commands, so
|
||||||
# keeping both would be confusing. For --here on an existing
|
# keeping both would be confusing. For --here on an existing
|
||||||
# repo we leave pre-existing commands untouched to avoid a
|
# repo we leave pre-existing commands untouched to avoid a
|
||||||
# breaking change. We only delete AFTER skills succeed so the
|
# breaking change. We only delete AFTER skills succeed so the
|
||||||
# project always has at least one of {commands, skills}.
|
# project always has at least one of {commands, skills}.
|
||||||
if skills_ok and not here:
|
if skills_ok and not here:
|
||||||
agent_cfg = AGENT_CONFIG.get(selected_ai, {})
|
agent_cfg = AGENT_CONFIG.get(selected_ai, {})
|
||||||
agent_folder = agent_cfg.get("folder", "")
|
agent_folder = agent_cfg.get("folder", "")
|
||||||
commands_subdir = agent_cfg.get("commands_subdir", "commands")
|
commands_subdir = agent_cfg.get("commands_subdir", "commands")
|
||||||
if agent_folder:
|
if agent_folder:
|
||||||
cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir
|
cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir
|
||||||
if cmds_dir.exists():
|
if cmds_dir.exists():
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(cmds_dir)
|
shutil.rmtree(cmds_dir)
|
||||||
except OSError:
|
except OSError:
|
||||||
# Best-effort cleanup: skills are already installed,
|
# Best-effort cleanup: skills are already installed,
|
||||||
# so leaving stale commands is non-fatal.
|
# so leaving stale commands is non-fatal.
|
||||||
console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]")
|
console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]")
|
||||||
|
|
||||||
if not no_git:
|
if not no_git:
|
||||||
tracker.start("git")
|
tracker.start("git")
|
||||||
@@ -1843,38 +1873,48 @@ def init(
|
|||||||
steps_lines.append("1. You're already in the project directory!")
|
steps_lines.append("1. You're already in the project directory!")
|
||||||
step_num = 2
|
step_num = 2
|
||||||
|
|
||||||
# Add Codex-specific setup step if needed
|
if selected_ai == "codex" and ai_skills:
|
||||||
if selected_ai == "codex":
|
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
|
||||||
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]")
|
|
||||||
step_num += 1
|
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")
|
def _display_cmd(name: str) -> str:
|
||||||
steps_lines.append(" 2.2 [cyan]/speckit.specify[/] - Create baseline specification")
|
if codex_skill_mode:
|
||||||
steps_lines.append(" 2.3 [cyan]/speckit.plan[/] - Create implementation plan")
|
return f"$speckit-{name}"
|
||||||
steps_lines.append(" 2.4 [cyan]/speckit.tasks[/] - Generate actionable tasks")
|
if kimi_skill_mode:
|
||||||
steps_lines.append(" 2.5 [cyan]/speckit.implement[/] - Execute implementation")
|
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))
|
steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2))
|
||||||
console.print()
|
console.print()
|
||||||
console.print(steps_panel)
|
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 = [
|
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)",
|
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)",
|
||||||
"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])",
|
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')}[/])",
|
||||||
"○ [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('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()
|
||||||
console.print(enhancements_panel)
|
console.print(enhancements_panel)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ command files into agent-specific directories in the correct format.
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
|
import platform
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
@@ -59,10 +60,10 @@ class CommandRegistrar:
|
|||||||
"extension": ".md"
|
"extension": ".md"
|
||||||
},
|
},
|
||||||
"codex": {
|
"codex": {
|
||||||
"dir": ".codex/prompts",
|
"dir": ".agents/skills",
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
"args": "$ARGUMENTS",
|
"args": "$ARGUMENTS",
|
||||||
"extension": ".md"
|
"extension": "/SKILL.md",
|
||||||
},
|
},
|
||||||
"windsurf": {
|
"windsurf": {
|
||||||
"dir": ".windsurf/workflows",
|
"dir": ".windsurf/workflows",
|
||||||
@@ -140,7 +141,7 @@ class CommandRegistrar:
|
|||||||
"dir": ".kimi/skills",
|
"dir": ".kimi/skills",
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
"args": "$ARGUMENTS",
|
"args": "$ARGUMENTS",
|
||||||
"extension": "/SKILL.md"
|
"extension": "/SKILL.md",
|
||||||
},
|
},
|
||||||
"trae": {
|
"trae": {
|
||||||
"dir": ".trae/rules",
|
"dir": ".trae/rules",
|
||||||
@@ -182,6 +183,9 @@ class CommandRegistrar:
|
|||||||
except yaml.YAMLError:
|
except yaml.YAMLError:
|
||||||
frontmatter = {}
|
frontmatter = {}
|
||||||
|
|
||||||
|
if not isinstance(frontmatter, dict):
|
||||||
|
frontmatter = {}
|
||||||
|
|
||||||
return frontmatter, body
|
return frontmatter, body
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -209,11 +213,14 @@ class CommandRegistrar:
|
|||||||
Returns:
|
Returns:
|
||||||
Modified frontmatter with adjusted paths
|
Modified frontmatter with adjusted paths
|
||||||
"""
|
"""
|
||||||
if "scripts" in frontmatter:
|
for script_key in ("scripts", "agent_scripts"):
|
||||||
for key in frontmatter["scripts"]:
|
scripts = frontmatter.get(script_key)
|
||||||
script_path = frontmatter["scripts"][key]
|
if not isinstance(scripts, dict):
|
||||||
if script_path.startswith("../../scripts/"):
|
continue
|
||||||
frontmatter["scripts"][key] = f".specify/scripts/{script_path[14:]}"
|
|
||||||
|
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
|
return frontmatter
|
||||||
|
|
||||||
def render_markdown_command(
|
def render_markdown_command(
|
||||||
@@ -270,6 +277,95 @@ class CommandRegistrar:
|
|||||||
|
|
||||||
return "\n".join(toml_lines)
|
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:
|
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
|
||||||
"""Convert argument placeholder format.
|
"""Convert argument placeholder format.
|
||||||
|
|
||||||
@@ -283,6 +379,18 @@ class CommandRegistrar:
|
|||||||
"""
|
"""
|
||||||
return content.replace(from_placeholder, to_placeholder)
|
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(
|
def register_commands(
|
||||||
self,
|
self,
|
||||||
agent_name: str,
|
agent_name: str,
|
||||||
@@ -334,14 +442,20 @@ class CommandRegistrar:
|
|||||||
body, "$ARGUMENTS", agent_config["args"]
|
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)
|
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
|
||||||
elif agent_config["format"] == "toml":
|
elif agent_config["format"] == "toml":
|
||||||
output = self.render_toml_command(frontmatter, body, source_id)
|
output = self.render_toml_command(frontmatter, body, source_id)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
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.parent.mkdir(parents=True, exist_ok=True)
|
||||||
dest_file.write_text(output, encoding="utf-8")
|
dest_file.write_text(output, encoding="utf-8")
|
||||||
|
|
||||||
@@ -351,9 +465,15 @@ class CommandRegistrar:
|
|||||||
registered.append(cmd_name)
|
registered.append(cmd_name)
|
||||||
|
|
||||||
for alias in cmd_info.get("aliases", []):
|
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.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":
|
if agent_name == "copilot":
|
||||||
self.write_copilot_prompt(project_root, alias)
|
self.write_copilot_prompt(project_root, alias)
|
||||||
registered.append(alias)
|
registered.append(alias)
|
||||||
@@ -396,7 +516,7 @@ class CommandRegistrar:
|
|||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
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():
|
if agent_dir.exists():
|
||||||
try:
|
try:
|
||||||
@@ -430,7 +550,8 @@ class CommandRegistrar:
|
|||||||
commands_dir = project_root / agent_config["dir"]
|
commands_dir = project_root / agent_config["dir"]
|
||||||
|
|
||||||
for cmd_name in cmd_names:
|
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():
|
if cmd_file.exists():
|
||||||
cmd_file.unlink()
|
cmd_file.unlink()
|
||||||
|
|
||||||
|
|||||||
@@ -646,8 +646,6 @@ class PresetManager:
|
|||||||
short_name = cmd_name
|
short_name = cmd_name
|
||||||
if short_name.startswith("speckit."):
|
if short_name.startswith("speckit."):
|
||||||
short_name = short_name[len("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":
|
if selected_ai == "kimi":
|
||||||
skill_name = f"speckit.{short_name}"
|
skill_name = f"speckit.{short_name}"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -29,11 +29,17 @@ class TestAgentConfigConsistency:
|
|||||||
assert "q" not in cfg
|
assert "q" not in cfg
|
||||||
|
|
||||||
def test_extension_registrar_includes_codex(self):
|
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
|
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||||
|
|
||||||
assert "codex" in cfg
|
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):
|
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."""
|
"""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"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
|
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):
|
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."""
|
"""CLI help text for --ai should stay in sync with agent config and alias guidance."""
|
||||||
assert "roo" in AI_ASSISTANT_HELP
|
assert "roo" in AI_ASSISTANT_HELP
|
||||||
|
|||||||
@@ -471,8 +471,7 @@ class TestInstallAiSkills:
|
|||||||
skills_dir = _get_skills_dir(proj, agent_key)
|
skills_dir = _get_skills_dir(proj, agent_key)
|
||||||
assert skills_dir.exists()
|
assert skills_dir.exists()
|
||||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
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;
|
# Kimi uses dotted skill names; other agents use hyphen-separated names.
|
||||||
# all other agents use hyphen-separator (speckit-specify).
|
|
||||||
expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify"
|
expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify"
|
||||||
assert expected_skill_name in skill_dirs
|
assert expected_skill_name in skill_dirs
|
||||||
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
|
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
|
||||||
@@ -694,6 +693,82 @@ class TestNewProjectCommandSkip:
|
|||||||
prompts_dir = target / ".kiro" / "prompts"
|
prompts_dir = target / ".kiro" / "prompts"
|
||||||
assert not prompts_dir.exists()
|
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):
|
def test_commands_preserved_when_skills_fail(self, tmp_path):
|
||||||
"""If skills fail, commands should NOT be removed (safety net)."""
|
"""If skills fail, commands should NOT be removed (safety net)."""
|
||||||
from typer.testing import CliRunner
|
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 "Explicit command support was deprecated in Antigravity version 1.20.5." in result.output
|
||||||
assert "--ai-skills" 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):
|
def test_interactive_agy_without_ai_skills_prompts_skills(self, monkeypatch):
|
||||||
"""Interactive selector returning agy without --ai-skills should automatically enable --ai-skills."""
|
"""Interactive selector returning agy without --ai-skills should automatically enable --ai-skills."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
@@ -879,6 +965,72 @@ class TestCliValidation:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Explicit command support was deprecated" not in result.output
|
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):
|
def test_ai_skills_flag_appears_in_help(self):
|
||||||
"""--ai-skills should appear in init --help output."""
|
"""--ai-skills should appear in init --help output."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|||||||
@@ -665,9 +665,10 @@ class TestCommandRegistrar:
|
|||||||
assert "q" not in CommandRegistrar.AGENT_CONFIGS
|
assert "q" not in CommandRegistrar.AGENT_CONFIGS
|
||||||
|
|
||||||
def test_codex_agent_config_present(self):
|
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 "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):
|
def test_pi_agent_config_present(self):
|
||||||
"""Pi should be mapped to .pi/prompts."""
|
"""Pi should be mapped to .pi/prompts."""
|
||||||
@@ -717,6 +718,21 @@ $ARGUMENTS
|
|||||||
assert frontmatter == {}
|
assert frontmatter == {}
|
||||||
assert body == content
|
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):
|
def test_render_frontmatter(self):
|
||||||
"""Test rendering frontmatter to YAML."""
|
"""Test rendering frontmatter to YAML."""
|
||||||
frontmatter = {
|
frontmatter = {
|
||||||
@@ -808,6 +824,299 @@ $ARGUMENTS
|
|||||||
assert (claude_dir / "speckit.alias.cmd.md").exists()
|
assert (claude_dir / "speckit.alias.cmd.md").exists()
|
||||||
assert (claude_dir / "speckit.shortcut.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):
|
def test_register_commands_for_copilot(self, extension_dir, project_dir):
|
||||||
"""Test registering commands for Copilot agent with .agent.md extension."""
|
"""Test registering commands for Copilot agent with .agent.md extension."""
|
||||||
# Create .github/agents directory (Copilot project)
|
# Create .github/agents directory (Copilot project)
|
||||||
|
|||||||
Reference in New Issue
Block a user