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:
@@ -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, [
|
||||
|
||||
Reference in New Issue
Block a user