From 46972523b802e6e9d4071fa21adc72a6d15d468b Mon Sep 17 00:00:00 2001 From: Yuming Chen Date: Sun, 14 Jun 2026 22:27:23 +0800 Subject: [PATCH 01/16] 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 --- .devcontainer/post-create.sh | 4 +- CHANGELOG.md | 1 + docs/reference/integrations.md | 6 +- src/specify_cli/integrations/kimi/__init__.py | 222 ++++++++++++++++-- tests/integrations/test_integration_kimi.py | 132 ++++++++++- .../test_integration_subcommand.py | 2 +- tests/test_agent_config_consistency.py | 6 +- tests/test_extensions.py | 2 +- tests/test_presets.py | 8 +- 9 files changed, 340 insertions(+), 43 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 4dd17294e7..c1dbdd9458 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -88,9 +88,9 @@ fi run_command "$kiro_binary --help > /dev/null" echo "✅ Done" -echo -e "\n🤖 Installing Kimi CLI..." +echo -e "\n🤖 Installing Kimi Code CLI..." # https://code.kimi.com -run_command "pipx install kimi-cli" +run_command "npm install -g @moonshot-ai/kimi-code@latest" echo "✅ Done" echo -e "\n🤖 Installing CodeBuddy CLI..." diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da48e5fed..74f00fe764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### Changed +- feat(integration): update Kimi integration for Kimi Code CLI (`@moonshot-ai/kimi-code`): skills path moved from `.kimi/skills/` to `.kimi-code/skills/`, context file moved from `KIMI.md` to `AGENTS.md`; `--migrate-legacy` migrates old installs and `teardown()` cleans up leftover legacy directories - Add Research Harness extension to community catalog (#2935) - Add Coding Standards Drift Control extension to community catalog (#2934) - Add Spec Trace extension to community catalog (#2527) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index d7480ac29d..8f3aed7ff2 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -24,7 +24,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | | | [Junie](https://junie.jetbrains.com/) | `junie` | | | [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | | -| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration | +| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs and `KIMI.md` context to the new paths | | [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` | | [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | @@ -154,7 +154,7 @@ Some integrations accept additional options via `--integration-options`: | Integration | Option | Description | | ----------- | ------------------- | -------------------------------------------------------------- | | `generic` | `--commands-dir` | Required. Directory for command files | -| `kimi` | `--migrate-legacy` | Migrate legacy dotted skill directories to hyphenated format | +| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs and `KIMI.md` to `.kimi-code/skills/` and `AGENTS.md`, including dotted→hyphenated directory names | Example: @@ -187,7 +187,7 @@ 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` | +| `kimi` | `.kimi-code/skills`, `AGENTS.md` | | `qodercli` | `.qoder/commands`, `QODER.md` | | `qwen` | `.qwen/commands`, `QWEN.md` | | `roo` | `.roo/commands`, `.roo/rules/specify-rules.md` | diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 3b257768e2..874cf86a49 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -1,11 +1,11 @@ """Kimi Code integration — skills-based agent (Moonshot AI). -Kimi uses the ``.kimi/skills/speckit-/SKILL.md`` layout with +Kimi uses the ``.kimi-code/skills/speckit-/SKILL.md`` layout with ``/skill:speckit-`` invocation syntax. Includes legacy migration logic for projects initialised before Kimi -moved from dotted skill directories (``speckit.xxx``) to hyphenated -(``speckit-xxx``). +Code CLI adopted the ``.kimi-code/`` directory, as well as for the +older dotted skill directory naming (``speckit.xxx`` → ``speckit-xxx``). """ from __future__ import annotations @@ -14,7 +14,7 @@ from pathlib import Path from typing import Any -from ..base import IntegrationOption, SkillsIntegration +from ..base import IntegrationBase, IntegrationOption, SkillsIntegration from ..manifest import IntegrationManifest @@ -24,19 +24,19 @@ 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 @classmethod def options(cls) -> list[IntegrationOption]: @@ -51,7 +51,10 @@ def options(cls) -> list[IntegrationOption]: "--migrate-legacy", is_flag=True, default=False, - help="Migrate legacy dotted skill dirs (speckit.xxx → speckit-xxx)", + help=( + "Migrate legacy Kimi installations: " + ".kimi/skills/ → .kimi-code/skills/ and speckit.xxx → speckit-xxx" + ), ), ] @@ -62,47 +65,98 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Install skills with optional legacy dotted-name migration.""" + """Install skills with optional legacy migration.""" parsed_options = parsed_options or {} - # Run base setup first so hyphenated targets (speckit-*) exist, - # then migrate/clean legacy dotted dirs without risking user content loss. + # Run base setup first so new-path targets (speckit-*) exist, + # then migrate/clean legacy dirs without risking user content loss. created = super().setup( project_root, manifest, parsed_options=parsed_options, **opts ) if parsed_options.get("migrate_legacy", False): - skills_dir = self.skills_dest(project_root) - if skills_dir.is_dir(): - _migrate_legacy_kimi_dotted_skills(skills_dir) + new_skills_dir = self.skills_dest(project_root) + old_skills_dir = project_root / ".kimi" / "skills" + if old_skills_dir.is_dir(): + _migrate_legacy_kimi_skills_dir(old_skills_dir, new_skills_dir) + _migrate_legacy_kimi_context_file(project_root) 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 old_skills_dir.is_dir(): + legacy_dirs = sorted( + [*old_skills_dir.glob("speckit-*"), *old_skills_dir.glob("speckit.*")] + ) + for legacy_dir in legacy_dirs: + if 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 _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.*")): + # 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 not legacy_dir.is_dir(): continue if not (legacy_dir / "SKILL.md").exists(): continue - suffix = legacy_dir.name[len("speckit."):] - if not suffix: + target_name = _legacy_to_target_name(legacy_dir.name) + if not target_name: continue - target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}" + target_dir = new_skills_dir / target_name + + # Skip if the legacy dir is already the target dir (same-directory call). + if legacy_dir.resolve() == target_dir.resolve(): + continue if not target_dir.exists(): + target_dir.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(legacy_dir), str(target_dir)) migrated_count += 1 continue @@ -122,4 +176,128 @@ def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: except OSError: pass + # Remove the legacy skills directory if it is now empty. + try: + old_skills_dir.rmdir() + except OSError: + pass + return (migrated_count, removed_count) + + +def _legacy_to_target_name(legacy_name: str) -> str: + """Convert a legacy skill directory name to the modern hyphenated form.""" + if legacy_name.startswith("speckit-"): + return legacy_name + if legacy_name.startswith("speckit."): + suffix = legacy_name[len("speckit."):] + if suffix: + return f"speckit-{suffix.replace('.', '-')}" + return "" + + +def _is_speckit_generated_skill(skill_dir: Path) -> bool: + """Return True when *skill_dir* contains a Speckit-generated SKILL.md. + + Uses the ``metadata.author`` and ``metadata.source`` fields written by + ``SkillsIntegration.setup()`` to avoid deleting user-authored skills. + """ + skill_file = skill_dir / "SKILL.md" + if not skill_file.is_file(): + return False + + try: + content = skill_file.read_text(encoding="utf-8") + except OSError: + return False + + if not content.startswith("---"): + return False + + parts = content.split("---", 2) + if len(parts) < 3: + return False + + try: + import yaml + + frontmatter = yaml.safe_load(parts[1]) + except Exception: + return False + + if not isinstance(frontmatter, dict): + return False + + metadata = frontmatter.get("metadata", {}) + if not isinstance(metadata, dict): + return False + + author = metadata.get("author", "") + source = metadata.get("source", "") + return author == "github-spec-kit" or ( + isinstance(source, str) and source.startswith("templates/commands/") + ) + + +def _migrate_legacy_kimi_context_file(project_root: Path) -> bool: + """Migrate user content from legacy ``KIMI.md`` to ``AGENTS.md``. + + The Speckit managed section is stripped from ``KIMI.md`` before the + remaining content is appended to ``AGENTS.md``. The legacy file is + deleted if it becomes empty. Returns ``True`` if ``KIMI.md`` existed + and was processed. + """ + legacy_path = project_root / "KIMI.md" + if not legacy_path.is_file(): + return False + + marker_start = IntegrationBase.CONTEXT_MARKER_START + marker_end = IntegrationBase.CONTEXT_MARKER_END + + content = legacy_path.read_text(encoding="utf-8-sig") + start_idx = content.find(marker_start) + end_idx = content.find(marker_end, start_idx if start_idx != -1 else 0) + + if start_idx != -1 and end_idx != -1 and end_idx > start_idx: + removal_start = start_idx + removal_end = end_idx + len(marker_end) + if removal_end < len(content) and content[removal_end] == "\r": + removal_end += 1 + if removal_end < len(content) and content[removal_end] == "\n": + removal_end += 1 + if removal_start > 0 and content[removal_start - 1] == "\n": + if removal_start > 1 and content[removal_start - 2] == "\n": + removal_start -= 1 + content = content[:removal_start] + content[removal_end:] + + user_content = content.replace("\r\n", "\n").replace("\r", "\n").strip() + if not user_content: + legacy_path.unlink() + return True + + target_path = project_root / "AGENTS.md" + if target_path.is_file(): + existing = target_path.read_text(encoding="utf-8-sig") + existing = existing.replace("\r\n", "\n").replace("\r", "\n") + if not existing.endswith("\n"): + existing += "\n" + new_content = existing + "\n" + user_content + "\n" + else: + new_content = user_content + "\n" + + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(new_content.encode("utf-8")) + legacy_path.unlink() + return True + + +def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: + """Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format. + + .. deprecated:: + Kept for direct callers/tests; new code should use + ``_migrate_legacy_kimi_skills_dir``. + + Returns ``(migrated_count, removed_count)``. + """ + return _migrate_legacy_kimi_skills_dir(skills_dir, skills_dir) diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index 112baf0301..fbdd211995 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -9,10 +9,10 @@ class TestKimiIntegration(SkillsIntegrationTests): KEY = "kimi" - FOLDER = ".kimi/" + FOLDER = ".kimi-code/" COMMANDS_SUBDIR = "skills" - REGISTRAR_DIR = ".kimi/skills" - CONTEXT_FILE = "KIMI.md" + REGISTRAR_DIR = ".kimi-code/skills" + CONTEXT_FILE = "AGENTS.md" class TestKimiOptions: @@ -103,12 +103,32 @@ def test_nonexistent_dir_returns_zeros(self, tmp_path): assert migrated == 0 assert removed == 0 + def test_setup_migrate_legacy_moves_old_skills_dir(self, tmp_path): + """--migrate-legacy moves hyphenated skills from .kimi/skills to .kimi-code/skills.""" + i = get_integration("kimi") + + old_skills_dir = tmp_path / ".kimi" / "skills" + new_skills_dir = tmp_path / ".kimi-code" / "skills" + legacy = old_skills_dir / "speckit-oldcmd" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text("# Legacy\n") + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + assert not legacy.exists() + assert not old_skills_dir.exists() + assert (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists() + # New skills from templates should also exist + assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists() + def test_setup_with_migrate_legacy_option(self, tmp_path): """KimiIntegration.setup() with --migrate-legacy migrates dotted dirs.""" i = get_integration("kimi") - skills_dir = tmp_path / ".kimi" / "skills" - legacy = skills_dir / "speckit.oldcmd" + old_skills_dir = tmp_path / ".kimi" / "skills" + new_skills_dir = tmp_path / ".kimi-code" / "skills" + legacy = old_skills_dir / "speckit.oldcmd" legacy.mkdir(parents=True) (legacy / "SKILL.md").write_text("# Legacy\n") @@ -116,9 +136,107 @@ def test_setup_with_migrate_legacy_option(self, tmp_path): i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) assert not legacy.exists() - assert (skills_dir / "speckit-oldcmd" / "SKILL.md").exists() + assert (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists() # New skills from templates should also exist - assert (skills_dir / "speckit-specify" / "SKILL.md").exists() + assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists() + + +class TestKimiContextFileMigration: + """KIMI.md → AGENTS.md migration under --migrate-legacy.""" + + def test_setup_migrate_legacy_moves_kimi_md_user_content(self, tmp_path): + i = get_integration("kimi") + + kimi_md = tmp_path / "KIMI.md" + kimi_md.write_text( + "# Project context\n\n" + "\n" + "old managed section\n" + "\n\n" + "Keep this user note.\n" + ) + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + agents_md = tmp_path / "AGENTS.md" + assert agents_md.exists() + content = agents_md.read_text(encoding="utf-8") + assert "Keep this user note." in content + assert "old managed section" not in content + assert "" in content + assert not kimi_md.exists() + + def test_setup_migrate_legacy_removes_empty_kimi_md(self, tmp_path): + i = get_integration("kimi") + + kimi_md = tmp_path / "KIMI.md" + kimi_md.write_text( + "\n" + "only managed section\n" + "\n" + ) + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + assert (tmp_path / "AGENTS.md").exists() + assert not kimi_md.exists() + + def test_setup_migrate_legacy_appends_to_existing_agents_md(self, tmp_path): + i = get_integration("kimi") + + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text("# Existing AGENTS.md\n\nExisting note.\n") + + kimi_md = tmp_path / "KIMI.md" + kimi_md.write_text("# Kimi context\n\nKimi-specific note.\n") + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + content = agents_md.read_text(encoding="utf-8") + assert "Existing note." in content + assert "Kimi-specific note." in content + assert "" in content + assert not kimi_md.exists() + + +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 TestKimiNextSteps: diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 4c09a9163d..f5b1385fd2 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1677,7 +1677,7 @@ def test_switch_migrates_extension_commands(self, tmp_path): assert result.exit_code == 0, f"extension add failed: {result.output}" # Verify git extension skills exist for kimi - kimi_git_feature = project / ".kimi" / "skills" / "speckit-git-feature" / "SKILL.md" + kimi_git_feature = project / ".kimi-code" / "skills" / "speckit-git-feature" / "SKILL.md" assert kimi_git_feature.exists(), "Git extension skill should exist for kimi" result = _run_in_project(project, [ diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 1176009778..f558eb206b 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -82,17 +82,17 @@ def test_agent_config_includes_tabnine(self): def test_kimi_in_agent_config(self): """AGENT_CONFIG should include kimi with correct folder and commands_subdir.""" assert "kimi" in AGENT_CONFIG - assert AGENT_CONFIG["kimi"]["folder"] == ".kimi/" + assert AGENT_CONFIG["kimi"]["folder"] == ".kimi-code/" assert AGENT_CONFIG["kimi"]["commands_subdir"] == "skills" assert AGENT_CONFIG["kimi"]["requires_cli"] is True def test_kimi_in_extension_registrar(self): - """Extension command registrar should include kimi using .kimi/skills and SKILL.md.""" + """Extension command registrar should include kimi using .kimi-code/skills and SKILL.md.""" cfg = CommandRegistrar.AGENT_CONFIGS assert "kimi" in cfg kimi_cfg = cfg["kimi"] - assert kimi_cfg["dir"] == ".kimi/skills" + assert kimi_cfg["dir"] == ".kimi-code/skills" assert kimi_cfg["extension"] == "/SKILL.md" def test_agent_config_includes_kimi(self): diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 1d05e1c2c4..6723e6081b 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1812,7 +1812,7 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir @pytest.mark.parametrize("agent_name,skills_path", [ ("codex", ".agents/skills"), - ("kimi", ".kimi/skills"), + ("kimi", ".kimi-code/skills"), ("claude", ".claude/skills"), ("cursor-agent", ".cursor/skills"), ("trae", ".trae/skills"), diff --git a/tests/test_presets.py b/tests/test_presets.py index e32440145d..ee14b53615 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3577,7 +3577,7 @@ def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, tem def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp_dir): """Preset overrides should still target legacy dotted Kimi skill directories.""" self._write_init_options(project_dir, ai="kimi") - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit.specify", body="untouched") (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) @@ -3597,7 +3597,7 @@ def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp_dir): """Kimi presets should still propagate command overrides to existing skills.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False) - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) @@ -3617,7 +3617,7 @@ def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, temp_dir): """Kimi native skills should still receive brand-new preset commands.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False) - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) preset_dir = temp_dir / "kimi-new-skill" @@ -3666,7 +3666,7 @@ def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir): """Kimi preset skill overrides should resolve placeholders and rewrite project paths.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh") - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) From a5dc347158de137c91fe8bee0b7add0044128046 Mon Sep 17 00:00:00 2001 From: Yuming Chen Date: Wed, 17 Jun 2026 00:57:07 +0800 Subject: [PATCH 02/16] 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. --- docs/reference/integrations.md | 1 - src/specify_cli/integrations/kimi/__init__.py | 47 ++++++++++-- tests/integrations/test_integration_kimi.py | 71 +++++++++++++++++++ 3 files changed, 114 insertions(+), 5 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 8f3aed7ff2..48948b585c 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -187,7 +187,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-code/skills`, `AGENTS.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 874cf86a49..e02214f862 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -38,6 +38,25 @@ class KimiIntegration(SkillsIntegration): context_file = "AGENTS.md" multi_install_safe = False + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Build Kimi's native skill invocation: ``/skill:speckit-``. + + Kimi Code CLI invokes installed skills with a ``/skill:`` + slash command (e.g. ``/skill:speckit-plan``), not the bare + ``/speckit-`` form produced by the generic skills base + class. Overriding here keeps ``dispatch_command()`` and workflow + command steps aligned with the ``/skill:`` guidance shown at init + time and in rendered hook invocations. + """ + stem = command_name + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + + invocation = "/skill:speckit-" + stem.replace(".", "-") + if args: + invocation = f"{invocation} {args}" + return invocation + @classmethod def options(cls) -> list[IntegrationOption]: return [ @@ -77,7 +96,7 @@ def setup( if parsed_options.get("migrate_legacy", False): new_skills_dir = self.skills_dest(project_root) old_skills_dir = project_root / ".kimi" / "skills" - if old_skills_dir.is_dir(): + if _is_safe_legacy_dir(old_skills_dir, project_root): _migrate_legacy_kimi_skills_dir(old_skills_dir, new_skills_dir) _migrate_legacy_kimi_context_file(project_root) @@ -94,12 +113,12 @@ def teardown( removed, skipped = super().teardown(project_root, manifest, force=force) old_skills_dir = project_root / ".kimi" / "skills" - if old_skills_dir.is_dir(): + 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 not legacy_dir.is_dir(): + if legacy_dir.is_symlink() or not legacy_dir.is_dir(): continue if _is_speckit_generated_skill(legacy_dir): try: @@ -116,6 +135,26 @@ def teardown( return removed, skipped +def _is_safe_legacy_dir(path: Path, project_root: Path) -> bool: + """Return ``True`` when *path* is a real directory safely inside *project_root*. + + Legacy migration and cleanup ``shutil.move()`` and ``shutil.rmtree()`` + directories, so a symlinked ``.kimi``/``.kimi/skills`` (or one reached + through a symlinked parent) must never be followed: doing so could + relocate or delete content living outside the project tree. We reject + the path when it is itself a symlink, when it is not a directory, or + when resolving every symlink lands outside *project_root*. + """ + if path.is_symlink() or not path.is_dir(): + return False + try: + resolved = path.resolve() + root = project_root.resolve() + except OSError: + return False + return resolved == root or root in resolved.parents + + def _migrate_legacy_kimi_skills_dir( old_skills_dir: Path, new_skills_dir: Path ) -> tuple[int, int]: @@ -140,7 +179,7 @@ def _migrate_legacy_kimi_skills_dir( ) for legacy_dir in legacy_dirs: - if not legacy_dir.is_dir(): + if legacy_dir.is_symlink() or not legacy_dir.is_dir(): continue if not (legacy_dir / "SKILL.md").exists(): continue diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index fbdd211995..ae2a225e16 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -239,6 +239,77 @@ def test_teardown_preserves_user_skills_in_legacy_dir(self, tmp_path): assert user_skill.exists() +class TestKimiCommandInvocation: + """Kimi dispatch must use the native ``/skill:`` slash command.""" + + def test_build_command_invocation_uses_skill_prefix(self): + i = get_integration("kimi") + assert i.build_command_invocation("specify") == "/skill:speckit-specify" + assert i.build_command_invocation("speckit.plan") == "/skill:speckit-plan" + + def test_build_command_invocation_dotted_extension(self): + i = get_integration("kimi") + assert ( + i.build_command_invocation("speckit.git.commit") + == "/skill:speckit-git-commit" + ) + + def test_build_command_invocation_appends_args(self): + i = get_integration("kimi") + assert ( + i.build_command_invocation("specify", "my feature") + == "/skill:speckit-specify my feature" + ) + + +class TestKimiLegacySymlinkSafety: + """Legacy migration/cleanup must not follow symlinks out of the project.""" + + def test_migrate_skips_symlinked_legacy_skills_dir(self, tmp_path): + # An attacker-controlled directory outside the project root. Use a + # non-template skill name so a successful migration would be visible + # (the bundled templates never create "speckit-evillegacy"). + outside = tmp_path / "outside" + (outside / "speckit-evillegacy").mkdir(parents=True) + (outside / "speckit-evillegacy" / "SKILL.md").write_text("# evil\n") + + project = tmp_path / "project" + (project / ".kimi").mkdir(parents=True) + # .kimi/skills is a symlink to the outside directory. + (project / ".kimi" / "skills").symlink_to( + outside, target_is_directory=True + ) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + i.setup(project, m, parsed_options={"migrate_legacy": True}) + + # Outside content must be untouched (not moved into .kimi-code). + assert (outside / "speckit-evillegacy" / "SKILL.md").exists() + assert not ( + project / ".kimi-code" / "skills" / "speckit-evillegacy" + ).exists() + + def test_teardown_skips_symlinked_legacy_skills_dir(self, tmp_path): + outside = tmp_path / "outside" + outside.mkdir() + keep = outside / "keep.txt" + keep.write_text("important\n") + + project = tmp_path / "project" + (project / ".kimi").mkdir(parents=True) + (project / ".kimi" / "skills").symlink_to( + outside, target_is_directory=True + ) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + i.teardown(project, m) + + # The symlink target and its contents must survive teardown. + assert keep.exists() + + class TestKimiNextSteps: """CLI output tests for kimi next-steps display.""" From a207446d0bfd3a268625eb0bb573498e800b79b1 Mon Sep 17 00:00:00 2001 From: Yuming Chen Date: Wed, 17 Jun 2026 07:27:58 +0800 Subject: [PATCH 03/16] 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. --- src/specify_cli/integrations/kimi/__init__.py | 15 +++++--- tests/integrations/test_integration_kimi.py | 35 +++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index e02214f862..0155292468 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -98,7 +98,10 @@ def setup( old_skills_dir = project_root / ".kimi" / "skills" if _is_safe_legacy_dir(old_skills_dir, project_root): _migrate_legacy_kimi_skills_dir(old_skills_dir, new_skills_dir) - _migrate_legacy_kimi_context_file(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 @@ -278,7 +281,12 @@ def _is_speckit_generated_skill(skill_dir: Path) -> bool: ) -def _migrate_legacy_kimi_context_file(project_root: Path) -> bool: +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 @@ -290,9 +298,6 @@ def _migrate_legacy_kimi_context_file(project_root: Path) -> bool: if not legacy_path.is_file(): return False - marker_start = IntegrationBase.CONTEXT_MARKER_START - marker_end = IntegrationBase.CONTEXT_MARKER_END - content = legacy_path.read_text(encoding="utf-8-sig") start_idx = content.find(marker_start) end_idx = content.find(marker_end, start_idx if start_idx != -1 else 0) diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index ae2a225e16..d91f7a88b1 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -201,6 +201,41 @@ def test_setup_migrate_legacy_appends_to_existing_agents_md(self, tmp_path): assert "" in content assert not kimi_md.exists() + def test_setup_migrate_legacy_uses_custom_context_markers(self, tmp_path): + """Migration respects context_markers from agent-context extension config.""" + i = get_integration("kimi") + + config_dir = tmp_path / ".specify" / "extensions" / "agent-context" + config_dir.mkdir(parents=True) + (config_dir / "agent-context-config.yml").write_text( + "context_file: AGENTS.md\n" + "context_markers:\n" + " start: ''\n" + " end: ''\n" + ) + + kimi_md = tmp_path / "KIMI.md" + kimi_md.write_text( + "# Project context\n\n" + "\n" + "old managed section\n" + "\n\n" + "Keep this user note.\n" + ) + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + agents_md = tmp_path / "AGENTS.md" + assert agents_md.exists() + content = agents_md.read_text(encoding="utf-8") + assert "Keep this user note." in content + assert "old managed section" not in content + assert "" in content + assert "" in content + assert "" not in content + assert not kimi_md.exists() + class TestKimiTeardownLegacyCleanup: """teardown() removes leftover legacy .kimi/skills/ directories.""" From 2d6641449406868f5948d3cac3d39bc1ea33119f Mon Sep 17 00:00:00 2001 From: Yuming Chen Date: Wed, 17 Jun 2026 23:18:24 +0800 Subject: [PATCH 04/16] fix(integration): harden Kimi legacy migration against symlinked paths --- src/specify_cli/integrations/kimi/__init__.py | 41 +++++++-- tests/integrations/test_integration_kimi.py | 87 ++++++++++++++++++- 2 files changed, 121 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 0155292468..938179353c 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -144,12 +144,32 @@ def _is_safe_legacy_dir(path: Path, project_root: Path) -> bool: Legacy migration and cleanup ``shutil.move()`` and ``shutil.rmtree()`` directories, so a symlinked ``.kimi``/``.kimi/skills`` (or one reached through a symlinked parent) must never be followed: doing so could - relocate or delete content living outside the project tree. We reject - the path when it is itself a symlink, when it is not a directory, or - when resolving every symlink lands outside *project_root*. + 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 path.is_symlink() or not path.is_dir(): + if not path.is_dir(): return False + + # Reject if any path component below project_root is a symlink. We trust + # project_root itself, so only components strictly under it are checked. + try: + relative = path.relative_to(project_root) + except ValueError: + return False + current = project_root + for part in relative.parts: + current = current / part + if current.is_symlink(): + return False + try: resolved = path.resolve() root = project_root.resolve() @@ -293,9 +313,19 @@ def _migrate_legacy_kimi_context_file( remaining content is appended to ``AGENTS.md``. The legacy file is deleted if it becomes empty. Returns ``True`` if ``KIMI.md`` existed and was processed. + + Both files are checked for symlinks first: a symlinked ``KIMI.md`` is not + followed (its target could be read from outside the project), and a + symlinked ``AGENTS.md`` is never written through (it could redirect the + write to an arbitrary file outside the project root). In either case the + migration is skipped and ``KIMI.md`` is left untouched. """ legacy_path = project_root / "KIMI.md" - if not legacy_path.is_file(): + if legacy_path.is_symlink() or not legacy_path.is_file(): + return False + + target_path = project_root / "AGENTS.md" + if target_path.is_symlink(): return False content = legacy_path.read_text(encoding="utf-8-sig") @@ -319,7 +349,6 @@ def _migrate_legacy_kimi_context_file( legacy_path.unlink() return True - target_path = project_root / "AGENTS.md" if target_path.is_file(): existing = target_path.read_text(encoding="utf-8-sig") existing = existing.replace("\r\n", "\n").replace("\r", "\n") diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index d91f7a88b1..eb81613535 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -1,7 +1,10 @@ """Tests for KimiIntegration — skills integration with legacy migration.""" from specify_cli.integrations import get_integration -from specify_cli.integrations.kimi import _migrate_legacy_kimi_dotted_skills +from specify_cli.integrations.kimi import ( + _migrate_legacy_kimi_context_file, + _migrate_legacy_kimi_dotted_skills, +) from specify_cli.integrations.manifest import IntegrationManifest from .test_integration_base_skills import SkillsIntegrationTests @@ -344,6 +347,88 @@ def test_teardown_skips_symlinked_legacy_skills_dir(self, tmp_path): # 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. + (project / ".kimi").symlink_to(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" + ) + (project / ".kimi").symlink_to(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_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() + (project / "AGENTS.md").symlink_to(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() + (project / "KIMI.md").symlink_to(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: """CLI output tests for kimi next-steps display.""" From 06a77ffc9027aed419e04ffe07ff96cecee15b7e Mon Sep 17 00:00:00 2001 From: Yuming Chen Date: Fri, 19 Jun 2026 01:50:46 +0800 Subject: [PATCH 05/16] fix(kimi): guard symlinked SKILL.md during migration and teardown --- src/specify_cli/integrations/kimi/__init__.py | 25 +++++++++++++------ tests/test_presets.py | 6 ++--- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 938179353c..549c2ef200 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -3,9 +3,11 @@ Kimi uses the ``.kimi-code/skills/speckit-/SKILL.md`` layout with ``/skill:speckit-`` invocation syntax. -Includes legacy migration logic for projects initialised before Kimi -Code CLI adopted the ``.kimi-code/`` directory, as well as for the -older dotted skill directory naming (``speckit.xxx`` → ``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 @@ -204,7 +206,10 @@ def _migrate_legacy_kimi_skills_dir( 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 target_name = _legacy_to_target_name(legacy_dir.name) @@ -223,9 +228,13 @@ def _migrate_legacy_kimi_skills_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 SKILL.md is a symlink so the byte comparison + # never follows it outside the project. (legacy_skill is already + # guaranteed to be a real file by the guard above.) target_skill = target_dir / "SKILL.md" - legacy_skill = legacy_dir / "SKILL.md" + if target_skill.is_symlink(): + continue if target_skill.is_file(): try: if target_skill.read_bytes() == legacy_skill.read_bytes(): @@ -265,7 +274,9 @@ def _is_speckit_generated_skill(skill_dir: Path) -> bool: ``SkillsIntegration.setup()`` to avoid deleting user-authored skills. """ skill_file = skill_dir / "SKILL.md" - if not skill_file.is_file(): + # 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: diff --git a/tests/test_presets.py b/tests/test_presets.py index 2c86fe2b4d..31ce43c42c 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3580,7 +3580,7 @@ def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp 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) @@ -3600,7 +3600,7 @@ def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp 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) @@ -3668,7 +3668,7 @@ def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_d self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh") 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() From b9dbb43e199db08692774495671a7bf05dfc70d2 Mon Sep 17 00:00:00 2001 From: Yuming Chen Date: Mon, 22 Jun 2026 01:20:07 +0800 Subject: [PATCH 06/16] =?UTF-8?q?docs(kimi):=20mention=20KIMI.md=E2=86=92A?= =?UTF-8?q?GENTS.md=20migration=20in=20--migrate-legacy=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/specify_cli/integrations/kimi/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 549c2ef200..54639c4cea 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -74,7 +74,8 @@ def options(cls) -> list[IntegrationOption]: default=False, help=( "Migrate legacy Kimi installations: " - ".kimi/skills/ → .kimi-code/skills/ and speckit.xxx → speckit-xxx" + ".kimi/skills/ → .kimi-code/skills/, speckit.xxx → speckit-xxx, " + "and KIMI.md user content → AGENTS.md" ), ), ] From 355746689abaff6d07d64984944f249e11470d7c Mon Sep 17 00:00:00 2001 From: meymchen Date: Tue, 23 Jun 2026 00:17:51 +0800 Subject: [PATCH 07/16] 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 --- src/specify_cli/integrations/kimi/__init__.py | 24 +++++++++++++++---- tests/test_presets.py | 6 ++++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 54639c4cea..4e405e2ea3 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -99,7 +99,15 @@ def setup( if parsed_options.get("migrate_legacy", False): new_skills_dir = self.skills_dest(project_root) old_skills_dir = project_root / ".kimi" / "skills" - if _is_safe_legacy_dir(old_skills_dir, project_root): + # 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) marker_start, marker_end = self._resolve_context_markers(project_root) _migrate_legacy_kimi_context_file( @@ -377,11 +385,19 @@ def _migrate_legacy_kimi_context_file( def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: - """Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format. + """Compatibility shim — migrate legacy dotted skill dirs in place. .. deprecated:: - Kept for direct callers/tests; new code should use - ``_migrate_legacy_kimi_skills_dir``. + 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)``. """ diff --git a/tests/test_presets.py b/tests/test_presets.py index a76cc4ba03..197e003649 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3653,7 +3653,11 @@ def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, tem 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-code" / "skills" self._create_skill(skills_dir, "speckit.specify", body="untouched") From b6d59b3af5b0fe8246b5ed82771bb0f3921841e5 Mon Sep 17 00:00:00 2001 From: meymchen Date: Tue, 23 Jun 2026 00:51:48 +0800 Subject: [PATCH 08/16] =?UTF-8?q?fix(kimi):=20harden=20legacy=20KIMI.md?= =?UTF-8?q?=E2=86=92AGENTS.md=20context=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/specify_cli/integrations/kimi/__init__.py | 85 +++++++++++++------ tests/integrations/test_integration_kimi.py | 66 ++++++++++++++ 2 files changed, 125 insertions(+), 26 deletions(-) diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 4e405e2ea3..e849f42afc 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -109,10 +109,14 @@ def setup( _is_safe_legacy_dir(new_skills_dir, project_root) ): _migrate_legacy_kimi_skills_dir(old_skills_dir, new_skills_dir) - marker_start, marker_end = self._resolve_context_markers(project_root) - _migrate_legacy_kimi_context_file( - project_root, marker_start=marker_start, marker_end=marker_end - ) + # 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 @@ -331,28 +335,53 @@ def _migrate_legacy_kimi_context_file( The Speckit managed section is stripped from ``KIMI.md`` before the remaining content is appended to ``AGENTS.md``. The legacy file is - deleted if it becomes empty. Returns ``True`` if ``KIMI.md`` existed - and was processed. - - Both files are checked for symlinks first: a symlinked ``KIMI.md`` is not - followed (its target could be read from outside the project), and a - symlinked ``AGENTS.md`` is never written through (it could redirect the - write to an arbitrary file outside the project root). In either case the - migration is skipped and ``KIMI.md`` is left untouched. + 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" - if target_path.is_symlink(): + # 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 - content = legacy_path.read_text(encoding="utf-8-sig") start_idx = content.find(marker_start) end_idx = content.find(marker_end, start_idx if start_idx != -1 else 0) + has_start = start_idx != -1 + has_end = end_idx != -1 + + # Refuse to migrate a corrupted managed section: exactly one marker, or + # an end marker that does not follow the start. Otherwise the unstripped + # managed block would leak into AGENTS.md (duplicating the section base + # setup just wrote). Leaving KIMI.md in place lets the user fix it. + if has_start != has_end or (has_start and end_idx <= start_idx): + return False - if start_idx != -1 and end_idx != -1 and end_idx > start_idx: + 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": @@ -369,17 +398,21 @@ def _migrate_legacy_kimi_context_file( legacy_path.unlink() return True - 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")) + 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 diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index eb81613535..6172d4b6b4 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -239,6 +239,72 @@ def test_setup_migrate_legacy_uses_custom_context_markers(self, tmp_path): 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.""" From 2d14a30c8552ab47c6959c247ce837510d2f0873 Mon Sep 17 00:00:00 2001 From: meymchen <86772442+meymchen@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:12:15 +0800 Subject: [PATCH 09/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/integrations/kimi/__init__.py | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index e849f42afc..9feedd1fd0 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -369,18 +369,34 @@ def _migrate_legacy_kimi_context_file( except (OSError, UnicodeDecodeError): return False - start_idx = content.find(marker_start) - end_idx = content.find(marker_end, start_idx if start_idx != -1 else 0) - has_start = start_idx != -1 - has_end = end_idx != -1 - - # Refuse to migrate a corrupted managed section: exactly one marker, or - # an end marker that does not follow the start. Otherwise the unstripped - # managed block would leak into AGENTS.md (duplicating the section base - # setup just wrote). Leaving KIMI.md in place lets the user fix it. - if has_start != has_end or (has_start and end_idx <= start_idx): - 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) From fc3c3343e20a5db4847cbe5589189a967d77b7e2 Mon Sep 17 00:00:00 2001 From: meymchen <86772442+meymchen@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:20:33 +0800 Subject: [PATCH 10/16] Fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/integrations/kimi/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 9feedd1fd0..abe04bbf5d 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -59,6 +59,11 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str: 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]: return [ From 8e4678831ac42193c95d860f9dd1ad2e4160fd85 Mon Sep 17 00:00:00 2001 From: meymchen <86772442+meymchen@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:55:40 +0800 Subject: [PATCH 11/16] Approve fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/integrations/test_integration_kimi.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index 6172d4b6b4..c07345f4c7 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -402,9 +402,14 @@ def test_teardown_skips_symlinked_legacy_skills_dir(self, tmp_path): project = tmp_path / "project" (project / ".kimi").mkdir(parents=True) - (project / ".kimi" / "skills").symlink_to( - outside, target_is_directory=True - ) + try: + (project / ".kimi" / "skills").symlink_to( + outside, target_is_directory=True + ) + except (OSError, NotImplementedError) as exc: + import pytest + + pytest.skip(f"symlinks unavailable: {exc}") i = get_integration("kimi") m = IntegrationManifest("kimi", project) From be5672be382eac1636c8c52040b733010bdd4fad Mon Sep 17 00:00:00 2001 From: meymchen Date: Wed, 24 Jun 2026 00:28:00 +0800 Subject: [PATCH 12/16] 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 --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdcd26c6b7..72b31f5274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -142,7 +142,6 @@ ### Changed -- feat(integration): update Kimi integration for Kimi Code CLI (`@moonshot-ai/kimi-code`): skills path moved from `.kimi/skills/` to `.kimi-code/skills/`, context file moved from `KIMI.md` to `AGENTS.md`; `--migrate-legacy` migrates old installs and `teardown()` cleans up leftover legacy directories - Add Research Harness extension to community catalog (#2935) - Add Coding Standards Drift Control extension to community catalog (#2934) - Add Spec Trace extension to community catalog (#2527) From ef328e49ac2884dc67ec73dd436f0c3bf31b98e9 Mon Sep 17 00:00:00 2001 From: meymchen Date: Wed, 24 Jun 2026 00:28:10 +0800 Subject: [PATCH 13/16] 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 --- tests/integrations/test_integration_kimi.py | 43 ++++++++++++++------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index c07345f4c7..ca6fefd4d9 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -1,5 +1,9 @@ """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_context_file, @@ -10,6 +14,22 @@ 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-code/" @@ -380,8 +400,8 @@ def test_migrate_skips_symlinked_legacy_skills_dir(self, tmp_path): project = tmp_path / "project" (project / ".kimi").mkdir(parents=True) # .kimi/skills is a symlink to the outside directory. - (project / ".kimi" / "skills").symlink_to( - outside, target_is_directory=True + _symlink_or_skip( + project / ".kimi" / "skills", outside, target_is_directory=True ) i = get_integration("kimi") @@ -402,14 +422,9 @@ def test_teardown_skips_symlinked_legacy_skills_dir(self, tmp_path): project = tmp_path / "project" (project / ".kimi").mkdir(parents=True) - try: - (project / ".kimi" / "skills").symlink_to( - outside, target_is_directory=True - ) - except (OSError, NotImplementedError) as exc: - import pytest - - pytest.skip(f"symlinks unavailable: {exc}") + _symlink_or_skip( + project / ".kimi" / "skills", outside, target_is_directory=True + ) i = get_integration("kimi") m = IntegrationManifest("kimi", project) @@ -428,7 +443,7 @@ def test_migrate_skips_symlinked_legacy_parent_dir(self, tmp_path): unrelated.mkdir(parents=True) (unrelated / "SKILL.md").write_text("# unrelated\n") # .kimi -> project root, so .kimi/skills == ./skills. - (project / ".kimi").symlink_to(project, target_is_directory=True) + _symlink_or_skip(project / ".kimi", project, target_is_directory=True) i = get_integration("kimi") m = IntegrationManifest("kimi", project) @@ -449,7 +464,7 @@ def test_teardown_skips_symlinked_legacy_parent_dir(self, tmp_path): (unrelated / "SKILL.md").write_text( "---\nmetadata:\n author: github-spec-kit\n---\n# x\n" ) - (project / ".kimi").symlink_to(project, target_is_directory=True) + _symlink_or_skip(project / ".kimi", project, target_is_directory=True) i = get_integration("kimi") m = IntegrationManifest("kimi", project) @@ -470,7 +485,7 @@ def test_context_migration_does_not_write_through_symlinked_agents_md( project = tmp_path / "project" project.mkdir() - (project / "AGENTS.md").symlink_to(secret) + _symlink_or_skip(project / "AGENTS.md", secret) (project / "KIMI.md").write_text("# Notes\n\nKeep this.\n") result = _migrate_legacy_kimi_context_file(project) @@ -490,7 +505,7 @@ def test_context_migration_does_not_follow_symlinked_kimi_md(self, tmp_path): project = tmp_path / "project" project.mkdir() - (project / "KIMI.md").symlink_to(external) + _symlink_or_skip(project / "KIMI.md", external) result = _migrate_legacy_kimi_context_file(project) From 4b2037a28edc0aa634255403a2d4aa7fec21d062 Mon Sep 17 00:00:00 2001 From: meymchen Date: Wed, 24 Jun 2026 01:14:58 +0800 Subject: [PATCH 14/16] 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 --- docs/reference/integrations.md | 4 +- src/specify_cli/integrations/kimi/__init__.py | 53 ++++++++++++++----- tests/integrations/test_integration_kimi.py | 19 +++++++ 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 03c54db8ee..d8407c92ff 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; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs and `KIMI.md` context to the new paths | +| [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` | | @@ -157,7 +157,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 `.kimi/skills/` installs and `KIMI.md` to `.kimi-code/skills/` and `AGENTS.md`, including dotted→hyphenated directory names | +| `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: diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index abe04bbf5d..6c40dba2ea 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -80,7 +80,8 @@ def options(cls) -> list[IntegrationOption]: help=( "Migrate legacy Kimi installations: " ".kimi/skills/ → .kimi-code/skills/, speckit.xxx → speckit-xxx, " - "and KIMI.md user content → AGENTS.md" + "and (when the agent-context extension is enabled) " + "KIMI.md user content → AGENTS.md" ), ), ] @@ -95,6 +96,20 @@ def setup( """Install skills with optional legacy migration.""" parsed_options = parsed_options or {} + # 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( @@ -102,7 +117,6 @@ def setup( ) if parsed_options.get("migrate_legacy", False): - new_skills_dir = self.skills_dest(project_root) 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 @@ -158,6 +172,27 @@ def teardown( 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*. @@ -178,17 +213,11 @@ def _is_safe_legacy_dir(path: Path, project_root: Path) -> bool: if not path.is_dir(): return False - # Reject if any path component below project_root is a symlink. We trust - # project_root itself, so only components strictly under it are checked. - try: - relative = path.relative_to(project_root) - except ValueError: + # 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 - current = project_root - for part in relative.parts: - current = current / part - if current.is_symlink(): - return False try: resolved = path.resolve() diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index ca6fefd4d9..f63b3cd19f 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -473,6 +473,25 @@ def test_teardown_skips_symlinked_legacy_parent_dir(self, tmp_path): # 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_context_migration_does_not_write_through_symlinked_agents_md( self, tmp_path ): From 3df99c44d12aa77d39a9fc27cb7b0144979d0780 Mon Sep 17 00:00:00 2001 From: meymchen Date: Wed, 24 Jun 2026 02:47:13 +0800 Subject: [PATCH 15/16] Refactor formatting and simplify logic in Kimi integration --- src/specify_cli/integrations/kimi/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 6c40dba2ea..e6e6a985c0 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -52,7 +52,7 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str: """ stem = command_name if stem.startswith("speckit."): - stem = stem[len("speckit."):] + stem = stem[len("speckit.") :] invocation = "/skill:speckit-" + stem.replace(".", "-") if args: @@ -308,7 +308,7 @@ def _legacy_to_target_name(legacy_name: str) -> str: if legacy_name.startswith("speckit-"): return legacy_name if legacy_name.startswith("speckit."): - suffix = legacy_name[len("speckit."):] + suffix = legacy_name[len("speckit.") :] if suffix: return f"speckit-{suffix.replace('.', '-')}" return "" @@ -354,8 +354,10 @@ def _is_speckit_generated_skill(skill_dir: Path) -> bool: author = metadata.get("author", "") source = metadata.get("source", "") - return author == "github-spec-kit" or ( - isinstance(source, str) and source.startswith("templates/commands/") + return ( + author == "github-spec-kit" + and isinstance(source, str) + and source.startswith("templates/commands/") ) @@ -393,9 +395,7 @@ def _migrate_legacy_kimi_context_file( 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() - ): + if target_path.is_symlink() or (target_path.exists() and not target_path.is_file()): return False try: From c425f4fe2d05a420b8ffbc66f9ca0a2bfecb5cc7 Mon Sep 17 00:00:00 2001 From: meymchen Date: Thu, 25 Jun 2026 00:16:24 +0800 Subject: [PATCH 16/16] 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 --- src/specify_cli/integrations/kimi/__init__.py | 32 ++++++++++--------- tests/integrations/test_integration_kimi.py | 30 +++++++++++++++++ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index e6e6a985c0..9c28855c02 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -276,23 +276,25 @@ def _migrate_legacy_kimi_skills_dir( continue # Target exists — only remove legacy if SKILL.md is identical. - # Skip when the target SKILL.md is a symlink so the byte comparison - # never follows it outside the project. (legacy_skill is already - # guaranteed to be a real file by the guard above.) + # 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" - if target_skill.is_symlink(): + if target_skill.is_symlink() or not target_skill.is_file(): continue - 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 + 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: diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index f63b3cd19f..2f752f66e1 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -8,6 +8,7 @@ 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 @@ -492,6 +493,35 @@ def test_setup_rejects_symlinked_destination_before_writing(self, tmp_path): # 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 ):