Stage 5: Skills, Generic & Option-Driven Integrations (#1924) (#2052)

* Stage 5: Skills, Generic & Option-Driven Integrations (#1924)

Add SkillsIntegration base class and migrate codex, kimi, agy, and
generic to the integration system.

Integrations:
- SkillsIntegration(IntegrationBase) in base.py — creates
  speckit-<name>/SKILL.md layout matching release ZIP output byte-for-byte
- CodexIntegration — .agents/skills/, --skills default=True
- KimiIntegration — .kimi/skills/, --skills + --migrate-legacy options,
  dotted→hyphenated skill directory migration
- AgyIntegration — .agent/skills/, skills-only (commands deprecated v1.20.5)
- GenericIntegration — user-specified --commands-dir, MarkdownIntegration
- All four have update-context.sh/.ps1 scripts
- All four registered in INTEGRATION_REGISTRY

CLI changes:
- --ai <agent> auto-promotes to integration path for all registered agents
- Interactive agent selection also auto-promotes (bug fix)
- --ai-skills and --ai-commands-dir show deprecation notices on integration path
- Next-steps display shows correct skill invocation syntax for skills integrations
- agy added to CommandRegistrar.AGENT_CONFIGS

Tests:
- test_integration_base_skills.py — reusable mixin with setup, frontmatter,
  directory structure, scripts, CLI auto-promote, and complete file inventory
  (sh+ps) tests
- Per-agent test files: test_integration_{codex,kimi,agy,generic}.py
- Kimi legacy migration tests, generic --commands-dir validation
- Registry updated with Stage 5 keys
- Removed 9 dead-mock tests, moved 4 integration tests to proper locations
- Fixed all bare project-name tests to use tmp_path
- Fixed 6 pre-existing ANSI escape code test failures in test_extensions.py
  and test_presets.py

1524 tests pass, 0 failures.

* fix: remove unused variable flagged by ruff (F841)

* fix: address PR review — integration-type-aware deprecation messages and early generic validation

- --ai-skills deprecation message now distinguishes SkillsIntegration
  ("skills are the default") from command-based integrations ("has no effect")
- --ai-commands-dir validation for generic runs even when auto-promoted,
  giving clear CLI error instead of late ValueError from setup()
- Resolves review comments from #2052

* fix: address PR review round 2

- Remove unused SKILL_DESCRIPTIONS dict from base.py (dead code after
  switching to template descriptions for ZIP parity)
- Narrow YAML parse catch from Exception to yaml.YAMLError
- Remove unused shutil import from test_integration_kimi.py
- Remove unused _REGISTRAR_EXEMPT class attr from test_registry.py
- Reword --ai-commands-dir deprecation to be actionable
- Update generic validation error to mention both --ai and --integration

* fix: address PR review round 3

- Clarify parsed_options forwarding is intentional (all options passed,
  integrations decide what to use)
- Extract _strip_ansi() helper in test_extensions.py and test_presets.py
- Remove unused pytest import (test_cli.py), unused locals (test_integration_base_skills.py)
- Reword --ai-commands-dir deprecation to be actionable without referencing
  the not-yet-implemented --integration-options

* fix: address PR review round 4

- Reorder kimi migration: run super().setup() first so hyphenated
  targets exist, then migrate dotted dirs (prevents user content loss)
- Move _strip_ansi() to shared tests/conftest.py, import from there
  in test_extensions.py, test_presets.py, test_ai_skills.py
- Remove now-unused re imports from all three test files

* fix: address PR review round 5

- Use write_bytes() for LF-only newlines (no CRLF on Windows)
- Add --integration-options CLI parameter — raw string passed through
  to the integration via opts['raw_options']; the integration owns
  parsing of its own options
- GenericIntegration.setup() reads --commands-dir from raw_options
  when not in parsed_options (supports --integration-options="...")
- Skip early --ai-commands-dir validation when --integration-options
  is provided (integration validates in its own setup())
- Remove parse_integration_options from core — integrations parse
  their own options

* fix: address PR review round 6

- GenericIntegration is now stateless: removed self._commands_dir
  instance state, overrides setup() directly to compute destination
  from parsed_options/raw_options on the stack
- commands_dest() raises by design (stateless singleton)
- _quote() in SkillsIntegration now escapes backslashes and double
  quotes to produce valid YAML even with special characters

* fix: address PR review round 7

- Support --commands-dir=value form in raw_options parsing (not just
  --commands-dir value with space separator)
- Normalize CRLF to LF in write_file_and_record() before encoding
- Persist ai_skills=True in init-options.json when using a
  SkillsIntegration, so extensions/presets emit SKILL.md overrides
  correctly even without explicit --ai-skills flag
This commit is contained in:
Manfred Riem
2026-04-02 08:00:12 -05:00
committed by GitHub
parent b44ffc0101
commit 4f9d966beb
28 changed files with 1777 additions and 414 deletions

View File

@@ -1907,6 +1907,7 @@ def init(
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"),
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
):
"""
Initialize a new Specify project.
@@ -1997,6 +1998,26 @@ def init(
f"--ai {ai_assistant}. The --ai flag will be deprecated in a future release.[/dim]"
)
# Deprecation warnings for --ai-skills and --ai-commands-dir when using integration path
if use_integration:
if ai_skills:
from .integrations.base import SkillsIntegration as _SkillsCheck
if isinstance(resolved_integration, _SkillsCheck):
console.print(
"[dim]Note: --ai-skills is not needed with --integration; "
"skills are the default for this integration.[/dim]"
)
else:
console.print(
"[dim]Note: --ai-skills has no effect with --integration "
f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]"
)
if ai_commands_dir and resolved_integration.key != "generic":
console.print(
"[dim]Note: --ai-commands-dir is deprecated; "
'use [bold]--integration generic --integration-options="--commands-dir <dir>"[/bold] instead.[/dim]'
)
if project_name == ".":
here = True
project_name = None # Clear project_name to use existing validation logic
@@ -2062,8 +2083,18 @@ def init(
"copilot"
)
# Auto-promote interactively selected agents to the integration path
# when a matching integration is registered (same behavior as --ai).
if not use_integration:
from .integrations import get_integration as _get_int
_resolved = _get_int(selected_ai)
if _resolved:
use_integration = True
resolved_integration = _resolved
# Agents that have moved from explicit commands/prompts to agent skills.
if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills:
# Skip this check when using the integration path — skills are the default.
if not use_integration and 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 fail-fast behavior only for explicit '--ai <agent>' without skills.
@@ -2073,14 +2104,20 @@ def init(
ai_skills = True
console.print(f"\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}")
# Validate --ai-commands-dir usage
if selected_ai == "generic":
# Validate --ai-commands-dir usage.
# Skip validation when --integration-options is provided — the integration
# will validate its own options in setup().
if selected_ai == "generic" and not integration_options:
if not ai_commands_dir:
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic")
console.print("[dim]Example: specify init my-project --ai generic --ai-commands-dir .myagent/commands/[/dim]")
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic")
console.print("[dim]Example: specify init my-project --integration generic --integration-options=\"--commands-dir .myagent/commands/\"[/dim]")
raise typer.Exit(1)
elif ai_commands_dir:
console.print(f"[red]Error:[/red] --ai-commands-dir can only be used with --ai generic (not '{selected_ai}')")
elif ai_commands_dir and not use_integration:
console.print(
f"[red]Error:[/red] --ai-commands-dir can only be used with the "
f"'generic' integration via --ai generic or --integration generic "
f"(not '{selected_ai}')"
)
raise typer.Exit(1)
current_dir = Path.cwd()
@@ -2210,9 +2247,21 @@ def init(
manifest = IntegrationManifest(
resolved_integration.key, project_path, version=get_speckit_version()
)
# Forward all legacy CLI flags to the integration as parsed_options.
# Integrations receive every option and decide what to use;
# irrelevant keys are simply ignored by the integration's setup().
integration_parsed_options: dict[str, Any] = {}
if ai_commands_dir:
integration_parsed_options["commands_dir"] = ai_commands_dir
if ai_skills:
integration_parsed_options["skills"] = True
resolved_integration.setup(
project_path, manifest,
parsed_options=integration_parsed_options or None,
script_type=selected_script,
raw_options=integration_options,
)
manifest.save()
@@ -2268,7 +2317,7 @@ def init(
shutil.rmtree(project_path)
raise typer.Exit(1)
# For generic agent, rename placeholder directory to user-specified path
if selected_ai == "generic" and ai_commands_dir:
if not use_integration and selected_ai == "generic" and ai_commands_dir:
placeholder_dir = project_path / ".speckit" / "commands"
target_dir = project_path / ai_commands_dir
if placeholder_dir.is_dir():
@@ -2284,10 +2333,11 @@ def init(
ensure_constitution_from_template(project_path, tracker=tracker)
# Determine skills directory and migrate any legacy Kimi dotted skills.
# (Legacy path only — integration path handles skills in setup().)
migrated_legacy_kimi_skills = 0
removed_legacy_kimi_skills = 0
skills_dir: Optional[Path] = None
if selected_ai in NATIVE_SKILLS_AGENTS:
if not use_integration and selected_ai in NATIVE_SKILLS_AGENTS:
skills_dir = _get_skills_dir(project_path, selected_ai)
if selected_ai == "kimi" and skills_dir.is_dir():
(
@@ -2295,7 +2345,7 @@ def init(
removed_legacy_kimi_skills,
) = _migrate_legacy_kimi_dotted_skills(skills_dir)
if ai_skills:
if not use_integration and ai_skills:
if selected_ai in NATIVE_SKILLS_AGENTS:
bundled_found = _has_bundled_skills(project_path, selected_ai)
if bundled_found:
@@ -2383,6 +2433,11 @@ def init(
}
if use_integration:
init_opts["integration"] = resolved_integration.key
# Ensure ai_skills is set for SkillsIntegration so downstream
# tools (extensions, presets) emit SKILL.md overrides correctly.
from .integrations.base import SkillsIntegration as _SkillsPersist
if isinstance(resolved_integration, _SkillsPersist):
init_opts["ai_skills"] = True
save_init_options(project_path, init_opts)
# Install preset if specified
@@ -2484,17 +2539,27 @@ def init(
steps_lines.append("1. You're already in the project directory!")
step_num = 2
if selected_ai == "codex" and ai_skills:
# Determine skill display mode for the next-steps panel.
# Skills integrations (codex, kimi, agy) should show skill invocation syntax
# regardless of whether --ai-skills was explicitly passed.
_is_skills_integration = False
if use_integration:
from .integrations.base import SkillsIntegration as _SkillsInt
_is_skills_integration = isinstance(resolved_integration, _SkillsInt)
codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
kimi_skill_mode = selected_ai == "kimi"
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
native_skill_mode = codex_skill_mode or kimi_skill_mode or agy_skill_mode
if codex_skill_mode and not ai_skills:
# Integration path installed skills; show the helpful notice
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
step_num += 1
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"
def _display_cmd(name: str) -> str:
if codex_skill_mode:
if codex_skill_mode or agy_skill_mode:
return f"$speckit-{name}"
if kimi_skill_mode:
return f"/skill:speckit-{name}"

View File

@@ -168,6 +168,12 @@ class CommandRegistrar:
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"agy": {
"dir": ".agent/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
}

View File

@@ -46,17 +46,21 @@ def _register_builtins() -> None:
users install and invoke.
"""
# -- Imports (alphabetical) -------------------------------------------
from .agy import AgyIntegration
from .amp import AmpIntegration
from .auggie import AuggieIntegration
from .bob import BobIntegration
from .claude import ClaudeIntegration
from .codex import CodexIntegration
from .codebuddy import CodebuddyIntegration
from .copilot import CopilotIntegration
from .cursor_agent import CursorAgentIntegration
from .gemini import GeminiIntegration
from .generic import GenericIntegration
from .iflow import IflowIntegration
from .junie import JunieIntegration
from .kilocode import KilocodeIntegration
from .kimi import KimiIntegration
from .kiro_cli import KiroCliIntegration
from .opencode import OpencodeIntegration
from .pi import PiIntegration
@@ -70,17 +74,21 @@ def _register_builtins() -> None:
from .windsurf import WindsurfIntegration
# -- Registration (alphabetical) --------------------------------------
_register(AgyIntegration())
_register(AmpIntegration())
_register(AuggieIntegration())
_register(BobIntegration())
_register(ClaudeIntegration())
_register(CodexIntegration())
_register(CodebuddyIntegration())
_register(CopilotIntegration())
_register(CursorAgentIntegration())
_register(GeminiIntegration())
_register(GenericIntegration())
_register(IflowIntegration())
_register(JunieIntegration())
_register(KilocodeIntegration())
_register(KimiIntegration())
_register(KiroCliIntegration())
_register(OpencodeIntegration())
_register(PiIntegration())

View File

@@ -0,0 +1,41 @@
"""Antigravity (agy) integration — skills-based agent.
Antigravity uses ``.agent/skills/speckit-<name>/SKILL.md`` layout.
Explicit command support was deprecated in version 1.20.5;
``--skills`` defaults to ``True``.
"""
from __future__ import annotations
from ..base import IntegrationOption, SkillsIntegration
class AgyIntegration(SkillsIntegration):
"""Integration for Antigravity IDE."""
key = "agy"
config = {
"name": "Antigravity",
"folder": ".agent/",
"commands_subdir": "skills",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".agent/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Antigravity since v1.20.5)",
),
]

View File

@@ -0,0 +1,17 @@
# update-context.ps1 — Antigravity (agy) integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = $scriptDir
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = Split-Path -Parent $repoRoot
}
}
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType agy

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# update-context.sh — Antigravity (agy) integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
set -euo pipefail
_script_dir="$(cd "$(dirname "$0")" && pwd)"
_root="$_script_dir"
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
if [ -z "${REPO_ROOT:-}" ]; then
if [ -d "$_root/.specify" ]; then
REPO_ROOT="$_root"
else
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
REPO_ROOT="$git_root"
else
REPO_ROOT="$_root"
fi
fi
fi
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" agy

View File

@@ -7,6 +7,8 @@ Provides:
integrations (the common case — subclass, set three class attrs, done).
- ``TomlIntegration`` — concrete base for TOML-format integrations
(Gemini, Tabnine — subclass, set three class attrs, done).
- ``SkillsIntegration`` — concrete base for integrations that install
commands as agent skills (``speckit-<name>/SKILL.md`` layout).
"""
from __future__ import annotations
@@ -200,10 +202,14 @@ class IntegrationBase(ABC):
) -> Path:
"""Write *content* to *dest*, hash it, and record in *manifest*.
Creates parent directories as needed. Returns *dest*.
Creates parent directories as needed. Writes bytes directly to
avoid platform newline translation (CRLF on Windows). Any
``\r\n`` sequences in *content* are normalised to ``\n`` before
writing. Returns *dest*.
"""
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content, encoding="utf-8")
normalized = content.replace("\r\n", "\n")
dest.write_bytes(normalized.encode("utf-8"))
rel = dest.resolve().relative_to(project_root.resolve())
manifest.record_existing(rel)
return dest
@@ -633,3 +639,155 @@ class TomlIntegration(IntegrationBase):
created.extend(self.install_scripts(project_root, manifest))
return created
# ---------------------------------------------------------------------------
# SkillsIntegration — skills-format agents (Codex, Kimi, Agy)
# ---------------------------------------------------------------------------
class SkillsIntegration(IntegrationBase):
"""Concrete base for integrations that install commands as agent skills.
Skills use the ``speckit-<name>/SKILL.md`` directory layout following
the `agentskills.io <https://agentskills.io/specification>`_ spec.
Subclasses set ``key``, ``config``, ``registrar_config`` (and
optionally ``context_file``) like any integration. They may also
override ``options()`` to declare additional CLI flags (e.g.
``--skills``, ``--migrate-legacy``).
``setup()`` processes each shared command template into a
``speckit-<name>/SKILL.md`` file with skills-oriented frontmatter.
"""
def skills_dest(self, project_root: Path) -> Path:
"""Return the absolute path to the skills output directory.
Derived from ``config["folder"]`` and the configured
``commands_subdir`` (defaults to ``"skills"``).
Raises ``ValueError`` when ``config`` or ``folder`` is missing.
"""
if not self.config:
raise ValueError(
f"{type(self).__name__}.config is not set."
)
folder = self.config.get("folder")
if not folder:
raise ValueError(
f"{type(self).__name__}.config is missing required 'folder' entry."
)
subdir = self.config.get("commands_subdir", "skills")
return project_root / folder / subdir
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install command templates as agent skills.
Creates ``speckit-<name>/SKILL.md`` for each shared command
template. Each SKILL.md has normalised frontmatter containing
``name``, ``description``, ``compatibility``, and ``metadata``.
"""
import yaml
templates = self.list_command_templates()
if not templates:
return []
project_root_resolved = project_root.resolve()
if manifest.project_root != project_root_resolved:
raise ValueError(
f"manifest.project_root ({manifest.project_root}) does not match "
f"project_root ({project_root_resolved})"
)
skills_dir = self.skills_dest(project_root).resolve()
try:
skills_dir.relative_to(project_root_resolved)
except ValueError as exc:
raise ValueError(
f"Skills destination {skills_dir} escapes "
f"project root {project_root_resolved}"
) from exc
script_type = opts.get("script_type", "sh")
arg_placeholder = (
self.registrar_config.get("args", "$ARGUMENTS")
if self.registrar_config
else "$ARGUMENTS"
)
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
# Derive the skill name from the template stem
command_name = src_file.stem # e.g. "plan"
skill_name = f"speckit-{command_name.replace('.', '-')}"
# Parse frontmatter for description
frontmatter: dict[str, Any] = {}
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
try:
fm = yaml.safe_load(parts[1])
if isinstance(fm, dict):
frontmatter = fm
except yaml.YAMLError:
pass
# Process body through the standard template pipeline
processed_body = self.process_template(
raw, self.key, script_type, arg_placeholder
)
# Strip the processed frontmatter — we rebuild it for skills.
# Preserve leading whitespace in the body to match release ZIP
# output byte-for-byte (the template body starts with \n after
# the closing ---).
if processed_body.startswith("---"):
parts = processed_body.split("---", 2)
if len(parts) >= 3:
processed_body = parts[2]
# Select description — use the original template description
# to stay byte-for-byte identical with release ZIP output.
description = frontmatter.get("description", "")
if not description:
description = f"Spec Kit: {command_name} workflow"
# Build SKILL.md with manually formatted frontmatter to match
# the release packaging script output exactly (double-quoted
# values, no yaml.safe_dump quoting differences).
def _quote(v: str) -> str:
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
skill_content = (
f"---\n"
f"name: {_quote(skill_name)}\n"
f"description: {_quote(description)}\n"
f"compatibility: {_quote('Requires spec-kit project structure with .specify/ directory')}\n"
f"metadata:\n"
f" author: {_quote('github-spec-kit')}\n"
f" source: {_quote('templates/commands/' + src_file.name)}\n"
f"---\n"
f"{processed_body}"
)
# Write speckit-<name>/SKILL.md
skill_dir = skills_dir / skill_name
skill_file = skill_dir / "SKILL.md"
dst = self.write_file_and_record(
skill_content, skill_file, project_root, manifest
)
created.append(dst)
created.extend(self.install_scripts(project_root, manifest))
return created

View File

@@ -0,0 +1,40 @@
"""Codex CLI integration — skills-based agent.
Codex uses the ``.agents/skills/speckit-<name>/SKILL.md`` layout.
Commands are deprecated; ``--skills`` defaults to ``True``.
"""
from __future__ import annotations
from ..base import IntegrationOption, SkillsIntegration
class CodexIntegration(SkillsIntegration):
"""Integration for OpenAI Codex CLI."""
key = "codex"
config = {
"name": "Codex CLI",
"folder": ".agents/",
"commands_subdir": "skills",
"install_url": "https://github.com/openai/codex",
"requires_cli": True,
}
registrar_config = {
"dir": ".agents/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Codex)",
),
]

View File

@@ -0,0 +1,17 @@
# update-context.ps1 — Codex CLI integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = $scriptDir
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = Split-Path -Parent $repoRoot
}
}
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType codex

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# update-context.sh — Codex CLI integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
set -euo pipefail
_script_dir="$(cd "$(dirname "$0")" && pwd)"
_root="$_script_dir"
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
if [ -z "${REPO_ROOT:-}" ]; then
if [ -d "$_root/.specify" ]; then
REPO_ROOT="$_root"
else
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
REPO_ROOT="$git_root"
else
REPO_ROOT="$_root"
fi
fi
fi
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" codex

View File

@@ -0,0 +1,133 @@
"""Generic integration — bring your own agent.
Requires ``--commands-dir`` to specify the output directory for command
files. No longer special-cased in the core CLI — just another
integration with its own required option.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ..base import IntegrationOption, MarkdownIntegration
from ..manifest import IntegrationManifest
class GenericIntegration(MarkdownIntegration):
"""Integration for user-specified (generic) agents."""
key = "generic"
config = {
"name": "Generic (bring your own agent)",
"folder": None, # Set dynamically from --commands-dir
"commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": "", # Set dynamically from --commands-dir
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = None
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--commands-dir",
required=True,
help="Directory for command files (e.g. .myagent/commands/)",
),
]
@staticmethod
def _resolve_commands_dir(
parsed_options: dict[str, Any] | None,
opts: dict[str, Any],
) -> str:
"""Extract ``--commands-dir`` from parsed options or raw_options.
Returns the directory string or raises ``ValueError``.
"""
parsed_options = parsed_options or {}
commands_dir = parsed_options.get("commands_dir")
if commands_dir:
return commands_dir
# Fall back to raw_options (--integration-options="--commands-dir ...")
raw = opts.get("raw_options")
if raw:
import shlex
tokens = shlex.split(raw)
for i, token in enumerate(tokens):
if token == "--commands-dir" and i + 1 < len(tokens):
return tokens[i + 1]
if token.startswith("--commands-dir="):
return token.split("=", 1)[1]
raise ValueError(
"--commands-dir is required for the generic integration"
)
def commands_dest(self, project_root: Path) -> Path:
"""Not supported for GenericIntegration — use setup() directly.
GenericIntegration is stateless; the output directory comes from
``parsed_options`` or ``raw_options`` at call time, not from
instance state.
"""
raise ValueError(
"GenericIntegration.commands_dest() cannot be called directly; "
"the output directory is resolved from parsed_options in setup()"
)
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install commands to the user-provided commands directory."""
commands_dir = self._resolve_commands_dir(parsed_options, opts)
templates = self.list_command_templates()
if not templates:
return []
project_root_resolved = project_root.resolve()
if manifest.project_root != project_root_resolved:
raise ValueError(
f"manifest.project_root ({manifest.project_root}) does not match "
f"project_root ({project_root_resolved})"
)
dest = (project_root / commands_dir).resolve()
try:
dest.relative_to(project_root_resolved)
except ValueError as exc:
raise ValueError(
f"Integration destination {dest} escapes "
f"project root {project_root_resolved}"
) from exc
dest.mkdir(parents=True, exist_ok=True)
script_type = opts.get("script_type", "sh")
arg_placeholder = "$ARGUMENTS"
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
processed, dest / dst_name, project_root, manifest
)
created.append(dst_file)
created.extend(self.install_scripts(project_root, manifest))
return created

View File

@@ -0,0 +1,17 @@
# update-context.ps1 — Generic integration: create/update context file
#
# Thin wrapper that delegates to the shared update-agent-context script.
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = $scriptDir
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = Split-Path -Parent $repoRoot
}
}
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType generic

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# update-context.sh — Generic integration: create/update context file
#
# Thin wrapper that delegates to the shared update-agent-context script.
set -euo pipefail
_script_dir="$(cd "$(dirname "$0")" && pwd)"
_root="$_script_dir"
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
if [ -z "${REPO_ROOT:-}" ]; then
if [ -d "$_root/.specify" ]; then
REPO_ROOT="$_root"
else
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
REPO_ROOT="$git_root"
else
REPO_ROOT="$_root"
fi
fi
fi
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" generic

View File

@@ -0,0 +1,124 @@
"""Kimi Code integration — skills-based agent (Moonshot AI).
Kimi uses the ``.kimi/skills/speckit-<name>/SKILL.md`` layout with
``/skill:speckit-<name>`` invocation syntax.
Includes legacy migration logic for projects initialised before Kimi
moved from dotted skill directories (``speckit.xxx``) to hyphenated
(``speckit-xxx``).
"""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
from ..base import IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
class KimiIntegration(SkillsIntegration):
"""Integration for Kimi Code CLI (Moonshot AI)."""
key = "kimi"
config = {
"name": "Kimi Code",
"folder": ".kimi/",
"commands_subdir": "skills",
"install_url": "https://code.kimi.com/",
"requires_cli": True,
}
registrar_config = {
"dir": ".kimi/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "KIMI.md"
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Kimi)",
),
IntegrationOption(
"--migrate-legacy",
is_flag=True,
default=False,
help="Migrate legacy dotted skill dirs (speckit.xxx → speckit-xxx)",
),
]
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install skills with optional legacy dotted-name migration."""
parsed_options = parsed_options or {}
# Run base setup first so hyphenated targets (speckit-*) exist,
# then migrate/clean legacy dotted dirs without risking user content loss.
created = super().setup(
project_root, manifest, parsed_options=parsed_options, **opts
)
if parsed_options.get("migrate_legacy", False):
skills_dir = self.skills_dest(project_root)
if skills_dir.is_dir():
_migrate_legacy_kimi_dotted_skills(skills_dir)
return created
def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]:
"""Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format.
Returns ``(migrated_count, removed_count)``.
"""
if not skills_dir.is_dir():
return (0, 0)
migrated_count = 0
removed_count = 0
for legacy_dir in sorted(skills_dir.glob("speckit.*")):
if not legacy_dir.is_dir():
continue
if not (legacy_dir / "SKILL.md").exists():
continue
suffix = legacy_dir.name[len("speckit."):]
if not suffix:
continue
target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}"
if not target_dir.exists():
shutil.move(str(legacy_dir), str(target_dir))
migrated_count += 1
continue
# Target exists — only remove legacy if SKILL.md is identical
target_skill = target_dir / "SKILL.md"
legacy_skill = legacy_dir / "SKILL.md"
if target_skill.is_file():
try:
if target_skill.read_bytes() == legacy_skill.read_bytes():
has_extra = any(
child.name != "SKILL.md" for child in legacy_dir.iterdir()
)
if not has_extra:
shutil.rmtree(legacy_dir)
removed_count += 1
except OSError:
pass
return (migrated_count, removed_count)

View File

@@ -0,0 +1,17 @@
# update-context.ps1 — Kimi Code integration: create/update KIMI.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = $scriptDir
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = Split-Path -Parent $repoRoot
}
}
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kimi

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# update-context.sh — Kimi Code integration: create/update KIMI.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
set -euo pipefail
_script_dir="$(cd "$(dirname "$0")" && pwd)"
_root="$_script_dir"
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
if [ -z "${REPO_ROOT:-}" ]; then
if [ -d "$_root/.specify" ]; then
REPO_ROOT="$_root"
else
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
REPO_ROOT="$git_root"
else
REPO_ROOT="$_root"
fi
fi
fi
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kimi

10
tests/conftest.py Normal file
View File

@@ -0,0 +1,10 @@
"""Shared test helpers for the Spec Kit test suite."""
import re
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
def strip_ansi(text: str) -> str:
"""Remove ANSI escape codes from Rich-formatted CLI output."""
return _ANSI_ESCAPE_RE.sub("", text)

View File

@@ -3,26 +3,24 @@
import json
import os
import pytest
class TestInitIntegrationFlag:
def test_integration_and_ai_mutually_exclusive(self):
def test_integration_and_ai_mutually_exclusive(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, [
"init", "test-project", "--ai", "claude", "--integration", "copilot",
"init", str(tmp_path / "test-project"), "--ai", "claude", "--integration", "copilot",
])
assert result.exit_code != 0
assert "mutually exclusive" in result.output
def test_unknown_integration_rejected(self):
def test_unknown_integration_rejected(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, [
"init", "test-project", "--integration", "nonexistent",
"init", str(tmp_path / "test-project"), "--integration", "nonexistent",
])
assert result.exit_code != 0
assert "Unknown integration" in result.output

View File

@@ -0,0 +1,25 @@
"""Tests for AgyIntegration (Antigravity)."""
from .test_integration_base_skills import SkillsIntegrationTests
class TestAgyIntegration(SkillsIntegrationTests):
KEY = "agy"
FOLDER = ".agent/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".agent/skills"
CONTEXT_FILE = "AGENTS.md"
class TestAgyAutoPromote:
"""--ai agy auto-promotes to integration path."""
def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path):
"""--ai agy (without --ai-skills) should auto-promote to integration."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "agy"])
assert "--integration agy" in result.output

View File

@@ -0,0 +1,402 @@
"""Reusable test mixin for standard SkillsIntegration subclasses.
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
logic from ``SkillsIntegrationTests``.
Mirrors ``MarkdownIntegrationTests`` / ``TomlIntegrationTests`` closely,
adapted for the ``speckit-<name>/SKILL.md`` skills layout.
"""
import os
import yaml
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import SkillsIntegration
from specify_cli.integrations.manifest import IntegrationManifest
class SkillsIntegrationTests:
"""Mixin — set class-level constants and inherit these tests.
Required class attrs on subclass::
KEY: str — integration registry key
FOLDER: str — e.g. ".agents/"
COMMANDS_SUBDIR: str — e.g. "skills"
REGISTRAR_DIR: str — e.g. ".agents/skills"
CONTEXT_FILE: str — e.g. "AGENTS.md"
"""
KEY: str
FOLDER: str
COMMANDS_SUBDIR: str
REGISTRAR_DIR: str
CONTEXT_FILE: str
# -- Registration -----------------------------------------------------
def test_registered(self):
assert self.KEY in INTEGRATION_REGISTRY
assert get_integration(self.KEY) is not None
def test_is_skills_integration(self):
assert isinstance(get_integration(self.KEY), SkillsIntegration)
# -- Config -----------------------------------------------------------
def test_config_folder(self):
i = get_integration(self.KEY)
assert i.config["folder"] == self.FOLDER
def test_config_commands_subdir(self):
i = get_integration(self.KEY)
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
def test_registrar_config(self):
i = get_integration(self.KEY)
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
assert i.registrar_config["format"] == "markdown"
assert i.registrar_config["args"] == "$ARGUMENTS"
assert i.registrar_config["extension"] == "/SKILL.md"
def test_context_file(self):
i = get_integration(self.KEY)
assert i.context_file == self.CONTEXT_FILE
# -- Setup / teardown -------------------------------------------------
def test_setup_creates_files(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
skill_files = [f for f in created if "scripts" not in f.parts]
for f in skill_files:
assert f.exists()
assert f.name == "SKILL.md"
assert f.parent.name.startswith("speckit-")
def test_setup_writes_to_correct_directory(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
expected_dir = i.skills_dest(tmp_path)
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
skill_files = [f for f in created if "scripts" not in f.parts]
assert len(skill_files) > 0, "No skill files were created"
for f in skill_files:
# Each SKILL.md is in speckit-<name>/ under the skills directory
assert f.resolve().parent.parent == expected_dir.resolve(), (
f"{f} is not under {expected_dir}"
)
def test_skill_directory_structure(self, tmp_path):
"""Each command produces speckit-<name>/SKILL.md."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
expected_commands = {
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
}
# Derive command names from the skill directory names
actual_commands = set()
for f in skill_files:
skill_dir_name = f.parent.name # e.g. "speckit-plan"
assert skill_dir_name.startswith("speckit-")
actual_commands.add(skill_dir_name.removeprefix("speckit-"))
assert actual_commands == expected_commands
def test_skill_frontmatter_structure(self, tmp_path):
"""SKILL.md must have name, description, compatibility, metadata."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert content.startswith("---\n"), f"{f} missing frontmatter"
parts = content.split("---", 2)
fm = yaml.safe_load(parts[1])
assert "name" in fm, f"{f} frontmatter missing 'name'"
assert "description" in fm, f"{f} frontmatter missing 'description'"
assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'"
assert "metadata" in fm, f"{f} frontmatter missing 'metadata'"
assert fm["metadata"]["author"] == "github-spec-kit"
assert "source" in fm["metadata"]
def test_skill_uses_template_descriptions(self, tmp_path):
"""SKILL.md should use the original template description for ZIP parity."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
for f in skill_files:
content = f.read_text(encoding="utf-8")
parts = content.split("---", 2)
fm = yaml.safe_load(parts[1])
# Description must be a non-empty string (from the template)
assert isinstance(fm["description"], str)
assert len(fm["description"]) > 0, f"{f} has empty description"
def test_templates_are_processed(self, tmp_path):
"""Skill body must have placeholders replaced, not raw templates."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
assert len(skill_files) > 0
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
def test_skill_body_has_content(self, tmp_path):
"""Each SKILL.md body should contain template content after the frontmatter."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
for f in skill_files:
content = f.read_text(encoding="utf-8")
# Body is everything after the second ---
parts = content.split("---", 2)
body = parts[2].strip() if len(parts) >= 3 else ""
assert len(body) > 0, f"{f} has empty body"
def test_all_files_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
def test_install_uninstall_roundtrip(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = i.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
removed, skipped = i.uninstall(tmp_path, m)
assert modified_file.exists()
assert modified_file in skipped
def test_pre_existing_skills_not_removed(self, tmp_path):
"""Pre-existing non-speckit skills should be left untouched."""
i = get_integration(self.KEY)
skills_dir = i.skills_dest(tmp_path)
foreign_dir = skills_dir / "other-tool"
foreign_dir.mkdir(parents=True)
(foreign_dir / "SKILL.md").write_text("# Foreign skill\n")
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed"
# -- Scripts ----------------------------------------------------------
def test_setup_installs_update_context_scripts(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
assert (scripts_dir / "update-context.sh").exists()
assert (scripts_dir / "update-context.ps1").exists()
def test_scripts_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
script_rels = [k for k in m.files if "update-context" in k]
assert len(script_rels) >= 2
def test_sh_script_is_executable(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh"
assert os.access(sh, os.X_OK)
# -- CLI auto-promote -------------------------------------------------
def test_ai_flag_auto_promotes(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"promote-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
assert f"--integration {self.KEY}" in result.output
def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"int-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
i = get_integration(self.KEY)
skills_dir = i.skills_dest(project)
assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created"
# -- IntegrationOption ------------------------------------------------
def test_options_include_skills_flag(self):
i = get_integration(self.KEY)
opts = i.options()
skills_opts = [o for o in opts if o.name == "--skills"]
assert len(skills_opts) == 1
assert skills_opts[0].is_flag is True
# -- Complete file inventory ------------------------------------------
_SKILL_COMMANDS = [
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
]
def _expected_files(self, script_variant: str) -> list[str]:
"""Build the full expected file list for a given script variant."""
i = get_integration(self.KEY)
skills_prefix = i.config["folder"].rstrip("/") + "/" + i.config.get("commands_subdir", "skills")
files = []
# Skill files
for cmd in self._SKILL_COMMANDS:
files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md")
# Integration metadata
files += [
".specify/init-options.json",
".specify/integration.json",
f".specify/integrations/{self.KEY}.manifest.json",
f".specify/integrations/{self.KEY}/scripts/update-context.ps1",
f".specify/integrations/{self.KEY}/scripts/update-context.sh",
".specify/integrations/speckit.manifest.json",
".specify/memory/constitution.md",
]
# Script variant
if script_variant == "sh":
files += [
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/update-agent-context.sh",
]
else:
files += [
".specify/scripts/powershell/check-prerequisites.ps1",
".specify/scripts/powershell/common.ps1",
".specify/scripts/powershell/create-new-feature.ps1",
".specify/scripts/powershell/setup-plan.ps1",
".specify/scripts/powershell/update-agent-context.ps1",
]
# Templates
files += [
".specify/templates/agent-file-template.md",
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
]
return sorted(files)
def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration <key> --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-sh-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY,
"--script", "sh", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("sh")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration <key> --script ps."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-ps-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY,
"--script", "ps", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("ps")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)

View File

@@ -0,0 +1,25 @@
"""Tests for CodexIntegration."""
from .test_integration_base_skills import SkillsIntegrationTests
class TestCodexIntegration(SkillsIntegrationTests):
KEY = "codex"
FOLDER = ".agents/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".agents/skills"
CONTEXT_FILE = "AGENTS.md"
class TestCodexAutoPromote:
"""--ai codex auto-promotes to integration path."""
def test_ai_codex_without_ai_skills_auto_promotes(self, tmp_path):
"""--ai codex (without --ai-skills) should auto-promote to integration."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "codex"])
assert "--integration codex" in result.output

View File

@@ -0,0 +1,311 @@
"""Tests for GenericIntegration."""
import os
import pytest
from specify_cli.integrations import get_integration
from specify_cli.integrations.base import MarkdownIntegration
from specify_cli.integrations.manifest import IntegrationManifest
class TestGenericIntegration:
"""Tests for GenericIntegration — requires --commands-dir option."""
# -- Registration -----------------------------------------------------
def test_registered(self):
from specify_cli.integrations import INTEGRATION_REGISTRY
assert "generic" in INTEGRATION_REGISTRY
def test_is_markdown_integration(self):
assert isinstance(get_integration("generic"), MarkdownIntegration)
# -- Config -----------------------------------------------------------
def test_config_folder_is_none(self):
i = get_integration("generic")
assert i.config["folder"] is None
def test_config_requires_cli_false(self):
i = get_integration("generic")
assert i.config["requires_cli"] is False
def test_context_file_is_none(self):
i = get_integration("generic")
assert i.context_file is None
# -- Options ----------------------------------------------------------
def test_options_include_commands_dir(self):
i = get_integration("generic")
opts = i.options()
assert len(opts) == 1
assert opts[0].name == "--commands-dir"
assert opts[0].required is True
assert opts[0].is_flag is False
# -- Setup / teardown -------------------------------------------------
def test_setup_requires_commands_dir(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
with pytest.raises(ValueError, match="--commands-dir is required"):
i.setup(tmp_path, m, parsed_options={})
def test_setup_requires_nonempty_commands_dir(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
with pytest.raises(ValueError, match="--commands-dir is required"):
i.setup(tmp_path, m, parsed_options={"commands_dir": ""})
def test_setup_writes_to_correct_directory(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.setup(
tmp_path, m,
parsed_options={"commands_dir": ".myagent/commands"},
)
expected_dir = tmp_path / ".myagent" / "commands"
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0, "No command files were created"
for f in cmd_files:
assert f.resolve().parent == expected_dir.resolve(), (
f"{f} is not under {expected_dir}"
)
def test_setup_creates_md_files(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.setup(
tmp_path, m,
parsed_options={"commands_dir": ".custom/cmds"},
)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0
for f in cmd_files:
assert f.name.startswith("speckit.")
assert f.name.endswith(".md")
def test_templates_are_processed(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.setup(
tmp_path, m,
parsed_options={"commands_dir": ".custom/cmds"},
)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
def test_all_files_tracked_in_manifest(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.setup(
tmp_path, m,
parsed_options={"commands_dir": ".custom/cmds"},
)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
def test_install_uninstall_roundtrip(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.install(
tmp_path, m,
parsed_options={"commands_dir": ".custom/cmds"},
)
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = i.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.install(
tmp_path, m,
parsed_options={"commands_dir": ".custom/cmds"},
)
m.save()
modified = created[0]
modified.write_text("user modified this", encoding="utf-8")
removed, skipped = i.uninstall(tmp_path, m)
assert modified.exists()
assert modified in skipped
def test_different_commands_dirs(self, tmp_path):
"""Generic should work with various user-specified paths."""
for path in [".agent/commands", "tools/ai-cmds", ".custom/prompts"]:
project = tmp_path / path.replace("/", "-")
project.mkdir()
i = get_integration("generic")
m = IntegrationManifest("generic", project)
created = i.setup(
project, m,
parsed_options={"commands_dir": path},
)
expected = project / path
assert expected.is_dir(), f"Dir {expected} not created for {path}"
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0
# -- Scripts ----------------------------------------------------------
def test_setup_installs_update_context_scripts(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
scripts_dir = tmp_path / ".specify" / "integrations" / "generic" / "scripts"
assert scripts_dir.is_dir(), "Scripts directory not created for generic"
assert (scripts_dir / "update-context.sh").exists()
assert (scripts_dir / "update-context.ps1").exists()
def test_scripts_tracked_in_manifest(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
script_rels = [k for k in m.files if "update-context" in k]
assert len(script_rels) >= 2
def test_sh_script_is_executable(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
sh = tmp_path / ".specify" / "integrations" / "generic" / "scripts" / "update-context.sh"
assert os.access(sh, os.X_OK)
# -- CLI --------------------------------------------------------------
def test_cli_generic_without_commands_dir_fails(self, tmp_path):
"""--integration generic without --ai-commands-dir should fail."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, [
"init", str(tmp_path / "test-generic"), "--integration", "generic",
"--script", "sh", "--no-git",
])
# Generic requires --commands-dir / --ai-commands-dir
# The integration path validates via setup()
assert result.exit_code != 0
def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-generic-sh"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic",
"--ai-commands-dir", ".myagent/commands",
"--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file()
)
expected = sorted([
".myagent/commands/speckit.analyze.md",
".myagent/commands/speckit.checklist.md",
".myagent/commands/speckit.clarify.md",
".myagent/commands/speckit.constitution.md",
".myagent/commands/speckit.implement.md",
".myagent/commands/speckit.plan.md",
".myagent/commands/speckit.specify.md",
".myagent/commands/speckit.tasks.md",
".myagent/commands/speckit.taskstoissues.md",
".specify/init-options.json",
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/generic/scripts/update-context.ps1",
".specify/integrations/generic/scripts/update-context.sh",
".specify/integrations/speckit.manifest.json",
".specify/memory/constitution.md",
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/update-agent-context.sh",
".specify/templates/agent-file-template.md",
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
])
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script ps."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-generic-ps"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic",
"--ai-commands-dir", ".myagent/commands",
"--script", "ps", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file()
)
expected = sorted([
".myagent/commands/speckit.analyze.md",
".myagent/commands/speckit.checklist.md",
".myagent/commands/speckit.clarify.md",
".myagent/commands/speckit.constitution.md",
".myagent/commands/speckit.implement.md",
".myagent/commands/speckit.plan.md",
".myagent/commands/speckit.specify.md",
".myagent/commands/speckit.tasks.md",
".myagent/commands/speckit.taskstoissues.md",
".specify/init-options.json",
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/generic/scripts/update-context.ps1",
".specify/integrations/generic/scripts/update-context.sh",
".specify/integrations/speckit.manifest.json",
".specify/memory/constitution.md",
".specify/scripts/powershell/check-prerequisites.ps1",
".specify/scripts/powershell/common.ps1",
".specify/scripts/powershell/create-new-feature.ps1",
".specify/scripts/powershell/setup-plan.ps1",
".specify/scripts/powershell/update-agent-context.ps1",
".specify/templates/agent-file-template.md",
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
])
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)

View File

@@ -0,0 +1,149 @@
"""Tests for KimiIntegration — skills integration with legacy migration."""
from specify_cli.integrations import get_integration
from specify_cli.integrations.kimi import _migrate_legacy_kimi_dotted_skills
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_skills import SkillsIntegrationTests
class TestKimiIntegration(SkillsIntegrationTests):
KEY = "kimi"
FOLDER = ".kimi/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".kimi/skills"
CONTEXT_FILE = "KIMI.md"
class TestKimiOptions:
"""Kimi declares --skills and --migrate-legacy options."""
def test_migrate_legacy_option(self):
i = get_integration("kimi")
opts = i.options()
migrate_opts = [o for o in opts if o.name == "--migrate-legacy"]
assert len(migrate_opts) == 1
assert migrate_opts[0].is_flag is True
assert migrate_opts[0].default is False
class TestKimiLegacyMigration:
"""Test Kimi dotted → hyphenated skill directory migration."""
def test_migrate_dotted_to_hyphenated(self, tmp_path):
skills_dir = tmp_path / ".kimi" / "skills"
legacy = skills_dir / "speckit.plan"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text("# Plan Skill\n")
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
assert migrated == 1
assert removed == 0
assert not legacy.exists()
assert (skills_dir / "speckit-plan" / "SKILL.md").exists()
def test_skip_when_target_exists_different_content(self, tmp_path):
skills_dir = tmp_path / ".kimi" / "skills"
legacy = skills_dir / "speckit.plan"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text("# Old\n")
target = skills_dir / "speckit-plan"
target.mkdir(parents=True)
(target / "SKILL.md").write_text("# New (different)\n")
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
assert migrated == 0
assert removed == 0
assert legacy.exists()
assert target.exists()
def test_remove_when_target_exists_same_content(self, tmp_path):
skills_dir = tmp_path / ".kimi" / "skills"
content = "# Identical\n"
legacy = skills_dir / "speckit.plan"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text(content)
target = skills_dir / "speckit-plan"
target.mkdir(parents=True)
(target / "SKILL.md").write_text(content)
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
assert migrated == 0
assert removed == 1
assert not legacy.exists()
assert target.exists()
def test_preserve_legacy_with_extra_files(self, tmp_path):
skills_dir = tmp_path / ".kimi" / "skills"
content = "# Same\n"
legacy = skills_dir / "speckit.plan"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text(content)
(legacy / "extra.md").write_text("user file")
target = skills_dir / "speckit-plan"
target.mkdir(parents=True)
(target / "SKILL.md").write_text(content)
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
assert migrated == 0
assert removed == 0
assert legacy.exists()
def test_nonexistent_dir_returns_zeros(self, tmp_path):
migrated, removed = _migrate_legacy_kimi_dotted_skills(
tmp_path / ".kimi" / "skills"
)
assert migrated == 0
assert removed == 0
def test_setup_with_migrate_legacy_option(self, tmp_path):
"""KimiIntegration.setup() with --migrate-legacy migrates dotted dirs."""
i = get_integration("kimi")
skills_dir = tmp_path / ".kimi" / "skills"
legacy = skills_dir / "speckit.oldcmd"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text("# Legacy\n")
m = IntegrationManifest("kimi", tmp_path)
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
assert not legacy.exists()
assert (skills_dir / "speckit-oldcmd" / "SKILL.md").exists()
# New skills from templates should also exist
assert (skills_dir / "speckit-specify" / "SKILL.md").exists()
class TestKimiNextSteps:
"""CLI output tests for kimi next-steps display."""
def test_next_steps_show_skill_invocation(self, tmp_path):
"""Kimi next-steps guidance should display /skill:speckit-* usage."""
import os
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "kimi-next-steps"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "kimi", "--no-git",
"--ignore-agent-tools", "--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
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

View File

@@ -1,5 +1,7 @@
"""Tests for KiroCliIntegration."""
import os
from .test_integration_base_markdown import MarkdownIntegrationTests
@@ -9,3 +11,30 @@ class TestKiroCliIntegration(MarkdownIntegrationTests):
COMMANDS_SUBDIR = "prompts"
REGISTRAR_DIR = ".kiro/prompts"
CONTEXT_FILE = "AGENTS.md"
class TestKiroAlias:
"""--ai kiro alias normalizes to kiro-cli and auto-promotes."""
def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path):
"""--ai kiro should normalize to canonical kiro-cli and auto-promote."""
from typer.testing import CliRunner
from specify_cli import app
target = tmp_path / "kiro-alias-proj"
target.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(target)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "kiro",
"--ignore-agent-tools", "--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "--integration kiro-cli" in result.output
assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists()

View File

@@ -11,13 +11,17 @@ from specify_cli.integrations.base import MarkdownIntegration
from .conftest import StubIntegration
# Every integration key that must be registered (Stage 2 + Stage 3).
# Every integration key that must be registered (Stage 2 + Stage 3 + Stage 4 + Stage 5).
ALL_INTEGRATION_KEYS = [
"copilot",
# Stage 3 — standard markdown integrations
"claude", "qwen", "opencode", "junie", "kilocode", "auggie",
"roo", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
"pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent",
# Stage 4 — TOML integrations
"gemini", "tabnine",
# Stage 5 — skills, generic & option-driven integrations
"codex", "kimi", "agy", "generic",
]
@@ -61,9 +65,16 @@ class TestRegistryCompleteness:
class TestRegistrarKeyAlignment:
"""Every integration key must have a matching AGENT_CONFIGS entry."""
"""Every integration key must have a matching AGENT_CONFIGS entry.
@pytest.mark.parametrize("key", ALL_INTEGRATION_KEYS)
``generic`` is excluded because it has no fixed directory — its
output path comes from ``--commands-dir`` at runtime.
"""
@pytest.mark.parametrize(
"key",
[k for k in ALL_INTEGRATION_KEYS if k != "generic"],
)
def test_integration_key_in_registrar(self, key):
from specify_cli.agents import CommandRegistrar
assert key in CommandRegistrar.AGENT_CONFIGS, (

View File

@@ -10,7 +10,6 @@ Tests cover:
- CLI validation: --ai-skills requires --ai
"""
import re
import zipfile
import pytest
import tempfile
@@ -21,6 +20,7 @@ from pathlib import Path
from unittest.mock import patch
import specify_cli
from tests.conftest import strip_ansi
from specify_cli import (
_get_skills_dir,
@@ -684,207 +684,15 @@ class TestCommandCoexistence:
assert result is True
# ===== New-Project Command Skip Tests =====
# ===== Legacy Download Path Tests =====
class TestNewProjectCommandSkip:
"""Test that init() removes extracted commands for new projects only.
class TestLegacyDownloadPath:
"""Tests for download_and_extract_template() called directly.
These tests run init() end-to-end via CliRunner with
download_and_extract_template patched to create local fixtures.
These test the legacy download/extract code that still exists in
__init__.py. They do NOT go through CLI auto-promote.
"""
def _fake_extract(self, agent, project_path, **_kwargs):
"""Simulate template extraction: create agent commands dir."""
agent_cfg = AGENT_CONFIG.get(agent, {})
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
cmds_dir.mkdir(parents=True, exist_ok=True)
(cmds_dir / "speckit.specify.md").write_text("# spec")
def test_new_project_commands_removed_after_skills_succeed(self, tmp_path):
"""For new projects, commands should be removed when skills succeed."""
from typer.testing import CliRunner
runner = CliRunner()
target = tmp_path / "new-proj"
def fake_download(project_path, *args, **kwargs):
self._fake_extract("claude", project_path)
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", return_value=True) as mock_skills, \
patch("specify_cli.is_git_repo", return_value=False), \
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"])
assert result.exit_code == 0
# Skills should have been called
mock_skills.assert_called_once()
# Commands dir should have been removed after skills succeeded
cmds_dir = target / ".claude" / "commands"
assert not cmds_dir.exists()
def test_new_project_nonstandard_commands_subdir_removed_after_skills_succeed(self, tmp_path):
"""For non-standard agents, configured commands_subdir should be removed on success."""
from typer.testing import CliRunner
runner = CliRunner()
target = tmp_path / "new-kiro-proj"
def fake_download(project_path, *args, **kwargs):
self._fake_extract("kiro-cli", project_path)
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", return_value=True) as mock_skills, \
patch("specify_cli.is_git_repo", return_value=False), \
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
result = runner.invoke(app, ["init", str(target), "--ai", "kiro-cli", "--ai-skills", "--script", "sh", "--no-git"])
assert result.exit_code == 0
mock_skills.assert_called_once()
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_falls_back_then_fails_cleanly(self, tmp_path):
"""Codex should attempt fallback conversion when 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", return_value=False) 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_called_once()
assert mock_skills.call_args.kwargs.get("overwrite_existing") is True
assert "Expected bundled agent skills" in result.output
assert "fallback conversion failed" in result.output
def test_codex_native_skills_ignores_non_speckit_skill_dirs(self, tmp_path):
"""Non-spec-kit SKILL.md files should trigger fallback conversion, not hard-fail."""
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", return_value=True) 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_called_once()
assert mock_skills.call_args.kwargs.get("overwrite_existing") is True
def test_kimi_legacy_migration_runs_without_ai_skills_flag(self, tmp_path):
"""Kimi init should migrate dotted legacy skills even when --ai-skills is not set."""
from typer.testing import CliRunner
runner = CliRunner()
target = tmp_path / "kimi-legacy-no-ai-skills"
def fake_download(project_path, *args, **kwargs):
legacy_dir = project_path / ".kimi" / "skills" / "speckit.plan"
legacy_dir.mkdir(parents=True, exist_ok=True)
(legacy_dir / "SKILL.md").write_text("---\nname: speckit.plan\n---\n\nlegacy\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.is_git_repo", return_value=False), \
patch("specify_cli.shutil.which", return_value="/usr/bin/kimi"):
result = runner.invoke(
app,
["init", str(target), "--ai", "kimi", "--script", "sh", "--no-git"],
)
assert result.exit_code == 0
assert not (target / ".kimi" / "skills" / "speckit.plan").exists()
assert (target / ".kimi" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_codex_ai_skills_here_mode_preserves_existing_codex_dir(self, tmp_path, monkeypatch):
"""Codex --here skills init should not delete a pre-existing .codex directory."""
from typer.testing import CliRunner
runner = CliRunner()
target = tmp_path / "codex-preserve-here"
target.mkdir()
existing_prompts = target / ".codex" / "prompts"
existing_prompts.mkdir(parents=True)
(existing_prompts / "custom.md").write_text("custom")
monkeypatch.chdir(target)
with patch("specify_cli.download_and_extract_template", return_value=target), \
patch("specify_cli.ensure_executable_scripts"), \
patch("specify_cli.ensure_constitution_from_template"), \
patch("specify_cli.install_ai_skills", return_value=True), \
patch("specify_cli.is_git_repo", return_value=True), \
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
result = runner.invoke(
app,
["init", "--here", "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
input="y\n",
)
assert result.exit_code == 0
assert (target / ".codex").exists()
assert (existing_prompts / "custom.md").exists()
def test_codex_ai_skills_fresh_dir_does_not_create_codex_dir(self, tmp_path):
"""Fresh-directory Codex skills init should not leave legacy .codex from archive."""
target = tmp_path / "fresh-codex-proj"
@@ -948,62 +756,6 @@ class TestNewProjectCommandSkip:
assert not (tmp_path / "evil.txt").exists()
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
runner = CliRunner()
target = tmp_path / "fail-proj"
def fake_download(project_path, *args, **kwargs):
self._fake_extract("claude", project_path)
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", return_value=False), \
patch("specify_cli.is_git_repo", return_value=False), \
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"])
assert result.exit_code == 0
# Commands should still exist since skills failed
cmds_dir = target / ".claude" / "commands"
assert cmds_dir.exists()
assert (cmds_dir / "speckit.specify.md").exists()
def test_here_mode_commands_preserved(self, tmp_path, monkeypatch):
"""For --here on existing repos, commands must NOT be removed."""
from typer.testing import CliRunner
runner = CliRunner()
# Create a mock existing project with commands already present
target = tmp_path / "existing"
target.mkdir()
agent_folder = AGENT_CONFIG["claude"]["folder"]
cmds_dir = target / agent_folder.rstrip("/") / "commands"
cmds_dir.mkdir(parents=True)
(cmds_dir / "speckit.specify.md").write_text("# spec")
# --here uses CWD, so chdir into the target
monkeypatch.chdir(target)
def fake_download(project_path, *args, **kwargs):
pass # commands already exist, no need to re-create
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", return_value=True), \
patch("specify_cli.is_git_repo", return_value=True), \
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
result = runner.invoke(app, ["init", "--here", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"], input="y\n")
assert result.exit_code == 0
# Commands must remain for --here
assert cmds_dir.exists()
assert (cmds_dir / "speckit.specify.md").exists()
# ===== Skip-If-Exists Tests =====
@@ -1075,92 +827,61 @@ class TestSkillDescriptions:
class TestCliValidation:
"""Test --ai-skills CLI flag validation."""
def test_ai_skills_without_ai_fails(self):
def test_ai_skills_without_ai_fails(self, tmp_path):
"""--ai-skills without --ai should fail with exit code 1."""
from typer.testing import CliRunner
runner = CliRunner()
result = runner.invoke(app, ["init", "test-proj", "--ai-skills"])
result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"])
assert result.exit_code == 1
assert "--ai-skills requires --ai" in result.output
def test_ai_skills_without_ai_shows_usage(self):
def test_ai_skills_without_ai_shows_usage(self, tmp_path):
"""Error message should include usage hint."""
from typer.testing import CliRunner
runner = CliRunner()
result = runner.invoke(app, ["init", "test-proj", "--ai-skills"])
result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"])
assert "Usage:" in result.output
assert "--ai" in result.output
def test_agy_without_ai_skills_fails(self):
"""--ai agy without --ai-skills should fail with exit code 1."""
def test_interactive_agy_without_ai_skills_uses_integration(self, tmp_path, monkeypatch):
"""Interactive selector returning agy should auto-promote to integration path."""
from typer.testing import CliRunner
runner = CliRunner()
result = runner.invoke(app, ["init", "test-proj", "--ai", "agy"])
assert result.exit_code == 1
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
# Mock select_with_arrows to simulate the user picking 'agy' for AI,
# and return a deterministic default for any other prompts to avoid
# calling the real interactive implementation.
def _fake_select_with_arrows(*args, **kwargs):
options = kwargs.get("options")
if options is None and len(args) >= 1:
options = args[0]
# If the options include 'agy', simulate selecting it.
if isinstance(options, dict) and "agy" in options:
return "agy"
if isinstance(options, (list, tuple)) and "agy" in options:
return "agy"
# For any other prompt, return a deterministic, non-interactive default:
# pick the first option if available.
if isinstance(options, dict) and options:
return next(iter(options.keys()))
if isinstance(options, (list, tuple)) and options:
return options[0]
# If no options are provided, fall back to None (should not occur in normal use).
return None
monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows)
# Mock download_and_extract_template to prevent real HTTP downloads during testing
monkeypatch.setattr("specify_cli.download_and_extract_template", lambda *args, **kwargs: None)
# We need to bypass the `git init` step, wait, it has `--no-git` by default in tests maybe?
runner = CliRunner()
# Create temp dir to avoid directory already exists errors or whatever
with runner.isolated_filesystem():
result = runner.invoke(app, ["init", "test-proj", "--no-git"])
target = tmp_path / "test-agy-interactive"
result = runner.invoke(app, ["init", str(target), "--no-git"])
# Interactive selection should NOT raise the deprecation error!
assert result.exit_code == 0
assert "Explicit command support was deprecated" not in result.output
assert result.exit_code == 0
# Should NOT raise the old deprecation error
assert "Explicit command support was deprecated" not in result.output
# Should use integration path (same as --ai agy)
assert "agy" 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."""
def test_interactive_codex_without_ai_skills_uses_integration(self, tmp_path, monkeypatch):
"""Interactive selector returning codex should auto-promote to integration path."""
from typer.testing import CliRunner
def _fake_select_with_arrows(*args, **kwargs):
@@ -1182,48 +903,18 @@ class TestCliValidation:
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"])
target = tmp_path / "test-codex-interactive"
result = runner.invoke(app, ["init", str(target), "--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
assert result.exit_code == 0
# Should NOT raise the old deprecation error
assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output
# Skills should be installed via integration path
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_ai_skills_flag_appears_in_help(self):
"""--ai-skills should appear in init --help output."""
@@ -1232,45 +923,10 @@ class TestCliValidation:
runner = CliRunner()
result = runner.invoke(app, ["init", "--help"])
plain = re.sub(r'\x1b\[[0-9;]*m', '', result.output)
plain = strip_ansi(result.output)
assert "--ai-skills" in plain
assert "agent skills" in plain.lower()
def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path):
"""--ai kiro should normalize to canonical kiro-cli and auto-promote to integration path."""
import os
from typer.testing import CliRunner
runner = CliRunner()
target = tmp_path / "kiro-alias-proj"
target.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(target)
result = runner.invoke(
app,
[
"init",
"--here",
"--ai",
"kiro",
"--ignore-agent-tools",
"--script",
"sh",
"--no-git",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# kiro alias should auto-promote to integration path with nudge
assert "--integration kiro-cli" in result.output
# Command files should be created via integration path
assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists()
def test_q_removed_from_agent_config(self):
"""Amazon Q legacy key should not remain in AGENT_CONFIG."""
assert "q" not in AGENT_CONFIG
@@ -1327,12 +983,12 @@ class TestParameterOrderingIssue:
output_lower = result.output.lower()
assert any(agent in output_lower for agent in ["claude", "copilot", "gemini"])
def test_ai_commands_dir_consuming_flag(self):
def test_ai_commands_dir_consuming_flag(self, tmp_path):
"""--ai-commands-dir without value should not consume next flag."""
from typer.testing import CliRunner
runner = CliRunner()
result = runner.invoke(app, ["init", "myproject", "--ai", "generic", "--ai-commands-dir", "--here"])
result = runner.invoke(app, ["init", str(tmp_path / "myproject"), "--ai", "generic", "--ai-commands-dir", "--here"])
assert result.exit_code == 1
assert "Invalid value for --ai-commands-dir" in result.output

View File

@@ -16,6 +16,7 @@ import shutil
from pathlib import Path
from datetime import datetime, timezone
from tests.conftest import strip_ansi
from specify_cli.extensions import (
CatalogEntry,
CORE_COMMAND_NAMES,
@@ -3126,11 +3127,12 @@ class TestExtensionListCLI:
result = runner.invoke(app, ["extension", "list"])
assert result.exit_code == 0, result.output
plain = strip_ansi(result.output)
# Verify the extension ID is shown in the output
assert "test-ext" in result.output
assert "test-ext" in plain
# Verify name and version are also shown
assert "Test Extension" in result.output
assert "1.0.0" in result.output
assert "Test Extension" in plain
assert "1.0.0" in plain
class TestExtensionPriority:
@@ -3360,7 +3362,8 @@ class TestExtensionPriorityCLI:
result = runner.invoke(app, ["extension", "list"])
assert result.exit_code == 0, result.output
assert "Priority: 7" in result.output
plain = strip_ansi(result.output)
assert "Priority: 7" in plain
def test_set_priority_changes_priority(self, extension_dir, project_dir):
"""Test set-priority command changes extension priority."""
@@ -3381,7 +3384,8 @@ class TestExtensionPriorityCLI:
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
assert result.exit_code == 0, result.output
assert "priority changed: 10 → 5" in result.output
plain = strip_ansi(result.output)
assert "priority changed: 10 → 5" in plain
# Reload registry to see updated value
manager2 = ExtensionManager(project_dir)
@@ -3403,7 +3407,8 @@ class TestExtensionPriorityCLI:
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
assert result.exit_code == 0, result.output
assert "already has priority 5" in result.output
plain = strip_ansi(result.output)
assert "already has priority 5" in plain
def test_set_priority_invalid_value(self, extension_dir, project_dir):
"""Test set-priority rejects invalid priority values."""

View File

@@ -20,6 +20,7 @@ from datetime import datetime, timezone
import yaml
from tests.conftest import strip_ansi
from specify_cli.presets import (
PresetManifest,
PresetRegistry,
@@ -2441,7 +2442,8 @@ class TestPresetSetPriority:
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
assert result.exit_code == 0, result.output
assert "priority changed: 10 → 5" in result.output
plain = strip_ansi(result.output)
assert "priority changed: 10 → 5" in plain
# Reload registry to see updated value
manager2 = PresetManager(project_dir)
@@ -2463,7 +2465,8 @@ class TestPresetSetPriority:
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
assert result.exit_code == 0, result.output
assert "already has priority 5" in result.output
plain = strip_ansi(result.output)
assert "already has priority 5" in plain
def test_set_priority_invalid_value(self, project_dir, pack_dir):
"""Test set-priority rejects invalid priority values."""