mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
feat(integration): update Kimi integration for Kimi Code CLI (#2979)
* 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-<stem> 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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..."
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Kimi Code integration — skills-based agent (Moonshot AI).
|
||||
|
||||
Kimi uses the ``.kimi/skills/speckit-<name>/SKILL.md`` layout with
|
||||
Kimi uses the ``.kimi-code/skills/speckit-<name>/SKILL.md`` layout with
|
||||
``/skill:speckit-<name>`` invocation syntax.
|
||||
|
||||
Includes legacy migration logic for projects initialised before Kimi
|
||||
moved from dotted skill directories (``speckit.xxx``) to hyphenated
|
||||
(``speckit-xxx``).
|
||||
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-<stem>``.
|
||||
|
||||
Kimi Code CLI invokes installed skills with a ``/skill:<name>``
|
||||
slash command (e.g. ``/skill:speckit-plan``), not the bare
|
||||
``/speckit-<name>`` 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)
|
||||
|
||||
@@ -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"
|
||||
"<!-- SPECKIT START -->\n"
|
||||
"old managed section\n"
|
||||
"<!-- SPECKIT END -->\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 "<!-- SPECKIT START -->" 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(
|
||||
"<!-- SPECKIT START -->\n"
|
||||
"only managed section\n"
|
||||
"<!-- SPECKIT END -->\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 "<!-- SPECKIT START -->" 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: '<!-- CUSTOM START -->'\n"
|
||||
" end: '<!-- CUSTOM END -->'\n"
|
||||
)
|
||||
|
||||
kimi_md = tmp_path / "KIMI.md"
|
||||
kimi_md.write_text(
|
||||
"# Project context\n\n"
|
||||
"<!-- CUSTOM START -->\n"
|
||||
"old managed section\n"
|
||||
"<!-- CUSTOM END -->\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 "<!-- CUSTOM START -->" in content
|
||||
assert "<!-- CUSTOM END -->" in content
|
||||
assert "<!-- SPECKIT START -->" 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"
|
||||
"<!-- SPECKIT START -->\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:
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user