Migrate Cursor Chat to Claude Code

June 2026 · Published by Amar Kumar

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>/
FormatJSONL → JSONL (schema conversion)
ResultMigrated 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

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.

ToolExample 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)

PathPurpose
~/.claude/projects/<path-hash>/*.jsonlPer-session transcripts
~/.claude/history.jsonlGlobal prompt index
~/.claude/settings.jsoncleanupPeriodDays (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:

  1. Reads the Cursor .jsonl transcript
  2. Converts each message to Claude format with new UUIDs
  3. Writes ~/.claude/projects/<path-hash>/<session-uuid>.jsonl
  4. 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

  1. cd ~/dev/my-app && claude
  2. Open session history (claude --resume)
  3. Migrated sessions should appear — tagged with entrypoint: cursor-migrated in 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

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

ProblemFix
No agent transcripts at ...--project must match the path Cursor used. Run ls ~/.cursor/projects/*/agent-transcripts and compare slugs.
Migration failed for one chatCheck if that .jsonl is corrupted: python3 -m json.tool < chat.jsonl
Claude Code does not show migrated chatsRestart Claude Code; verify files exist under ~/.claude/projects/<path-hash>/; confirm path hash (leading dash).
Permission deniedchmod -R u+w ~/.claude/projects/
Wrong workspace folderCompare 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.

Dry-run first, verify path hashes, then migrate — your Cursor files stay safe.