mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
* Stage 5: Skills, Generic & Option-Driven Integrations (#1924) Add SkillsIntegration base class and migrate codex, kimi, agy, and generic to the integration system. Integrations: - SkillsIntegration(IntegrationBase) in base.py — creates speckit-<name>/SKILL.md layout matching release ZIP output byte-for-byte - CodexIntegration — .agents/skills/, --skills default=True - KimiIntegration — .kimi/skills/, --skills + --migrate-legacy options, dotted→hyphenated skill directory migration - AgyIntegration — .agent/skills/, skills-only (commands deprecated v1.20.5) - GenericIntegration — user-specified --commands-dir, MarkdownIntegration - All four have update-context.sh/.ps1 scripts - All four registered in INTEGRATION_REGISTRY CLI changes: - --ai <agent> auto-promotes to integration path for all registered agents - Interactive agent selection also auto-promotes (bug fix) - --ai-skills and --ai-commands-dir show deprecation notices on integration path - Next-steps display shows correct skill invocation syntax for skills integrations - agy added to CommandRegistrar.AGENT_CONFIGS Tests: - test_integration_base_skills.py — reusable mixin with setup, frontmatter, directory structure, scripts, CLI auto-promote, and complete file inventory (sh+ps) tests - Per-agent test files: test_integration_{codex,kimi,agy,generic}.py - Kimi legacy migration tests, generic --commands-dir validation - Registry updated with Stage 5 keys - Removed 9 dead-mock tests, moved 4 integration tests to proper locations - Fixed all bare project-name tests to use tmp_path - Fixed 6 pre-existing ANSI escape code test failures in test_extensions.py and test_presets.py 1524 tests pass, 0 failures. * fix: remove unused variable flagged by ruff (F841) * fix: address PR review — integration-type-aware deprecation messages and early generic validation - --ai-skills deprecation message now distinguishes SkillsIntegration ("skills are the default") from command-based integrations ("has no effect") - --ai-commands-dir validation for generic runs even when auto-promoted, giving clear CLI error instead of late ValueError from setup() - Resolves review comments from #2052 * fix: address PR review round 2 - Remove unused SKILL_DESCRIPTIONS dict from base.py (dead code after switching to template descriptions for ZIP parity) - Narrow YAML parse catch from Exception to yaml.YAMLError - Remove unused shutil import from test_integration_kimi.py - Remove unused _REGISTRAR_EXEMPT class attr from test_registry.py - Reword --ai-commands-dir deprecation to be actionable - Update generic validation error to mention both --ai and --integration * fix: address PR review round 3 - Clarify parsed_options forwarding is intentional (all options passed, integrations decide what to use) - Extract _strip_ansi() helper in test_extensions.py and test_presets.py - Remove unused pytest import (test_cli.py), unused locals (test_integration_base_skills.py) - Reword --ai-commands-dir deprecation to be actionable without referencing the not-yet-implemented --integration-options * fix: address PR review round 4 - Reorder kimi migration: run super().setup() first so hyphenated targets exist, then migrate dotted dirs (prevents user content loss) - Move _strip_ansi() to shared tests/conftest.py, import from there in test_extensions.py, test_presets.py, test_ai_skills.py - Remove now-unused re imports from all three test files * fix: address PR review round 5 - Use write_bytes() for LF-only newlines (no CRLF on Windows) - Add --integration-options CLI parameter — raw string passed through to the integration via opts['raw_options']; the integration owns parsing of its own options - GenericIntegration.setup() reads --commands-dir from raw_options when not in parsed_options (supports --integration-options="...") - Skip early --ai-commands-dir validation when --integration-options is provided (integration validates in its own setup()) - Remove parse_integration_options from core — integrations parse their own options * fix: address PR review round 6 - GenericIntegration is now stateless: removed self._commands_dir instance state, overrides setup() directly to compute destination from parsed_options/raw_options on the stack - commands_dest() raises by design (stateless singleton) - _quote() in SkillsIntegration now escapes backslashes and double quotes to produce valid YAML even with special characters * fix: address PR review round 7 - Support --commands-dir=value form in raw_options parsing (not just --commands-dir value with space separator) - Normalize CRLF to LF in write_file_and_record() before encoding - Persist ai_skills=True in init-options.json when using a SkillsIntegration, so extensions/presets emit SKILL.md overrides correctly even without explicit --ai-skills flag
150 lines
5.2 KiB
Python
150 lines
5.2 KiB
Python
"""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
|