mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* Stage 5: Skills, Generic & Option-Driven Integrations (#1924) Add SkillsIntegration base class and migrate codex, kimi, agy, and generic to the integration system. Integrations: - SkillsIntegration(IntegrationBase) in base.py — creates speckit-<name>/SKILL.md layout matching release ZIP output byte-for-byte - CodexIntegration — .agents/skills/, --skills default=True - KimiIntegration — .kimi/skills/, --skills + --migrate-legacy options, dotted→hyphenated skill directory migration - AgyIntegration — .agent/skills/, skills-only (commands deprecated v1.20.5) - GenericIntegration — user-specified --commands-dir, MarkdownIntegration - All four have update-context.sh/.ps1 scripts - All four registered in INTEGRATION_REGISTRY CLI changes: - --ai <agent> auto-promotes to integration path for all registered agents - Interactive agent selection also auto-promotes (bug fix) - --ai-skills and --ai-commands-dir show deprecation notices on integration path - Next-steps display shows correct skill invocation syntax for skills integrations - agy added to CommandRegistrar.AGENT_CONFIGS Tests: - test_integration_base_skills.py — reusable mixin with setup, frontmatter, directory structure, scripts, CLI auto-promote, and complete file inventory (sh+ps) tests - Per-agent test files: test_integration_{codex,kimi,agy,generic}.py - Kimi legacy migration tests, generic --commands-dir validation - Registry updated with Stage 5 keys - Removed 9 dead-mock tests, moved 4 integration tests to proper locations - Fixed all bare project-name tests to use tmp_path - Fixed 6 pre-existing ANSI escape code test failures in test_extensions.py and test_presets.py 1524 tests pass, 0 failures. * fix: remove unused variable flagged by ruff (F841) * fix: address PR review — integration-type-aware deprecation messages and early generic validation - --ai-skills deprecation message now distinguishes SkillsIntegration ("skills are the default") from command-based integrations ("has no effect") - --ai-commands-dir validation for generic runs even when auto-promoted, giving clear CLI error instead of late ValueError from setup() - Resolves review comments from #2052 * fix: address PR review round 2 - Remove unused SKILL_DESCRIPTIONS dict from base.py (dead code after switching to template descriptions for ZIP parity) - Narrow YAML parse catch from Exception to yaml.YAMLError - Remove unused shutil import from test_integration_kimi.py - Remove unused _REGISTRAR_EXEMPT class attr from test_registry.py - Reword --ai-commands-dir deprecation to be actionable - Update generic validation error to mention both --ai and --integration * fix: address PR review round 3 - Clarify parsed_options forwarding is intentional (all options passed, integrations decide what to use) - Extract _strip_ansi() helper in test_extensions.py and test_presets.py - Remove unused pytest import (test_cli.py), unused locals (test_integration_base_skills.py) - Reword --ai-commands-dir deprecation to be actionable without referencing the not-yet-implemented --integration-options * fix: address PR review round 4 - Reorder kimi migration: run super().setup() first so hyphenated targets exist, then migrate dotted dirs (prevents user content loss) - Move _strip_ansi() to shared tests/conftest.py, import from there in test_extensions.py, test_presets.py, test_ai_skills.py - Remove now-unused re imports from all three test files * fix: address PR review round 5 - Use write_bytes() for LF-only newlines (no CRLF on Windows) - Add --integration-options CLI parameter — raw string passed through to the integration via opts['raw_options']; the integration owns parsing of its own options - GenericIntegration.setup() reads --commands-dir from raw_options when not in parsed_options (supports --integration-options="...") - Skip early --ai-commands-dir validation when --integration-options is provided (integration validates in its own setup()) - Remove parse_integration_options from core — integrations parse their own options * fix: address PR review round 6 - GenericIntegration is now stateless: removed self._commands_dir instance state, overrides setup() directly to compute destination from parsed_options/raw_options on the stack - commands_dest() raises by design (stateless singleton) - _quote() in SkillsIntegration now escapes backslashes and double quotes to produce valid YAML even with special characters * fix: address PR review round 7 - Support --commands-dir=value form in raw_options parsing (not just --commands-dir value with space separator) - Normalize CRLF to LF in write_file_and_record() before encoding - Persist ai_skills=True in init-options.json when using a SkillsIntegration, so extensions/presets emit SKILL.md overrides correctly even without explicit --ai-skills flag
This commit is contained in:
@@ -1907,6 +1907,7 @@ def init(
|
||||
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
||||
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"),
|
||||
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
|
||||
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
||||
):
|
||||
"""
|
||||
Initialize a new Specify project.
|
||||
@@ -1997,6 +1998,26 @@ def init(
|
||||
f"--ai {ai_assistant}. The --ai flag will be deprecated in a future release.[/dim]"
|
||||
)
|
||||
|
||||
# Deprecation warnings for --ai-skills and --ai-commands-dir when using integration path
|
||||
if use_integration:
|
||||
if ai_skills:
|
||||
from .integrations.base import SkillsIntegration as _SkillsCheck
|
||||
if isinstance(resolved_integration, _SkillsCheck):
|
||||
console.print(
|
||||
"[dim]Note: --ai-skills is not needed with --integration; "
|
||||
"skills are the default for this integration.[/dim]"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
"[dim]Note: --ai-skills has no effect with --integration "
|
||||
f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]"
|
||||
)
|
||||
if ai_commands_dir and resolved_integration.key != "generic":
|
||||
console.print(
|
||||
"[dim]Note: --ai-commands-dir is deprecated; "
|
||||
'use [bold]--integration generic --integration-options="--commands-dir <dir>"[/bold] instead.[/dim]'
|
||||
)
|
||||
|
||||
if project_name == ".":
|
||||
here = True
|
||||
project_name = None # Clear project_name to use existing validation logic
|
||||
@@ -2062,8 +2083,18 @@ def init(
|
||||
"copilot"
|
||||
)
|
||||
|
||||
# Auto-promote interactively selected agents to the integration path
|
||||
# when a matching integration is registered (same behavior as --ai).
|
||||
if not use_integration:
|
||||
from .integrations import get_integration as _get_int
|
||||
_resolved = _get_int(selected_ai)
|
||||
if _resolved:
|
||||
use_integration = True
|
||||
resolved_integration = _resolved
|
||||
|
||||
# Agents that have moved from explicit commands/prompts to agent skills.
|
||||
if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills:
|
||||
# Skip this check when using the integration path — skills are the default.
|
||||
if not use_integration and selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills:
|
||||
# If selected interactively (no --ai provided), automatically enable
|
||||
# ai_skills so the agent remains usable without requiring an extra flag.
|
||||
# Preserve fail-fast behavior only for explicit '--ai <agent>' without skills.
|
||||
@@ -2073,14 +2104,20 @@ def init(
|
||||
ai_skills = True
|
||||
console.print(f"\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}")
|
||||
|
||||
# Validate --ai-commands-dir usage
|
||||
if selected_ai == "generic":
|
||||
# Validate --ai-commands-dir usage.
|
||||
# Skip validation when --integration-options is provided — the integration
|
||||
# will validate its own options in setup().
|
||||
if selected_ai == "generic" and not integration_options:
|
||||
if not ai_commands_dir:
|
||||
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic")
|
||||
console.print("[dim]Example: specify init my-project --ai generic --ai-commands-dir .myagent/commands/[/dim]")
|
||||
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic")
|
||||
console.print("[dim]Example: specify init my-project --integration generic --integration-options=\"--commands-dir .myagent/commands/\"[/dim]")
|
||||
raise typer.Exit(1)
|
||||
elif ai_commands_dir:
|
||||
console.print(f"[red]Error:[/red] --ai-commands-dir can only be used with --ai generic (not '{selected_ai}')")
|
||||
elif ai_commands_dir and not use_integration:
|
||||
console.print(
|
||||
f"[red]Error:[/red] --ai-commands-dir can only be used with the "
|
||||
f"'generic' integration via --ai generic or --integration generic "
|
||||
f"(not '{selected_ai}')"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
current_dir = Path.cwd()
|
||||
@@ -2210,9 +2247,21 @@ def init(
|
||||
manifest = IntegrationManifest(
|
||||
resolved_integration.key, project_path, version=get_speckit_version()
|
||||
)
|
||||
|
||||
# Forward all legacy CLI flags to the integration as parsed_options.
|
||||
# Integrations receive every option and decide what to use;
|
||||
# irrelevant keys are simply ignored by the integration's setup().
|
||||
integration_parsed_options: dict[str, Any] = {}
|
||||
if ai_commands_dir:
|
||||
integration_parsed_options["commands_dir"] = ai_commands_dir
|
||||
if ai_skills:
|
||||
integration_parsed_options["skills"] = True
|
||||
|
||||
resolved_integration.setup(
|
||||
project_path, manifest,
|
||||
parsed_options=integration_parsed_options or None,
|
||||
script_type=selected_script,
|
||||
raw_options=integration_options,
|
||||
)
|
||||
manifest.save()
|
||||
|
||||
@@ -2268,7 +2317,7 @@ def init(
|
||||
shutil.rmtree(project_path)
|
||||
raise typer.Exit(1)
|
||||
# For generic agent, rename placeholder directory to user-specified path
|
||||
if selected_ai == "generic" and ai_commands_dir:
|
||||
if not use_integration and selected_ai == "generic" and ai_commands_dir:
|
||||
placeholder_dir = project_path / ".speckit" / "commands"
|
||||
target_dir = project_path / ai_commands_dir
|
||||
if placeholder_dir.is_dir():
|
||||
@@ -2284,10 +2333,11 @@ def init(
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
|
||||
# Determine skills directory and migrate any legacy Kimi dotted skills.
|
||||
# (Legacy path only — integration path handles skills in setup().)
|
||||
migrated_legacy_kimi_skills = 0
|
||||
removed_legacy_kimi_skills = 0
|
||||
skills_dir: Optional[Path] = None
|
||||
if selected_ai in NATIVE_SKILLS_AGENTS:
|
||||
if not use_integration and selected_ai in NATIVE_SKILLS_AGENTS:
|
||||
skills_dir = _get_skills_dir(project_path, selected_ai)
|
||||
if selected_ai == "kimi" and skills_dir.is_dir():
|
||||
(
|
||||
@@ -2295,7 +2345,7 @@ def init(
|
||||
removed_legacy_kimi_skills,
|
||||
) = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
if ai_skills:
|
||||
if not use_integration and ai_skills:
|
||||
if selected_ai in NATIVE_SKILLS_AGENTS:
|
||||
bundled_found = _has_bundled_skills(project_path, selected_ai)
|
||||
if bundled_found:
|
||||
@@ -2383,6 +2433,11 @@ def init(
|
||||
}
|
||||
if use_integration:
|
||||
init_opts["integration"] = resolved_integration.key
|
||||
# Ensure ai_skills is set for SkillsIntegration so downstream
|
||||
# tools (extensions, presets) emit SKILL.md overrides correctly.
|
||||
from .integrations.base import SkillsIntegration as _SkillsPersist
|
||||
if isinstance(resolved_integration, _SkillsPersist):
|
||||
init_opts["ai_skills"] = True
|
||||
save_init_options(project_path, init_opts)
|
||||
|
||||
# Install preset if specified
|
||||
@@ -2484,17 +2539,27 @@ def init(
|
||||
steps_lines.append("1. You're already in the project directory!")
|
||||
step_num = 2
|
||||
|
||||
if selected_ai == "codex" and ai_skills:
|
||||
# Determine skill display mode for the next-steps panel.
|
||||
# Skills integrations (codex, kimi, agy) should show skill invocation syntax
|
||||
# regardless of whether --ai-skills was explicitly passed.
|
||||
_is_skills_integration = False
|
||||
if use_integration:
|
||||
from .integrations.base import SkillsIntegration as _SkillsInt
|
||||
_is_skills_integration = isinstance(resolved_integration, _SkillsInt)
|
||||
|
||||
codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
|
||||
native_skill_mode = codex_skill_mode or kimi_skill_mode or agy_skill_mode
|
||||
|
||||
if codex_skill_mode and not ai_skills:
|
||||
# Integration path installed skills; show the helpful notice
|
||||
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
|
||||
step_num += 1
|
||||
|
||||
codex_skill_mode = selected_ai == "codex" and ai_skills
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
native_skill_mode = codex_skill_mode or kimi_skill_mode
|
||||
usage_label = "skills" if native_skill_mode else "slash commands"
|
||||
|
||||
def _display_cmd(name: str) -> str:
|
||||
if codex_skill_mode:
|
||||
if codex_skill_mode or agy_skill_mode:
|
||||
return f"$speckit-{name}"
|
||||
if kimi_skill_mode:
|
||||
return f"/skill:speckit-{name}"
|
||||
|
||||
@@ -168,6 +168,12 @@ class CommandRegistrar:
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
},
|
||||
"agy": {
|
||||
"dir": ".agent/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
41
src/specify_cli/integrations/agy/__init__.py
Normal file
41
src/specify_cli/integrations/agy/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Antigravity (agy) integration — skills-based agent.
|
||||
|
||||
Antigravity uses ``.agent/skills/speckit-<name>/SKILL.md`` layout.
|
||||
Explicit command support was deprecated in version 1.20.5;
|
||||
``--skills`` defaults to ``True``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
|
||||
|
||||
class AgyIntegration(SkillsIntegration):
|
||||
"""Integration for Antigravity IDE."""
|
||||
|
||||
key = "agy"
|
||||
config = {
|
||||
"name": "Antigravity",
|
||||
"folder": ".agent/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".agent/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills (default for Antigravity since v1.20.5)",
|
||||
),
|
||||
]
|
||||
17
src/specify_cli/integrations/agy/scripts/update-context.ps1
Normal file
17
src/specify_cli/integrations/agy/scripts/update-context.ps1
Normal file
@@ -0,0 +1,17 @@
|
||||
# update-context.ps1 — Antigravity (agy) integration: create/update AGENTS.md
|
||||
#
|
||||
# Thin wrapper that delegates to the shared update-agent-context script.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
|
||||
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
|
||||
$repoRoot = $scriptDir
|
||||
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
|
||||
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
|
||||
$repoRoot = Split-Path -Parent $repoRoot
|
||||
}
|
||||
}
|
||||
|
||||
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType agy
|
||||
24
src/specify_cli/integrations/agy/scripts/update-context.sh
Executable file
24
src/specify_cli/integrations/agy/scripts/update-context.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# update-context.sh — Antigravity (agy) integration: create/update AGENTS.md
|
||||
#
|
||||
# Thin wrapper that delegates to the shared update-agent-context script.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
_script_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||
_root="$_script_dir"
|
||||
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
|
||||
if [ -z "${REPO_ROOT:-}" ]; then
|
||||
if [ -d "$_root/.specify" ]; then
|
||||
REPO_ROOT="$_root"
|
||||
else
|
||||
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
|
||||
REPO_ROOT="$git_root"
|
||||
else
|
||||
REPO_ROOT="$_root"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" agy
|
||||
@@ -7,6 +7,8 @@ Provides:
|
||||
integrations (the common case — subclass, set three class attrs, done).
|
||||
- ``TomlIntegration`` — concrete base for TOML-format integrations
|
||||
(Gemini, Tabnine — subclass, set three class attrs, done).
|
||||
- ``SkillsIntegration`` — concrete base for integrations that install
|
||||
commands as agent skills (``speckit-<name>/SKILL.md`` layout).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -200,10 +202,14 @@ class IntegrationBase(ABC):
|
||||
) -> Path:
|
||||
"""Write *content* to *dest*, hash it, and record in *manifest*.
|
||||
|
||||
Creates parent directories as needed. Returns *dest*.
|
||||
Creates parent directories as needed. Writes bytes directly to
|
||||
avoid platform newline translation (CRLF on Windows). Any
|
||||
``\r\n`` sequences in *content* are normalised to ``\n`` before
|
||||
writing. Returns *dest*.
|
||||
"""
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(content, encoding="utf-8")
|
||||
normalized = content.replace("\r\n", "\n")
|
||||
dest.write_bytes(normalized.encode("utf-8"))
|
||||
rel = dest.resolve().relative_to(project_root.resolve())
|
||||
manifest.record_existing(rel)
|
||||
return dest
|
||||
@@ -633,3 +639,155 @@ class TomlIntegration(IntegrationBase):
|
||||
|
||||
created.extend(self.install_scripts(project_root, manifest))
|
||||
return created
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SkillsIntegration — skills-format agents (Codex, Kimi, Agy)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SkillsIntegration(IntegrationBase):
|
||||
"""Concrete base for integrations that install commands as agent skills.
|
||||
|
||||
Skills use the ``speckit-<name>/SKILL.md`` directory layout following
|
||||
the `agentskills.io <https://agentskills.io/specification>`_ spec.
|
||||
|
||||
Subclasses set ``key``, ``config``, ``registrar_config`` (and
|
||||
optionally ``context_file``) like any integration. They may also
|
||||
override ``options()`` to declare additional CLI flags (e.g.
|
||||
``--skills``, ``--migrate-legacy``).
|
||||
|
||||
``setup()`` processes each shared command template into a
|
||||
``speckit-<name>/SKILL.md`` file with skills-oriented frontmatter.
|
||||
"""
|
||||
|
||||
def skills_dest(self, project_root: Path) -> Path:
|
||||
"""Return the absolute path to the skills output directory.
|
||||
|
||||
Derived from ``config["folder"]`` and the configured
|
||||
``commands_subdir`` (defaults to ``"skills"``).
|
||||
|
||||
Raises ``ValueError`` when ``config`` or ``folder`` is missing.
|
||||
"""
|
||||
if not self.config:
|
||||
raise ValueError(
|
||||
f"{type(self).__name__}.config is not set."
|
||||
)
|
||||
folder = self.config.get("folder")
|
||||
if not folder:
|
||||
raise ValueError(
|
||||
f"{type(self).__name__}.config is missing required 'folder' entry."
|
||||
)
|
||||
subdir = self.config.get("commands_subdir", "skills")
|
||||
return project_root / folder / subdir
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install command templates as agent skills.
|
||||
|
||||
Creates ``speckit-<name>/SKILL.md`` for each shared command
|
||||
template. Each SKILL.md has normalised frontmatter containing
|
||||
``name``, ``description``, ``compatibility``, and ``metadata``.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
templates = self.list_command_templates()
|
||||
if not templates:
|
||||
return []
|
||||
|
||||
project_root_resolved = project_root.resolve()
|
||||
if manifest.project_root != project_root_resolved:
|
||||
raise ValueError(
|
||||
f"manifest.project_root ({manifest.project_root}) does not match "
|
||||
f"project_root ({project_root_resolved})"
|
||||
)
|
||||
|
||||
skills_dir = self.skills_dest(project_root).resolve()
|
||||
try:
|
||||
skills_dir.relative_to(project_root_resolved)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Skills destination {skills_dir} escapes "
|
||||
f"project root {project_root_resolved}"
|
||||
) from exc
|
||||
|
||||
script_type = opts.get("script_type", "sh")
|
||||
arg_placeholder = (
|
||||
self.registrar_config.get("args", "$ARGUMENTS")
|
||||
if self.registrar_config
|
||||
else "$ARGUMENTS"
|
||||
)
|
||||
created: list[Path] = []
|
||||
|
||||
for src_file in templates:
|
||||
raw = src_file.read_text(encoding="utf-8")
|
||||
|
||||
# Derive the skill name from the template stem
|
||||
command_name = src_file.stem # e.g. "plan"
|
||||
skill_name = f"speckit-{command_name.replace('.', '-')}"
|
||||
|
||||
# Parse frontmatter for description
|
||||
frontmatter: dict[str, Any] = {}
|
||||
if raw.startswith("---"):
|
||||
parts = raw.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
fm = yaml.safe_load(parts[1])
|
||||
if isinstance(fm, dict):
|
||||
frontmatter = fm
|
||||
except yaml.YAMLError:
|
||||
pass
|
||||
|
||||
# Process body through the standard template pipeline
|
||||
processed_body = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder
|
||||
)
|
||||
# Strip the processed frontmatter — we rebuild it for skills.
|
||||
# Preserve leading whitespace in the body to match release ZIP
|
||||
# output byte-for-byte (the template body starts with \n after
|
||||
# the closing ---).
|
||||
if processed_body.startswith("---"):
|
||||
parts = processed_body.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
processed_body = parts[2]
|
||||
|
||||
# Select description — use the original template description
|
||||
# to stay byte-for-byte identical with release ZIP output.
|
||||
description = frontmatter.get("description", "")
|
||||
if not description:
|
||||
description = f"Spec Kit: {command_name} workflow"
|
||||
|
||||
# Build SKILL.md with manually formatted frontmatter to match
|
||||
# the release packaging script output exactly (double-quoted
|
||||
# values, no yaml.safe_dump quoting differences).
|
||||
def _quote(v: str) -> str:
|
||||
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
f"name: {_quote(skill_name)}\n"
|
||||
f"description: {_quote(description)}\n"
|
||||
f"compatibility: {_quote('Requires spec-kit project structure with .specify/ directory')}\n"
|
||||
f"metadata:\n"
|
||||
f" author: {_quote('github-spec-kit')}\n"
|
||||
f" source: {_quote('templates/commands/' + src_file.name)}\n"
|
||||
f"---\n"
|
||||
f"{processed_body}"
|
||||
)
|
||||
|
||||
# Write speckit-<name>/SKILL.md
|
||||
skill_dir = skills_dir / skill_name
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
dst = self.write_file_and_record(
|
||||
skill_content, skill_file, project_root, manifest
|
||||
)
|
||||
created.append(dst)
|
||||
|
||||
created.extend(self.install_scripts(project_root, manifest))
|
||||
return created
|
||||
|
||||
40
src/specify_cli/integrations/codex/__init__.py
Normal file
40
src/specify_cli/integrations/codex/__init__.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Codex CLI integration — skills-based agent.
|
||||
|
||||
Codex uses the ``.agents/skills/speckit-<name>/SKILL.md`` layout.
|
||||
Commands are deprecated; ``--skills`` defaults to ``True``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
|
||||
|
||||
class CodexIntegration(SkillsIntegration):
|
||||
"""Integration for OpenAI Codex CLI."""
|
||||
|
||||
key = "codex"
|
||||
config = {
|
||||
"name": "Codex CLI",
|
||||
"folder": ".agents/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": "https://github.com/openai/codex",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".agents/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills (default for Codex)",
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
24
src/specify_cli/integrations/codex/scripts/update-context.sh
Executable file
24
src/specify_cli/integrations/codex/scripts/update-context.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# update-context.sh — Codex CLI integration: create/update AGENTS.md
|
||||
#
|
||||
# Thin wrapper that delegates to the shared update-agent-context script.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
_script_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||
_root="$_script_dir"
|
||||
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
|
||||
if [ -z "${REPO_ROOT:-}" ]; then
|
||||
if [ -d "$_root/.specify" ]; then
|
||||
REPO_ROOT="$_root"
|
||||
else
|
||||
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
|
||||
REPO_ROOT="$git_root"
|
||||
else
|
||||
REPO_ROOT="$_root"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" codex
|
||||
133
src/specify_cli/integrations/generic/__init__.py
Normal file
133
src/specify_cli/integrations/generic/__init__.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Generic integration — bring your own agent.
|
||||
|
||||
Requires ``--commands-dir`` to specify the output directory for command
|
||||
files. No longer special-cased in the core CLI — just another
|
||||
integration with its own required option.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import IntegrationOption, MarkdownIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
class GenericIntegration(MarkdownIntegration):
|
||||
"""Integration for user-specified (generic) agents."""
|
||||
|
||||
key = "generic"
|
||||
config = {
|
||||
"name": "Generic (bring your own agent)",
|
||||
"folder": None, # Set dynamically from --commands-dir
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": "", # Set dynamically from --commands-dir
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = None
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--commands-dir",
|
||||
required=True,
|
||||
help="Directory for command files (e.g. .myagent/commands/)",
|
||||
),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _resolve_commands_dir(
|
||||
parsed_options: dict[str, Any] | None,
|
||||
opts: dict[str, Any],
|
||||
) -> str:
|
||||
"""Extract ``--commands-dir`` from parsed options or raw_options.
|
||||
|
||||
Returns the directory string or raises ``ValueError``.
|
||||
"""
|
||||
parsed_options = parsed_options or {}
|
||||
|
||||
commands_dir = parsed_options.get("commands_dir")
|
||||
if commands_dir:
|
||||
return commands_dir
|
||||
|
||||
# Fall back to raw_options (--integration-options="--commands-dir ...")
|
||||
raw = opts.get("raw_options")
|
||||
if raw:
|
||||
import shlex
|
||||
tokens = shlex.split(raw)
|
||||
for i, token in enumerate(tokens):
|
||||
if token == "--commands-dir" and i + 1 < len(tokens):
|
||||
return tokens[i + 1]
|
||||
if token.startswith("--commands-dir="):
|
||||
return token.split("=", 1)[1]
|
||||
|
||||
raise ValueError(
|
||||
"--commands-dir is required for the generic integration"
|
||||
)
|
||||
|
||||
def commands_dest(self, project_root: Path) -> Path:
|
||||
"""Not supported for GenericIntegration — use setup() directly.
|
||||
|
||||
GenericIntegration is stateless; the output directory comes from
|
||||
``parsed_options`` or ``raw_options`` at call time, not from
|
||||
instance state.
|
||||
"""
|
||||
raise ValueError(
|
||||
"GenericIntegration.commands_dest() cannot be called directly; "
|
||||
"the output directory is resolved from parsed_options in setup()"
|
||||
)
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install commands to the user-provided commands directory."""
|
||||
commands_dir = self._resolve_commands_dir(parsed_options, opts)
|
||||
|
||||
templates = self.list_command_templates()
|
||||
if not templates:
|
||||
return []
|
||||
|
||||
project_root_resolved = project_root.resolve()
|
||||
if manifest.project_root != project_root_resolved:
|
||||
raise ValueError(
|
||||
f"manifest.project_root ({manifest.project_root}) does not match "
|
||||
f"project_root ({project_root_resolved})"
|
||||
)
|
||||
|
||||
dest = (project_root / commands_dir).resolve()
|
||||
try:
|
||||
dest.relative_to(project_root_resolved)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Integration destination {dest} escapes "
|
||||
f"project root {project_root_resolved}"
|
||||
) from exc
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
script_type = opts.get("script_type", "sh")
|
||||
arg_placeholder = "$ARGUMENTS"
|
||||
created: list[Path] = []
|
||||
|
||||
for src_file in templates:
|
||||
raw = src_file.read_text(encoding="utf-8")
|
||||
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
|
||||
dst_name = self.command_filename(src_file.stem)
|
||||
dst_file = self.write_file_and_record(
|
||||
processed, dest / dst_name, project_root, manifest
|
||||
)
|
||||
created.append(dst_file)
|
||||
|
||||
created.extend(self.install_scripts(project_root, manifest))
|
||||
return created
|
||||
@@ -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
|
||||
24
src/specify_cli/integrations/generic/scripts/update-context.sh
Executable file
24
src/specify_cli/integrations/generic/scripts/update-context.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# update-context.sh — Generic integration: create/update context file
|
||||
#
|
||||
# Thin wrapper that delegates to the shared update-agent-context script.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
_script_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||
_root="$_script_dir"
|
||||
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
|
||||
if [ -z "${REPO_ROOT:-}" ]; then
|
||||
if [ -d "$_root/.specify" ]; then
|
||||
REPO_ROOT="$_root"
|
||||
else
|
||||
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
|
||||
REPO_ROOT="$git_root"
|
||||
else
|
||||
REPO_ROOT="$_root"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" generic
|
||||
124
src/specify_cli/integrations/kimi/__init__.py
Normal file
124
src/specify_cli/integrations/kimi/__init__.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Kimi Code integration — skills-based agent (Moonshot AI).
|
||||
|
||||
Kimi uses the ``.kimi/skills/speckit-<name>/SKILL.md`` layout with
|
||||
``/skill:speckit-<name>`` invocation syntax.
|
||||
|
||||
Includes legacy migration logic for projects initialised before Kimi
|
||||
moved from dotted skill directories (``speckit.xxx``) to hyphenated
|
||||
(``speckit-xxx``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
class KimiIntegration(SkillsIntegration):
|
||||
"""Integration for Kimi Code CLI (Moonshot AI)."""
|
||||
|
||||
key = "kimi"
|
||||
config = {
|
||||
"name": "Kimi Code",
|
||||
"folder": ".kimi/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": "https://code.kimi.com/",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".kimi/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = "KIMI.md"
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills (default for Kimi)",
|
||||
),
|
||||
IntegrationOption(
|
||||
"--migrate-legacy",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Migrate legacy dotted skill dirs (speckit.xxx → speckit-xxx)",
|
||||
),
|
||||
]
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install skills with optional legacy dotted-name migration."""
|
||||
parsed_options = parsed_options or {}
|
||||
|
||||
# Run base setup first so hyphenated targets (speckit-*) exist,
|
||||
# then migrate/clean legacy dotted dirs without risking user content loss.
|
||||
created = super().setup(
|
||||
project_root, manifest, parsed_options=parsed_options, **opts
|
||||
)
|
||||
|
||||
if parsed_options.get("migrate_legacy", False):
|
||||
skills_dir = self.skills_dest(project_root)
|
||||
if skills_dir.is_dir():
|
||||
_migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]:
|
||||
"""Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format.
|
||||
|
||||
Returns ``(migrated_count, removed_count)``.
|
||||
"""
|
||||
if not skills_dir.is_dir():
|
||||
return (0, 0)
|
||||
|
||||
migrated_count = 0
|
||||
removed_count = 0
|
||||
|
||||
for legacy_dir in sorted(skills_dir.glob("speckit.*")):
|
||||
if not legacy_dir.is_dir():
|
||||
continue
|
||||
if not (legacy_dir / "SKILL.md").exists():
|
||||
continue
|
||||
|
||||
suffix = legacy_dir.name[len("speckit."):]
|
||||
if not suffix:
|
||||
continue
|
||||
|
||||
target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}"
|
||||
|
||||
if not target_dir.exists():
|
||||
shutil.move(str(legacy_dir), str(target_dir))
|
||||
migrated_count += 1
|
||||
continue
|
||||
|
||||
# Target exists — only remove legacy if SKILL.md is identical
|
||||
target_skill = target_dir / "SKILL.md"
|
||||
legacy_skill = legacy_dir / "SKILL.md"
|
||||
if target_skill.is_file():
|
||||
try:
|
||||
if target_skill.read_bytes() == legacy_skill.read_bytes():
|
||||
has_extra = any(
|
||||
child.name != "SKILL.md" for child in legacy_dir.iterdir()
|
||||
)
|
||||
if not has_extra:
|
||||
shutil.rmtree(legacy_dir)
|
||||
removed_count += 1
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return (migrated_count, removed_count)
|
||||
17
src/specify_cli/integrations/kimi/scripts/update-context.ps1
Normal file
17
src/specify_cli/integrations/kimi/scripts/update-context.ps1
Normal file
@@ -0,0 +1,17 @@
|
||||
# update-context.ps1 — Kimi Code integration: create/update KIMI.md
|
||||
#
|
||||
# Thin wrapper that delegates to the shared update-agent-context script.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
|
||||
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
|
||||
$repoRoot = $scriptDir
|
||||
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
|
||||
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
|
||||
$repoRoot = Split-Path -Parent $repoRoot
|
||||
}
|
||||
}
|
||||
|
||||
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kimi
|
||||
24
src/specify_cli/integrations/kimi/scripts/update-context.sh
Executable file
24
src/specify_cli/integrations/kimi/scripts/update-context.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# update-context.sh — Kimi Code integration: create/update KIMI.md
|
||||
#
|
||||
# Thin wrapper that delegates to the shared update-agent-context script.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
_script_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||
_root="$_script_dir"
|
||||
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
|
||||
if [ -z "${REPO_ROOT:-}" ]; then
|
||||
if [ -d "$_root/.specify" ]; then
|
||||
REPO_ROOT="$_root"
|
||||
else
|
||||
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
|
||||
REPO_ROOT="$git_root"
|
||||
else
|
||||
REPO_ROOT="$_root"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kimi
|
||||
10
tests/conftest.py
Normal file
10
tests/conftest.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Shared test helpers for the Spec Kit test suite."""
|
||||
|
||||
import re
|
||||
|
||||
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
|
||||
|
||||
def strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes from Rich-formatted CLI output."""
|
||||
return _ANSI_ESCAPE_RE.sub("", text)
|
||||
@@ -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
|
||||
|
||||
25
tests/integrations/test_integration_agy.py
Normal file
25
tests/integrations/test_integration_agy.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Tests for AgyIntegration (Antigravity)."""
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestAgyIntegration(SkillsIntegrationTests):
|
||||
KEY = "agy"
|
||||
FOLDER = ".agent/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".agent/skills"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
|
||||
class TestAgyAutoPromote:
|
||||
"""--ai agy auto-promotes to integration path."""
|
||||
|
||||
def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path):
|
||||
"""--ai agy (without --ai-skills) should auto-promote to integration."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "agy"])
|
||||
|
||||
assert "--integration agy" in result.output
|
||||
402
tests/integrations/test_integration_base_skills.py
Normal file
402
tests/integrations/test_integration_base_skills.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""Reusable test mixin for standard SkillsIntegration subclasses.
|
||||
|
||||
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
|
||||
``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
|
||||
logic from ``SkillsIntegrationTests``.
|
||||
|
||||
Mirrors ``MarkdownIntegrationTests`` / ``TomlIntegrationTests`` closely,
|
||||
adapted for the ``speckit-<name>/SKILL.md`` skills layout.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
||||
from specify_cli.integrations.base import SkillsIntegration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
class SkillsIntegrationTests:
|
||||
"""Mixin — set class-level constants and inherit these tests.
|
||||
|
||||
Required class attrs on subclass::
|
||||
|
||||
KEY: str — integration registry key
|
||||
FOLDER: str — e.g. ".agents/"
|
||||
COMMANDS_SUBDIR: str — e.g. "skills"
|
||||
REGISTRAR_DIR: str — e.g. ".agents/skills"
|
||||
CONTEXT_FILE: str — e.g. "AGENTS.md"
|
||||
"""
|
||||
|
||||
KEY: str
|
||||
FOLDER: str
|
||||
COMMANDS_SUBDIR: str
|
||||
REGISTRAR_DIR: str
|
||||
CONTEXT_FILE: str
|
||||
|
||||
# -- Registration -----------------------------------------------------
|
||||
|
||||
def test_registered(self):
|
||||
assert self.KEY in INTEGRATION_REGISTRY
|
||||
assert get_integration(self.KEY) is not None
|
||||
|
||||
def test_is_skills_integration(self):
|
||||
assert isinstance(get_integration(self.KEY), SkillsIntegration)
|
||||
|
||||
# -- Config -----------------------------------------------------------
|
||||
|
||||
def test_config_folder(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["folder"] == self.FOLDER
|
||||
|
||||
def test_config_commands_subdir(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
|
||||
|
||||
def test_registrar_config(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
|
||||
assert i.registrar_config["format"] == "markdown"
|
||||
assert i.registrar_config["args"] == "$ARGUMENTS"
|
||||
assert i.registrar_config["extension"] == "/SKILL.md"
|
||||
|
||||
def test_context_file(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.context_file == self.CONTEXT_FILE
|
||||
|
||||
# -- Setup / teardown -------------------------------------------------
|
||||
|
||||
def test_setup_creates_files(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
for f in skill_files:
|
||||
assert f.exists()
|
||||
assert f.name == "SKILL.md"
|
||||
assert f.parent.name.startswith("speckit-")
|
||||
|
||||
def test_setup_writes_to_correct_directory(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
expected_dir = i.skills_dest(tmp_path)
|
||||
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(skill_files) > 0, "No skill files were created"
|
||||
for f in skill_files:
|
||||
# Each SKILL.md is in speckit-<name>/ under the skills directory
|
||||
assert f.resolve().parent.parent == expected_dir.resolve(), (
|
||||
f"{f} is not under {expected_dir}"
|
||||
)
|
||||
|
||||
def test_skill_directory_structure(self, tmp_path):
|
||||
"""Each command produces speckit-<name>/SKILL.md."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
|
||||
expected_commands = {
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
}
|
||||
|
||||
# Derive command names from the skill directory names
|
||||
actual_commands = set()
|
||||
for f in skill_files:
|
||||
skill_dir_name = f.parent.name # e.g. "speckit-plan"
|
||||
assert skill_dir_name.startswith("speckit-")
|
||||
actual_commands.add(skill_dir_name.removeprefix("speckit-"))
|
||||
|
||||
assert actual_commands == expected_commands
|
||||
|
||||
def test_skill_frontmatter_structure(self, tmp_path):
|
||||
"""SKILL.md must have name, description, compatibility, metadata."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert content.startswith("---\n"), f"{f} missing frontmatter"
|
||||
parts = content.split("---", 2)
|
||||
fm = yaml.safe_load(parts[1])
|
||||
assert "name" in fm, f"{f} frontmatter missing 'name'"
|
||||
assert "description" in fm, f"{f} frontmatter missing 'description'"
|
||||
assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'"
|
||||
assert "metadata" in fm, f"{f} frontmatter missing 'metadata'"
|
||||
assert fm["metadata"]["author"] == "github-spec-kit"
|
||||
assert "source" in fm["metadata"]
|
||||
|
||||
def test_skill_uses_template_descriptions(self, tmp_path):
|
||||
"""SKILL.md should use the original template description for ZIP parity."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
fm = yaml.safe_load(parts[1])
|
||||
# Description must be a non-empty string (from the template)
|
||||
assert isinstance(fm["description"], str)
|
||||
assert len(fm["description"]) > 0, f"{f} has empty description"
|
||||
|
||||
def test_templates_are_processed(self, tmp_path):
|
||||
"""Skill body must have placeholders replaced, not raw templates."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||
|
||||
def test_skill_body_has_content(self, tmp_path):
|
||||
"""Each SKILL.md body should contain template content after the frontmatter."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
# Body is everything after the second ---
|
||||
parts = content.split("---", 2)
|
||||
body = parts[2].strip() if len(parts) >= 3 else ""
|
||||
assert len(body) > 0, f"{f} has empty body"
|
||||
|
||||
def test_all_files_tracked_in_manifest(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
for f in created:
|
||||
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
||||
assert rel in m.files, f"{rel} not tracked in manifest"
|
||||
|
||||
def test_install_uninstall_roundtrip(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
m.save()
|
||||
for f in created:
|
||||
assert f.exists()
|
||||
removed, skipped = i.uninstall(tmp_path, m)
|
||||
assert len(removed) == len(created)
|
||||
assert skipped == []
|
||||
|
||||
def test_modified_file_survives_uninstall(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
m.save()
|
||||
modified_file = created[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
removed, skipped = i.uninstall(tmp_path, m)
|
||||
assert modified_file.exists()
|
||||
assert modified_file in skipped
|
||||
|
||||
def test_pre_existing_skills_not_removed(self, tmp_path):
|
||||
"""Pre-existing non-speckit skills should be left untouched."""
|
||||
i = get_integration(self.KEY)
|
||||
skills_dir = i.skills_dest(tmp_path)
|
||||
foreign_dir = skills_dir / "other-tool"
|
||||
foreign_dir.mkdir(parents=True)
|
||||
(foreign_dir / "SKILL.md").write_text("# Foreign skill\n")
|
||||
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
|
||||
assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed"
|
||||
|
||||
# -- Scripts ----------------------------------------------------------
|
||||
|
||||
def test_setup_installs_update_context_scripts(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
|
||||
assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
|
||||
assert (scripts_dir / "update-context.sh").exists()
|
||||
assert (scripts_dir / "update-context.ps1").exists()
|
||||
|
||||
def test_scripts_tracked_in_manifest(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
script_rels = [k for k in m.files if "update-context" in k]
|
||||
assert len(script_rels) >= 2
|
||||
|
||||
def test_sh_script_is_executable(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh"
|
||||
assert os.access(sh, os.X_OK)
|
||||
|
||||
# -- CLI auto-promote -------------------------------------------------
|
||||
|
||||
def test_ai_flag_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"promote-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
||||
assert f"--integration {self.KEY}" in result.output
|
||||
|
||||
def test_integration_flag_creates_files(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"int-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
||||
i = get_integration(self.KEY)
|
||||
skills_dir = i.skills_dest(project)
|
||||
assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created"
|
||||
|
||||
# -- IntegrationOption ------------------------------------------------
|
||||
|
||||
def test_options_include_skills_flag(self):
|
||||
i = get_integration(self.KEY)
|
||||
opts = i.options()
|
||||
skills_opts = [o for o in opts if o.name == "--skills"]
|
||||
assert len(skills_opts) == 1
|
||||
assert skills_opts[0].is_flag is True
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
_SKILL_COMMANDS = [
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
"""Build the full expected file list for a given script variant."""
|
||||
i = get_integration(self.KEY)
|
||||
skills_prefix = i.config["folder"].rstrip("/") + "/" + i.config.get("commands_subdir", "skills")
|
||||
|
||||
files = []
|
||||
# Skill files
|
||||
for cmd in self._SKILL_COMMANDS:
|
||||
files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md")
|
||||
# Integration metadata
|
||||
files += [
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
f".specify/integrations/{self.KEY}.manifest.json",
|
||||
f".specify/integrations/{self.KEY}/scripts/update-context.ps1",
|
||||
f".specify/integrations/{self.KEY}/scripts/update-context.sh",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".specify/memory/constitution.md",
|
||||
]
|
||||
# Script variant
|
||||
if script_variant == "sh":
|
||||
files += [
|
||||
".specify/scripts/bash/check-prerequisites.sh",
|
||||
".specify/scripts/bash/common.sh",
|
||||
".specify/scripts/bash/create-new-feature.sh",
|
||||
".specify/scripts/bash/setup-plan.sh",
|
||||
".specify/scripts/bash/update-agent-context.sh",
|
||||
]
|
||||
else:
|
||||
files += [
|
||||
".specify/scripts/powershell/check-prerequisites.ps1",
|
||||
".specify/scripts/powershell/common.ps1",
|
||||
".specify/scripts/powershell/create-new-feature.ps1",
|
||||
".specify/scripts/powershell/setup-plan.ps1",
|
||||
".specify/scripts/powershell/update-agent-context.ps1",
|
||||
]
|
||||
# Templates
|
||||
files += [
|
||||
".specify/templates/agent-file-template.md",
|
||||
".specify/templates/checklist-template.md",
|
||||
".specify/templates/constitution-template.md",
|
||||
".specify/templates/plan-template.md",
|
||||
".specify/templates/spec-template.md",
|
||||
".specify/templates/tasks-template.md",
|
||||
]
|
||||
return sorted(files)
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration <key> --script sh."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-sh-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY,
|
||||
"--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
)
|
||||
expected = self._expected_files("sh")
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
|
||||
def test_complete_file_inventory_ps(self, tmp_path):
|
||||
"""Every file produced by specify init --integration <key> --script ps."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-ps-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY,
|
||||
"--script", "ps", "--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
)
|
||||
expected = self._expected_files("ps")
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
25
tests/integrations/test_integration_codex.py
Normal file
25
tests/integrations/test_integration_codex.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Tests for CodexIntegration."""
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestCodexIntegration(SkillsIntegrationTests):
|
||||
KEY = "codex"
|
||||
FOLDER = ".agents/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".agents/skills"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
|
||||
class TestCodexAutoPromote:
|
||||
"""--ai codex auto-promotes to integration path."""
|
||||
|
||||
def test_ai_codex_without_ai_skills_auto_promotes(self, tmp_path):
|
||||
"""--ai codex (without --ai-skills) should auto-promote to integration."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "codex"])
|
||||
|
||||
assert "--integration codex" in result.output
|
||||
311
tests/integrations/test_integration_generic.py
Normal file
311
tests/integrations/test_integration_generic.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""Tests for GenericIntegration."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.base import MarkdownIntegration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
class TestGenericIntegration:
|
||||
"""Tests for GenericIntegration — requires --commands-dir option."""
|
||||
|
||||
# -- Registration -----------------------------------------------------
|
||||
|
||||
def test_registered(self):
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY
|
||||
assert "generic" in INTEGRATION_REGISTRY
|
||||
|
||||
def test_is_markdown_integration(self):
|
||||
assert isinstance(get_integration("generic"), MarkdownIntegration)
|
||||
|
||||
# -- Config -----------------------------------------------------------
|
||||
|
||||
def test_config_folder_is_none(self):
|
||||
i = get_integration("generic")
|
||||
assert i.config["folder"] is None
|
||||
|
||||
def test_config_requires_cli_false(self):
|
||||
i = get_integration("generic")
|
||||
assert i.config["requires_cli"] is False
|
||||
|
||||
def test_context_file_is_none(self):
|
||||
i = get_integration("generic")
|
||||
assert i.context_file is None
|
||||
|
||||
# -- Options ----------------------------------------------------------
|
||||
|
||||
def test_options_include_commands_dir(self):
|
||||
i = get_integration("generic")
|
||||
opts = i.options()
|
||||
assert len(opts) == 1
|
||||
assert opts[0].name == "--commands-dir"
|
||||
assert opts[0].required is True
|
||||
assert opts[0].is_flag is False
|
||||
|
||||
# -- Setup / teardown -------------------------------------------------
|
||||
|
||||
def test_setup_requires_commands_dir(self, tmp_path):
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
with pytest.raises(ValueError, match="--commands-dir is required"):
|
||||
i.setup(tmp_path, m, parsed_options={})
|
||||
|
||||
def test_setup_requires_nonempty_commands_dir(self, tmp_path):
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
with pytest.raises(ValueError, match="--commands-dir is required"):
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ""})
|
||||
|
||||
def test_setup_writes_to_correct_directory(self, tmp_path):
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
created = i.setup(
|
||||
tmp_path, m,
|
||||
parsed_options={"commands_dir": ".myagent/commands"},
|
||||
)
|
||||
expected_dir = tmp_path / ".myagent" / "commands"
|
||||
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(cmd_files) > 0, "No command files were created"
|
||||
for f in cmd_files:
|
||||
assert f.resolve().parent == expected_dir.resolve(), (
|
||||
f"{f} is not under {expected_dir}"
|
||||
)
|
||||
|
||||
def test_setup_creates_md_files(self, tmp_path):
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
created = i.setup(
|
||||
tmp_path, m,
|
||||
parsed_options={"commands_dir": ".custom/cmds"},
|
||||
)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(cmd_files) > 0
|
||||
for f in cmd_files:
|
||||
assert f.name.startswith("speckit.")
|
||||
assert f.name.endswith(".md")
|
||||
|
||||
def test_templates_are_processed(self, tmp_path):
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
created = i.setup(
|
||||
tmp_path, m,
|
||||
parsed_options={"commands_dir": ".custom/cmds"},
|
||||
)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
for f in cmd_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||
|
||||
def test_all_files_tracked_in_manifest(self, tmp_path):
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
created = i.setup(
|
||||
tmp_path, m,
|
||||
parsed_options={"commands_dir": ".custom/cmds"},
|
||||
)
|
||||
for f in created:
|
||||
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
||||
assert rel in m.files, f"{rel} not tracked in manifest"
|
||||
|
||||
def test_install_uninstall_roundtrip(self, tmp_path):
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
created = i.install(
|
||||
tmp_path, m,
|
||||
parsed_options={"commands_dir": ".custom/cmds"},
|
||||
)
|
||||
assert len(created) > 0
|
||||
m.save()
|
||||
for f in created:
|
||||
assert f.exists()
|
||||
removed, skipped = i.uninstall(tmp_path, m)
|
||||
assert len(removed) == len(created)
|
||||
assert skipped == []
|
||||
|
||||
def test_modified_file_survives_uninstall(self, tmp_path):
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
created = i.install(
|
||||
tmp_path, m,
|
||||
parsed_options={"commands_dir": ".custom/cmds"},
|
||||
)
|
||||
m.save()
|
||||
modified = created[0]
|
||||
modified.write_text("user modified this", encoding="utf-8")
|
||||
removed, skipped = i.uninstall(tmp_path, m)
|
||||
assert modified.exists()
|
||||
assert modified in skipped
|
||||
|
||||
def test_different_commands_dirs(self, tmp_path):
|
||||
"""Generic should work with various user-specified paths."""
|
||||
for path in [".agent/commands", "tools/ai-cmds", ".custom/prompts"]:
|
||||
project = tmp_path / path.replace("/", "-")
|
||||
project.mkdir()
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", project)
|
||||
created = i.setup(
|
||||
project, m,
|
||||
parsed_options={"commands_dir": path},
|
||||
)
|
||||
expected = project / path
|
||||
assert expected.is_dir(), f"Dir {expected} not created for {path}"
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(cmd_files) > 0
|
||||
|
||||
# -- Scripts ----------------------------------------------------------
|
||||
|
||||
def test_setup_installs_update_context_scripts(self, tmp_path):
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
scripts_dir = tmp_path / ".specify" / "integrations" / "generic" / "scripts"
|
||||
assert scripts_dir.is_dir(), "Scripts directory not created for generic"
|
||||
assert (scripts_dir / "update-context.sh").exists()
|
||||
assert (scripts_dir / "update-context.ps1").exists()
|
||||
|
||||
def test_scripts_tracked_in_manifest(self, tmp_path):
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
script_rels = [k for k in m.files if "update-context" in k]
|
||||
assert len(script_rels) >= 2
|
||||
|
||||
def test_sh_script_is_executable(self, tmp_path):
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
sh = tmp_path / ".specify" / "integrations" / "generic" / "scripts" / "update-context.sh"
|
||||
assert os.access(sh, os.X_OK)
|
||||
|
||||
# -- CLI --------------------------------------------------------------
|
||||
|
||||
def test_cli_generic_without_commands_dir_fails(self, tmp_path):
|
||||
"""--integration generic without --ai-commands-dir should fail."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", str(tmp_path / "test-generic"), "--integration", "generic",
|
||||
"--script", "sh", "--no-git",
|
||||
])
|
||||
# Generic requires --commands-dir / --ai-commands-dir
|
||||
# The integration path validates via setup()
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script sh."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "inventory-generic-sh"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "generic",
|
||||
"--ai-commands-dir", ".myagent/commands",
|
||||
"--script", "sh", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
)
|
||||
expected = sorted([
|
||||
".myagent/commands/speckit.analyze.md",
|
||||
".myagent/commands/speckit.checklist.md",
|
||||
".myagent/commands/speckit.clarify.md",
|
||||
".myagent/commands/speckit.constitution.md",
|
||||
".myagent/commands/speckit.implement.md",
|
||||
".myagent/commands/speckit.plan.md",
|
||||
".myagent/commands/speckit.specify.md",
|
||||
".myagent/commands/speckit.tasks.md",
|
||||
".myagent/commands/speckit.taskstoissues.md",
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
".specify/integrations/generic.manifest.json",
|
||||
".specify/integrations/generic/scripts/update-context.ps1",
|
||||
".specify/integrations/generic/scripts/update-context.sh",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".specify/memory/constitution.md",
|
||||
".specify/scripts/bash/check-prerequisites.sh",
|
||||
".specify/scripts/bash/common.sh",
|
||||
".specify/scripts/bash/create-new-feature.sh",
|
||||
".specify/scripts/bash/setup-plan.sh",
|
||||
".specify/scripts/bash/update-agent-context.sh",
|
||||
".specify/templates/agent-file-template.md",
|
||||
".specify/templates/checklist-template.md",
|
||||
".specify/templates/constitution-template.md",
|
||||
".specify/templates/plan-template.md",
|
||||
".specify/templates/spec-template.md",
|
||||
".specify/templates/tasks-template.md",
|
||||
])
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
|
||||
def test_complete_file_inventory_ps(self, tmp_path):
|
||||
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script ps."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "inventory-generic-ps"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "generic",
|
||||
"--ai-commands-dir", ".myagent/commands",
|
||||
"--script", "ps", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
)
|
||||
expected = sorted([
|
||||
".myagent/commands/speckit.analyze.md",
|
||||
".myagent/commands/speckit.checklist.md",
|
||||
".myagent/commands/speckit.clarify.md",
|
||||
".myagent/commands/speckit.constitution.md",
|
||||
".myagent/commands/speckit.implement.md",
|
||||
".myagent/commands/speckit.plan.md",
|
||||
".myagent/commands/speckit.specify.md",
|
||||
".myagent/commands/speckit.tasks.md",
|
||||
".myagent/commands/speckit.taskstoissues.md",
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
".specify/integrations/generic.manifest.json",
|
||||
".specify/integrations/generic/scripts/update-context.ps1",
|
||||
".specify/integrations/generic/scripts/update-context.sh",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".specify/memory/constitution.md",
|
||||
".specify/scripts/powershell/check-prerequisites.ps1",
|
||||
".specify/scripts/powershell/common.ps1",
|
||||
".specify/scripts/powershell/create-new-feature.ps1",
|
||||
".specify/scripts/powershell/setup-plan.ps1",
|
||||
".specify/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/templates/agent-file-template.md",
|
||||
".specify/templates/checklist-template.md",
|
||||
".specify/templates/constitution-template.md",
|
||||
".specify/templates/plan-template.md",
|
||||
".specify/templates/spec-template.md",
|
||||
".specify/templates/tasks-template.md",
|
||||
])
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
149
tests/integrations/test_integration_kimi.py
Normal file
149
tests/integrations/test_integration_kimi.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Tests for KimiIntegration — skills integration with legacy migration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.kimi import _migrate_legacy_kimi_dotted_skills
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestKimiIntegration(SkillsIntegrationTests):
|
||||
KEY = "kimi"
|
||||
FOLDER = ".kimi/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".kimi/skills"
|
||||
CONTEXT_FILE = "KIMI.md"
|
||||
|
||||
|
||||
class TestKimiOptions:
|
||||
"""Kimi declares --skills and --migrate-legacy options."""
|
||||
|
||||
def test_migrate_legacy_option(self):
|
||||
i = get_integration("kimi")
|
||||
opts = i.options()
|
||||
migrate_opts = [o for o in opts if o.name == "--migrate-legacy"]
|
||||
assert len(migrate_opts) == 1
|
||||
assert migrate_opts[0].is_flag is True
|
||||
assert migrate_opts[0].default is False
|
||||
|
||||
|
||||
class TestKimiLegacyMigration:
|
||||
"""Test Kimi dotted → hyphenated skill directory migration."""
|
||||
|
||||
def test_migrate_dotted_to_hyphenated(self, tmp_path):
|
||||
skills_dir = tmp_path / ".kimi" / "skills"
|
||||
legacy = skills_dir / "speckit.plan"
|
||||
legacy.mkdir(parents=True)
|
||||
(legacy / "SKILL.md").write_text("# Plan Skill\n")
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 1
|
||||
assert removed == 0
|
||||
assert not legacy.exists()
|
||||
assert (skills_dir / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_skip_when_target_exists_different_content(self, tmp_path):
|
||||
skills_dir = tmp_path / ".kimi" / "skills"
|
||||
legacy = skills_dir / "speckit.plan"
|
||||
legacy.mkdir(parents=True)
|
||||
(legacy / "SKILL.md").write_text("# Old\n")
|
||||
|
||||
target = skills_dir / "speckit-plan"
|
||||
target.mkdir(parents=True)
|
||||
(target / "SKILL.md").write_text("# New (different)\n")
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 0
|
||||
assert removed == 0
|
||||
assert legacy.exists()
|
||||
assert target.exists()
|
||||
|
||||
def test_remove_when_target_exists_same_content(self, tmp_path):
|
||||
skills_dir = tmp_path / ".kimi" / "skills"
|
||||
content = "# Identical\n"
|
||||
legacy = skills_dir / "speckit.plan"
|
||||
legacy.mkdir(parents=True)
|
||||
(legacy / "SKILL.md").write_text(content)
|
||||
|
||||
target = skills_dir / "speckit-plan"
|
||||
target.mkdir(parents=True)
|
||||
(target / "SKILL.md").write_text(content)
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 0
|
||||
assert removed == 1
|
||||
assert not legacy.exists()
|
||||
assert target.exists()
|
||||
|
||||
def test_preserve_legacy_with_extra_files(self, tmp_path):
|
||||
skills_dir = tmp_path / ".kimi" / "skills"
|
||||
content = "# Same\n"
|
||||
legacy = skills_dir / "speckit.plan"
|
||||
legacy.mkdir(parents=True)
|
||||
(legacy / "SKILL.md").write_text(content)
|
||||
(legacy / "extra.md").write_text("user file")
|
||||
|
||||
target = skills_dir / "speckit-plan"
|
||||
target.mkdir(parents=True)
|
||||
(target / "SKILL.md").write_text(content)
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 0
|
||||
assert removed == 0
|
||||
assert legacy.exists()
|
||||
|
||||
def test_nonexistent_dir_returns_zeros(self, tmp_path):
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(
|
||||
tmp_path / ".kimi" / "skills"
|
||||
)
|
||||
assert migrated == 0
|
||||
assert removed == 0
|
||||
|
||||
def test_setup_with_migrate_legacy_option(self, tmp_path):
|
||||
"""KimiIntegration.setup() with --migrate-legacy migrates dotted dirs."""
|
||||
i = get_integration("kimi")
|
||||
|
||||
skills_dir = tmp_path / ".kimi" / "skills"
|
||||
legacy = skills_dir / "speckit.oldcmd"
|
||||
legacy.mkdir(parents=True)
|
||||
(legacy / "SKILL.md").write_text("# Legacy\n")
|
||||
|
||||
m = IntegrationManifest("kimi", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
|
||||
|
||||
assert not legacy.exists()
|
||||
assert (skills_dir / "speckit-oldcmd" / "SKILL.md").exists()
|
||||
# New skills from templates should also exist
|
||||
assert (skills_dir / "speckit-specify" / "SKILL.md").exists()
|
||||
|
||||
|
||||
class TestKimiNextSteps:
|
||||
"""CLI output tests for kimi next-steps display."""
|
||||
|
||||
def test_next_steps_show_skill_invocation(self, tmp_path):
|
||||
"""Kimi next-steps guidance should display /skill:speckit-* usage."""
|
||||
import os
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "kimi-next-steps"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "kimi", "--no-git",
|
||||
"--ignore-agent-tools", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "/skill:speckit-constitution" in result.output
|
||||
assert "/speckit.constitution" not in result.output
|
||||
assert "Optional skills that you can use for your specs" in result.output
|
||||
@@ -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()
|
||||
|
||||
@@ -11,13 +11,17 @@ from specify_cli.integrations.base import MarkdownIntegration
|
||||
from .conftest import StubIntegration
|
||||
|
||||
|
||||
# Every integration key that must be registered (Stage 2 + Stage 3).
|
||||
# Every integration key that must be registered (Stage 2 + Stage 3 + Stage 4 + Stage 5).
|
||||
ALL_INTEGRATION_KEYS = [
|
||||
"copilot",
|
||||
# Stage 3 — standard markdown integrations
|
||||
"claude", "qwen", "opencode", "junie", "kilocode", "auggie",
|
||||
"roo", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
|
||||
"pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent",
|
||||
# Stage 4 — TOML integrations
|
||||
"gemini", "tabnine",
|
||||
# Stage 5 — skills, generic & option-driven integrations
|
||||
"codex", "kimi", "agy", "generic",
|
||||
]
|
||||
|
||||
|
||||
@@ -61,9 +65,16 @@ class TestRegistryCompleteness:
|
||||
|
||||
|
||||
class TestRegistrarKeyAlignment:
|
||||
"""Every integration key must have a matching AGENT_CONFIGS entry."""
|
||||
"""Every integration key must have a matching AGENT_CONFIGS entry.
|
||||
|
||||
@pytest.mark.parametrize("key", ALL_INTEGRATION_KEYS)
|
||||
``generic`` is excluded because it has no fixed directory — its
|
||||
output path comes from ``--commands-dir`` at runtime.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[k for k in ALL_INTEGRATION_KEYS if k != "generic"],
|
||||
)
|
||||
def test_integration_key_in_registrar(self, key):
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
assert key in CommandRegistrar.AGENT_CONFIGS, (
|
||||
|
||||
@@ -10,7 +10,6 @@ Tests cover:
|
||||
- CLI validation: --ai-skills requires --ai
|
||||
"""
|
||||
|
||||
import re
|
||||
import zipfile
|
||||
import pytest
|
||||
import tempfile
|
||||
@@ -21,6 +20,7 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import specify_cli
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
from specify_cli import (
|
||||
_get_skills_dir,
|
||||
@@ -684,207 +684,15 @@ class TestCommandCoexistence:
|
||||
assert result is True
|
||||
|
||||
|
||||
# ===== New-Project Command Skip Tests =====
|
||||
# ===== Legacy Download Path Tests =====
|
||||
|
||||
class TestNewProjectCommandSkip:
|
||||
"""Test that init() removes extracted commands for new projects only.
|
||||
class TestLegacyDownloadPath:
|
||||
"""Tests for download_and_extract_template() called directly.
|
||||
|
||||
These tests run init() end-to-end via CliRunner with
|
||||
download_and_extract_template patched to create local fixtures.
|
||||
These test the legacy download/extract code that still exists in
|
||||
__init__.py. They do NOT go through CLI auto-promote.
|
||||
"""
|
||||
|
||||
def _fake_extract(self, agent, project_path, **_kwargs):
|
||||
"""Simulate template extraction: create agent commands dir."""
|
||||
agent_cfg = AGENT_CONFIG.get(agent, {})
|
||||
agent_folder = agent_cfg.get("folder", "")
|
||||
commands_subdir = agent_cfg.get("commands_subdir", "commands")
|
||||
if agent_folder:
|
||||
cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir
|
||||
cmds_dir.mkdir(parents=True, exist_ok=True)
|
||||
(cmds_dir / "speckit.specify.md").write_text("# spec")
|
||||
|
||||
def test_new_project_commands_removed_after_skills_succeed(self, tmp_path):
|
||||
"""For new projects, commands should be removed when skills succeed."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "new-proj"
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
self._fake_extract("claude", project_path)
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
# Skills should have been called
|
||||
mock_skills.assert_called_once()
|
||||
|
||||
# Commands dir should have been removed after skills succeeded
|
||||
cmds_dir = target / ".claude" / "commands"
|
||||
assert not cmds_dir.exists()
|
||||
|
||||
def test_new_project_nonstandard_commands_subdir_removed_after_skills_succeed(self, tmp_path):
|
||||
"""For non-standard agents, configured commands_subdir should be removed on success."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "new-kiro-proj"
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
self._fake_extract("kiro-cli", project_path)
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "kiro-cli", "--ai-skills", "--script", "sh", "--no-git"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_skills.assert_called_once()
|
||||
|
||||
prompts_dir = target / ".kiro" / "prompts"
|
||||
assert not prompts_dir.exists()
|
||||
|
||||
def test_codex_native_skills_preserved_without_conversion(self, tmp_path):
|
||||
"""Codex should keep bundled .agents/skills and skip install_ai_skills conversion."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "new-codex-proj"
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
skill_dir = project_path / ".agents" / "skills" / "speckit-specify"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills") as mock_skills, \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_skills.assert_not_called()
|
||||
assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md").exists()
|
||||
|
||||
def test_codex_native_skills_missing_falls_back_then_fails_cleanly(self, tmp_path):
|
||||
"""Codex should attempt fallback conversion when bundled skills are missing."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "missing-codex-skills"
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", lambda *args, **kwargs: None), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills", return_value=False) as mock_skills, \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
mock_skills.assert_called_once()
|
||||
assert mock_skills.call_args.kwargs.get("overwrite_existing") is True
|
||||
assert "Expected bundled agent skills" in result.output
|
||||
assert "fallback conversion failed" in result.output
|
||||
|
||||
def test_codex_native_skills_ignores_non_speckit_skill_dirs(self, tmp_path):
|
||||
"""Non-spec-kit SKILL.md files should trigger fallback conversion, not hard-fail."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "foreign-codex-skills"
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
skill_dir = project_path / ".agents" / "skills" / "other-tool"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\ndescription: Foreign skill\n---\n\nBody.\n")
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_skills.assert_called_once()
|
||||
assert mock_skills.call_args.kwargs.get("overwrite_existing") is True
|
||||
|
||||
def test_kimi_legacy_migration_runs_without_ai_skills_flag(self, tmp_path):
|
||||
"""Kimi init should migrate dotted legacy skills even when --ai-skills is not set."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "kimi-legacy-no-ai-skills"
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
legacy_dir = project_path / ".kimi" / "skills" / "speckit.plan"
|
||||
legacy_dir.mkdir(parents=True, exist_ok=True)
|
||||
(legacy_dir / "SKILL.md").write_text("---\nname: speckit.plan\n---\n\nlegacy\n")
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/kimi"):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--ai", "kimi", "--script", "sh", "--no-git"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert not (target / ".kimi" / "skills" / "speckit.plan").exists()
|
||||
assert (target / ".kimi" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_codex_ai_skills_here_mode_preserves_existing_codex_dir(self, tmp_path, monkeypatch):
|
||||
"""Codex --here skills init should not delete a pre-existing .codex directory."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "codex-preserve-here"
|
||||
target.mkdir()
|
||||
existing_prompts = target / ".codex" / "prompts"
|
||||
existing_prompts.mkdir(parents=True)
|
||||
(existing_prompts / "custom.md").write_text("custom")
|
||||
monkeypatch.chdir(target)
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", return_value=target), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills", return_value=True), \
|
||||
patch("specify_cli.is_git_repo", return_value=True), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", "--here", "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
|
||||
input="y\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert (target / ".codex").exists()
|
||||
assert (existing_prompts / "custom.md").exists()
|
||||
|
||||
def test_codex_ai_skills_fresh_dir_does_not_create_codex_dir(self, tmp_path):
|
||||
"""Fresh-directory Codex skills init should not leave legacy .codex from archive."""
|
||||
target = tmp_path / "fresh-codex-proj"
|
||||
@@ -948,62 +756,6 @@ class TestNewProjectCommandSkip:
|
||||
|
||||
assert not (tmp_path / "evil.txt").exists()
|
||||
|
||||
def test_commands_preserved_when_skills_fail(self, tmp_path):
|
||||
"""If skills fail, commands should NOT be removed (safety net)."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "fail-proj"
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
self._fake_extract("claude", project_path)
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills", return_value=False), \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
# Commands should still exist since skills failed
|
||||
cmds_dir = target / ".claude" / "commands"
|
||||
assert cmds_dir.exists()
|
||||
assert (cmds_dir / "speckit.specify.md").exists()
|
||||
|
||||
def test_here_mode_commands_preserved(self, tmp_path, monkeypatch):
|
||||
"""For --here on existing repos, commands must NOT be removed."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
# Create a mock existing project with commands already present
|
||||
target = tmp_path / "existing"
|
||||
target.mkdir()
|
||||
agent_folder = AGENT_CONFIG["claude"]["folder"]
|
||||
cmds_dir = target / agent_folder.rstrip("/") / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
(cmds_dir / "speckit.specify.md").write_text("# spec")
|
||||
|
||||
# --here uses CWD, so chdir into the target
|
||||
monkeypatch.chdir(target)
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
pass # commands already exist, no need to re-create
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.install_ai_skills", return_value=True), \
|
||||
patch("specify_cli.is_git_repo", return_value=True), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/git"):
|
||||
result = runner.invoke(app, ["init", "--here", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"], input="y\n")
|
||||
|
||||
assert result.exit_code == 0
|
||||
# Commands must remain for --here
|
||||
assert cmds_dir.exists()
|
||||
assert (cmds_dir / "speckit.specify.md").exists()
|
||||
|
||||
|
||||
# ===== Skip-If-Exists Tests =====
|
||||
|
||||
@@ -1075,92 +827,61 @@ class TestSkillDescriptions:
|
||||
class TestCliValidation:
|
||||
"""Test --ai-skills CLI flag validation."""
|
||||
|
||||
def test_ai_skills_without_ai_fails(self):
|
||||
def test_ai_skills_without_ai_fails(self, tmp_path):
|
||||
"""--ai-skills without --ai should fail with exit code 1."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", "test-proj", "--ai-skills"])
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "--ai-skills requires --ai" in result.output
|
||||
|
||||
def test_ai_skills_without_ai_shows_usage(self):
|
||||
def test_ai_skills_without_ai_shows_usage(self, tmp_path):
|
||||
"""Error message should include usage hint."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", "test-proj", "--ai-skills"])
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"])
|
||||
|
||||
assert "Usage:" in result.output
|
||||
assert "--ai" in result.output
|
||||
|
||||
def test_agy_without_ai_skills_fails(self):
|
||||
"""--ai agy without --ai-skills should fail with exit code 1."""
|
||||
def test_interactive_agy_without_ai_skills_uses_integration(self, tmp_path, monkeypatch):
|
||||
"""Interactive selector returning agy should auto-promote to integration path."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", "test-proj", "--ai", "agy"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Explicit command support was deprecated in Antigravity version 1.20.5." in result.output
|
||||
assert "--ai-skills" in result.output
|
||||
|
||||
def test_codex_without_ai_skills_fails(self):
|
||||
"""--ai codex without --ai-skills should fail with exit code 1."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", "test-proj", "--ai", "codex"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" in result.output
|
||||
assert "--ai-skills" in result.output
|
||||
|
||||
def test_interactive_agy_without_ai_skills_prompts_skills(self, monkeypatch):
|
||||
"""Interactive selector returning agy without --ai-skills should automatically enable --ai-skills."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
# Mock select_with_arrows to simulate the user picking 'agy' for AI,
|
||||
# and return a deterministic default for any other prompts to avoid
|
||||
# calling the real interactive implementation.
|
||||
def _fake_select_with_arrows(*args, **kwargs):
|
||||
options = kwargs.get("options")
|
||||
if options is None and len(args) >= 1:
|
||||
options = args[0]
|
||||
|
||||
# If the options include 'agy', simulate selecting it.
|
||||
if isinstance(options, dict) and "agy" in options:
|
||||
return "agy"
|
||||
if isinstance(options, (list, tuple)) and "agy" in options:
|
||||
return "agy"
|
||||
|
||||
# For any other prompt, return a deterministic, non-interactive default:
|
||||
# pick the first option if available.
|
||||
if isinstance(options, dict) and options:
|
||||
return next(iter(options.keys()))
|
||||
if isinstance(options, (list, tuple)) and options:
|
||||
return options[0]
|
||||
|
||||
# If no options are provided, fall back to None (should not occur in normal use).
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows)
|
||||
|
||||
# Mock download_and_extract_template to prevent real HTTP downloads during testing
|
||||
monkeypatch.setattr("specify_cli.download_and_extract_template", lambda *args, **kwargs: None)
|
||||
# We need to bypass the `git init` step, wait, it has `--no-git` by default in tests maybe?
|
||||
|
||||
runner = CliRunner()
|
||||
# Create temp dir to avoid directory already exists errors or whatever
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(app, ["init", "test-proj", "--no-git"])
|
||||
target = tmp_path / "test-agy-interactive"
|
||||
result = runner.invoke(app, ["init", str(target), "--no-git"])
|
||||
|
||||
# Interactive selection should NOT raise the deprecation error!
|
||||
assert result.exit_code == 0
|
||||
assert "Explicit command support was deprecated" not in result.output
|
||||
assert result.exit_code == 0
|
||||
# Should NOT raise the old deprecation error
|
||||
assert "Explicit command support was deprecated" not in result.output
|
||||
# Should use integration path (same as --ai agy)
|
||||
assert "agy" in result.output
|
||||
|
||||
def test_interactive_codex_without_ai_skills_enables_skills(self, monkeypatch):
|
||||
"""Interactive selector returning codex without --ai-skills should automatically enable --ai-skills."""
|
||||
def test_interactive_codex_without_ai_skills_uses_integration(self, tmp_path, monkeypatch):
|
||||
"""Interactive selector returning codex should auto-promote to integration path."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
def _fake_select_with_arrows(*args, **kwargs):
|
||||
@@ -1182,48 +903,18 @@ class TestCliValidation:
|
||||
|
||||
monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows)
|
||||
|
||||
def _fake_download(*args, **kwargs):
|
||||
project_path = Path(args[0])
|
||||
skill_dir = project_path / ".agents" / "skills" / "speckit-specify"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
||||
|
||||
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
|
||||
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(app, ["init", "test-proj", "--no-git", "--ignore-agent-tools"])
|
||||
target = tmp_path / "test-codex-interactive"
|
||||
result = runner.invoke(app, ["init", str(target), "--no-git", "--ignore-agent-tools"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output
|
||||
assert ".agents/skills" in result.output
|
||||
assert "$speckit-constitution" in result.output
|
||||
assert "/speckit.constitution" not in result.output
|
||||
assert "Optional skills that you can use for your specs" in result.output
|
||||
|
||||
def test_kimi_next_steps_show_skill_invocation(self, monkeypatch):
|
||||
"""Kimi next-steps guidance should display /skill:speckit-* usage."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
def _fake_download(*args, **kwargs):
|
||||
project_path = Path(args[0])
|
||||
skill_dir = project_path / ".kimi" / "skills" / "speckit-specify"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
||||
|
||||
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
|
||||
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", "test-proj", "--ai", "kimi", "--no-git", "--ignore-agent-tools"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "/skill:speckit-constitution" in result.output
|
||||
assert "/speckit.constitution" not in result.output
|
||||
assert "Optional skills that you can use for your specs" in result.output
|
||||
assert result.exit_code == 0
|
||||
# Should NOT raise the old deprecation error
|
||||
assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output
|
||||
# Skills should be installed via integration path
|
||||
assert ".agents/skills" in result.output
|
||||
assert "$speckit-constitution" in result.output
|
||||
assert "/speckit.constitution" not in result.output
|
||||
assert "Optional skills that you can use for your specs" in result.output
|
||||
|
||||
def test_ai_skills_flag_appears_in_help(self):
|
||||
"""--ai-skills should appear in init --help output."""
|
||||
@@ -1232,45 +923,10 @@ class TestCliValidation:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", "--help"])
|
||||
|
||||
plain = re.sub(r'\x1b\[[0-9;]*m', '', result.output)
|
||||
plain = strip_ansi(result.output)
|
||||
assert "--ai-skills" in plain
|
||||
assert "agent skills" in plain.lower()
|
||||
|
||||
def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path):
|
||||
"""--ai kiro should normalize to canonical kiro-cli and auto-promote to integration path."""
|
||||
import os
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "kiro-alias-proj"
|
||||
target.mkdir()
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(target)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--ai",
|
||||
"kiro",
|
||||
"--ignore-agent-tools",
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0
|
||||
# kiro alias should auto-promote to integration path with nudge
|
||||
assert "--integration kiro-cli" in result.output
|
||||
# Command files should be created via integration path
|
||||
assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists()
|
||||
|
||||
def test_q_removed_from_agent_config(self):
|
||||
"""Amazon Q legacy key should not remain in AGENT_CONFIG."""
|
||||
assert "q" not in AGENT_CONFIG
|
||||
@@ -1327,12 +983,12 @@ class TestParameterOrderingIssue:
|
||||
output_lower = result.output.lower()
|
||||
assert any(agent in output_lower for agent in ["claude", "copilot", "gemini"])
|
||||
|
||||
def test_ai_commands_dir_consuming_flag(self):
|
||||
def test_ai_commands_dir_consuming_flag(self, tmp_path):
|
||||
"""--ai-commands-dir without value should not consume next flag."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", "myproject", "--ai", "generic", "--ai-commands-dir", "--here"])
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "myproject"), "--ai", "generic", "--ai-commands-dir", "--here"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid value for --ai-commands-dir" in result.output
|
||||
|
||||
@@ -16,6 +16,7 @@ import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from tests.conftest import strip_ansi
|
||||
from specify_cli.extensions import (
|
||||
CatalogEntry,
|
||||
CORE_COMMAND_NAMES,
|
||||
@@ -3126,11 +3127,12 @@ class TestExtensionListCLI:
|
||||
result = runner.invoke(app, ["extension", "list"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
plain = strip_ansi(result.output)
|
||||
# Verify the extension ID is shown in the output
|
||||
assert "test-ext" in result.output
|
||||
assert "test-ext" in plain
|
||||
# Verify name and version are also shown
|
||||
assert "Test Extension" in result.output
|
||||
assert "1.0.0" in result.output
|
||||
assert "Test Extension" in plain
|
||||
assert "1.0.0" in plain
|
||||
|
||||
|
||||
class TestExtensionPriority:
|
||||
@@ -3360,7 +3362,8 @@ class TestExtensionPriorityCLI:
|
||||
result = runner.invoke(app, ["extension", "list"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Priority: 7" in result.output
|
||||
plain = strip_ansi(result.output)
|
||||
assert "Priority: 7" in plain
|
||||
|
||||
def test_set_priority_changes_priority(self, extension_dir, project_dir):
|
||||
"""Test set-priority command changes extension priority."""
|
||||
@@ -3381,7 +3384,8 @@ class TestExtensionPriorityCLI:
|
||||
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "priority changed: 10 → 5" in result.output
|
||||
plain = strip_ansi(result.output)
|
||||
assert "priority changed: 10 → 5" in plain
|
||||
|
||||
# Reload registry to see updated value
|
||||
manager2 = ExtensionManager(project_dir)
|
||||
@@ -3403,7 +3407,8 @@ class TestExtensionPriorityCLI:
|
||||
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "already has priority 5" in result.output
|
||||
plain = strip_ansi(result.output)
|
||||
assert "already has priority 5" in plain
|
||||
|
||||
def test_set_priority_invalid_value(self, extension_dir, project_dir):
|
||||
"""Test set-priority rejects invalid priority values."""
|
||||
|
||||
@@ -20,6 +20,7 @@ from datetime import datetime, timezone
|
||||
|
||||
import yaml
|
||||
|
||||
from tests.conftest import strip_ansi
|
||||
from specify_cli.presets import (
|
||||
PresetManifest,
|
||||
PresetRegistry,
|
||||
@@ -2441,7 +2442,8 @@ class TestPresetSetPriority:
|
||||
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "priority changed: 10 → 5" in result.output
|
||||
plain = strip_ansi(result.output)
|
||||
assert "priority changed: 10 → 5" in plain
|
||||
|
||||
# Reload registry to see updated value
|
||||
manager2 = PresetManager(project_dir)
|
||||
@@ -2463,7 +2465,8 @@ class TestPresetSetPriority:
|
||||
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "already has priority 5" in result.output
|
||||
plain = strip_ansi(result.output)
|
||||
assert "already has priority 5" in plain
|
||||
|
||||
def test_set_priority_invalid_value(self, project_dir, pack_dir):
|
||||
"""Test set-priority rejects invalid priority values."""
|
||||
|
||||
Reference in New Issue
Block a user