From dc840f07d03baca1e07d2364ea682aa028b18204 Mon Sep 17 00:00:00 2001 From: meymchen <86772442+meymchen@users.noreply.github.com> Date: Thu, 25 Jun 2026 04:22:08 +0800 Subject: [PATCH] feat(integration): update Kimi integration for Kimi Code CLI (#2979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(integration): update Kimi integration for Kimi Code CLI Update the Kimi integration to target the new Kimi Code CLI (MoonshotAI/kimi-code) layout: - Change skills directory from .kimi/skills/ to .kimi-code/skills/ - Change context file from KIMI.md to AGENTS.md - Extend --migrate-legacy to move old .kimi/skills/ installs and migrate KIMI.md user content to AGENTS.md - Clean up leftover legacy .kimi/skills/ directories on teardown - Update devcontainer installer to @moonshot-ai/kimi-code - Update docs and tests Relates to #1532 * fix(integration): align Kimi dispatch and harden legacy migration - Override build_command_invocation to emit /skill:speckit- so dispatched commands match Kimi Code CLI's native slash syntax. - Skip symlinked .kimi/skills directories during legacy migration and teardown to avoid operating on files outside the project. - Remove kimi from the multi-install-safe integrations table. - Add tests for command invocation and symlink safety. * fix(integration): resolve custom context markers in Kimi legacy migration Use IntegrationBase._resolve_context_markers() when migrating legacy KIMI.md content so that projects with customized context_markers in .specify/extensions/agent-context/agent-context-config.yml have the managed section stripped with the correct markers instead of the hard-coded defaults. Adds a test verifying custom markers are respected during --migrate-legacy. * fix(integration): harden Kimi legacy migration against symlinked paths * fix(kimi): guard symlinked SKILL.md during migration and teardown * docs(kimi): mention KIMI.md→AGENTS.md migration in --migrate-legacy help The --migrate-legacy help text listed only the skills directory move and dotted→hyphenated renaming, but the flag also migrates KIMI.md user content into AGENTS.md. Align the help with the actual behavior, docs, and tests. Co-Authored-By: Claude Opus 4.8 * fix(kimi): validate legacy migration destination; clarify docstrings Address Copilot review feedback on PR #2979: - setup(): gate skills migration on _is_safe_legacy_dir(new_skills_dir) as well as the source. base setup() already rejects a destination that escapes the project root, but an in-tree symlinked .kimi-code/skills (e.g. -> .) could still misdirect the move; this gives the destination the same symlink-component protection as the source. - _migrate_legacy_kimi_dotted_skills: rewrite docstring as a compatibility shim describing same-path delegation to _migrate_legacy_kimi_skills_dir. - test_presets: clarify that the dotted-skill test exercises legacy naming under the current .kimi-code/ base, not the legacy .kimi/ location. Co-Authored-By: Claude Opus 4.8 * fix(kimi): harden legacy KIMI.md→AGENTS.md context migration - Skip context-file migration when the agent-context extension is disabled, matching upsert/remove_context_section opt-out behavior so an opted-out project's KIMI.md/AGENTS.md are left untouched. - Safely skip (instead of raising) on filesystem edge cases: unreadable or non-UTF-8 KIMI.md, and AGENTS.md existing as a non-file/unwritable. - Refuse to migrate a corrupted managed section (single marker, or end before start) so a partial managed block is never copied into AGENTS.md; KIMI.md is preserved for manual repair. Add regression tests for all three cases. Co-Authored-By: Claude Opus 4.8 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Approve fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * chore(kimi): revert CHANGELOG.md edit (auto-generated) The CHANGELOG is generated from merged PR titles, so a hand-written entry is redundant; it was also placed under the already-released 0.10.2 section, which would make those release notes historically inaccurate. Revert to match main per maintainer feedback. Co-Authored-By: Claude Opus 4.8 * test(kimi): skip symlink-safety tests when symlinks are unavailable The Kimi legacy-migration safety tests create symlinks to assert that migration/teardown never follow them out of the project. Symlink creation fails on Windows without the create-symlink privilege and in some restricted CI sandboxes, so these tests errored during setup instead of skipping. Wrap every symlink_to() call in a shared _symlink_or_skip() helper that pytest.skip()s on OSError/NotImplementedError, matching the guard pattern already used by one of these tests. Verified on Windows: the 6 symlink tests now skip cleanly (51 passed, 6 skipped) instead of erroring. Co-Authored-By: Claude Opus 4.8 * fix(kimi): reject symlinked skills destination before install Add a destination symlink pre-check in KimiIntegration.setup() before super().setup() writes any SKILL.md. The base class only rejects a destination that escapes project_root after resolve(), so an in-tree symlinked .kimi-code/.kimi-code/skills (e.g. `-> .`) would still misdirect writes into an unintended in-tree location (./skills/). Extract the symlink-component walk into a shared _has_symlinked_component() helper and reuse it from _is_safe_legacy_dir(). Add a regression test. Also clarify that --migrate-legacy only migrates KIMI.md -> AGENTS.md when the agent-context extension is enabled, in the CLI help text and the integration docs. Co-Authored-By: Claude Opus 4.8 * Refactor formatting and simplify logic in Kimi integration * fix(kimi): reject symlinked target dir during legacy skills migration When the migration destination already exists, guard against a symlinked (or non-directory) target_dir before comparing SKILL.md bytes, so the comparison never follows a link outside the project root. Also skip a missing/non-file target SKILL.md explicitly. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .devcontainer/post-create.sh | 4 +- docs/reference/integrations.md | 5 +- src/specify_cli/integrations/kimi/__init__.py | 440 +++++++++++++++-- tests/integrations/test_integration_kimi.py | 464 +++++++++++++++++- .../test_integration_subcommand.py | 2 +- tests/test_agent_config_consistency.py | 6 +- tests/test_extensions.py | 2 +- tests/test_presets.py | 20 +- 8 files changed, 877 insertions(+), 66 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 4dd17294e..c1dbdd945 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/docs/reference/integrations.md b/docs/reference/integrations.md index 1ec4c223f..574638216 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -25,7 +25,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 to the new paths, and (when the `agent-context` extension is enabled) migrates `KIMI.md` context into `AGENTS.md` | | [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` | | @@ -158,7 +158,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 to `.kimi-code/skills/` (including dotted→hyphenated directory names); when the `agent-context` extension is enabled, also migrates `KIMI.md` to `AGENTS.md` | Example: @@ -192,7 +192,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 3b257768e..9c28855c0 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -1,11 +1,13 @@ """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``). +Legacy migration covers projects created before Kimi Code CLI moved to +this layout and handles two distinct changes: the directory move from +``.kimi/`` to ``.kimi-code/`` (including the ``KIMI.md`` → ``AGENTS.md`` +context file), and the dotted-to-hyphenated skill naming +(``speckit.xxx`` → ``speckit-xxx``). """ from __future__ import annotations @@ -14,7 +16,7 @@ import shutil 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 +26,43 @@ 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 + + def post_process_skill_content(self, content: str) -> str: + """Ensure in-skill cross-command references use Kimi's `/skill:` syntax.""" + content = super().post_process_skill_content(content) + return content.replace("/speckit-", "/skill:speckit-") @classmethod def options(cls) -> list[IntegrationOption]: @@ -51,7 +77,12 @@ class KimiIntegration(SkillsIntegration): "--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/, speckit.xxx → speckit-xxx, " + "and (when the agent-context extension is enabled) " + "KIMI.md user content → AGENTS.md" + ), ), ] @@ -62,64 +93,397 @@ class KimiIntegration(SkillsIntegration): 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. + # Refuse a symlinked destination before any writes occur. base + # setup() only rejects a destination that *escapes* project_root + # after resolve(), so an in-tree symlinked ``.kimi-code`` / + # ``.kimi-code/skills`` (e.g. ``-> .``) would still pass that check + # and misdirect the SKILL.md writes into an unintended in-tree + # location (e.g. ``./skills/``). Reject any symlinked destination + # component up front so this never happens. + new_skills_dir = self.skills_dest(project_root) + if _has_symlinked_component(new_skills_dir, project_root): + raise ValueError( + f"Skills destination {new_skills_dir} contains a symlinked " + f"path component; refusing to install into it." + ) + + # 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) + old_skills_dir = project_root / ".kimi" / "skills" + # Validate both endpoints. base setup() already rejects a + # destination that *escapes* the project root, but an in-tree + # symlinked ``.kimi-code``/``.kimi-code/skills`` (e.g. ``-> .``) + # would still misdirect the move; ``_is_safe_legacy_dir`` rejects + # any symlinked component, giving the destination the same + # protection as the source. + if _is_safe_legacy_dir(old_skills_dir, project_root) and ( + _is_safe_legacy_dir(new_skills_dir, project_root) + ): + _migrate_legacy_kimi_skills_dir(old_skills_dir, new_skills_dir) + # Mirror upsert/remove_context_section: a disabled agent-context + # extension is a full opt-out, so skip the KIMI.md → AGENTS.md + # migration entirely and leave both files untouched. + if self._agent_context_extension_enabled(project_root): + 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 _has_symlinked_component(path: Path, project_root: Path) -> bool: + """Return ``True`` when *path* escapes *project_root* or any component is a symlink. + + Walks the components strictly between *project_root* and *path* + (including the final one) and reports whether any of them is a symlink. + Components that do not exist yet are not symlinks, so this safely handles + a not-yet-created destination. *project_root* itself is trusted and never + checked. A *path* outside *project_root* is treated as unsafe. + """ + try: + relative = path.relative_to(project_root) + except ValueError: + return True + current = project_root + for part in relative.parts: + current = current / part + if current.is_symlink(): + return True + return False + + +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 — or operate + on an unrelated in-tree directory (e.g. ``.kimi -> .`` makes + ``.kimi/skills`` resolve to ``./skills``). + + Checking only the fully-resolved path is insufficient, because a symlink + pointing elsewhere *inside* the project still resolves to a location under + *project_root*. We therefore reject the path when it is not a directory, + when any component between *project_root* and *path* is a symlink + (including the final component), or when the resolved path escapes the + resolved *project_root*. + """ + if not path.is_dir(): + return False + + # Reject if any path component below project_root is a symlink (or the + # path escapes project_root). We trust project_root itself, so only + # components strictly under it are checked. + if _has_symlinked_component(path, project_root): + 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(): + legacy_skill = legacy_dir / "SKILL.md" + # Treat a symlinked SKILL.md as invalid: later read_bytes() would + # otherwise follow it and read content from outside the project. + if legacy_skill.is_symlink() or not legacy_skill.is_file(): 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 - # Target exists — only remove legacy if SKILL.md is identical + # Target exists — only remove legacy if SKILL.md is identical. + # Skip when the target dir or its SKILL.md is a symlink (or the dir is + # not a real directory) so the byte comparison never follows a link + # outside the project. (legacy_skill is already guaranteed to be a real + # file by the guard above.) + if target_dir.is_symlink() or not target_dir.is_dir(): + continue 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 + if target_skill.is_symlink() or not target_skill.is_file(): + continue + 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 + + # 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" + # A symlinked SKILL.md is never treated as Speckit-generated, so teardown + # cleanup never follows it to read frontmatter from outside the project. + if skill_file.is_symlink() or 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" + and 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`` was + migrated, ``False`` when the migration is skipped. + + The migration is skipped (leaving ``KIMI.md`` untouched) in any of these + cases, so a best-effort legacy cleanup never aborts ``setup()`` or + corrupts ``AGENTS.md``: + + - ``KIMI.md`` is a symlink, missing, or unreadable (its target could be + read from outside the project, or it may not be valid UTF-8). + - ``AGENTS.md`` is a symlink (it could redirect the write to a file + outside the project root), exists as a non-file (e.g. a directory), + or is unreadable/unwritable. + - ``KIMI.md`` has a corrupted managed section — only one marker is + present, or the end marker precedes the start. Stripping is only done + when both markers are present and well-ordered, so a partial managed + block is never copied into ``AGENTS.md``; the user repairs it manually. + """ + legacy_path = project_root / "KIMI.md" + if legacy_path.is_symlink() or not legacy_path.is_file(): + return False + + target_path = project_root / "AGENTS.md" + # Never follow a symlinked target, and never treat an existing non-file + # (e.g. a directory) as a writable context file. + if target_path.is_symlink() or (target_path.exists() and not target_path.is_file()): + return False + + try: + content = legacy_path.read_text(encoding="utf-8-sig") + except (OSError, UnicodeDecodeError): + return False + + marker_pairs = [(marker_start, marker_end)] + default_pair = ( + IntegrationBase.CONTEXT_MARKER_START, + IntegrationBase.CONTEXT_MARKER_END, + ) + if default_pair not in marker_pairs: + marker_pairs.append(default_pair) + + start_idx = -1 + end_idx = -1 + has_start = False + has_end = False + for s, e in marker_pairs: + s_idx = content.find(s) + e_idx = content.find(e, s_idx if s_idx != -1 else 0) + has_s = s_idx != -1 + has_e = e_idx != -1 + if not has_s and not has_e: + continue + # Refuse to migrate a corrupted managed section: exactly one marker, or + # an end marker that does not follow the start. + if has_s != has_e or e_idx <= s_idx: + return False + marker_start, marker_end = s, e + start_idx, end_idx = s_idx, e_idx + has_start = True + has_end = True + break + if has_start and has_end: + 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 + + try: + 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")) + except (OSError, UnicodeDecodeError): + return False + + legacy_path.unlink() + return True + + +def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: + """Compatibility shim — migrate legacy dotted skill dirs in place. + + .. deprecated:: + Kept for direct callers/tests. New code should call + ``_migrate_legacy_kimi_skills_dir`` directly. + + Delegates to ``_migrate_legacy_kimi_skills_dir`` with *skills_dir* as both + source and destination, so it processes every ``speckit-*`` and + ``speckit.*`` entry under *skills_dir*. Because the two paths are + identical, the same-path short-circuit there skips any directory whose + target resolves to itself; in practice this renames dotted + ``speckit.xxx`` dirs to hyphenated ``speckit-xxx`` in place and never + moves content outside *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 112baf030..2f752f66e 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -1,18 +1,42 @@ """Tests for KimiIntegration — skills integration with legacy migration.""" +from pathlib import Path + +import pytest + from specify_cli.integrations import get_integration -from specify_cli.integrations.kimi import _migrate_legacy_kimi_dotted_skills +from specify_cli.integrations.kimi import ( + _migrate_legacy_kimi_context_file, + _migrate_legacy_kimi_dotted_skills, + _migrate_legacy_kimi_skills_dir, +) from specify_cli.integrations.manifest import IntegrationManifest from .test_integration_base_skills import SkillsIntegrationTests +def _symlink_or_skip( + link: Path, target: Path, *, target_is_directory: bool = False +) -> None: + """Create *link* pointing at *target*, skipping the test if unsupported. + + Symlink creation fails on Windows without the create-symlink privilege and + in some restricted CI sandboxes. The symlink-safety tests below assert + behavior that only matters when symlinks exist, so skip (rather than error) + when the platform cannot create them. + """ + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + 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 +127,13 @@ class TestKimiLegacyMigration: assert migrated == 0 assert removed == 0 - def test_setup_with_migrate_legacy_option(self, tmp_path): - """KimiIntegration.setup() with --migrate-legacy migrates dotted dirs.""" + 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") - 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 +141,428 @@ class TestKimiLegacyMigration: i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) assert not legacy.exists() - assert (skills_dir / "speckit-oldcmd" / "SKILL.md").exists() + assert not old_skills_dir.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() + + def test_setup_with_migrate_legacy_option(self, tmp_path): + """KimiIntegration.setup() with --migrate-legacy migrates dotted dirs.""" + 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 (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists() + # New skills from templates should also exist + 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() + + def test_setup_migrate_legacy_skipped_when_agent_context_disabled( + self, tmp_path + ): + """A disabled agent-context extension opts out of KIMI.md migration.""" + i = get_integration("kimi") + + registry = tmp_path / ".specify" / "extensions" / ".registry" + registry.parent.mkdir(parents=True) + registry.write_text('{"extensions": {"agent-context": {"enabled": false}}}') + + kimi_md = tmp_path / "KIMI.md" + kimi_md.write_text("# Kimi context\n\nKeep this user note.\n") + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + # Opted-out project: KIMI.md is left untouched and AGENTS.md is not + # created/modified by the migration. + assert kimi_md.is_file() + assert kimi_md.read_text() == "# Kimi context\n\nKeep this user note.\n" + assert not (tmp_path / "AGENTS.md").exists() + + def test_context_migration_skips_corrupted_single_marker(self, tmp_path): + """A KIMI.md with only a start marker is left untouched (no leak).""" + project = tmp_path + kimi_md = project / "KIMI.md" + kimi_md.write_text( + "# Notes\n\n" + "\n" + "dangling managed content\n" + ) + + result = _migrate_legacy_kimi_context_file(project) + + assert result is False + # KIMI.md untouched; managed block never copied into AGENTS.md. + assert kimi_md.is_file() + assert "dangling managed content" in kimi_md.read_text() + assert not (project / "AGENTS.md").exists() + + def test_context_migration_skips_unreadable_kimi_md(self, tmp_path): + """Non-UTF-8 KIMI.md is skipped instead of raising during setup.""" + project = tmp_path + kimi_md = project / "KIMI.md" + kimi_md.write_bytes(b"\xff\xfe invalid utf-8 \xa6\n") + + result = _migrate_legacy_kimi_context_file(project) + + assert result is False + assert kimi_md.is_file() + assert not (project / "AGENTS.md").exists() + + def test_context_migration_skips_when_agents_md_is_directory(self, tmp_path): + """An AGENTS.md that exists as a directory is skipped, not written to.""" + project = tmp_path + (project / "AGENTS.md").mkdir() + kimi_md = project / "KIMI.md" + kimi_md.write_text("# Notes\n\nKeep this.\n") + + result = _migrate_legacy_kimi_context_file(project) + + assert result is False + # KIMI.md is preserved and the directory is untouched. + assert kimi_md.is_file() + assert (project / "AGENTS.md").is_dir() + + +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. + _symlink_or_skip( + project / ".kimi" / "skills", 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) + _symlink_or_skip( + project / ".kimi" / "skills", 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() + + def test_migrate_skips_symlinked_legacy_parent_dir(self, tmp_path): + # `.kimi` is itself a symlink to the project root, so `.kimi/skills` + # resolves to `./skills` — an unrelated in-tree directory. Even though + # the resolved path stays inside the project, migration must not + # operate on it because a path component is a symlink. + project = tmp_path / "project" + unrelated = project / "skills" / "speckit-evillegacy" + unrelated.mkdir(parents=True) + (unrelated / "SKILL.md").write_text("# unrelated\n") + # .kimi -> project root, so .kimi/skills == ./skills. + _symlink_or_skip(project / ".kimi", project, target_is_directory=True) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + i.setup(project, m, parsed_options={"migrate_legacy": True}) + + # The unrelated ./skills content must be untouched. + assert (unrelated / "SKILL.md").exists() + assert not ( + project / ".kimi-code" / "skills" / "speckit-evillegacy" + ).exists() + + def test_teardown_skips_symlinked_legacy_parent_dir(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + # Looks Speckit-generated, so only the symlink check protects it. + unrelated = project / "skills" / "speckit-evillegacy" + unrelated.mkdir(parents=True) + (unrelated / "SKILL.md").write_text( + "---\nmetadata:\n author: github-spec-kit\n---\n# x\n" + ) + _symlink_or_skip(project / ".kimi", project, target_is_directory=True) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + i.teardown(project, m) + + # The unrelated ./skills content must survive teardown. + assert (unrelated / "SKILL.md").exists() + + def test_setup_rejects_symlinked_destination_before_writing(self, tmp_path): + # `.kimi-code` is a symlink to the project root, so the skills + # destination `.kimi-code/skills` resolves to `./skills` — an + # unintended in-tree location. base setup() only rejects a + # destination that escapes the project root, so without the + # pre-check it would write SKILL.md files into `./skills`. setup() + # must refuse before any write occurs. + project = tmp_path / "project" + project.mkdir() + _symlink_or_skip(project / ".kimi-code", project, target_is_directory=True) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + with pytest.raises(ValueError, match="symlinked"): + i.setup(project, m) + + # Nothing was written into the unintended `./skills` location. + assert not (project / "skills").exists() + + def test_migrate_skips_symlinked_target_dir(self, tmp_path): + # The destination `.kimi-code/skills/speckit-foo` already exists but is + # a symlink to a directory outside the project. Migration compares + # SKILL.md bytes to decide whether to drop the legacy copy; it must not + # follow the symlinked target dir to read SKILL.md from outside. + outside = tmp_path / "outside" + outside.mkdir() + (outside / "SKILL.md").write_text("# shared\n") + + project = tmp_path / "project" + legacy = project / ".kimi" / "skills" / "speckit-foo" + legacy.mkdir(parents=True) + # Identical bytes: without the symlink guard the legacy dir would be + # removed after following the link out of the project. + (legacy / "SKILL.md").write_text("# shared\n") + + target = project / ".kimi-code" / "skills" / "speckit-foo" + target.parent.mkdir(parents=True) + _symlink_or_skip(target, outside, target_is_directory=True) + + _migrate_legacy_kimi_skills_dir( + project / ".kimi" / "skills", project / ".kimi-code" / "skills" + ) + + # Legacy copy is preserved (migration refused to follow the symlink), + # and the outside target is untouched. + assert (legacy / "SKILL.md").exists() + assert (outside / "SKILL.md").exists() + + def test_context_migration_does_not_write_through_symlinked_agents_md( + self, tmp_path + ): + # A sensitive file outside the project that a malicious AGENTS.md + # symlink points at. Migration must never overwrite it. + outside = tmp_path / "outside" + outside.mkdir() + secret = outside / "secret.txt" + secret.write_text("original secret\n") + + project = tmp_path / "project" + project.mkdir() + _symlink_or_skip(project / "AGENTS.md", secret) + (project / "KIMI.md").write_text("# Notes\n\nKeep this.\n") + + result = _migrate_legacy_kimi_context_file(project) + + # The outside file must not be overwritten through the symlink. + assert secret.read_text() == "original secret\n" + # KIMI.md is preserved so the user can migrate manually. + assert (project / "KIMI.md").is_file() + assert result is False + + def test_context_migration_does_not_follow_symlinked_kimi_md(self, tmp_path): + # A symlinked KIMI.md (source) must not be followed/consumed. + outside = tmp_path / "outside" + outside.mkdir() + external = outside / "external.md" + external.write_text("# external\n") + + project = tmp_path / "project" + project.mkdir() + _symlink_or_skip(project / "KIMI.md", external) + + result = _migrate_legacy_kimi_context_file(project) + + assert result is False + # The external file and the symlink are left intact. + assert external.read_text() == "# external\n" + assert (project / "KIMI.md").is_symlink() + assert not (project / "AGENTS.md").exists() class TestKimiNextSteps: diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index c3ebb9773..34114a564 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1812,7 +1812,7 @@ class TestIntegrationSwitch: 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 82bd8be58..94496af5e 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -226,17 +226,17 @@ class TestAgentConfigConsistency: 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 b37b5350b..6b181a120 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1937,7 +1937,7 @@ Agent __AGENT__ @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 39f2905a4..ff37dd3a9 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3763,12 +3763,16 @@ class TestPresetSkills: assert note_file.read_text(encoding="utf-8") == "user content" def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp_dir): - """Preset overrides should still target legacy dotted Kimi skill directories.""" + """Preset overrides should still target legacy dotted-named skill dirs. + + This exercises legacy *naming* (``speckit.specify``) under the current + ``.kimi-code/`` base — distinct from the legacy ``.kimi/`` *location*. + """ 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) + (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) manager = PresetManager(project_dir) install_self_test_preset(manager) @@ -3785,10 +3789,10 @@ class TestPresetSkills: 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) + (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) manager = PresetManager(project_dir) install_self_test_preset(manager) @@ -3805,7 +3809,7 @@ class TestPresetSkills: 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" @@ -3854,9 +3858,9 @@ class TestPresetSkills: 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) + (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) preset_dir = temp_dir / "kimi-placeholder-override" preset_dir.mkdir()