hugh.kim
Claude → Kimi / Migration Manual

Claude Code Kimi Code CLI 완전 마이그레이션 가이드 — 환경, 스킬, 스크립트, 훅, MCP까지 똑같이 재현하는 절차서

버전1.0
작성일2026-06-21
난이도중급
소요 예상 시간2~3시간
QA Status41/41 passed · Smoke PASS · 7 MCP servers
Video Walkthrough — 마이그레이션 전체 흐름 미리보기

이 문서만 읽고 동일한 환경을 똑같이 재현할 수 있도록 작성되었습니다. 모든 명령어, 파일 내용, 경로, 검증 단계를 상세히 포함합니다.


0. 사전 준비

0.1 환경 확인

이 가이드는 다음 환경을 가정합니다:

0.2 백업

반드시 먼저 백업하세요.

bash
# ~/.claude 백업
mkdir -p ~/.migration-backup
cp -R ~/.claude ~/.migration-backup/claude.$(date +%Y%m%d-%H%M%S)

# ~/.kimi 백업 (이미 존재하는 경우)
cp -R ~/.kimi ~/.migration-backup/kimi.$(date +%Y%m%d-%H%M%S)

0.3 필요한 Python 패키지

bash
python3 -m pip install pyyaml

1. Phase 1: 초기 스킬 동기화

1.1 동기화 스크립트 생성

~/.kimi/scripts/sync-claude-skills.py를 다음 내용으로 생성합니다.

python
#!/usr/bin/env python3
"""Sync Claude Code skills to Kimi Code CLI skills.

Source of truth: ~/.claude/skills/
Target: ~/.kimi/skills/

Usage:
    python3 sync-claude-skills.py --dry-run
    python3 sync-claude-skills.py --diff
    python3 sync-claude-skills.py --force
"""

from __future__ import annotations

import argparse
import json
import logging
import re
import shutil
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import yaml

HOME = Path.home()
CLAUDE_SKILLS_DIR = HOME / ".claude" / "skills"
KIMI_SKILLS_DIR = HOME / ".kimi" / "skills"
CONFIG_DIR = HOME / ".kimi" / "config"
CONFIG_FILE = CONFIG_DIR / "sync-claude-skills.json"

logger = logging.getLogger(__name__)


@dataclass
class SkillInfo:
    name: str
    source_path: Path
    is_flat: bool
    aux_dirs: list[str] = field(default_factory=list)
    aux_files: list[str] = field(default_factory=list)
    has_claude_refs: bool = False
    has_user_invocable: bool = False
    is_flow: bool = False
    frontmatter: dict[str, Any] = field(default_factory=dict)
    original_name: str = ""


def load_config() -> dict[str, Any]:
    defaults = {
        "exclude_skills": [],
        "rename_skills": {
            "claude-code-scaffold": "kimi-code-scaffold",
            "claude-code-site-scaffold": "kimi-code-site-scaffold",
        },
        "global_path_replacements": [
            {"from": "~/.claude/", "to": "~/.kimi/"},
            {"from": "$HOME/.claude/", "to": "$HOME/.kimi/"},
            {"from": "~/.claude", "to": "~/.kimi"},
            {"from": "$HOME/.claude", "to": "$HOME/.kimi"},
        ],
        "path_replacements": [
            {"from": ".claude/", "to": ".kimi/"},
        ],
        "word_boundary": True,
    }
    if CONFIG_FILE.exists():
        with open(CONFIG_FILE, encoding="utf-8") as f:
            user = json.load(f)
        defaults.update(user)
    return defaults


def save_config(config: dict[str, Any]) -> None:
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    with open(CONFIG_FILE, "w", encoding="utf-8") as f:
        json.dump(config, f, indent=2, ensure_ascii=False)


def parse_frontmatter(text: str) -> dict[str, Any] | None:
    lines = text.splitlines()
    if not lines or lines[0].strip() != "---":
        return None
    fm_lines: list[str] = []
    for line in lines[1:]:
        if line.strip() == "---":
            break
        fm_lines.append(line)
    else:
        return None
    if not fm_lines:
        return None
    try:
        data = yaml.safe_load("\n".join(fm_lines))
    except yaml.YAMLError:
        return None
    return data if isinstance(data, dict) else None


def discover_skills(skills_dir: Path) -> list[SkillInfo]:
    skills: list[SkillInfo] = []
    if not skills_dir.exists():
        return skills

    subdir_names: set[str] = set()
    for entry in skills_dir.iterdir():
        if entry.is_dir():
            skill_md = entry / "SKILL.md"
            if skill_md.is_file():
                text = skill_md.read_text(encoding="utf-8")
                fm = parse_frontmatter(text) or {}
                name = str(fm.get("name", entry.name))
                subdir_names.add(name.lower())
                aux_dirs = []
                aux_files = []
                for aux in entry.iterdir():
                    if aux.name == "SKILL.md":
                        continue
                    if aux.is_dir():
                        aux_dirs.append(aux.name)
                    elif aux.is_file():
                        aux_files.append(aux.name)
                skills.append(
                    SkillInfo(
                        name=name,
                        source_path=entry,
                        is_flat=False,
                        aux_dirs=aux_dirs,
                        aux_files=aux_files,
                        has_claude_refs=".claude/" in text or "~/.claude/" in text,
                        has_user_invocable="user-invocable" in text,
                        is_flow=fm.get("type") == "flow",
                        frontmatter=fm,
                        original_name=entry.name,
                    )
                )

    for entry in skills_dir.iterdir():
        if entry.is_file() and entry.suffix.lower() == ".md":
            name_stem = entry.stem
            if name_stem.lower() in subdir_names:
                continue
            text = entry.read_text(encoding="utf-8")
            fm = parse_frontmatter(text) or {}
            name = str(fm.get("name", name_stem))
            skills.append(
                SkillInfo(
                    name=name,
                    source_path=entry,
                    is_flat=True,
                    has_claude_refs=".claude/" in text or "~/.claude/" in text,
                    has_user_invocable="user-invocable" in text,
                    is_flow=fm.get("type") == "flow",
                    frontmatter=fm,
                    original_name=name_stem,
                )
            )

    return skills


def inventory(args: argparse.Namespace) -> int:
    skills = discover_skills(CLAUDE_SKILLS_DIR)
    print(f"Total Claude skills: {len(skills)}")
    claude_ref_count = 0
    for s in skills:
        if s.has_claude_refs:
            claude_ref_count += 1
    if claude_ref_count:
        print(f"Skills with .claude/ refs: {claude_ref_count}")
    if args.verbose:
        print("\nAll skills:")
        for s in skills:
            flags = []
            if s.has_claude_refs:
                flags.append("claude-refs")
            if s.is_flat:
                flags.append("flat")
            if s.is_flow:
                flags.append("flow")
            if s.has_user_invocable:
                flags.append("user-invocable")
            print(f"  - {s.name} ({', '.join(flags) if flags else 'subdir'})")
    return 0


def _safe_path_replace(text: str, from_str: str, to_str: str) -> str:
    esc = re.escape(from_str)
    pattern = r"(?<![A-Za-z0-9.])" + esc
    return re.sub(pattern, to_str, text)


def replace_paths(text: str, config: dict[str, Any]) -> str:
    for repl in config.get("global_path_replacements", []):
        text = text.replace(repl["from"], repl["to"])
    for repl in config["path_replacements"]:
        text = _safe_path_replace(text, repl["from"], repl["to"])
    return text


SLASH_ALIASES: dict[str, str] = {
    "hc": "hugh-clone",
}


def replace_slash_commands(text: str, skill_names: set[str]) -> str:
    """Replace Claude slash command invocations with Kimi /skill: invocations."""
    for alias, target in SLASH_ALIASES.items():
        pattern = rf"(?<!\S)/{re.escape(alias)}(?!\S)"
        text = re.sub(pattern, rf"/skill:{target}", text)

    for name in sorted(skill_names, key=len, reverse=True):
        if name in SLASH_ALIASES.values():
            continue
        pattern = rf"(?<!\S)/{re.escape(name)}(?!\S)"
        text = re.sub(pattern, rf"/skill:{name}", text)

    return text


def _first_meaningful_line(body: str) -> str | None:
    for line in body.splitlines():
        stripped = line.strip()
        if not stripped or stripped == "---" or stripped.startswith("#") or stripped.startswith(">"):
            continue
        return stripped
    return None


def transform_skill_text(text: str, skill_name: str, config: dict[str, Any], skill_names: set[str] | None = None) -> str:
    fm = parse_frontmatter(text) or {}
    body = text
    if fm:
        lines = text.splitlines()
        end_idx = 1
        for i, line in enumerate(lines[1:], start=1):
            if line.strip() == "---":
                end_idx = i
                break
        body = "\n".join(lines[end_idx + 1 :])

    fm.pop("user-invocable", None)
    if "name" not in fm:
        fm["name"] = skill_name
    if "description" not in fm:
        fallback = _first_meaningful_line(body)
        if fallback:
            fm["description"] = fallback[:240]
        else:
            fm["description"] = f"Migrated from Claude skill: {skill_name}"

    fm_yaml = yaml.safe_dump(fm, allow_unicode=True, sort_keys=False).strip()
    body = replace_paths(body, config)
    fm_yaml = replace_paths(fm_yaml, config)

    if skill_names:
        body = replace_slash_commands(body, skill_names)

    slash_note = (
        "\n\n## Kimi에서의 호출\n\n"
        f"이 스킬은 Kimi에서 `/skill:{skill_name}` 또는 `/flow:{skill_name}`로 불러올 수 있습니다.\n"
    )
    if "## Kimi에서의 호출" not in body:
        body = body + slash_note

    new_text = f"---\n{fm_yaml}\n---\n{body}"
    return new_text


def sync(args: argparse.Namespace) -> int:
    config = load_config()
    if not CONFIG_FILE.exists():
        save_config(config)
        logger.info("Created default config at %s", CONFIG_FILE)

    skills = discover_skills(CLAUDE_SKILLS_DIR)
    exclude = set(config.get("exclude_skills", []))

    report: dict[str, Any] = {
        "source": str(CLAUDE_SKILLS_DIR),
        "target": str(KIMI_SKILLS_DIR),
        "synced": [],
        "excluded": [],
        "renamed": {},
        "errors": [],
    }

    if args.force and KIMI_SKILLS_DIR.exists():
        if not args.dry_run:
            shutil.rmtree(KIMI_SKILLS_DIR)
            logger.info("Removed existing target directory: %s", KIMI_SKILLS_DIR)
        else:
            logger.info("[dry-run] Would remove existing target directory: %s", KIMI_SKILLS_DIR)

    KIMI_SKILLS_DIR.mkdir(parents=True, exist_ok=True)

    for skill in skills:
        if skill.name in exclude:
            report["excluded"].append(skill.name)
            continue

        source_display_name = skill.original_name or skill.name
        target_name = config["rename_skills"].get(skill.name, skill.name)
        if target_name != skill.name:
            report["renamed"][skill.name] = target_name

        target_path = KIMI_SKILLS_DIR / target_name

        try:
            if skill.is_flat:
                text = skill.source_path.read_text(encoding="utf-8")
                new_text = transform_skill_text(text, target_name, config, skill_names={s.name for s in skills})
                target_path = target_path.with_suffix(".md")
                if not args.dry_run:
                    target_path.write_text(new_text, encoding="utf-8")
            else:
                if not args.dry_run:
                    if target_path.exists():
                        shutil.rmtree(target_path)
                    shutil.copytree(skill.source_path, target_path)
                    skill_md = target_path / "SKILL.md"
                    if skill_md.exists():
                        text = skill_md.read_text(encoding="utf-8")
                        skill_md.write_text(
                            transform_skill_text(text, target_name, config, skill_names={s.name for s in skills}),
                            encoding="utf-8",
                        )
                    for aux_file in target_path.rglob("*.md"):
                        if aux_file == skill_md:
                            continue
                        aux_text = aux_file.read_text(encoding="utf-8")
                        aux_file.write_text(replace_paths(aux_text, config), encoding="utf-8")
                    for aux_file in target_path.rglob("*.py"):
                        aux_text = aux_file.read_text(encoding="utf-8")
                        aux_file.write_text(replace_paths(aux_text, config), encoding="utf-8")
                    for aux_file in target_path.rglob("*.sh"):
                        aux_text = aux_file.read_text(encoding="utf-8")
                        aux_file.write_text(replace_paths(aux_text, config), encoding="utf-8")

            report["synced"].append(skill.name)
            if args.dry_run:
                logger.info("[dry-run] Would sync: %s -> %s", skill.name, target_name)
            else:
                logger.info("Synced: %s -> %s", skill.name, target_name)
        except Exception as exc:
            report["errors"].append({"skill": skill.name, "error": str(exc)})
            logger.error("Failed to sync %s: %s", skill.name, exc)

    if KIMI_SKILLS_DIR.exists():
        expected_entries: set[str] = set()
        for s in skills:
            if s.name in exclude:
                continue
            target = config["rename_skills"].get(s.name, s.name)
            if s.is_flat:
                expected_entries.add(f"{target}.md")
            else:
                expected_entries.add(target)
        for entry in KIMI_SKILLS_DIR.iterdir():
            if entry.name in expected_entries:
                continue
            if entry.name in (
                "MIGRATION.md",
                "README.md",
                "kimi-skill-bridge",
                "sync-claude-skills",
                "claude-to-kimi-manual-sync",
            ):
                continue
            if not args.dry_run:
                if entry.is_dir():
                    shutil.rmtree(entry)
                else:
                    entry.unlink()
                logger.info("Removed stale target entry: %s", entry.name)
            else:
                logger.info("[dry-run] Would remove stale target entry: %s", entry.name)

    report_path = CONFIG_DIR / "sync-report.json"
    if not args.dry_run:
        with open(report_path, "w", encoding="utf-8") as f:
            json.dump(report, f, indent=2, ensure_ascii=False)
        logger.info("Report written to %s", report_path)
    else:
        logger.info("[dry-run] Report would be written to %s", report_path)

    print(json.dumps(report, indent=2, ensure_ascii=False))
    return 0 if not report["errors"] else 1


def main() -> int:
    parser = argparse.ArgumentParser(description="Sync Claude skills to Kimi skills")
    parser.add_argument("--verbose", action="store_true", help="Verbose output")
    subparsers = parser.add_subparsers(dest="command")

    inv_parser = subparsers.add_parser("inventory", help="Show inventory of Claude skills")
    inv_parser.add_argument("--verbose", action="store_true", help="Verbose output")

    sync_parser = subparsers.add_parser("sync", help="Sync skills to Kimi")
    sync_parser.add_argument("--dry-run", action="store_true", help="Show what would happen")
    sync_parser.add_argument("--force", action="store_true", help="Regenerate target directory")
    sync_parser.add_argument("--diff", action="store_true", help="Show diff for changed files")
    sync_parser.add_argument("--verbose", action="store_true", help="Verbose output")

    args = parser.parse_args()

    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.INFO,
        format="%(levelname)s: %(message)s",
    )

    if args.command == "inventory":
        return inventory(args)
    return sync(args)


if __name__ == "__main__":
    sys.exit(main())

1.2 검증 스크립트 생성

~/.kimi/scripts/validate-kimi-skills.py를 다음 내용으로 생성합니다.

python
#!/usr/bin/env python3
"""Validate that generated Kimi skills are well-formed."""

from __future__ import annotations

import re
import sys
from pathlib import Path

import yaml

HOME = Path.home()
KIMI_SKILLS_DIR = HOME / ".kimi" / "skills"
BRIDGE_SKILLS = {"kimi-skill-bridge", "sync-claude-skills", "claude-to-kimi-manual-sync"}


def parse_frontmatter(text: str) -> dict | None:
    lines = text.splitlines()
    if not lines or lines[0].strip() != "---":
        return None
    fm_lines: list[str] = []
    for line in lines[1:]:
        if line.strip() == "---":
            break
        fm_lines.append(line)
    else:
        return None
    if not fm_lines:
        return None
    try:
        data = yaml.safe_load("\n".join(fm_lines))
    except yaml.YAMLError as exc:
        raise ValueError(f"Invalid frontmatter YAML: {exc}") from exc
    return data if isinstance(data, dict) else None


def validate_skill(skill_path: Path) -> list[str]:
    errors: list[str] = []
    if skill_path.is_dir():
        skill_md = skill_path / "SKILL.md"
        if not skill_md.is_file():
            errors.append("SKILL.md missing")
            return errors
        text = skill_md.read_text(encoding="utf-8")
    elif skill_path.is_file() and skill_path.suffix.lower() == ".md":
        text = skill_path.read_text(encoding="utf-8")
    else:
        errors.append("unknown skill layout")
        return errors

    fm = parse_frontmatter(text)
    if fm is None:
        errors.append("missing or invalid frontmatter")
    else:
        if "name" not in fm:
            errors.append("missing 'name' in frontmatter")
        if "description" not in fm:
            errors.append("missing 'description' in frontmatter")
        if "type" in fm and fm["type"] not in ("standard", "flow"):
            errors.append(f"invalid type: {fm['type']}")

    skill_key = skill_path.stem if skill_path.is_file() else skill_path.name
    if skill_key not in BRIDGE_SKILLS:
        if re.search(r"(?<![A-Za-z0-9.])\.claude/", text):
            errors.append("remaining .claude/ path reference")
        if re.search(r"~/.claude", text):
            errors.append("remaining ~/.claude reference")
        if re.search(r"\$HOME/.claude", text):
            errors.append("remaining $HOME/.claude reference")

    return errors


def main() -> int:
    if not KIMI_SKILLS_DIR.exists():
        print(f"Skills directory not found: {KIMI_SKILLS_DIR}", file=sys.stderr)
        return 1

    all_ok = True
    for entry in sorted(KIMI_SKILLS_DIR.iterdir()):
        if entry.name in ("README.md", "MIGRATION.md"):
            continue
        errors = validate_skill(entry)
        if errors:
            all_ok = False
            print(f"[FAIL] {entry.name}:")
            for err in errors:
                print(f"  - {err}")
        else:
            print(f"[OK]   {entry.name}")

    return 0 if all_ok else 1


if __name__ == "__main__":
    sys.exit(main())

1.3 일괄 동기화 스크립트 생성

~/.kimi/scripts/run-sync.sh를 다음 내용으로 생성합니다.

bash
#!/bin/bash
# Run Claude -> Kimi skills sync and validation
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

python3 "${SCRIPT_DIR}/sync-claude-skills.py" sync
python3 "${SCRIPT_DIR}/validate-kimi-skills.py"

1.4 실행 권한 부여 및 동기화

bash
chmod +x ~/.kimi/scripts/sync-claude-skills.py
chmod +x ~/.kimi/scripts/validate-kimi-skills.py
chmod +x ~/.kimi/scripts/run-sync.sh

# dry-run으로 미리보기
python3 ~/.kimi/scripts/sync-claude-skills.py sync --dry-run

# 실제 동기화
python3 ~/.kimi/scripts/sync-claude-skills.py sync

# 검증
python3 ~/.kimi/scripts/validate-kimi-skills.py

2. Phase 2: 수동 동기화 스킬 생성

2.1 스킬 디렉토리 생성

bash
mkdir -p ~/.kimi/skills/claude-to-kimi-manual-sync

2.2 SKILL.md 작성

~/.kimi/skills/claude-to-kimi-manual-sync/SKILL.md를 다음 내용으로 작성합니다.

markdown
---
name: claude-to-kimi-manual-sync
description: Claude Code의 스킬/에이전트/구성 변경을 Kimi Code CLI로 수동 동기화
---

# Claude → Kimi 수동 동기화

Claude Code(`~/.claude/`)의 스킬·에이전트·구성 변경사항을 Kimi Code CLI(`~/.kimi/`)로 수동 동기화합니다.

## 사용 시점

- `~/.claude/skills/`의 SKILL.md를 신규/수정/삭제한 후
- `~/.claude/agents/`의 에이전트 정의를 변경한 후
- `~/.claude/settings.json`, `~/.claude/hooks/`, `~/.claude/AGENTS.md`를 변경한 후
- Kimi에서 Claude 스킬이 누락되었거나 오래된 것 같을 때

## 동기화 절차

이 스킬이 호출되면 아래 순서로 동기화를 수행하고 결과를 보고합니다.

### 1. 변경 사항 미리보기 (dry-run)

python3 ~/.kimi/scripts/sync-claude-skills.py sync --dry-run

text

### 2. 실제 동기화

dry-run 결과가 정상이면 동기화를 실행합니다.

python3 ~/.kimi/scripts/sync-claude-skills.py sync

text

완전히 초기화 후 새로 동기화하려면 `--force`를 사용합니다.

python3 ~/.kimi/scripts/sync-claude-skills.py sync --force

text

### 3. 유효성 검증

python3 ~/.kimi/scripts/validate-kimi-skills.py

text

### 4. 일괄 실행

~/.kimi/scripts/run-sync.sh

text

## 동기화 범위

| 항목 | 소스 | 대상 | 방식 |
|---|---|---|---|
| 스킬 | `~/.claude/skills/` | `~/.kimi/skills/` | 자동 (경로/이름 변환 포함) |
| 에이전트 | `~/.claude/agents/` | `~/.kimi/agents/` | **수동** (개별 이식 필요) |
| 설정 | `~/.claude/settings.json` | `~/.kimi/settings.json` | **수동** (아직 미이식) |
| 훅 | `~/.claude/hooks/` | `~/.kimi/hooks/` | **수동** (아직 미이식) |
| AGENTS.md | `~/.claude/AGENTS.md` | `~/.kimi/AGENTS.md` | **수동** (아직 미이식) |

## 주의사항

- `--force`는 `~/.kimi/skills/`를 완전히 삭제 후 재생성하므로, dry-run으로 충분히 확인한 뒤 사용하세요.
- `kimi-skill-bridge`, `sync-claude-skills`, `claude-to-kimi-manual-sync` 등 Kimi 전용 스킬은 동기화 중 보존됩니다.
- 스크립트 실행 결과는 `~/.kimi/config/sync-report.json`에 기록됩니다.

## 문제 해결

- `.claude/` 경로가 남아 있다고 검증 실패가 뜨면, 소스 스킬의 경로 참조를 먼저 `.kimi/`로 바꾼 뒤 다시 동기화하세요.
- 동기화 후에도 Kimi에서 스킬이 보이지 않으면 Kimi Code CLI를 재시작하세요.

## Kimi에서의 호출

`/skill:claude-to-kimi-manual-sync`

2.3 동기화 스크립트 보호 목록 업데이트

~/.kimi/scripts/sync-claude-skills.py의 보존 목록을 다음과 같이 수정합니다.

python
            if entry.name in (
                "MIGRATION.md",
                "README.md",
                "kimi-skill-bridge",
                "sync-claude-skills",
                "claude-to-kimi-manual-sync",
            ):

2.4 검증 스크립트 bridge skills 업데이트

~/.kimi/scripts/validate-kimi-skills.pyBRIDGE_SKILLS를 다음과 같이 수정합니다.

python
BRIDGE_SKILLS = {"kimi-skill-bridge", "sync-claude-skills", "claude-to-kimi-manual-sync"}

2.5 동기화 및 검증

bash
python3 ~/.kimi/scripts/sync-claude-skills.py sync
python3 ~/.kimi/scripts/validate-kimi-skills.py

3. Phase 3: 납부 참조 감사

3.1 감사 스크립트 생성

~/.kimi/scripts/audit-kimi-skill-refs.py를 다음 내용으로 생성합니다.

python
#!/usr/bin/env python3
"""Audit internal references in Kimi skills for broken/migrated-from-Claude refs."""

from __future__ import annotations

import re
from pathlib import Path

HOME = Path.home()
SKILLS_DIR = HOME / ".kimi" / "skills"

SCRIPT_RE = re.compile(r"(?:~|\$HOME|/Users/[^/]+)/\.kimi/scripts/[a-zA-Z0-9_./-]+\.(?:sh|py)")
HOOK_RE = re.compile(r"(?:~|\$HOME|/Users/[^/]+)/\.kimi/hooks/[a-zA-Z0-9_./-]+(?:\.sh)?")
CLAUDE_SLASH_RE = re.compile(r"(?:^|\s)(/team|/init-project|/qa-cycle|/qa-scenario-gen|/auto-issue|/bs-auto-issue|/self-improve)(?:\s|$|\n)")
CLAUDE_PATH_RE = re.compile(r"(?:~|\$HOME|/Users/[^/]+)/\.claude/[a-zA-Z0-9_./-]+")


def collect_md_files(skills_dir: Path) -> list[Path]:
    files: list[Path] = []
    if not skills_dir.exists():
        return files
    for entry in skills_dir.iterdir():
        if entry.is_file() and entry.suffix == ".md":
            files.append(entry)
        elif entry.is_dir():
            files.extend(entry.rglob("*.md"))
    return files


def audit() -> dict:
    files = collect_md_files(SKILLS_DIR)
    report = {
        "missing_scripts": {},
        "missing_hooks": {},
        "claude_slashes": {},
        "claude_paths": {},
    }

    for md_file in files:
        rel = md_file.relative_to(SKILLS_DIR)
        text = md_file.read_text(encoding="utf-8", errors="ignore")

        for match in SCRIPT_RE.finditer(text):
            ref = match.group(0)
            path_str = ref.replace("~", str(HOME)).replace("$HOME", str(HOME))
            if not Path(path_str).exists():
                report["missing_scripts"].setdefault(str(rel), []).append(ref)

        for match in HOOK_RE.finditer(text):
            ref = match.group(0)
            path_str = ref.replace("~", str(HOME)).replace("$HOME", str(HOME))
            if not Path(path_str).exists():
                report["missing_hooks"].setdefault(str(rel), []).append(ref)

        for match in CLAUDE_SLASH_RE.finditer(text):
            cmd = match.group(1)
            report["claude_slashes"].setdefault(str(rel), []).append(cmd)

        for match in CLAUDE_PATH_RE.finditer(text):
            ref = match.group(0)
            if ".claude/settings.json" in ref or ".claude/hooks/" in ref or ".claude/AGENTS.md" in ref:
                continue
            report["claude_paths"].setdefault(str(rel), []).append(ref)

    return report


def main() -> int:
    report = audit()
    print("# Kimi 스킬 납부 참조 감사 보고서\n")
    print(f"## 1. 누락된 ~/.kimi/scripts/ 참조 ({len(report['missing_scripts'])}개 스킬)")
    for skill, refs in sorted(report["missing_scripts"].items()):
        print(f"\n### {skill}")
        for r in sorted(set(refs)):
            print(f"- `{r}`")
    print(f"\n## 2. 누락된 ~/.kimi/hooks/ 참조 ({len(report['missing_hooks'])}개 스킬)")
    for skill, refs in sorted(report["missing_hooks"].items()):
        print(f"\n### {skill}")
        for r in sorted(set(refs)):
            print(f"- `{r}`")
    print(f"\n## 3. Claude slash command ({len(report['claude_slashes'])}개 스킬)")
    for skill, cmds in sorted(report["claude_slashes"].items()):
        print(f"\n### {skill}")
        for c in sorted(set(cmds)):
            print(f"- `{c}`")
    print(f"\n## 4. 남은 .claude/ 경로 ({len(report['claude_paths'])}개 스킬)")
    for skill, paths in sorted(report["claude_paths"].items()):
        print(f"\n### {skill}")
        for p in sorted(set(paths))[:10]:
            print(f"- `{p}`")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

3.2 실행

bash
chmod +x ~/.kimi/scripts/audit-kimi-skill-refs.py
python3 ~/.kimi/scripts/audit-kimi-skill-refs.py

4. Phase 4: 핵심 스크립트 이식

4.1 스크립트 복사 + 경로 치환

다음 Python 스크립트를 실행합니다.

python
from pathlib import Path

home = Path.home()
src_dir = home / ".claude" / "scripts"
dst_dir = home / ".kimi" / "scripts"
dst_dir.mkdir(parents=True, exist_ok=True)

scripts = [
    "validate-hard-process-contract.py",
    "verify-project-scope-load.sh",
    "install-project-hooks.sh",
    "rotate-hook-triggers.sh",
    "qa-inventory-gen.sh",
    "qa-evidence-cache.sh",
    "soft-to-hard-promoter.sh",
    "rule-effectiveness-check.sh",
    "telegram-notify.sh",
    "start-harness.sh",
    "loopy-era-workflow.sh",
    "loopy-era-self-evolve.sh",
    "harness-system-eval.sh",
    "keynote-conformance-check.sh",
    "loopy-era-scorecard.sh",
    "action-log.sh",
    "trend-harvest-apply.sh",
    "trend-harvest-to-html.sh",
    "loopy-era-rule-dedup.sh",
    "hook-self-test.sh",
]

for name in scripts:
    src = src_dir / name
    dst = dst_dir / name
    if not src.exists():
        print(f"MISSING: {src}")
        continue
    text = src.read_text(encoding="utf-8", errors="ignore")
    text = text.replace("$HOME/.claude", "$HOME/.kimi")
    text = text.replace("~/.claude", "~/.kimi")
    text = text.replace("/.claude/", "/.kimi/")
    dst.write_text(text, encoding="utf-8")
    dst.chmod(0o755)
    print(f"COPIED: {name}")

4.2 스크립트별 추가 수정

4.2.1 verify-project-scope-load.sh

다음 항목을 수정합니다.

수정 위치:

bash
# Check 3: trigger log evidence (project-scope hooks fired?)
FOUND=0
if [ -f "$TRIGGER_LOG" ]; then

bash
    echo "      a) Claude Code / Kimi Code CLI doesn't load project-scope settings yet"

4.2.2 validate-hard-process-contract.py

다음 항목을 수정합니다.

python
def project_contract_path(root: Path) -> Path:
    return root / ".kimi" / "loopy-era" / "hard-process-contract.json"


def team_handoff_path(root: Path) -> Path:
    return root / ".kimi" / "loopy-era" / "team-handoff.json"
python
    add(checks, "team-handoff:from-to", data.get("from") in {"/init-project", "/skill:init-project", "$init-project"} and data.get("to") in {"/team", "/skill:team", "$team"}, f"{data.get('from')}->{data.get('to')}")
    add(checks, "team-handoff:hard-contract-path", data.get("hard_contract_path") in {".claude/loopy-era/hard-process-contract.json", ".kimi/loopy-era/hard-process-contract.json"}, str(data.get("hard_contract_path")))
python
    launch_hint = str(data.get("team_launch_hint", data.get("claude_team_launch_hint", "")))
    add(checks, "team-handoff:team-launch-hint", launch_hint.startswith("/team") or launch_hint.startswith("/skill:team"), launch_hint)
python
def validate_user_scope() -> list[dict[str, Any]]:
    checks: list[dict[str, Any]] = []
    kimi_home = Path.home() / ".kimi"
    for rel, markers in USER_SCOPE_MARKERS.items():
        path = kimi_home / rel
        ...

4.2.3 keynote-conformance-check.sh

다음 정규식을 수정합니다.

bash
HOOKPATH = re.compile(r"^\S*/\.(?:claude|kimi)/hooks/" + hk + r"$")

4.2.4 install-project-hooks.sh

주석을 다음과 같이 수정합니다.

bash
# 기존 .kimi/settings.json이 있으면 hooks만 병합, 없으면 신규 생성
bash
# 병합: event.matcher.hooks[].command로 "~/.kimi/hooks/<name>" 참조 추가

4.2.5 start-harness.sh

다음 메시지를 수정합니다.

bash
    echo "[project-scope] .kimi/settings.json absent; run /skill:init-project for project-local hooks"

4.3 구문 검사

bash
for f in ~/.kimi/scripts/*.sh; do bash -n "$f" && echo "OK: $f"; done
for f in ~/.kimi/scripts/*.py; do python3 -m py_compile "$f" && echo "OK: $f"; done

5. Phase 5: 훅 이식

5.1 hook-templates 복사

bash
cp -R ~/.claude/hook-templates ~/.kimi/hook-templates

5.2 사용자 스코프 훅 복사

bash
mkdir -p ~/.kimi/hooks

# user-scope hooks
for h in fix-commit-detector.sh self-improve-check.sh self-improve-trigger.sh stale-rule-check.sh no-env-commit.sh scaffold-violation-check.sh; do
    cp ~/.claude/hooks/$h ~/.kimi/hooks/$h
    chmod +x ~/.kimi/hooks/$h
    echo "Copied: $h"
done

# template hooks referenced as user-scope
for pair in "universal/qa-gate-before-push.sh:qa-gate-before-push.sh" "web-ts/code-quality-check.sh:code-quality-check.sh" "web-ts/validate-before-commit.sh:validate-before-commit.sh" "web-ts/dependency-audit.sh:dependency-audit.sh" "web-ts/formatter-check.sh:formatter-check.sh"; do
    src="${pair%%:*}"
    dst="${pair##*:}"
    cp ~/.claude/hook-templates/$src ~/.kimi/hooks/$dst
    chmod +x ~/.kimi/hooks/$dst
    echo "Copied: $dst"
done

5.3 경로 치환

bash
find ~/.kimi/hooks ~/.kimi/hook-templates -type f -name "*.sh" -exec sed -i '' 's|~/.claude|~/.kimi|g; s|\$HOME/.claude|\$HOME/.kimi|g; s|/.claude/|/.kimi/|g' {} \;

6. Phase 6: Commands & Agents 이식

6.1 Commands

bash
mkdir -p ~/.kimi/commands
cp ~/.claude/commands/init-project.md ~/.kimi/commands/init-project.md
cp ~/.claude/commands/team.md ~/.kimi/commands/team.md

6.2 Agents

bash
mkdir -p ~/.kimi/agents
cp ~/.claude/agents/team-orchestrator.md ~/.kimi/agents/team-orchestrator.md

6.3 경로 치환

bash
sed -i '' 's|~/.claude|~/.kimi|g; s|\$HOME/.claude|\$HOME/.kimi|g; s|/.claude/|/.kimi/|g' ~/.kimi/commands/*.md ~/.kimi/agents/*.md

7. Phase 7: MCP 서버 이식

7.1 mcp.json 생성

다음 Python 스크립트를 실행합니다.

python
import json
from pathlib import Path

home = Path.home()
claude_settings = home / ".claude" / "settings.json"
kimi_mcp = home / ".kimi" / "mcp.json"

with open(claude_settings, encoding="utf-8") as f:
    claude = json.load(f)

mcp_servers = claude.get("mcpServers", {})
converted = {}
for name, server in mcp_servers.items():
    new_server = dict(server)
    if new_server.get("type") == "http":
        new_server["transport"] = "http"
        del new_server["type"]
    converted[name] = new_server

config = {"mcpServers": converted}
kimi_mcp.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"Created: {kimi_mcp}")

7.2 검증

bash
kimi mcp list

8. Phase 8: 최종 검증

8.1 스킬 동기화 및 검증

bash
python3 ~/.kimi/scripts/sync-claude-skills.py sync
python3 ~/.kimi/scripts/validate-kimi-skills.py

8.2 납부 참조 감사

bash
python3 ~/.kimi/scripts/audit-kimi-skill-refs.py

기대 결과:

8.3 User-scope 하드 컨트랙트 검증

bash
python3 ~/.kimi/scripts/validate-hard-process-contract.py --user-scope

기대 결과: 41/41 required checks passed

8.4 start-harness smoke

bash
TEST_DIR=$(mktemp -d)
cd "$TEST_DIR"
git init -q
echo '{"name":"test","dependencies":{"react":"^18"}}' > package.json
bash ~/.kimi/scripts/install-project-hooks.sh "$TEST_DIR"
bash ~/.kimi/scripts/start-harness.sh smoke --project-root "$TEST_DIR"
rm -rf "$TEST_DIR"

기대 결과: SMOKE: PASS

8.5 /skill:init-project 및 /skill:team 시뮬레이션

임시 프로젝트를 만들고 다음을 실행합니다.

bash
TEST_DIR=$(mktemp -d)
cd "$TEST_DIR"
git init -q
echo '{"name":"test-project","dependencies":{"react":"^18"}}' > package.json

# hook 설치
bash ~/.kimi/scripts/install-project-hooks.sh "$TEST_DIR"

# hard contract 샘플 작성
mkdir -p .kimi/loopy-era

# (이하 hard-process-contract.json과 team-handoff.json 샘플은
#  validate-hard-process-contract.py가 요구하는 필수 필드 포함 필요)

# 검증
python3 ~/.kimi/scripts/validate-hard-process-contract.py --project-root "$TEST_DIR" --require-project-contract --json
python3 ~/.kimi/scripts/verify-project-scope-load.sh

rm -rf "$TEST_DIR"

기대 결과:


9. Phase 9: 지속적인 유지보수

9.1 주기적 동기화 (선택)

crontab에 등록:

cron
*/30 * * * * /Users/$USER/.kimi/scripts/run-sync.sh >> /Users/$USER/.kimi/logs/sync-cron.log 2>&1

9.2 감사 도구

bash
python3 ~/.kimi/scripts/audit-kimi-skill-refs.py

9.3 주의사항


10. 문제 해결

Q: 동기화 후 스킬이 Kimi에 보이지 않음

A: Kimi Code CLI를 재시작하세요.

Q: .claude/ 경로 검증 실패

A: 해당 스킬의 원본 .claude/skills/ 파일을 수정하여 .kimi/로 경로를 바꾼 뒤 재동기화하세요.

Q: start-harness.sh smoke 실패

A: commands/init-project.md, commands/team.md, agents/team-orchestrator.md~/.kimi/에 있는지 확인하세요.

Q: MCP 서버 연결 실패

A: kimi mcp list로 등록 확인 후, 각 서버가 로컬에서 실행 중인지 확인하세요. whimsical은 원본 파일이 누락되어 동작하지 않습니다.


11. 완료 기준

이 문서의 모든 Phase를 순서대로 실행한 후 다음 명령어들이 정상 동작하면 마이그레이션이 완료된 것입니다.

bash
python3 ~/.kimi/scripts/validate-kimi-skills.py        # FAIL 0개
python3 ~/.kimi/scripts/audit-kimi-skill-refs.py       # missing 0개
python3 ~/.kimi/scripts/validate-hard-process-contract.py --user-scope  # 41/41 passed
bash ~/.kimi/scripts/start-harness.sh smoke            # PASS
kimi mcp list                                          # 서버 목록 출력