- 删除 5 个 AGENTS.md(根 + 4 子模块)与 .cursor/、.cursorignore,全部已备份 - 在 CLAUDE.md 末尾追加 5 节迁移必需内容(CLI/Shell 中文与编码、Claude Code 资产入口、Hook 与权限、不破坏原则、历史追溯),保留用户选定的 226 行项目规则全集 - 用户级 12 个 skills 从 ~/.cursor/skills/ 剥包装回迁到 ~/.claude/skills/(neozqyy-cursor-migration → neozqyy-claude-code-migration) - docs/ai-env-history/ 顶层 10 文件入仓(含 conversation_index.csv、file_impact_index.csv,已脱敏);sessions/ 原文继续本地保留 - 新增 tools/claude-code/migrate_ai_environment.py(--check 14/14 通过) - 新增 docs/claude_code_migration.md 与 docs/audit/changes/2026-05-02__claude_code_migration.md - .gitignore 调整:开放 2 个 CSV 索引入仓,保留 sessions/ 与 claude-history/ 排除 - 不混入 124 个业务变更(AI 模块重构、runtime_context、sandbox 等保持 unstaged) - 备份位置:~/.claude/backups/pre-claude-code-migration-2026-05-02/ 第二轮迁移(第一轮 commit 6facb2d 已被 git reset 回滚;本轮策略为追加而非重写 CLAUDE.md)
400 lines
14 KiB
Python
400 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""Cursor → Claude Code 反向迁移决策固化脚本。
|
||
|
||
用途:
|
||
1. `--check` 模式:扫描当前仓库与用户目录,校验本次反向迁移的所有产物
|
||
是否就位(CLAUDE.md 单轨化追加章节、AGENTS.md 已归档、.cursor/ 已删除、
|
||
用户级 skills 已剥包装、敏感串扫描通过)。
|
||
2. 作为决策记录与回滚参考——脚本注释里明确写了每条决策的原因和源/目标。
|
||
3. 不再实现 `--apply` 模式:本次迁移已手动执行完毕,重跑没有价值;
|
||
如需复用决策,请参考 `tools/cursor/migrate_ai_environment.py` 的结构
|
||
与本脚本里的常量定义。
|
||
|
||
使用:
|
||
python tools/claude-code/migrate_ai_environment.py --check
|
||
python tools/claude-code/migrate_ai_environment.py --check --strict # 失败立即退出
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Callable
|
||
|
||
REPO_ROOT = Path(os.environ.get("NEOZQYY_ROOT", Path(__file__).resolve().parents[2]))
|
||
USER_HOME = Path(os.environ.get("USERPROFILE") or os.path.expanduser("~"))
|
||
|
||
BACKUP_ROOT = USER_HOME / ".claude" / "backups" / "pre-claude-code-migration-2026-05-02"
|
||
|
||
|
||
# ---------- 决策常量(迁移决策一处定义) ----------
|
||
|
||
# 单轨化:以下 5 个 AGENTS.md 应被删除(备份在 BACKUP_ROOT/project_root/)
|
||
AGENTS_MD_TO_REMOVE = [
|
||
"AGENTS.md",
|
||
"apps/backend/AGENTS.md",
|
||
"apps/etl/connectors/feiqiu/AGENTS.md",
|
||
"apps/demo-miniprogram/AGENTS.md",
|
||
"db/AGENTS.md",
|
||
]
|
||
|
||
# 单轨化:以下文件应保留(CLAUDE.md 是 Claude Code 原生加载入口)
|
||
CLAUDE_MD_REQUIRED = [
|
||
"CLAUDE.md",
|
||
"apps/backend/CLAUDE.md",
|
||
"apps/etl/connectors/feiqiu/CLAUDE.md",
|
||
"apps/demo-miniprogram/CLAUDE.md",
|
||
"db/CLAUDE.md",
|
||
]
|
||
|
||
# 已归档删除的 Cursor 资产
|
||
CURSOR_PATHS_TO_REMOVE = [
|
||
".cursor",
|
||
".cursorignore",
|
||
]
|
||
|
||
# 项目级 Claude Code 资产(必须存在)
|
||
PROJECT_CLAUDE_REQUIRED = [
|
||
".claude/settings.json",
|
||
".claude/commands/audit.md",
|
||
".claude/commands/db-docs.md",
|
||
".claude/commands/doc-sync.md",
|
||
".claude/commands/pre-change.md",
|
||
".claude/commands/spec-close.md",
|
||
".claude/hooks/session_start_context.py",
|
||
".claude/hooks/pre_read_archived_block.py",
|
||
".claude/hooks/pre_demo_protect.py",
|
||
".claude/hooks/post_edit_audit_reminder.py",
|
||
".claude/hooks/post_edit_db_doc_sync.py",
|
||
".claude/hooks/post_edit_rls_dual_schema.py",
|
||
".claude/hooks/stop_audit_check.py",
|
||
".claude/hooks/stop_verify_check.py",
|
||
]
|
||
|
||
# 用户级 8 个 subagent
|
||
USER_AGENTS_REQUIRED = [
|
||
"architect.md",
|
||
"code-reviewer.md",
|
||
"database-reviewer.md",
|
||
"planner.md",
|
||
"python-reviewer.md",
|
||
"refactor-cleaner.md",
|
||
"security-reviewer.md",
|
||
"tdd-guide.md",
|
||
]
|
||
|
||
# 用户级 skills(12 个,回迁自 ~/.cursor/skills/,剥包装)
|
||
USER_SKILLS_REQUIRED = [
|
||
"agent-introspection-debugging",
|
||
"claude-agent-roles",
|
||
"claude-rules-reference",
|
||
"code-tour",
|
||
"codebase-onboarding",
|
||
"neozqyy-claude-code-migration", # 重命名自 neozqyy-cursor-migration
|
||
"repo-scan",
|
||
"rules-distill",
|
||
"search-first",
|
||
"security-review",
|
||
"strategic-compact",
|
||
"tdd-workflow",
|
||
]
|
||
|
||
# 第 10.1 节:自动触发(保留 description 关键词激活)
|
||
USER_SKILLS_AUTO_TRIGGER = {"strategic-compact", "neozqyy-claude-code-migration"}
|
||
|
||
# Cursor 时代留下的兼容前缀,迁回时应被剥掉
|
||
CURSOR_RESIDUAL_PATTERNS = [
|
||
re.compile(r"disable-model-invocation:\s*true", re.IGNORECASE),
|
||
re.compile(r"^>\s*迁移说明:本 skill 从 [^\n]+ 转换而来", re.MULTILINE),
|
||
]
|
||
|
||
# 根 CLAUDE.md 必须包含的迁移章节(与原有项目章节并存)
|
||
CLAUDE_MD_MIGRATION_SECTIONS = [
|
||
"## CLI / Shell 中文与编码(强制)",
|
||
"## Claude Code 资产入口",
|
||
"## Hook 与权限",
|
||
"## 不破坏原则",
|
||
"## 历史追溯",
|
||
]
|
||
|
||
# 历史索引敏感扫描
|
||
SENSITIVE_PATTERNS = {
|
||
"DSN_with_password": re.compile(r"://[^:/\s]+:[^@/\s'\"]+@[^/\s'\"]+/[A-Za-z]"),
|
||
"Anthropic_key": re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{30,}\b"),
|
||
"OpenAI_key": re.compile(r"\bsk-proj-[A-Za-z0-9_\-]{30,}\b"),
|
||
"JWT_long": re.compile(r"\beyJ[A-Za-z0-9_\-]{30,}\.[A-Za-z0-9_\-]{30,}\.[A-Za-z0-9_\-]{30,}\b"),
|
||
"AWS_AKIA": re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
||
}
|
||
|
||
|
||
# ---------- 检查框架 ----------
|
||
|
||
@dataclass
|
||
class CheckResult:
|
||
name: str
|
||
passed: bool
|
||
detail: str = ""
|
||
|
||
|
||
@dataclass
|
||
class CheckReport:
|
||
results: list[CheckResult] = field(default_factory=list)
|
||
|
||
def add(self, result: CheckResult) -> None:
|
||
self.results.append(result)
|
||
|
||
def passed_count(self) -> int:
|
||
return sum(1 for r in self.results if r.passed)
|
||
|
||
def failed(self) -> list[CheckResult]:
|
||
return [r for r in self.results if not r.passed]
|
||
|
||
|
||
def _check_files_exist(paths: list[str], base: Path, label: str) -> CheckResult:
|
||
missing = [p for p in paths if not (base / p).exists()]
|
||
if missing:
|
||
return CheckResult(label, False, f"缺失 {len(missing)} 个:{missing[:5]}")
|
||
return CheckResult(label, True, f"全部 {len(paths)} 个就位")
|
||
|
||
|
||
def _check_files_absent(paths: list[str], base: Path, label: str) -> CheckResult:
|
||
present = [p for p in paths if (base / p).exists()]
|
||
if present:
|
||
return CheckResult(label, False, f"应已删除但仍存在 {len(present)} 个:{present}")
|
||
return CheckResult(label, True, f"全部 {len(paths)} 个已归档删除")
|
||
|
||
|
||
def check_root_claude_md(report: CheckReport) -> None:
|
||
"""根 CLAUDE.md 是否包含迁移所需章节(与原有项目章节并存)。"""
|
||
p = REPO_ROOT / "CLAUDE.md"
|
||
if not p.exists():
|
||
report.add(CheckResult("根 CLAUDE.md 存在", False, "文件不存在"))
|
||
return
|
||
text = p.read_text(encoding="utf-8")
|
||
missing = [s for s in CLAUDE_MD_MIGRATION_SECTIONS if s not in text]
|
||
if missing:
|
||
report.add(CheckResult("根 CLAUDE.md 迁移章节", False, f"缺章节:{missing}"))
|
||
else:
|
||
report.add(CheckResult("根 CLAUDE.md 迁移章节", True, f"全部 {len(CLAUDE_MD_MIGRATION_SECTIONS)} 节存在"))
|
||
|
||
|
||
def check_agents_md_removed(report: CheckReport) -> None:
|
||
report.add(_check_files_absent(AGENTS_MD_TO_REMOVE, REPO_ROOT, "AGENTS.md 已归档(5 个)"))
|
||
|
||
|
||
def check_cursor_removed(report: CheckReport) -> None:
|
||
report.add(_check_files_absent(CURSOR_PATHS_TO_REMOVE, REPO_ROOT, ".cursor/ 与 .cursorignore 已归档"))
|
||
|
||
|
||
def check_claude_md_present(report: CheckReport) -> None:
|
||
report.add(_check_files_exist(CLAUDE_MD_REQUIRED, REPO_ROOT, "CLAUDE.md 单轨保留(5 个)"))
|
||
|
||
|
||
def check_project_claude_assets(report: CheckReport) -> None:
|
||
report.add(_check_files_exist(PROJECT_CLAUDE_REQUIRED, REPO_ROOT, ".claude/ 项目级资产"))
|
||
|
||
|
||
def check_user_agents(report: CheckReport) -> None:
|
||
base = USER_HOME / ".claude" / "agents"
|
||
report.add(_check_files_exist(USER_AGENTS_REQUIRED, base, "用户级 8 个 subagent"))
|
||
|
||
|
||
def check_user_skills(report: CheckReport) -> None:
|
||
base = USER_HOME / ".claude" / "skills"
|
||
if not base.exists():
|
||
report.add(CheckResult("用户级 12 个 skills 目录", False, f"目录不存在:{base}"))
|
||
return
|
||
missing = [name for name in USER_SKILLS_REQUIRED if not (base / name / "SKILL.md").exists()]
|
||
if missing:
|
||
report.add(CheckResult("用户级 12 个 skills 目录", False, f"缺失 {len(missing)}:{missing}"))
|
||
else:
|
||
report.add(CheckResult("用户级 12 个 skills 目录", True, "全部 12 个 SKILL.md 就位"))
|
||
|
||
|
||
def check_user_skills_stripped(report: CheckReport) -> None:
|
||
"""检查用户级 skills 已剥掉 Cursor 包装。"""
|
||
base = USER_HOME / ".claude" / "skills"
|
||
if not base.exists():
|
||
report.add(CheckResult("Skills 已剥 Cursor 包装", False, "目录不存在"))
|
||
return
|
||
residual = []
|
||
for name in USER_SKILLS_REQUIRED:
|
||
skill_md = base / name / "SKILL.md"
|
||
if not skill_md.exists():
|
||
continue
|
||
text = skill_md.read_text(encoding="utf-8")
|
||
for pat in CURSOR_RESIDUAL_PATTERNS:
|
||
if pat.search(text):
|
||
residual.append((name, pat.pattern[:50]))
|
||
break
|
||
if residual:
|
||
report.add(CheckResult("Skills 已剥 Cursor 包装", False, f"残留 {len(residual)}:{residual[:5]}"))
|
||
else:
|
||
report.add(CheckResult("Skills 已剥 Cursor 包装", True, "12 个 skill 均已清理"))
|
||
|
||
|
||
def check_mcp_single_source(report: CheckReport) -> None:
|
||
""".mcp.json 是单一源,不应有 .cursor/mcp.json 副本。"""
|
||
primary = REPO_ROOT / ".mcp.json"
|
||
duplicate = REPO_ROOT / ".cursor" / "mcp.json"
|
||
if not primary.exists():
|
||
report.add(CheckResult("MCP 单一源", False, ".mcp.json 不存在"))
|
||
return
|
||
if duplicate.exists():
|
||
report.add(CheckResult("MCP 单一源", False, ".cursor/mcp.json 仍存在(应已随 .cursor/ 归档)"))
|
||
return
|
||
try:
|
||
cfg = json.loads(primary.read_text(encoding="utf-8"))
|
||
except Exception as e:
|
||
report.add(CheckResult("MCP 单一源", False, f".mcp.json 解析失败:{e}"))
|
||
return
|
||
servers = cfg.get("mcpServers", {})
|
||
issues = []
|
||
for name in ("pg-etl", "pg-app"):
|
||
if name in servers and not servers[name].get("disabled", False):
|
||
issues.append(f"{name} 应 disabled=true")
|
||
for name in ("pg-etl-test", "pg-app-test"):
|
||
if name in servers and servers[name].get("disabled", False):
|
||
issues.append(f"{name} 应 disabled=false")
|
||
if issues:
|
||
report.add(CheckResult("MCP 单一源", False, "; ".join(issues)))
|
||
else:
|
||
report.add(CheckResult("MCP 单一源", True, ".mcp.json 是唯一源,prod/test 状态正确"))
|
||
|
||
|
||
def check_history_index_present(report: CheckReport) -> None:
|
||
p = REPO_ROOT / "docs" / "ai-env-history"
|
||
if not p.exists():
|
||
report.add(CheckResult("docs/ai-env-history/ 入仓", False, "目录不存在"))
|
||
return
|
||
file_count = sum(1 for f in p.rglob("*") if f.is_file())
|
||
if file_count < 100:
|
||
report.add(CheckResult("docs/ai-env-history/ 入仓", False, f"文件偏少:{file_count}"))
|
||
else:
|
||
report.add(CheckResult("docs/ai-env-history/ 入仓", True, f"{file_count} 个文件"))
|
||
|
||
|
||
def check_history_no_secrets(report: CheckReport) -> None:
|
||
p = REPO_ROOT / "docs" / "ai-env-history"
|
||
if not p.exists():
|
||
report.add(CheckResult("历史索引敏感扫描", False, "目录不存在"))
|
||
return
|
||
hits = []
|
||
for f in p.rglob("*"):
|
||
if not f.is_file():
|
||
continue
|
||
try:
|
||
text = f.read_text(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
continue
|
||
for label, pat in SENSITIVE_PATTERNS.items():
|
||
for m in pat.finditer(text):
|
||
snippet = m.group(0)
|
||
# 白名单:文档中描述敏感扫描正则模式自身
|
||
if "postgresql://|postgres://" in text[max(0, m.start()-30):m.end()+30]:
|
||
continue
|
||
hits.append((f.relative_to(REPO_ROOT).as_posix(), label, snippet[:80]))
|
||
if hits:
|
||
report.add(CheckResult("历史索引敏感扫描", False, f"{len(hits)} hits:{hits[:3]}"))
|
||
else:
|
||
report.add(CheckResult("历史索引敏感扫描", True, "无密钥/DSN 泄露"))
|
||
|
||
|
||
def check_cold_storage_intact(report: CheckReport) -> None:
|
||
"""冷备路径仍在(tools/cursor、tools/codex 不应被删除,因为 .mcp.json 引用 mcp-postgres.ps1)。"""
|
||
required = [
|
||
"tools/cursor/migrate_ai_environment.py",
|
||
"tools/codex/mcp-postgres.ps1",
|
||
]
|
||
report.add(_check_files_exist(required, REPO_ROOT, "冷备资产保留"))
|
||
|
||
|
||
def check_backup_present(report: CheckReport) -> None:
|
||
if not BACKUP_ROOT.exists():
|
||
report.add(CheckResult("迁移备份目录", False, f"不存在:{BACKUP_ROOT}"))
|
||
return
|
||
manifest = BACKUP_ROOT / "BACKUP_MANIFEST.md"
|
||
if not manifest.exists():
|
||
report.add(CheckResult("迁移备份目录", False, "BACKUP_MANIFEST.md 缺失"))
|
||
return
|
||
report.add(CheckResult("迁移备份目录", True, str(BACKUP_ROOT)))
|
||
|
||
|
||
def check_memory_untouched(report: CheckReport) -> None:
|
||
"""用户记忆目录必须存在且未被迁移触碰。"""
|
||
mem = USER_HOME / ".claude" / "projects" / "c--Project-NeoZQYY" / "memory"
|
||
if not mem.exists():
|
||
report.add(CheckResult("用户记忆目录未受影响", False, f"不存在:{mem}"))
|
||
return
|
||
files = list(mem.glob("*.md"))
|
||
if len(files) < 5:
|
||
report.add(CheckResult("用户记忆目录未受影响", False, f"文件数过少:{len(files)}"))
|
||
else:
|
||
report.add(CheckResult("用户记忆目录未受影响", True, f"{len(files)} 个记忆文件"))
|
||
|
||
|
||
CHECKS: list[Callable[[CheckReport], None]] = [
|
||
check_backup_present,
|
||
check_root_claude_md,
|
||
check_claude_md_present,
|
||
check_agents_md_removed,
|
||
check_cursor_removed,
|
||
check_project_claude_assets,
|
||
check_user_agents,
|
||
check_user_skills,
|
||
check_user_skills_stripped,
|
||
check_mcp_single_source,
|
||
check_history_index_present,
|
||
check_history_no_secrets,
|
||
check_cold_storage_intact,
|
||
check_memory_untouched,
|
||
]
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="Cursor → Claude Code 反向迁移决策固化与校验")
|
||
parser.add_argument("--check", action="store_true", help="校验当前状态(默认行为)")
|
||
parser.add_argument("--strict", action="store_true", help="任一检查失败即退出非零")
|
||
parser.add_argument("--json", action="store_true", help="JSON 输出(供脚本消费)")
|
||
args = parser.parse_args()
|
||
|
||
report = CheckReport()
|
||
for fn in CHECKS:
|
||
try:
|
||
fn(report)
|
||
except Exception as e:
|
||
report.add(CheckResult(fn.__name__, False, f"异常:{e}"))
|
||
|
||
if args.json:
|
||
out = {
|
||
"passed": report.passed_count(),
|
||
"total": len(report.results),
|
||
"results": [
|
||
{"name": r.name, "passed": r.passed, "detail": r.detail}
|
||
for r in report.results
|
||
],
|
||
}
|
||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||
else:
|
||
for r in report.results:
|
||
mark = "[OK]" if r.passed else "[X] "
|
||
print(f"{mark} {r.name}: {r.detail}")
|
||
print()
|
||
print(f"通过 {report.passed_count()} / {len(report.results)}")
|
||
if report.failed():
|
||
print("\n失败项:")
|
||
for r in report.failed():
|
||
print(f" - {r.name}: {r.detail}")
|
||
|
||
if args.strict and report.failed():
|
||
return 1
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|