diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py
index 698b672dab..7d1ecbc007 100644
--- a/src/specify_cli/__init__.py
+++ b/src/specify_cli/__init__.py
@@ -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
"[/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 ' 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:
- 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
+ # 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
+ codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
kimi_skill_mode = selected_ai == "kimi"
- native_skill_mode = codex_skill_mode or kimi_skill_mode
+ 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
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}"
diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py
index 4a8c2d1b29..8107ae7017 100644
--- a/src/specify_cli/agents.py
+++ b/src/specify_cli/agents.py
@@ -168,6 +168,12 @@ class CommandRegistrar:
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
+ },
+ "agy": {
+ "dir": ".agent/skills",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": "/SKILL.md",
}
}
diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py
index ed131103c1..bb87cec996 100644
--- a/src/specify_cli/integrations/__init__.py
+++ b/src/specify_cli/integrations/__init__.py
@@ -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())
diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py
new file mode 100644
index 0000000000..9cd522745e
--- /dev/null
+++ b/src/specify_cli/integrations/agy/__init__.py
@@ -0,0 +1,41 @@
+"""Antigravity (agy) integration — skills-based agent.
+
+Antigravity uses ``.agent/skills/speckit-/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)",
+ ),
+ ]
diff --git a/src/specify_cli/integrations/agy/scripts/update-context.ps1 b/src/specify_cli/integrations/agy/scripts/update-context.ps1
new file mode 100644
index 0000000000..9eeb461657
--- /dev/null
+++ b/src/specify_cli/integrations/agy/scripts/update-context.ps1
@@ -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
diff --git a/src/specify_cli/integrations/agy/scripts/update-context.sh b/src/specify_cli/integrations/agy/scripts/update-context.sh
new file mode 100755
index 0000000000..d7303f6197
--- /dev/null
+++ b/src/specify_cli/integrations/agy/scripts/update-context.sh
@@ -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
diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py
index a88039b9a0..dac5063f5c 100644
--- a/src/specify_cli/integrations/base.py
+++ b/src/specify_cli/integrations/base.py
@@ -7,6 +7,8 @@
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-/SKILL.md`` layout).
"""
from __future__ import annotations
@@ -200,10 +202,14 @@ def write_file_and_record(
) -> 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 @@ def setup(
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-/SKILL.md`` directory layout following
+ the `agentskills.io `_ 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-/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-/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-/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
diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py
new file mode 100644
index 0000000000..f6415f9bb2
--- /dev/null
+++ b/src/specify_cli/integrations/codex/__init__.py
@@ -0,0 +1,40 @@
+"""Codex CLI integration — skills-based agent.
+
+Codex uses the ``.agents/skills/speckit-/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)",
+ ),
+ ]
diff --git a/src/specify_cli/integrations/codex/scripts/update-context.ps1 b/src/specify_cli/integrations/codex/scripts/update-context.ps1
new file mode 100644
index 0000000000..d73a5a4d34
--- /dev/null
+++ b/src/specify_cli/integrations/codex/scripts/update-context.ps1
@@ -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
diff --git a/src/specify_cli/integrations/codex/scripts/update-context.sh b/src/specify_cli/integrations/codex/scripts/update-context.sh
new file mode 100755
index 0000000000..512d6e91d3
--- /dev/null
+++ b/src/specify_cli/integrations/codex/scripts/update-context.sh
@@ -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
diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py
new file mode 100644
index 0000000000..4107c48690
--- /dev/null
+++ b/src/specify_cli/integrations/generic/__init__.py
@@ -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
diff --git a/src/specify_cli/integrations/generic/scripts/update-context.ps1 b/src/specify_cli/integrations/generic/scripts/update-context.ps1
new file mode 100644
index 0000000000..2e9467f801
--- /dev/null
+++ b/src/specify_cli/integrations/generic/scripts/update-context.ps1
@@ -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
diff --git a/src/specify_cli/integrations/generic/scripts/update-context.sh b/src/specify_cli/integrations/generic/scripts/update-context.sh
new file mode 100755
index 0000000000..d8ad30a7b8
--- /dev/null
+++ b/src/specify_cli/integrations/generic/scripts/update-context.sh
@@ -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
diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py
new file mode 100644
index 0000000000..5421d48012
--- /dev/null
+++ b/src/specify_cli/integrations/kimi/__init__.py
@@ -0,0 +1,124 @@
+"""Kimi Code integration — skills-based agent (Moonshot AI).
+
+Kimi uses the ``.kimi/skills/speckit-/SKILL.md`` layout with
+``/skill:speckit-`` 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)
diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.ps1 b/src/specify_cli/integrations/kimi/scripts/update-context.ps1
new file mode 100644
index 0000000000..aa6678d052
--- /dev/null
+++ b/src/specify_cli/integrations/kimi/scripts/update-context.ps1
@@ -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
diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.sh b/src/specify_cli/integrations/kimi/scripts/update-context.sh
new file mode 100755
index 0000000000..2f81bc2a48
--- /dev/null
+++ b/src/specify_cli/integrations/kimi/scripts/update-context.sh
@@ -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
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000000..4387c9ac8f
--- /dev/null
+++ b/tests/conftest.py
@@ -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)
diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py
index 03b0e11866..cd0071783f 100644
--- a/tests/integrations/test_cli.py
+++ b/tests/integrations/test_cli.py
@@ -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
diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py
new file mode 100644
index 0000000000..3efaa99362
--- /dev/null
+++ b/tests/integrations/test_integration_agy.py
@@ -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
diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py
new file mode 100644
index 0000000000..23505c3062
--- /dev/null
+++ b/tests/integrations/test_integration_base_skills.py
@@ -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-/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-/ 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-/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 --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 --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))}"
+ )
diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py
new file mode 100644
index 0000000000..eb633f02ba
--- /dev/null
+++ b/tests/integrations/test_integration_codex.py
@@ -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
diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py
new file mode 100644
index 0000000000..2815456f21
--- /dev/null
+++ b/tests/integrations/test_integration_generic.py
@@ -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))}"
+ )
diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py
new file mode 100644
index 0000000000..25787e612e
--- /dev/null
+++ b/tests/integrations/test_integration_kimi.py
@@ -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
diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py
index d6ae7afce1..6b2b27b777 100644
--- a/tests/integrations/test_integration_kiro_cli.py
+++ b/tests/integrations/test_integration_kiro_cli.py
@@ -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()
diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py
index e70f3006ac..8ab1425148 100644
--- a/tests/integrations/test_registry.py
+++ b/tests/integrations/test_registry.py
@@ -11,13 +11,17 @@
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 @@ def test_key_registered(self, key):
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, (
diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py
index 7f9ecf66ab..e4ee41828c 100644
--- a/tests/test_ai_skills.py
+++ b/tests/test_ai_skills.py
@@ -10,7 +10,6 @@
- CLI validation: --ai-skills requires --ai
"""
-import re
import zipfile
import pytest
import tempfile
@@ -21,6 +20,7 @@
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 @@ def test_no_commands_dir_no_error(self, project_dir, templates_dir):
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 @@ def test_download_and_extract_template_blocks_zip_path_traversal(self, tmp_path,
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 @@ def test_all_known_commands_have_descriptions(self):
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."""
- 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."""
+ 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
- # 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 @@ def _fake_select_with_arrows(*args, **kwargs):
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"],
- )
+ target = tmp_path / "test-codex-interactive"
+ result = runner.invoke(app, ["init", str(target), "--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 @@ def test_ai_skills_flag_appears_in_help(self):
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 @@ def test_error_message_lists_available_agents(self):
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
diff --git a/tests/test_extensions.py b/tests/test_extensions.py
index a5ee4e03a6..df269d86c4 100644
--- a/tests/test_extensions.py
+++ b/tests/test_extensions.py
@@ -16,6 +16,7 @@
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 @@ def test_list_shows_extension_id(self, extension_dir, project_dir):
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 @@ def test_list_shows_priority(self, extension_dir, project_dir):
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 @@ def test_set_priority_changes_priority(self, extension_dir, project_dir):
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 @@ def test_set_priority_same_value_no_change(self, extension_dir, project_dir):
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."""
diff --git a/tests/test_presets.py b/tests/test_presets.py
index 1b2704c57f..cf02709b27 100644
--- a/tests/test_presets.py
+++ b/tests/test_presets.py
@@ -20,6 +20,7 @@
import yaml
+from tests.conftest import strip_ansi
from specify_cli.presets import (
PresetManifest,
PresetRegistry,
@@ -2441,7 +2442,8 @@ def test_set_priority_changes_priority(self, project_dir, pack_dir):
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 @@ def test_set_priority_same_value_no_change(self, project_dir, pack_dir):
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."""