#!/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())