From c8af730b1453d1a108b5f49fd9050c792c34532a Mon Sep 17 00:00:00 2001 From: Hamilton Snow Date: Thu, 19 Mar 2026 22:00:41 +0800 Subject: [PATCH] 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 --- .../scripts/create-release-packages.ps1 | 23 +- .../scripts/create-release-packages.sh | 22 +- README.md | 15 +- src/specify_cli/__init__.py | 188 ++++++----- src/specify_cli/agents.py | 149 ++++++++- src/specify_cli/presets.py | 2 - tests/test_agent_config_consistency.py | 20 +- tests/test_ai_skills.py | 156 ++++++++- tests/test_extensions.py | 313 +++++++++++++++++- 9 files changed, 767 insertions(+), 121 deletions(-) diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index 54698b9e..a14e7363 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -201,20 +201,22 @@ agent: $basename } } -# Create Kimi Code skills in .kimi/skills//SKILL.md format. -# Kimi CLI discovers skills as directories containing a SKILL.md file, -# invoked with /skill: (e.g. /skill:speckit.specify). -function New-KimiSkills { +# Create skills in \\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" diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index cb3c0552..ab170085 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -121,18 +121,20 @@ EOF done } -# Create Kimi Code skills in .kimi/skills//SKILL.md format. -# Kimi CLI discovers skills as directories containing a SKILL.md file, -# invoked with /skill: (e.g. /skill:speckit.specify). -create_kimi_skills() { +# Create skills in //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" ;; diff --git a/README.md b/README.md index 883becc3..8e99a953 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai c ### 2. Establish project principles -Launch your AI assistant in the project directory. The `/speckit.*` commands are available in the assistant. +Launch your AI assistant in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead. Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development. @@ -173,7 +173,7 @@ See Spec-Driven Development in action across different scenarios with these comm | [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | | | [Claude Code](https://www.anthropic.com/claude-code) | ✅ | | | [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | | -| [Codex CLI](https://github.com/openai/codex) | ✅ | | +| [Codex CLI](https://github.com/openai/codex) | ✅ | Requires `--ai-skills`. Codex recommends [skills](https://developers.openai.com/codex/skills) and treats [custom prompts](https://developers.openai.com/codex/custom-prompts) as deprecated. Spec-kit installs Codex skills into `.agents/skills` and invokes them as `$speckit-`. | | [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 --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 diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 4865a93d..5c361d70 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -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: — 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 --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 --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 --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 /skills//). - # 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 ' 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) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 2f71cb18..d59e841c 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -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() diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index aaa6e52e..c53915fa 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -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: — use dot separator to match packaging convention. if selected_ai == "kimi": skill_name = f"speckit.{short_name}" else: diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index b7482875..fe5c01cf 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -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 diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index b2bc01a9..08017430 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -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 diff --git a/tests/test_extensions.py b/tests/test_extensions.py index d9db203b..c0aa00ad 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -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 "