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