Created
February 1, 2026 16:38
-
-
Save thsunkid/8fc73f0acd68e7dc1850ad0a23bf1832 to your computer and use it in GitHub Desktop.
Export full Cursor agent chats on macOS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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