How to Migrate Cursor Chat to Claude Code
This guide documents how to migrate Cursor agent chat history into Claude Code's native JSONL sessions so conversations appear in Claude's session picker — the same approach used in production migration tooling. It also covers plan files and where Cursor stores related data on disk.
Composer vs agent chats: Composer threads live in state.vscdb (SQLite). Agent transcripts are JSONL under
~/.cursor/projects/.../agent-transcripts/— this article migrates those.
Overview
| Path | |
|---|---|
| Source (chats) | ~/.cursor/projects/<slug>/agent-transcripts/<runId>/<runId>.jsonl |
| Target (chats) | ~/.claude/projects/-<slug>/<session-uuid>.jsonl |
| Source (plans) | ~/.cursor/plans/*.plan.md |
| Target (plans) | ~/.claude/plans/*.md |
| Manifest | ~/.claude/.cursor-migration-manifest.json |
The migration is non-destructive: Cursor files stay untouched. Each agent chat becomes one Claude session file with queue-operation wrappers, per-message UUIDs, and entrypoint: cursor-migrated. Session file names are stable (derived from the source path hash) so re-running skips unchanged chats.
Cursor local data map
On macOS, Cursor keeps most state under ~/Library/Application Support/Cursor/. Linux and Windows use analogous paths under ~/.config/Cursor or %APPDATA%/Cursor.
state.vscdb (Composer + blobs)
User/globalStorage/state.vscdb — read-only SQLite with two tables:
ItemTable— settings, auth metadata,composer.composerHeaders(allComposers index)cursorDiskKV— chat payloads and blobs
Notable cursorDiskKV key prefixes:
composerData:<composerId>— composer metadatabubbleId:<composerId>:<bubbleId>— chat bubble bodymessageRequestContext:<composerId>:<bubbleId>— request contextcheckpointId:<composerId>:<uuid>— checkpoint pointersagentKv:blob:<sha256>— content-addressed JSON blobs
Per-workspace copies: User/workspaceStorage/<hash>/state.vscdb — includes aiService.prompts (verbatim prompt history).
Agent transcripts (what this migrator reads)
~/.cursor/projects/<slug>/agent-transcripts/<runId>/<runId>.jsonl
One folder per agent run. The migrator reads the main <runId>.jsonl only — not subagent sidechains.
Other useful paths
| Path | Contents |
|---|---|
~/.cursor/ai-tracking/ai-code-tracking.db | scored_commits, conversation_summaries (TL;DR for picking threads) |
.../anysphere.cursor-commits/checkpoints/ | AI-edit before/after snapshots (metadata.json, files/, diffs/) |
~/.cursor/plans/*.plan.md | Plan mode markdown (also migrated by the script) |
Security: never copy cursorAuth/accessToken from ItemTable into scripts that send data off-machine. This migrator is local-filesystem only.
Claude Code targets
| Path | Purpose |
|---|---|
~/.claude/projects/-<slug>/*.jsonl | Session transcripts (migrated chats land here) |
~/.claude/plans/*.md | Plan mode outputs |
~/.claude/history.jsonl | Global prompt index |
~/.claude/settings.json | cleanupPeriodDays (default 30) |
What gets migrated
- All user and assistant messages from agent-transcript JSONL
- One Claude session per Cursor agent run
- Correct
cwdresolved fromworkspaceStorage/.../workspace.json - Plan markdown from
~/.cursor/plans/→~/.claude/plans/(YAML front matter stripped)
Not migrated: subagent sidechains, Composer bubbles in state.vscdb, checkpoints, tool replay, or live UI state.
Slug mapping (Cursor → Claude)
Cursor names project folders under ~/.cursor/projects/ by encoding the absolute repo path (slashes, dots, underscores → dashes). Claude uses the same slug with a leading dash:
| Repo | Cursor | Claude |
|---|---|---|
~/dev/my-app | Users-you-dev-my-app | -Users-you-dev-my-app |
The script builds a slug → real path map from workspaceStorage/*/workspace.json so each migrated message gets the correct cwd field — not the slug string.
Format differences
Cursor agent transcript (per line)
{"role": "user",
"message": { "content": "..." }
}
Claude Code session (per line)
{"type": "user",
"message": { "role": "user", "content": "..." },
"uuid": "...",
"timestamp": "2026-06-17T12:00:00Z",
"sessionId": "...",
"entrypoint": "cursor-migrated",
"cwd": "/Users/you/dev/my-app",
"version": "migrated-from-cursor"
}
Each session file starts and ends with queue-operation lines (enqueue / dequeue).
Migration script
Save as migrate_cursor_to_claude.py. Python 3.10+, stdlib only.
#!/usr/bin/env python3
"""Migrate Cursor agent transcripts and plans into Claude Code storage.
Scans ~/.cursor/projects/*/agent-transcripts/, converts JSONL to Claude Code
session format, and optionally copies ~/.cursor/plans/ to ~/.claude/plans/.
python3 migrate_cursor_to_claude.py --dry-run
python3 migrate_cursor_to_claude.py --yes
python3 migrate_cursor_to_claude.py --project ~/dev/my-app --yes
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sqlite3
import sys
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterator, Optional
from urllib.parse import unquote, urlparse
HOME = Path.home()
CURSOR_APP_SUPPORT = (
HOME / "Library/Application Support/Cursor"
if sys.platform == "darwin"
else HOME / "AppData/Roaming/Cursor"
if sys.platform == "win32"
else HOME / ".config/Cursor"
)
WORKSPACE_ROOT = CURSOR_APP_SUPPORT / "User/workspaceStorage"
PROJECTS_ROOT = HOME / ".cursor/projects"
PLANS_ROOT = HOME / ".cursor/plans"
CLAUDE_PROJECTS = HOME / ".claude/projects"
CLAUDE_PLANS = HOME / ".claude/plans"
MANIFEST = HOME / ".claude/.cursor-migration-manifest.json"
@contextmanager
def ro_sqlite(path: Path) -> Iterator[sqlite3.Connection]:
if not path.exists():
raise FileNotFoundError(path)
con = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5.0)
try:
yield con
finally:
con.close()
def workspace_folders() -> dict[str, Optional[str]]:
"""Map workspaceStorage hash -> folder URI from workspace.json."""
out: dict[str, Optional[str]] = {}
if not WORKSPACE_ROOT.exists():
return out
for ws in WORKSPACE_ROOT.iterdir():
if not ws.is_dir():
continue
wj = ws / "workspace.json"
if wj.exists():
try:
out[ws.name] = json.loads(wj.read_text()).get("folder")
except Exception:
pass
return out
def uri_to_path(folder_uri: Optional[str]) -> Optional[str]:
if not folder_uri:
return None
if folder_uri.startswith("file://"):
return unquote(urlparse(folder_uri).path)
return folder_uri
def path_to_cursor_slug(abs_path: str) -> str:
encoded = abs_path
for ch in ("/", "\\", ":", ".", "_", " "):
encoded = encoded.replace(ch, "-")
return encoded.lstrip("-")
def build_slug_cwd_map() -> dict[str, str]:
"""Cursor project slug -> real repo path (from workspaceStorage index)."""
mapping: dict[str, str] = {path_to_cursor_slug(str(HOME)): str(HOME)}
for ws_hash, folder_uri in workspace_folders().items():
path = uri_to_path(folder_uri)
if path and os.path.isdir(path):
mapping[path_to_cursor_slug(path)] = path
return mapping
def cursor_slug_to_claude_slug(cursor_slug: str) -> str:
return cursor_slug if cursor_slug.startswith("-") else f"-{cursor_slug}"
def stable_session_id(source_path: str) -> str:
"""Same Cursor chat always maps to the same Claude session file."""
digest = hashlib.sha256(source_path.encode()).hexdigest()
return f"{digest[:8]}-{digest[8:12]}-{digest[12:16]}-{digest[16:20]}-{digest[20:32]}"
def load_manifest() -> dict:
default = {"chats": {}, "plans": {}}
if not MANIFEST.exists():
return default
try:
data = json.loads(MANIFEST.read_text(encoding="utf-8"))
return {"chats": data.get("chats") or {}, "plans": data.get("plans") or {}}
except Exception:
return default
def save_manifest(manifest: dict) -> None:
MANIFEST.parent.mkdir(parents=True, exist_ok=True)
MANIFEST.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
def list_agent_chats(project_filter: Optional[str] = None) -> list[dict]:
out: list[dict] = []
if not PROJECTS_ROOT.exists():
return out
slug_map = build_slug_cwd_map()
for proj_dir in sorted(PROJECTS_ROOT.iterdir()):
if not proj_dir.is_dir():
continue
transcripts = proj_dir / "agent-transcripts"
if not transcripts.exists():
continue
if project_filter:
cwd = slug_map.get(proj_dir.name, "")
if cwd and Path(cwd).resolve() != Path(project_filter).expanduser().resolve():
continue
for run_dir in transcripts.iterdir():
if not run_dir.is_dir():
continue
chat_file = run_dir / f"{run_dir.name}.jsonl"
if chat_file.is_file():
out.append({
"project_slug": proj_dir.name,
"run_id": run_dir.name,
"path": str(chat_file),
"mtime": chat_file.stat().st_mtime,
"size": chat_file.stat().st_size,
})
out.sort(key=lambda x: x["mtime"], reverse=True)
return out
def list_cursor_plans() -> list[dict]:
if not PLANS_ROOT.exists():
return []
return [
{
"file": p.name,
"path": str(p),
"mtime": p.stat().st_mtime,
"size": p.stat().st_size,
}
for p in sorted(PLANS_ROOT.glob("*.plan.md"), key=lambda x: x.stat().st_mtime, reverse=True)
]
def project_slug_to_cwd(slug: str) -> str:
return build_slug_cwd_map().get(slug, str(HOME))
def chat_dest(chat: dict) -> Path:
claude_slug = cursor_slug_to_claude_slug(chat["project_slug"])
session_id = stable_session_id(chat["path"])
return CLAUDE_PROJECTS / claude_slug / f"{session_id}.jsonl"
def plan_dest_name(cursor_plan: Path) -> str:
stem = cursor_plan.stem
if stem.endswith(".plan"):
stem = stem[:-5]
stem = re.sub(r"_[0-9a-f]{8}$", "", stem)
return f"{stem}.md"
def plan_dest(plan: dict) -> Path:
return CLAUDE_PLANS / plan_dest_name(Path(plan["path"]))
def should_migrate_chat(chat: dict, manifest: dict) -> bool:
dest = chat_dest(chat)
entry = manifest.get("chats", {}).get(chat["path"])
if dest.exists() and entry and chat["mtime"] <= float(entry.get("mtime") or 0):
return False
return True
def should_migrate_plan(plan: dict, manifest: dict) -> bool:
dest = plan_dest(plan)
entry = manifest.get("plans", {}).get(plan["path"])
if dest.exists() and entry and plan["mtime"] <= float(entry.get("mtime") or 0):
return False
return True
def cursor_to_claude_message(
cursor_msg: dict, session_id: str, idx: int, cwd: str
) -> dict:
role = cursor_msg.get("role", "user")
content = cursor_msg.get("message", {}).get("content", "")
msg: dict[str, Any] = {
"parentUuid": None,
"isSidechain": False,
"promptId": str(uuid.uuid4()),
"type": role,
"message": {"role": role, "content": content},
"uuid": str(uuid.uuid4()),
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"userType": "external",
"entrypoint": "cursor-migrated",
"cwd": cwd,
"sessionId": session_id,
"version": "migrated-from-cursor",
"gitBranch": "unknown",
"slug": f"migrated-chat-{idx}",
}
if role == "user":
msg["isMeta"] = False
return msg
def queue_operation(session_id: str, operation: str = "enqueue") -> dict:
return {
"type": "queue-operation",
"operation": operation,
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"sessionId": session_id,
}
def migrate_chat(chat: dict, manifest: dict, *, dry_run: bool) -> str:
if not should_migrate_chat(chat, manifest):
return "skipped"
chat_path = Path(chat["path"])
try:
lines = [json.loads(ln) for ln in chat_path.read_text().splitlines() if ln.strip()]
except Exception:
return "failed"
if not lines:
return "failed"
session_id = stable_session_id(chat["path"])
cwd = project_slug_to_cwd(chat["project_slug"])
dest = chat_dest(chat)
messages = [queue_operation(session_id, "enqueue")]
for idx, row in enumerate(lines):
try:
messages.append(cursor_to_claude_message(row, session_id, idx, cwd))
except Exception:
continue
messages.append(queue_operation(session_id, "dequeue"))
if not dry_run:
dest.parent.mkdir(parents=True, exist_ok=True)
with dest.open("w", encoding="utf-8") as fh:
for m in messages:
fh.write(json.dumps(m) + "\n")
manifest.setdefault("chats", {})[chat["path"]] = {
"dest": str(dest),
"mtime": chat["mtime"],
"session_id": session_id,
"cwd": cwd,
}
return "migrated"
def strip_yaml_front_matter(text: str) -> str:
if not text.startswith("---"):
return text
end = text.find("\n---", 3)
if end == -1:
return text
return text[end + 4 :].lstrip("\n")
def migrate_plan(plan: dict, manifest: dict, *, dry_run: bool) -> str:
if not should_migrate_plan(plan, manifest):
return "skipped"
src = Path(plan["path"])
dest = plan_dest(plan)
try:
content = strip_yaml_front_matter(src.read_text(encoding="utf-8"))
if not dry_run:
CLAUDE_PLANS.mkdir(parents=True, exist_ok=True)
dest.write_text(content, encoding="utf-8")
manifest.setdefault("plans", {})[plan["path"]] = {
"dest": str(dest),
"mtime": plan["mtime"],
}
return "migrated"
except Exception:
return "failed"
def main() -> None:
ap = argparse.ArgumentParser(description="Migrate Cursor chats and plans to Claude Code")
ap.add_argument("--project", type=Path, help="Only migrate chats for this repo path")
ap.add_argument("--dry-run", action="store_true", help="Preview without writing")
ap.add_argument("--yes", action="store_true", help="Skip confirmation")
ap.add_argument("--chats-only", action="store_true")
ap.add_argument("--plans-only", action="store_true")
args = ap.parse_args()
project = str(args.project.expanduser().resolve()) if args.project else None
chats = list_agent_chats(project)
plans = [] if args.chats_only else list_cursor_plans()
if args.plans_only:
chats = []
print(f"Found {len(chats)} agent chat(s), {len(plans)} plan(s)")
if not chats and not plans:
sys.exit("Nothing to migrate.")
manifest = load_manifest()
pending_chats = sum(1 for c in chats if should_migrate_chat(c, manifest))
pending_plans = sum(1 for p in plans if should_migrate_plan(p, manifest))
print(f"Pending: {pending_chats} chat(s), {pending_plans} plan(s)")
if args.dry_run:
for c in chats:
if should_migrate_chat(c, manifest):
kb = c["size"] / 1024
print(f" chat {c['run_id'][:12]} {kb:.1f} KB -> {chat_dest(c).parent.name}/")
for p in plans:
if should_migrate_plan(p, manifest):
print(f" plan {p['file']} -> {plan_dest(p).name}")
print("DRY RUN — no files written")
return
if not args.yes:
ans = input("Continue? (yes/no): ").strip().lower()
if ans not in ("yes", "y"):
print("Cancelled.")
return
stats = {"migrated": 0, "skipped": 0, "failed": 0}
for chat in chats:
result = migrate_chat(chat, manifest, dry_run=False)
stats[result if result in stats else "failed"] += 1
sym = {"migrated": "ok", "skipped": "skip", "failed": "fail"}.get(result, "fail")
print(f" {sym} chat {chat['run_id'][:12]}")
for plan in plans:
result = migrate_plan(plan, manifest, dry_run=False)
stats[result if result in stats else "failed"] += 1
sym = {"migrated": "ok", "skipped": "skip", "failed": "fail"}.get(result, "fail")
print(f" {sym} plan {plan['file']}")
if not args.dry_run:
save_manifest(manifest)
print(
f"Done: {stats['migrated']} migrated, {stats['skipped']} skipped, "
f"{stats['failed']} failed"
)
print("Open Claude Code and check session history (entrypoint=cursor-migrated).")
if __name__ == "__main__":
main()
How to run
1. Dry run (recommended)
python3 migrate_cursor_to_claude.py --dry-run
# Only one repo
python3 migrate_cursor_to_claude.py --project ~/dev/my-app --dry-run
Lists pending chats with sizes and target Claude project folders. Skips already-migrated files unless the source mtime changed.
2. Migrate everything
python3 migrate_cursor_to_claude.py --yes
Migrates chats first, then plans. Per-item status: ok, skip, or fail.
3. Chats or plans only
python3 migrate_cursor_to_claude.py --chats-only --yes
python3 migrate_cursor_to_claude.py --plans-only --yes
Before / after
# Cursor agent runs (unchanged)
find ~/.cursor/projects -path '*/agent-transcripts/*/*.jsonl' | wc -l
# Claude sessions for one repo
ls ~/.claude/projects/-Users-you-dev-my-app/*.jsonl | wc -l
Verify in Claude Code
cd ~/dev/my-app && claudeclaude --resume— migrated sessions should appear- Confirm tag:
grep -l '"entrypoint": "cursor-migrated"' ~/.claude/projects/-Users-you-dev-my-app/*.jsonl
Restart Claude Code if sessions do not show immediately.
Manifest and re-runs
The script writes ~/.claude/.cursor-migration-manifest.json mapping each source path to its destination, mtime, and session_id. Re-running skips files where the destination exists and the source has not changed — safe to run after every Cursor session during a transition period.
Session filenames use stable UUIDs (SHA-256 of the source path), so the same Cursor chat always maps to the same Claude file.
Plans migration
Cursor plan files like api-redesign_387ebee5.plan.md are copied to ~/.claude/plans/api-redesign.md with YAML front matter removed. Plans already up to date are skipped via the manifest.
Safety
- Cursor source files are read-only — never modified
- Dry-run previews all pending work
- Per-chat/plan errors do not abort the batch
- No network calls; no JWT or token handling
Rollback
grep -l '"entrypoint": "cursor-migrated"' ~/.claude/projects/-Users-you-dev-my-app/*.jsonl
Delete those JSONL files to remove migrated sessions. Deleting the entire project folder also removes native Claude sessions for that repo. Remove ~/.claude/.cursor-migration-manifest.json to force a full re-migration.
Troubleshooting
| Problem | Fix |
|---|---|
| Nothing to migrate | Confirm ~/.cursor/projects/*/agent-transcripts/ exists. Open the repo in Cursor first so the slug is created. |
Wrong cwd in migrated chats | Check workspaceStorage/*/workspace.json maps the slug to your path. Re-delete manifest entry and re-migrate. |
| Sessions not in Claude UI | Verify Claude folder has leading dash. Restart Claude Code. |
| Chat fails to parse | python3 -m json.tool < chat.jsonl — file may be corrupted. |
| Need Composer history too | Use the state.vscdb export guide separately. |
FAQ
Will migrated chats appear in Claude Code history?
Yes — when JSONL files land in ~/.claude/projects/-<slug>/ with valid Claude schema.
Why stable session IDs instead of random UUIDs?
So re-running the migrator updates the same file instead of duplicating sessions. Random per-message UUIDs inside the session are still generated fresh each run.
Are Cursor files modified?
No.
What about api2.cursor.sh / usage APIs?
Out of scope — this migrator is local disk only. Cloud usage requires your Cursor JWT and is unrelated to chat history migration.
Related
Dry-run, migrate chats then plans, verify in claude --resume.