How to Migrate Cursor Chat to Claude Code

June 2026 · Published by Amar Kumar

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:

Notable cursorDiskKV key prefixes:

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

PathContents
~/.cursor/ai-tracking/ai-code-tracking.dbscored_commits, conversation_summaries (TL;DR for picking threads)
.../anysphere.cursor-commits/checkpoints/AI-edit before/after snapshots (metadata.json, files/, diffs/)
~/.cursor/plans/*.plan.mdPlan 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

PathPurpose
~/.claude/projects/-<slug>/*.jsonlSession transcripts (migrated chats land here)
~/.claude/plans/*.mdPlan mode outputs
~/.claude/history.jsonlGlobal prompt index
~/.claude/settings.jsoncleanupPeriodDays (default 30)

What gets migrated

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:

RepoCursorClaude
~/dev/my-appUsers-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

  1. cd ~/dev/my-app && claude
  2. claude --resume — migrated sessions should appear
  3. 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

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

ProblemFix
Nothing to migrateConfirm ~/.cursor/projects/*/agent-transcripts/ exists. Open the repo in Cursor first so the slug is created.
Wrong cwd in migrated chatsCheck workspaceStorage/*/workspace.json maps the slug to your path. Re-delete manifest entry and re-migrate.
Sessions not in Claude UIVerify Claude folder has leading dash. Restart Claude Code.
Chat fails to parsepython3 -m json.tool < chat.jsonl — file may be corrupted.
Need Composer history tooUse 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.

Dry-run, migrate chats then plans, verify in claude --resume.