Skip to content

Instantly share code, notes, and snippets.

@thsunkid
Created February 1, 2026 16:38
Show Gist options
  • Select an option

  • Save thsunkid/8fc73f0acd68e7dc1850ad0a23bf1832 to your computer and use it in GitHub Desktop.

Select an option

Save thsunkid/8fc73f0acd68e7dc1850ad0a23bf1832 to your computer and use it in GitHub Desktop.
Export full Cursor agent chats on macOS
#!/usr/bin/env python3
"""
Export Cursor agent chats on macOS (portable Markdown).
What it does:
- Searches Cursor workspace state DBs for a conversation title (Composer name)
and returns matching composerId(s).
- Locates the corresponding local transcript(s) under ~/.cursor/projects/**/agent-transcripts/
- Writes a Markdown file containing the raw transcript(s), with paths sanitized for sharing.
Where it searches (macOS defaults):
- Cursor state DBs:
~/Library/Application Support/Cursor/User/workspaceStorage/*/state.vscdb
- Table: ItemTable
- Key: composer.composerData
- Agent transcripts:
~/.cursor/projects/*/agent-transcripts/<composerId>.txt
Usage examples:
- Export recent matches (last 2 days) to Desktop:
python export_cursor_chat_mac.py --conversation-name "Document link verification"
- Export all matches (no recency filter):
python export_cursor_chat_mac.py --conversation-name "Document link verification" --days 0
- Fuzzy match conversation titles:
python export_cursor_chat_mac.py --conversation-name "waveform" --match-mode contains
- Choose interactively if multiple matches exist:
python export_cursor_chat_mac.py --conversation-name "Document link verification" --interactive
- Disable sanitization (keeps absolute paths):
python export_cursor_chat_mac.py --conversation-name "Document link verification" --no-sanitize
Note:
- Cursor transcripts often include tool-call blocks, but tool *results* are frequently not persisted
in the transcript file (you may see blank [Tool result] sections).
"""
from __future__ import annotations
import argparse
import json
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterable
@dataclass(frozen=True)
class ConversationMatch:
conversation_name: str
composer_id: str
created_at_ms: int | None
last_updated_at_ms: int | None
workspace_storage_id: str
state_vscdb_path: Path
transcript_paths: tuple[Path, ...]
def _ms_to_iso(ms: int | None) -> str | None:
if ms is None:
return None
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()
def _read_state_vscdb_composer_data(state_vscdb: Path) -> dict[str, Any] | None:
# Read-only open; keeps things safer if Cursor is open.
conn = sqlite3.connect(f"file:{state_vscdb}?mode=ro", uri=True)
try:
row = conn.execute(
"SELECT CAST(value AS TEXT) FROM ItemTable WHERE key=? LIMIT 1;",
("composer.composerData",),
).fetchone()
finally:
conn.close()
if not row or not row[0]:
return None
try:
return json.loads(row[0])
except json.JSONDecodeError:
return None
def _iter_workspace_state_dbs(
workspace_storage_dir: Path,
) -> Iterable[tuple[str, Path]]:
for child in sorted(workspace_storage_dir.iterdir()):
if not child.is_dir():
continue
state_vscdb = child / "state.vscdb"
if state_vscdb.exists():
yield child.name, state_vscdb
def _name_matches(candidate: str, query: str, mode: str) -> bool:
c = candidate.strip().lower()
q = query.strip().lower()
if mode == "exact":
return c == q
return q in c
def find_conversations_by_name(
*,
workspace_storage_dir: Path,
conversation_name: str,
match_mode: str,
updated_within_days: int,
) -> list[ConversationMatch]:
now = datetime.now(timezone.utc)
cutoff_ms: int | None = None
if updated_within_days > 0:
cutoff_ms = int((now - timedelta(days=updated_within_days)).timestamp() * 1000)
matches: list[ConversationMatch] = []
for workspace_storage_id, state_vscdb in _iter_workspace_state_dbs(
workspace_storage_dir
):
composer_data = _read_state_vscdb_composer_data(state_vscdb)
if not composer_data:
continue
for item in composer_data.get("allComposers", []):
if not isinstance(item, dict):
continue
name = str(item.get("name") or "")
if not _name_matches(name, conversation_name, match_mode):
continue
composer_id = item.get("composerId")
if not composer_id:
continue
created_at_ms = item.get("createdAt")
last_updated_at_ms = item.get("lastUpdatedAt")
if (
cutoff_ms is not None
and isinstance(last_updated_at_ms, int)
and last_updated_at_ms < cutoff_ms
):
continue
matches.append(
ConversationMatch(
conversation_name=name,
composer_id=str(composer_id),
created_at_ms=int(created_at_ms)
if isinstance(created_at_ms, int)
else None,
last_updated_at_ms=int(last_updated_at_ms)
if isinstance(last_updated_at_ms, int)
else None,
workspace_storage_id=workspace_storage_id,
state_vscdb_path=state_vscdb,
transcript_paths=(),
)
)
# Most-recent first.
matches.sort(key=lambda m: m.last_updated_at_ms or 0, reverse=True)
return matches
def find_transcripts_for_composer_id(
*,
cursor_projects_dir: Path,
composer_id: str,
) -> tuple[Path, ...]:
found: list[Path] = []
if not cursor_projects_dir.exists():
return ()
for project_dir in sorted(cursor_projects_dir.iterdir()):
if not project_dir.is_dir():
continue
tdir = project_dir / "agent-transcripts"
if not tdir.exists():
continue
for ext in ("txt", "json"):
p = tdir / f"{composer_id}.{ext}"
if p.exists():
found.append(p)
return tuple(found)
def _extract_project_dir_guesses_from_transcript(transcript_text: str) -> list[str]:
guesses: list[str] = []
for raw_line in transcript_text.splitlines():
line = raw_line.strip()
if not line.startswith("arguments: "):
continue
payload = line[len("arguments: ") :]
try:
args = json.loads(payload)
except json.JSONDecodeError:
continue
if isinstance(args, dict) and isinstance(args.get("project_directory"), str):
guesses.append(args["project_directory"])
return guesses
def _sanitize_text(text: str, replacements: dict[str, str]) -> str:
out = text
# Replace longer strings first so we don't partially mask paths.
for src in sorted(replacements.keys(), key=len, reverse=True):
out = out.replace(src, replacements[src])
return out
def _prompt_user_to_select(matches: list[ConversationMatch]) -> list[ConversationMatch]:
if not matches:
return []
if len(matches) == 1:
return matches
print("\nMultiple matches found:\n")
for i, m in enumerate(matches, start=1):
print(
f"{i}. composerId={m.composer_id} updatedAt={_ms_to_iso(m.last_updated_at_ms) or 'unknown'} "
f"workspaceStorage={m.workspace_storage_id}"
)
print("\nEnter a selection (e.g. '1' or '1,3') or press Enter for 'all': ", end="")
choice = input().strip()
if not choice:
return matches
indices: set[int] = set()
for part in choice.split(","):
part = part.strip()
if not part:
continue
try:
indices.add(int(part))
except ValueError:
pass
selected: list[ConversationMatch] = []
for i in sorted(indices):
if 1 <= i <= len(matches):
selected.append(matches[i - 1])
return selected or matches
def build_markdown_export(
*,
home_dir: Path,
cursor_app_support_dir: Path,
cursor_projects_dir: Path,
matches: list[ConversationMatch],
sanitize: bool,
) -> str:
generated_at = datetime.now(timezone.utc).isoformat()
lines: list[str] = []
lines.append("# Cursor chat export (macOS)")
lines.append("")
lines.append(f"Generated: `{generated_at}`")
lines.append("")
replacements: dict[str, str] = {}
if sanitize:
replacements[str(home_dir)] = "$HOME"
replacements[str(cursor_app_support_dir)] = "$CURSOR_APP_SUPPORT"
replacements[str(cursor_projects_dir)] = "$CURSOR_PROJECTS_DIR"
# Catch any non-path mentions of the username.
replacements[home_dir.name] = "<USER>"
lines.append("## Matches")
lines.append("")
for idx, m in enumerate(matches, start=1):
lines.append(f"### Match {idx}")
lines.append("")
lines.append(f"- Conversation name: `{m.conversation_name}`")
lines.append(f"- Composer ID: `{m.composer_id}`")
if m.created_at_ms is not None:
lines.append(f"- createdAt (UTC): `{_ms_to_iso(m.created_at_ms)}`")
if m.last_updated_at_ms is not None:
lines.append(f"- lastUpdatedAt (UTC): `{_ms_to_iso(m.last_updated_at_ms)}`")
lines.append(f"- workspaceStorage id: `{m.workspace_storage_id}`")
if sanitize:
lines.append(
f"- state DB: `$CURSOR_APP_SUPPORT/User/workspaceStorage/{m.workspace_storage_id}/state.vscdb`"
)
else:
lines.append(f"- state DB: `{m.state_vscdb_path}`")
if m.transcript_paths:
for p in m.transcript_paths:
if sanitize:
lines.append(
f"- transcript: `$CURSOR_PROJECTS_DIR/*/agent-transcripts/{m.composer_id}{p.suffix}`"
)
else:
lines.append(f"- transcript: `{p}`")
else:
lines.append(
"- transcript: _not found under $CURSOR_PROJECTS_DIR/*/agent-transcripts/_"
)
lines.append("")
for transcript_path in m.transcript_paths:
try:
transcript_text = transcript_path.read_text(
encoding="utf-8", errors="replace"
)
except OSError:
continue
if sanitize:
# If we can infer a project directory, replace it with $PROJECT_DIR too.
guesses = _extract_project_dir_guesses_from_transcript(transcript_text)
if guesses:
# Most common guess wins.
most_common = max(set(guesses), key=guesses.count)
replacements.setdefault(most_common, "$PROJECT_DIR")
transcript_text = _sanitize_text(transcript_text, replacements)
lines.append(f"#### Transcript: `{transcript_path.name}`")
lines.append("")
lines.append("```text")
lines.append(transcript_text.rstrip())
lines.append("```")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Export Cursor agent chat transcript(s) on macOS.\n\n"
"Finds conversations by name from Cursor workspace state DBs (state.vscdb),\n"
"then locates agent transcript files under ~/.cursor/projects/*/agent-transcripts/.\n"
"Writes a Markdown export with optional path/user sanitization for sharing."
)
)
parser.add_argument(
"--home",
type=Path,
default=Path.home(),
help="Home directory (default: current user home). Example: /Users/alice",
)
parser.add_argument(
"--conversation-name",
required=True,
help="Conversation name as shown in Cursor (Composer title).",
)
parser.add_argument(
"--match-mode",
choices=["exact", "contains"],
default="exact",
help="How to match conversation names (default: exact).",
)
parser.add_argument(
"--days",
type=int,
default=2,
help="Only include conversations updated within N days (0 disables filter). Default: 2.",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="Output .md path (default: ~/Desktop/cursor-chat-export__<name>__YYYY-MM-DD.md).",
)
parser.add_argument(
"--no-sanitize",
action="store_true",
help="Do not sanitize user/project paths in the exported markdown.",
)
parser.add_argument(
"--interactive",
action="store_true",
help="Interactively select which matches to export when multiple are found.",
)
args = parser.parse_args()
home_dir: Path = args.home.expanduser().resolve()
cursor_app_support_dir = home_dir / "Library" / "Application Support" / "Cursor"
workspace_storage_dir = cursor_app_support_dir / "User" / "workspaceStorage"
cursor_projects_dir = home_dir / ".cursor" / "projects"
if not workspace_storage_dir.exists():
raise SystemExit(
f"Could not find Cursor workspaceStorage at: {workspace_storage_dir}"
)
matches = find_conversations_by_name(
workspace_storage_dir=workspace_storage_dir,
conversation_name=args.conversation_name,
match_mode=args.match_mode,
updated_within_days=args.days,
)
if not matches:
raise SystemExit("No matching conversations found.")
# Fill transcript paths.
hydrated: list[ConversationMatch] = []
for m in matches:
hydrated.append(
ConversationMatch(
conversation_name=m.conversation_name,
composer_id=m.composer_id,
created_at_ms=m.created_at_ms,
last_updated_at_ms=m.last_updated_at_ms,
workspace_storage_id=m.workspace_storage_id,
state_vscdb_path=m.state_vscdb_path,
transcript_paths=find_transcripts_for_composer_id(
cursor_projects_dir=cursor_projects_dir,
composer_id=m.composer_id,
),
)
)
selected = _prompt_user_to_select(hydrated) if args.interactive else hydrated
if args.output is not None:
out_path = args.output.expanduser().resolve()
else:
safe_name = "".join(
ch if ch.isalnum() or ch in ("-", "_") else "-"
for ch in args.conversation_name
).strip("-")
out_path = (
home_dir
/ "Desktop"
/ f"cursor-chat-export__{safe_name}__{datetime.now().date().isoformat()}.md"
)
markdown = build_markdown_export(
home_dir=home_dir,
cursor_app_support_dir=cursor_app_support_dir,
cursor_projects_dir=cursor_projects_dir,
matches=selected,
sanitize=not args.no_sanitize,
)
out_path.write_text(markdown, encoding="utf-8")
print(f"Wrote: {out_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment