Migrate Cursor Chat to Claude Code
This guide shows how to migrate Cursor agent chat history into Claude Code's native session format so conversations appear in Claude's chat history — not just as pasted context. Source files live under ~/.cursor/projects/.../agent-transcripts/; targets are JSONL session files in ~/.claude/projects/.
Different data? Composer chats in state.vscdb use a separate SQLite export path. This article covers agent transcripts (JSONL on disk).
Overview
| Path | |
|---|---|
| Source | ~/.cursor/projects/<slug>/agent-transcripts/ |
| Target | ~/.claude/projects/<path-hash>/ |
| Format | JSONL → JSONL (schema conversion) |
| Result | Migrated chats show up in Claude Code session history |
The script reads each Cursor agent transcript, converts messages to Claude Code's richer JSON schema (UUIDs, timestamps, queue-operation envelopes), and writes one .jsonl file per chat into the Claude projects folder for your repo.
What gets migrated
- All user and assistant messages
- Conversation structure (one Claude session per Cursor chat)
- Metadata tags so you can identify imports (
entrypoint: cursor-migrated)
Not included: subagent sidechain transcripts (only the main <chat-id>/<chat-id>.jsonl file per folder). Tool replay and live UI state are not restored — only message content.
Where files live
Both tools store history locally. Folder names are derived from your project's absolute path — slashes, dots, and underscores become dashes.
| Tool | Example path for ~/dev/my-app |
|---|---|
| Cursor transcripts | ~/.cursor/projects/Users-you-dev-my-app/agent-transcripts/ |
| Claude sessions | ~/.claude/projects/-Users-you-dev-my-app/ |
Claude's folder has a leading dash; Cursor's slug does not. If auto-detection fails, list both roots and match manually:
ls ~/.cursor/projects/*/agent-transcripts
ls ~/.claude/projects/
Claude Code session storage (reference)
| Path | Purpose |
|---|---|
~/.claude/projects/<path-hash>/*.jsonl | Per-session transcripts |
~/.claude/history.jsonl | Global prompt index |
~/.claude/settings.json | cleanupPeriodDays (default 30 — back up before migrating if needed) |
CLAUDE.md (repo root) | Project instructions — separate from chat history |
Format differences
Cursor agent transcripts use a minimal schema. Claude Code expects richer per-line JSON with session metadata.
Cursor (source)
{
"role": "user",
"message": {
"content": "..."
}
}
Claude Code (target)
{
"type": "user",
"message": {
"role": "user",
"content": "..."
},
"uuid": "...",
"timestamp": "2026-06-17T12:00:00Z",
"sessionId": "...",
"entrypoint": "cursor-migrated",
"cwd": "/path/to/repo",
"version": "migrated-from-cursor"
}
The script also wraps each session with queue-operation lines (enqueue / dequeue) that Claude Code uses internally for session management.
Migration script
Copy migrate_cursor_to_claude.py anywhere, install nothing beyond Python 3.10+. Pass your repo path with --project — the same folder you opened in Cursor.
#!/usr/bin/env python3
"""Migrate Cursor agent transcripts to Claude Code chat JSONL.
Reads ~/.cursor/projects/<slug>/agent-transcripts/ and writes native
Claude Code session files under ~/.claude/projects/<path-hash>/.
python3 migrate_cursor_to_claude.py --project ~/dev/my-app --dry-run
python3 migrate_cursor_to_claude.py --project ~/dev/my-app
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
def path_slug(project: Path, *, leading_dash: bool = False) -> str:
"""Derive Cursor/Claude project folder name from an absolute repo path."""
resolved = str(project.expanduser().resolve())
slug = resolved.replace(os.sep, "-").lstrip(os.sep)
slug = slug.replace(".", "-").replace("_", "-")
return f"-{slug}" if leading_dash else slug
def cursor_transcripts_dir(project: Path) -> Path:
slug = path_slug(project)
return Path.home() / ".cursor/projects" / slug / "agent-transcripts"
def claude_workspace_dir(project: Path) -> Path:
slug = path_slug(project, leading_dash=True)
return Path.home() / ".claude/projects" / slug
def new_uuid() -> str:
return str(uuid.uuid4())
def iso_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def queue_operation(session_id: str, operation: str = "enqueue") -> dict[str, Any]:
return {
"type": "queue-operation",
"operation": operation,
"timestamp": iso_now(),
"sessionId": session_id,
}
def cursor_to_claude_message(
cursor_msg: dict[str, Any],
session_id: str,
msg_index: int,
cwd: str,
) -> dict[str, Any]:
role = cursor_msg.get("role", "user")
content = cursor_msg.get("message", {}).get("content", "")
claude_msg: dict[str, Any] = {
"parentUuid": None,
"isSidechain": False,
"promptId": new_uuid(),
"type": role,
"message": {"role": role, "content": content},
"uuid": new_uuid(),
"timestamp": iso_now(),
"userType": "external",
"entrypoint": "cursor-migrated",
"cwd": cwd,
"sessionId": session_id,
"version": "migrated-from-cursor",
"gitBranch": "unknown",
"slug": f"migrated-chat-{msg_index}",
}
if role == "user":
claude_msg["isMeta"] = False
return claude_msg
def find_cursor_chats(transcripts_root: Path) -> list[Path]:
"""Main agent chats only — skip subagent sidechains."""
chats: list[Path] = []
if not transcripts_root.exists():
return chats
for chat_dir in transcripts_root.iterdir():
if not chat_dir.is_dir():
continue
main = chat_dir / f"{chat_dir.name}.jsonl"
if main.is_file():
chats.append(main)
chats.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return chats
def migrate_chat(
cursor_chat_path: Path,
claude_workspace: Path,
project: Path,
*,
dry_run: bool = False,
) -> bool:
try:
lines = [
json.loads(line)
for line in cursor_chat_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
if not lines:
print(f" skip empty: {cursor_chat_path.name}")
return False
session_id = new_uuid()
claude_messages: list[dict[str, Any]] = [queue_operation(session_id, "enqueue")]
cwd = str(project.resolve())
for idx, cursor_msg in enumerate(lines):
try:
claude_messages.append(
cursor_to_claude_message(cursor_msg, session_id, idx, cwd)
)
except Exception as exc:
print(f" warn message {idx}: {exc}")
claude_messages.append(queue_operation(session_id, "dequeue"))
out_path = claude_workspace / f"{session_id}.jsonl"
size_kb = cursor_chat_path.stat().st_size / 1024
mod = datetime.fromtimestamp(cursor_chat_path.stat().st_mtime).strftime(
"%Y-%m-%d %H:%M"
)
print(
f" ok {cursor_chat_path.parent.name} | "
f"{len(lines)} msgs | {size_kb:.1f} KB | {mod}"
)
if not dry_run:
claude_workspace.mkdir(parents=True, exist_ok=True)
with out_path.open("w", encoding="utf-8") as fh:
for msg in claude_messages:
fh.write(json.dumps(msg) + "\n")
print(f" -> {out_path.name}")
return True
except Exception as exc:
print(f" fail {cursor_chat_path.name}: {exc}")
return False
def main() -> None:
ap = argparse.ArgumentParser(
description="Migrate Cursor agent transcripts into Claude Code JSONL sessions"
)
ap.add_argument(
"--project",
type=Path,
required=True,
help="Absolute path to the repo you used in Cursor",
)
ap.add_argument(
"--dry-run",
action="store_true",
help="Preview migration without writing files",
)
ap.add_argument(
"--days",
type=int,
default=0,
help="Only migrate chats modified in the last N days (0 = all)",
)
ap.add_argument(
"--yes",
action="store_true",
help="Skip confirmation prompt",
)
args = ap.parse_args()
project = args.project.expanduser().resolve()
transcripts = cursor_transcripts_dir(project)
claude_ws = claude_workspace_dir(project)
print("Cursor -> Claude Code chat migration")
print(f" project: {project}")
print(f" source: {transcripts}")
print(f" target: {claude_ws}")
if not transcripts.exists():
sys.exit(
f"No agent transcripts at {transcripts}\n"
"Check --project matches the folder Cursor opened, or run:\n"
f" ls ~/.cursor/projects/*/agent-transcripts"
)
chats = find_cursor_chats(transcripts)
if args.days > 0:
cutoff = datetime.now() - timedelta(days=args.days)
chats = [
c
for c in chats
if datetime.fromtimestamp(c.stat().st_mtime) >= cutoff
]
print(f" chats: {len(chats)}")
if not chats:
sys.exit("No chats to migrate.")
if not args.dry_run and not args.yes:
answer = input("Continue? (yes/no/dry-run): ").strip().lower()
if answer in ("dry-run", "dry"):
args.dry_run = True
elif answer not in ("yes", "y"):
print("Cancelled.")
return
if args.dry_run:
print("\nDRY RUN — no files written\n")
ok = fail = 0
for chat_path in chats:
if migrate_chat(chat_path, claude_ws, project, dry_run=args.dry_run):
ok += 1
else:
fail += 1
print(f"\nDone: {ok} migrated, {fail} failed")
if not args.dry_run:
print(f"Open Claude Code in {project} and check session history.")
print("Migrated sessions are tagged entrypoint=cursor-migrated.")
if __name__ == "__main__":
main()
How to run
1. Dry run (recommended)
Preview counts and sizes without writing files:
python3 migrate_cursor_to_claude.py --project ~/dev/my-app --dry-run
Output shows each chat's message count, file size, and last-modified date.
2. Migrate
python3 migrate_cursor_to_claude.py --project ~/dev/my-app --yes
Without --yes, the script prompts Continue? (yes/no/dry-run). For each chat it:
- Reads the Cursor
.jsonltranscript - Converts each message to Claude format with new UUIDs
- Writes
~/.claude/projects/<path-hash>/<session-uuid>.jsonl - Prints success or failure per chat (failures do not stop the batch)
3. Before / after counts
# Cursor agent chats (unchanged after migration)
find ~/.cursor/projects -path '*/agent-transcripts/*/*.jsonl' | wc -l
# Claude sessions for your repo
ls ~/.claude/projects/-Users-you-dev-my-app/*.jsonl | wc -l
After migration, Claude's count should increase by the number of successfully migrated chats. Original Cursor files are untouched.
Verify in Claude Code
cd ~/dev/my-app && claude- Open session history (
claude --resume) - Migrated sessions should appear — tagged with
entrypoint: cursor-migratedin the JSONL
Spot-check one file:
head -1 ~/.claude/projects/-Users-you-dev-my-app/*.jsonl | python3 -m json.tool
If sessions do not appear, restart Claude Code and confirm the path hash matches your absolute project path.
Safety features
- Non-destructive — Cursor transcripts are read-only; originals stay in place
- Dry-run mode — preview before writing
- Per-chat error handling — one bad file does not abort the batch
- Fresh UUIDs — avoids collisions with existing Claude sessions
Selective migration
Migrate only recent chats with --days:
python3 migrate_cursor_to_claude.py --project ~/dev/my-app --days 30 --dry-run
Or filter in the script by editing the chat loop — the --days flag compares file mtime against a cutoff.
Rollback
Remove migrated sessions by deleting JSONL files Claude created. Migrated files use random UUID filenames:
# List first — confirm you are in the right workspace folder
ls ~/.claude/projects/-Users-you-dev-my-app/
# Remove only files you know are from migration (check entrypoint field)
grep -l '"entrypoint": "cursor-migrated"' ~/.claude/projects/-Users-you-dev-my-app/*.jsonl
Warning: deleting everything in the workspace folder also removes native Claude sessions for that repo.
Troubleshooting
| Problem | Fix |
|---|---|
No agent transcripts at ... | --project must match the path Cursor used. Run ls ~/.cursor/projects/*/agent-transcripts and compare slugs. |
| Migration failed for one chat | Check if that .jsonl is corrupted: python3 -m json.tool < chat.jsonl |
| Claude Code does not show migrated chats | Restart Claude Code; verify files exist under ~/.claude/projects/<path-hash>/; confirm path hash (leading dash). |
| Permission denied | chmod -R u+w ~/.claude/projects/ |
| Wrong workspace folder | Compare slug algorithm output with existing folders — symlinked paths can differ from what Cursor recorded. |
FAQ
Will migrated chats appear in Claude Code history?
Yes — when converted JSONL files land in the correct ~/.claude/projects/<path-hash>/ directory for your repo.
Are original Cursor chats modified?
No. They remain in agent-transcripts/. You can re-run migration after fixing the script.
What about Composer / state.vscdb chats?
Those are stored separately in SQLite. Use the state.vscdb export guide and seed CLAUDE.md for Composer-only history.
Why new UUIDs instead of preserving Cursor IDs?
Claude Code's schema requires its own session and message UUIDs. Collisions with existing sessions would break history.
Related guides
- Export Cursor Chat History from state.vscdb — Composer threads in SQLite
- Best Economical LLM Models for RAG
Dry-run first, verify path hashes, then migrate — your Cursor files stay safe.