From 46972523b802e6e9d4071fa21adc72a6d15d468b Mon Sep 17 00:00:00 2001 From: Yuming Chen Date: Sun, 14 Jun 2026 22:27:23 +0800 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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."""