이 문서만 읽고 동일한 환경을 똑같이 재현할 수 있도록 작성되었습니다. 모든 명령어, 파일 내용, 경로, 검증 단계를 상세히 포함합니다.
0. 사전 준비
0.1 환경 확인
이 가이드는 다음 환경을 가정합니다:
- macOS (Linux에서도 대부분 동일)
- Python 3.11+
- Kimi Code CLI가 설치되어 있음
- 기존 Claude Code 설정이
~/.claude/에 존재
0.2 백업
반드시 먼저 백업하세요.
# ~/.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 패키지
python3 -m pip install pyyaml1. Phase 1: 초기 스킬 동기화
1.1 동기화 스크립트 생성
~/.kimi/scripts/sync-claude-skills.py를 다음 내용으로 생성합니다.
#!/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를 다음 내용으로 생성합니다.
#!/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를 다음 내용으로 생성합니다.
#!/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 실행 권한 부여 및 동기화
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.py2. Phase 2: 수동 동기화 스킬 생성
2.1 스킬 디렉토리 생성
mkdir -p ~/.kimi/skills/claude-to-kimi-manual-sync2.2 SKILL.md 작성
~/.kimi/skills/claude-to-kimi-manual-sync/SKILL.md를 다음 내용으로 작성합니다.
---
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
### 2. 실제 동기화
dry-run 결과가 정상이면 동기화를 실행합니다.
python3 ~/.kimi/scripts/sync-claude-skills.py sync
완전히 초기화 후 새로 동기화하려면 `--force`를 사용합니다.
python3 ~/.kimi/scripts/sync-claude-skills.py sync --force
### 3. 유효성 검증
python3 ~/.kimi/scripts/validate-kimi-skills.py
### 4. 일괄 실행
~/.kimi/scripts/run-sync.sh
## 동기화 범위
| 항목 | 소스 | 대상 | 방식 |
|---|---|---|---|
| 스킬 | `~/.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의 보존 목록을 다음과 같이 수정합니다.
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.py의 BRIDGE_SKILLS를 다음과 같이 수정합니다.
BRIDGE_SKILLS = {"kimi-skill-bridge", "sync-claude-skills", "claude-to-kimi-manual-sync"}2.5 동기화 및 검증
python3 ~/.kimi/scripts/sync-claude-skills.py sync
python3 ~/.kimi/scripts/validate-kimi-skills.py3. Phase 3: 납부 참조 감사
3.1 감사 스크립트 생성
~/.kimi/scripts/audit-kimi-skill-refs.py를 다음 내용으로 생성합니다.
#!/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 실행
chmod +x ~/.kimi/scripts/audit-kimi-skill-refs.py
python3 ~/.kimi/scripts/audit-kimi-skill-refs.py4. Phase 4: 핵심 스크립트 이식
4.1 스크립트 복사 + 경로 치환
다음 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
다음 항목을 수정합니다.
FOUND=0초기화 추가.claude/→.kimi/메시지 업데이트
수정 위치:
# Check 3: trigger log evidence (project-scope hooks fired?)
FOUND=0
if [ -f "$TRIGGER_LOG" ]; then및
echo " a) Claude Code / Kimi Code CLI doesn't load project-scope settings yet"4.2.2 validate-hard-process-contract.py
다음 항목을 수정합니다.
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" 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"))) 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)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
다음 정규식을 수정합니다.
HOOKPATH = re.compile(r"^\S*/\.(?:claude|kimi)/hooks/" + hk + r"$")4.2.4 install-project-hooks.sh
주석을 다음과 같이 수정합니다.
# 기존 .kimi/settings.json이 있으면 hooks만 병합, 없으면 신규 생성# 병합: event.matcher.hooks[].command로 "~/.kimi/hooks/<name>" 참조 추가4.2.5 start-harness.sh
다음 메시지를 수정합니다.
echo "[project-scope] .kimi/settings.json absent; run /skill:init-project for project-local hooks"4.3 구문 검사
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"; done5. Phase 5: 훅 이식
5.1 hook-templates 복사
cp -R ~/.claude/hook-templates ~/.kimi/hook-templates5.2 사용자 스코프 훅 복사
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"
done5.3 경로 치환
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
mkdir -p ~/.kimi/commands
cp ~/.claude/commands/init-project.md ~/.kimi/commands/init-project.md
cp ~/.claude/commands/team.md ~/.kimi/commands/team.md6.2 Agents
mkdir -p ~/.kimi/agents
cp ~/.claude/agents/team-orchestrator.md ~/.kimi/agents/team-orchestrator.md6.3 경로 치환
sed -i '' 's|~/.claude|~/.kimi|g; s|\$HOME/.claude|\$HOME/.kimi|g; s|/.claude/|/.kimi/|g' ~/.kimi/commands/*.md ~/.kimi/agents/*.md7. Phase 7: MCP 서버 이식
7.1 mcp.json 생성
다음 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 검증
kimi mcp list8. Phase 8: 최종 검증
8.1 스킬 동기화 및 검증
python3 ~/.kimi/scripts/sync-claude-skills.py sync
python3 ~/.kimi/scripts/validate-kimi-skills.py8.2 납부 참조 감사
python3 ~/.kimi/scripts/audit-kimi-skill-refs.py기대 결과:
- 누락 scripts 참조: 0개
- 누락 hooks 참조: 0개
8.3 User-scope 하드 컨트랙트 검증
python3 ~/.kimi/scripts/validate-hard-process-contract.py --user-scope기대 결과: 41/41 required checks passed
8.4 start-harness smoke
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 시뮬레이션
임시 프로젝트를 만들고 다음을 실행합니다.
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"기대 결과:
- validate:
25/25 required checks passed - verify:
VERDICT: INSTALLED
9. Phase 9: 지속적인 유지보수
9.1 주기적 동기화 (선택)
crontab에 등록:
*/30 * * * * /Users/$USER/.kimi/scripts/run-sync.sh >> /Users/$USER/.kimi/logs/sync-cron.log 2>&19.2 감사 도구
python3 ~/.kimi/scripts/audit-kimi-skill-refs.py9.3 주의사항
.kimi/skills/에 직접 파일을 추가하면 동기화 시 삭제될 수 있습니다.- 지속적으로 보존하려면
sync-claude-skills.py의 보존 목록에 추가하세요. .claude/settings.json의 MCP 외 설정은 아직 자동 이식되지 않습니다.
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를 순서대로 실행한 후 다음 명령어들이 정상 동작하면 마이그레이션이 완료된 것입니다.
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 # 서버 목록 출력