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:
Hamilton Snow
2026-03-19 22:00:41 +08:00
committed by GitHub
parent a4b60aca7f
commit c8af730b14
9 changed files with 767 additions and 121 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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