diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 4dd17294e7..c1dbdd9458 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -88,9 +88,9 @@ fi run_command "$kiro_binary --help > /dev/null" echo "✅ Done" -echo -e "\n🤖 Installing Kimi CLI..." +echo -e "\n🤖 Installing Kimi Code CLI..." # https://code.kimi.com -run_command "pipx install kimi-cli" +run_command "npm install -g @moonshot-ai/kimi-code@latest" echo "✅ Done" echo -e "\n🤖 Installing CodeBuddy CLI..." diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dfe13572e..f288ed1b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ ### Changed +- feat(integration): update Kimi integration for Kimi Code CLI (`@moonshot-ai/kimi-code`): skills path moved from `.kimi/skills/` to `.kimi-code/skills/`, context file moved from `KIMI.md` to `AGENTS.md`; `--migrate-legacy` migrates old installs and `teardown()` cleans up leftover legacy directories - Add Research Harness extension to community catalog (#2935) - Add Coding Standards Drift Control extension to community catalog (#2934) - Add Spec Trace extension to community catalog (#2527) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index a790389774..dbb3f19159 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -24,7 +24,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | | | [Junie](https://junie.jetbrains.com/) | `junie` | | | [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | | -| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration | +| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs and `KIMI.md` context to the new paths | | [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` | | [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | @@ -155,7 +155,7 @@ Some integrations accept additional options via `--integration-options`: | Integration | Option | Description | | ----------- | ------------------- | -------------------------------------------------------------- | | `generic` | `--commands-dir` | Required. Directory for command files | -| `kimi` | `--migrate-legacy` | Migrate legacy dotted skill directories to hyphenated format | +| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs and `KIMI.md` to `.kimi-code/skills/` and `AGENTS.md`, including dotted→hyphenated directory names | Example: @@ -188,7 +188,6 @@ The currently declared multi-install safe integrations are: | `iflow` | `.iflow/commands`, `IFLOW.md` | | `junie` | `.junie/commands`, `.junie/AGENTS.md` | | `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` | -| `kimi` | `.kimi/skills`, `KIMI.md` | | `qodercli` | `.qoder/commands`, `QODER.md` | | `qwen` | `.qwen/commands`, `QWEN.md` | | `roo` | `.roo/commands`, `.roo/rules/specify-rules.md` | diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 3b257768e2..0155292468 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -1,11 +1,11 @@ """Kimi Code integration — skills-based agent (Moonshot AI). -Kimi uses the ``.kimi/skills/speckit-/SKILL.md`` layout with +Kimi uses the ``.kimi-code/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``). +Code CLI adopted the ``.kimi-code/`` directory, as well as for the +older dotted skill directory naming (``speckit.xxx`` → ``speckit-xxx``). """ from __future__ import annotations @@ -14,7 +14,7 @@ from pathlib import Path from typing import Any -from ..base import IntegrationOption, SkillsIntegration +from ..base import IntegrationBase, IntegrationOption, SkillsIntegration from ..manifest import IntegrationManifest @@ -24,19 +24,38 @@ class KimiIntegration(SkillsIntegration): key = "kimi" config = { "name": "Kimi Code", - "folder": ".kimi/", + "folder": ".kimi-code/", "commands_subdir": "skills", "install_url": "https://code.kimi.com/", "requires_cli": True, } registrar_config = { - "dir": ".kimi/skills", + "dir": ".kimi-code/skills", "format": "markdown", "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "KIMI.md" - multi_install_safe = True + context_file = "AGENTS.md" + multi_install_safe = False + + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Build Kimi's native skill invocation: ``/skill:speckit-``. + + Kimi Code CLI invokes installed skills with a ``/skill:`` + slash command (e.g. ``/skill:speckit-plan``), not the bare + ``/speckit-`` form produced by the generic skills base + class. Overriding here keeps ``dispatch_command()`` and workflow + command steps aligned with the ``/skill:`` guidance shown at init + time and in rendered hook invocations. + """ + stem = command_name + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + + invocation = "/skill:speckit-" + stem.replace(".", "-") + if args: + invocation = f"{invocation} {args}" + return invocation @classmethod def options(cls) -> list[IntegrationOption]: @@ -51,7 +70,10 @@ def options(cls) -> list[IntegrationOption]: "--migrate-legacy", is_flag=True, default=False, - help="Migrate legacy dotted skill dirs (speckit.xxx → speckit-xxx)", + help=( + "Migrate legacy Kimi installations: " + ".kimi/skills/ → .kimi-code/skills/ and speckit.xxx → speckit-xxx" + ), ), ] @@ -62,47 +84,121 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Install skills with optional legacy dotted-name migration.""" + """Install skills with optional legacy 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. + # Run base setup first so new-path targets (speckit-*) exist, + # then migrate/clean legacy 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) + new_skills_dir = self.skills_dest(project_root) + old_skills_dir = project_root / ".kimi" / "skills" + if _is_safe_legacy_dir(old_skills_dir, project_root): + _migrate_legacy_kimi_skills_dir(old_skills_dir, new_skills_dir) + marker_start, marker_end = self._resolve_context_markers(project_root) + _migrate_legacy_kimi_context_file( + project_root, marker_start=marker_start, marker_end=marker_end + ) return created + def teardown( + self, + project_root: Path, + manifest: IntegrationManifest, + *, + force: bool = False, + ) -> tuple[list[Path], list[Path]]: + """Uninstall Kimi skills and remove leftover legacy directories.""" + removed, skipped = super().teardown(project_root, manifest, force=force) -def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: - """Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format. + old_skills_dir = project_root / ".kimi" / "skills" + if _is_safe_legacy_dir(old_skills_dir, project_root): + legacy_dirs = sorted( + [*old_skills_dir.glob("speckit-*"), *old_skills_dir.glob("speckit.*")] + ) + for legacy_dir in legacy_dirs: + if legacy_dir.is_symlink() or not legacy_dir.is_dir(): + continue + if _is_speckit_generated_skill(legacy_dir): + try: + shutil.rmtree(legacy_dir) + removed.append(legacy_dir) + except OSError: + skipped.append(legacy_dir) + + try: + old_skills_dir.rmdir() + except OSError: + pass + + return removed, skipped + + +def _is_safe_legacy_dir(path: Path, project_root: Path) -> bool: + """Return ``True`` when *path* is a real directory safely inside *project_root*. + + Legacy migration and cleanup ``shutil.move()`` and ``shutil.rmtree()`` + directories, so a symlinked ``.kimi``/``.kimi/skills`` (or one reached + through a symlinked parent) must never be followed: doing so could + relocate or delete content living outside the project tree. We reject + the path when it is itself a symlink, when it is not a directory, or + when resolving every symlink lands outside *project_root*. + """ + if path.is_symlink() or not path.is_dir(): + return False + try: + resolved = path.resolve() + root = project_root.resolve() + except OSError: + return False + return resolved == root or root in resolved.parents + + +def _migrate_legacy_kimi_skills_dir( + old_skills_dir: Path, new_skills_dir: Path +) -> tuple[int, int]: + """Migrate skills from the legacy ``.kimi/skills/`` directory to ``.kimi-code/skills/``. + + Handles both hyphenated (``speckit-xxx``) and dotted (``speckit.xxx``) + legacy directory names. If a target already exists, the legacy dir is + only removed when its ``SKILL.md`` is byte-identical and no extra user + files are present. Returns ``(migrated_count, removed_count)``. """ - if not skills_dir.is_dir(): + if not old_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(): + # Process hyphenated dirs first, then dotted dirs. + legacy_dirs = sorted(old_skills_dir.glob("speckit-*")) + sorted( + old_skills_dir.glob("speckit.*") + ) + + for legacy_dir in legacy_dirs: + if legacy_dir.is_symlink() or not legacy_dir.is_dir(): continue if not (legacy_dir / "SKILL.md").exists(): continue - suffix = legacy_dir.name[len("speckit."):] - if not suffix: + target_name = _legacy_to_target_name(legacy_dir.name) + if not target_name: continue - target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}" + target_dir = new_skills_dir / target_name + + # Skip if the legacy dir is already the target dir (same-directory call). + if legacy_dir.resolve() == target_dir.resolve(): + continue if not target_dir.exists(): + target_dir.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(legacy_dir), str(target_dir)) migrated_count += 1 continue @@ -122,4 +218,130 @@ def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: except OSError: pass + # Remove the legacy skills directory if it is now empty. + try: + old_skills_dir.rmdir() + except OSError: + pass + return (migrated_count, removed_count) + + +def _legacy_to_target_name(legacy_name: str) -> str: + """Convert a legacy skill directory name to the modern hyphenated form.""" + if legacy_name.startswith("speckit-"): + return legacy_name + if legacy_name.startswith("speckit."): + suffix = legacy_name[len("speckit."):] + if suffix: + return f"speckit-{suffix.replace('.', '-')}" + return "" + + +def _is_speckit_generated_skill(skill_dir: Path) -> bool: + """Return True when *skill_dir* contains a Speckit-generated SKILL.md. + + Uses the ``metadata.author`` and ``metadata.source`` fields written by + ``SkillsIntegration.setup()`` to avoid deleting user-authored skills. + """ + skill_file = skill_dir / "SKILL.md" + if not skill_file.is_file(): + return False + + try: + content = skill_file.read_text(encoding="utf-8") + except OSError: + return False + + if not content.startswith("---"): + return False + + parts = content.split("---", 2) + if len(parts) < 3: + return False + + try: + import yaml + + frontmatter = yaml.safe_load(parts[1]) + except Exception: + return False + + if not isinstance(frontmatter, dict): + return False + + metadata = frontmatter.get("metadata", {}) + if not isinstance(metadata, dict): + return False + + author = metadata.get("author", "") + source = metadata.get("source", "") + return author == "github-spec-kit" or ( + isinstance(source, str) and source.startswith("templates/commands/") + ) + + +def _migrate_legacy_kimi_context_file( + project_root: Path, + *, + marker_start: str = IntegrationBase.CONTEXT_MARKER_START, + marker_end: str = IntegrationBase.CONTEXT_MARKER_END, +) -> bool: + """Migrate user content from legacy ``KIMI.md`` to ``AGENTS.md``. + + The Speckit managed section is stripped from ``KIMI.md`` before the + remaining content is appended to ``AGENTS.md``. The legacy file is + deleted if it becomes empty. Returns ``True`` if ``KIMI.md`` existed + and was processed. + """ + legacy_path = project_root / "KIMI.md" + if not legacy_path.is_file(): + return False + + content = legacy_path.read_text(encoding="utf-8-sig") + start_idx = content.find(marker_start) + end_idx = content.find(marker_end, start_idx if start_idx != -1 else 0) + + if start_idx != -1 and end_idx != -1 and end_idx > start_idx: + removal_start = start_idx + removal_end = end_idx + len(marker_end) + if removal_end < len(content) and content[removal_end] == "\r": + removal_end += 1 + if removal_end < len(content) and content[removal_end] == "\n": + removal_end += 1 + if removal_start > 0 and content[removal_start - 1] == "\n": + if removal_start > 1 and content[removal_start - 2] == "\n": + removal_start -= 1 + content = content[:removal_start] + content[removal_end:] + + user_content = content.replace("\r\n", "\n").replace("\r", "\n").strip() + if not user_content: + legacy_path.unlink() + return True + + target_path = project_root / "AGENTS.md" + if target_path.is_file(): + existing = target_path.read_text(encoding="utf-8-sig") + existing = existing.replace("\r\n", "\n").replace("\r", "\n") + if not existing.endswith("\n"): + existing += "\n" + new_content = existing + "\n" + user_content + "\n" + else: + new_content = user_content + "\n" + + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(new_content.encode("utf-8")) + legacy_path.unlink() + return True + + +def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: + """Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format. + + .. deprecated:: + Kept for direct callers/tests; new code should use + ``_migrate_legacy_kimi_skills_dir``. + + Returns ``(migrated_count, removed_count)``. + """ + return _migrate_legacy_kimi_skills_dir(skills_dir, skills_dir) diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index 112baf0301..d91f7a88b1 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -9,10 +9,10 @@ class TestKimiIntegration(SkillsIntegrationTests): KEY = "kimi" - FOLDER = ".kimi/" + FOLDER = ".kimi-code/" COMMANDS_SUBDIR = "skills" - REGISTRAR_DIR = ".kimi/skills" - CONTEXT_FILE = "KIMI.md" + REGISTRAR_DIR = ".kimi-code/skills" + CONTEXT_FILE = "AGENTS.md" class TestKimiOptions: @@ -103,12 +103,32 @@ def test_nonexistent_dir_returns_zeros(self, tmp_path): assert migrated == 0 assert removed == 0 + def test_setup_migrate_legacy_moves_old_skills_dir(self, tmp_path): + """--migrate-legacy moves hyphenated skills from .kimi/skills to .kimi-code/skills.""" + i = get_integration("kimi") + + old_skills_dir = tmp_path / ".kimi" / "skills" + new_skills_dir = tmp_path / ".kimi-code" / "skills" + legacy = old_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 not old_skills_dir.exists() + assert (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists() + # New skills from templates should also exist + assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists() + 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" + old_skills_dir = tmp_path / ".kimi" / "skills" + new_skills_dir = tmp_path / ".kimi-code" / "skills" + legacy = old_skills_dir / "speckit.oldcmd" legacy.mkdir(parents=True) (legacy / "SKILL.md").write_text("# Legacy\n") @@ -116,9 +136,213 @@ def test_setup_with_migrate_legacy_option(self, tmp_path): i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) assert not legacy.exists() - assert (skills_dir / "speckit-oldcmd" / "SKILL.md").exists() + assert (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists() # New skills from templates should also exist - assert (skills_dir / "speckit-specify" / "SKILL.md").exists() + assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists() + + +class TestKimiContextFileMigration: + """KIMI.md → AGENTS.md migration under --migrate-legacy.""" + + def test_setup_migrate_legacy_moves_kimi_md_user_content(self, tmp_path): + i = get_integration("kimi") + + kimi_md = tmp_path / "KIMI.md" + kimi_md.write_text( + "# Project context\n\n" + "\n" + "old managed section\n" + "\n\n" + "Keep this user note.\n" + ) + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + agents_md = tmp_path / "AGENTS.md" + assert agents_md.exists() + content = agents_md.read_text(encoding="utf-8") + assert "Keep this user note." in content + assert "old managed section" not in content + assert "" in content + assert not kimi_md.exists() + + def test_setup_migrate_legacy_removes_empty_kimi_md(self, tmp_path): + i = get_integration("kimi") + + kimi_md = tmp_path / "KIMI.md" + kimi_md.write_text( + "\n" + "only managed section\n" + "\n" + ) + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + assert (tmp_path / "AGENTS.md").exists() + assert not kimi_md.exists() + + def test_setup_migrate_legacy_appends_to_existing_agents_md(self, tmp_path): + i = get_integration("kimi") + + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text("# Existing AGENTS.md\n\nExisting note.\n") + + kimi_md = tmp_path / "KIMI.md" + kimi_md.write_text("# Kimi context\n\nKimi-specific note.\n") + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + content = agents_md.read_text(encoding="utf-8") + assert "Existing note." in content + assert "Kimi-specific note." in content + assert "" in content + assert not kimi_md.exists() + + def test_setup_migrate_legacy_uses_custom_context_markers(self, tmp_path): + """Migration respects context_markers from agent-context extension config.""" + i = get_integration("kimi") + + config_dir = tmp_path / ".specify" / "extensions" / "agent-context" + config_dir.mkdir(parents=True) + (config_dir / "agent-context-config.yml").write_text( + "context_file: AGENTS.md\n" + "context_markers:\n" + " start: ''\n" + " end: ''\n" + ) + + kimi_md = tmp_path / "KIMI.md" + kimi_md.write_text( + "# Project context\n\n" + "\n" + "old managed section\n" + "\n\n" + "Keep this user note.\n" + ) + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + agents_md = tmp_path / "AGENTS.md" + assert agents_md.exists() + content = agents_md.read_text(encoding="utf-8") + assert "Keep this user note." in content + assert "old managed section" not in content + assert "" in content + assert "" in content + assert "" not in content + assert not kimi_md.exists() + + +class TestKimiTeardownLegacyCleanup: + """teardown() removes leftover legacy .kimi/skills/ directories.""" + + def test_teardown_removes_legacy_speckit_skills(self, tmp_path): + i = get_integration("kimi") + + legacy_skill = tmp_path / ".kimi" / "skills" / "speckit-plan" / "SKILL.md" + legacy_skill.parent.mkdir(parents=True) + legacy_skill.write_text( + "---\n" + "name: \"speckit-plan\"\n" + "description: \"Plan workflow\"\n" + "metadata:\n" + " author: \"github-spec-kit\"\n" + " source: \"templates/commands/plan.md\"\n" + "---\n" + ) + + m = IntegrationManifest("kimi", tmp_path) + i.teardown(tmp_path, m) + + assert not legacy_skill.exists() + assert not (tmp_path / ".kimi" / "skills").exists() + + def test_teardown_preserves_user_skills_in_legacy_dir(self, tmp_path): + i = get_integration("kimi") + + user_skill = tmp_path / ".kimi" / "skills" / "my-custom" / "SKILL.md" + user_skill.parent.mkdir(parents=True) + user_skill.write_text("# My custom skill\n") + + m = IntegrationManifest("kimi", tmp_path) + i.teardown(tmp_path, m) + + assert user_skill.exists() + + +class TestKimiCommandInvocation: + """Kimi dispatch must use the native ``/skill:`` slash command.""" + + def test_build_command_invocation_uses_skill_prefix(self): + i = get_integration("kimi") + assert i.build_command_invocation("specify") == "/skill:speckit-specify" + assert i.build_command_invocation("speckit.plan") == "/skill:speckit-plan" + + def test_build_command_invocation_dotted_extension(self): + i = get_integration("kimi") + assert ( + i.build_command_invocation("speckit.git.commit") + == "/skill:speckit-git-commit" + ) + + def test_build_command_invocation_appends_args(self): + i = get_integration("kimi") + assert ( + i.build_command_invocation("specify", "my feature") + == "/skill:speckit-specify my feature" + ) + + +class TestKimiLegacySymlinkSafety: + """Legacy migration/cleanup must not follow symlinks out of the project.""" + + def test_migrate_skips_symlinked_legacy_skills_dir(self, tmp_path): + # An attacker-controlled directory outside the project root. Use a + # non-template skill name so a successful migration would be visible + # (the bundled templates never create "speckit-evillegacy"). + outside = tmp_path / "outside" + (outside / "speckit-evillegacy").mkdir(parents=True) + (outside / "speckit-evillegacy" / "SKILL.md").write_text("# evil\n") + + project = tmp_path / "project" + (project / ".kimi").mkdir(parents=True) + # .kimi/skills is a symlink to the outside directory. + (project / ".kimi" / "skills").symlink_to( + outside, target_is_directory=True + ) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + i.setup(project, m, parsed_options={"migrate_legacy": True}) + + # Outside content must be untouched (not moved into .kimi-code). + assert (outside / "speckit-evillegacy" / "SKILL.md").exists() + assert not ( + project / ".kimi-code" / "skills" / "speckit-evillegacy" + ).exists() + + def test_teardown_skips_symlinked_legacy_skills_dir(self, tmp_path): + outside = tmp_path / "outside" + outside.mkdir() + keep = outside / "keep.txt" + keep.write_text("important\n") + + project = tmp_path / "project" + (project / ".kimi").mkdir(parents=True) + (project / ".kimi" / "skills").symlink_to( + outside, target_is_directory=True + ) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + i.teardown(project, m) + + # The symlink target and its contents must survive teardown. + assert keep.exists() class TestKimiNextSteps: diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index b62f06e821..77b0dd799e 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1678,7 +1678,7 @@ def test_switch_migrates_extension_commands(self, tmp_path): assert result.exit_code == 0, f"extension add failed: {result.output}" # Verify git extension skills exist for kimi - kimi_git_feature = project / ".kimi" / "skills" / "speckit-git-feature" / "SKILL.md" + kimi_git_feature = project / ".kimi-code" / "skills" / "speckit-git-feature" / "SKILL.md" assert kimi_git_feature.exists(), "Git extension skill should exist for kimi" result = _run_in_project(project, [ diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 1176009778..f558eb206b 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -82,17 +82,17 @@ def test_agent_config_includes_tabnine(self): def test_kimi_in_agent_config(self): """AGENT_CONFIG should include kimi with correct folder and commands_subdir.""" assert "kimi" in AGENT_CONFIG - assert AGENT_CONFIG["kimi"]["folder"] == ".kimi/" + assert AGENT_CONFIG["kimi"]["folder"] == ".kimi-code/" assert AGENT_CONFIG["kimi"]["commands_subdir"] == "skills" assert AGENT_CONFIG["kimi"]["requires_cli"] is True def test_kimi_in_extension_registrar(self): - """Extension command registrar should include kimi using .kimi/skills and SKILL.md.""" + """Extension command registrar should include kimi using .kimi-code/skills and SKILL.md.""" cfg = CommandRegistrar.AGENT_CONFIGS assert "kimi" in cfg kimi_cfg = cfg["kimi"] - assert kimi_cfg["dir"] == ".kimi/skills" + assert kimi_cfg["dir"] == ".kimi-code/skills" assert kimi_cfg["extension"] == "/SKILL.md" def test_agent_config_includes_kimi(self): diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 1d05e1c2c4..6723e6081b 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1812,7 +1812,7 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir @pytest.mark.parametrize("agent_name,skills_path", [ ("codex", ".agents/skills"), - ("kimi", ".kimi/skills"), + ("kimi", ".kimi-code/skills"), ("claude", ".claude/skills"), ("cursor-agent", ".cursor/skills"), ("trae", ".trae/skills"), diff --git a/tests/test_presets.py b/tests/test_presets.py index de6054d99c..2c86fe2b4d 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3577,7 +3577,7 @@ def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, tem def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp_dir): """Preset overrides should still target legacy dotted Kimi skill directories.""" self._write_init_options(project_dir, ai="kimi") - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit.specify", body="untouched") (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) @@ -3597,7 +3597,7 @@ def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp_dir): """Kimi presets should still propagate command overrides to existing skills.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False) - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) @@ -3617,7 +3617,7 @@ def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, temp_dir): """Kimi native skills should still receive brand-new preset commands.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False) - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) preset_dir = temp_dir / "kimi-new-skill" @@ -3666,7 +3666,7 @@ def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir): """Kimi preset skill overrides should resolve placeholders and rewrite project paths.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh") - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)