Skip to content

Commit 9754154

Browse files
authored
Merge pull request #8 from CppDigest/feature/cli-agent-sessions
Add support for Cursor CLI agent sessions (closes #7)
2 parents a48cd99 + 369e08a commit 9754154

11 files changed

Lines changed: 1931 additions & 98 deletions

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Inspired by [cursor-chat-browser](https://github.com/thomas-pedersen/cursor-chat
88

99
- Browse and search all workspaces with Cursor chat history
1010
- Support for both workspace-specific and global storage (newer Cursor versions)
11+
- **Cursor CLI agent sessions** — browses and exports sessions from `cursor agent` (stored in `~/.cursor/chats/`)
1112
- View AI chat and Composer/Agent logs
1213
- Organize chats by workspace/project
1314
- Full-text search with filters for chat/composer logs
@@ -101,7 +102,9 @@ python scripts/export.py --no-composer
101102

102103
- **Zip mode** (default): A single `cursor-export-YYYY-MM-DD.zip` file containing all Markdown files organized by date, workspace, and chat.
103104
- **File mode** (`--no-zip`): Individual Markdown files at `<out>/YYYY-MM-DD/<workspace>/chat/<timestamp>__<title>__<id>.md`, plus a `manifest.jsonl` index.
104-
- Each Markdown file includes YAML frontmatter (log ID, title, timestamps, message count, model, token usage, etc.) and the full conversation transcript.
105+
- Each Markdown file includes YAML frontmatter (log ID, title, timestamps, message count, model, token usage, tool calls, etc.) and the full conversation transcript.
106+
- IDE chats are written under `<workspace>/chat/`; Cursor CLI agent sessions are written under `<workspace>/cli/`.
107+
- If the Cursor IDE database is absent (e.g. on a machine with only `cursor agent` installed), only CLI sessions are exported — the script no longer exits with an error.
105108

106109
Export state is saved to `~/.cursor-chat-browser/export_state.json` so that `--since last` works across runs.
107110

@@ -119,6 +122,8 @@ The application automatically detects your Cursor workspace storage location:
119122

120123
To override, set the `WORKSPACE_PATH` environment variable or use the Configuration page in the web UI.
121124

125+
Cursor CLI agent sessions are read from `~/.cursor/chats/` (the default path used by the `cursor agent` CLI). Override with the `CLI_CHATS_PATH` environment variable.
126+
122127
## Project Structure
123128

124129
```
@@ -134,7 +139,9 @@ cursor-chat-browser-python/
134139
│ ├── pdf.py # /api/generate-pdf endpoint
135140
│ └── config_api.py # Config-related endpoints
136141
├── utils/ # Utility modules
137-
│ ├── workspace_path.py # Workspace path detection
142+
│ ├── workspace_path.py # Workspace path detection (IDE + CLI)
143+
│ ├── cli_chat_reader.py # Reader for Cursor CLI agent sessions (~/.cursor/chats/)
144+
│ ├── cursor_md_exporter.py # Markdown exporter for CLI agent sessions
138145
│ ├── path_helpers.py # Path normalization helpers
139146
│ ├── text_extract.py # Text extraction from bubbles
140147
│ └── tool_parser.py # Tool call parsing

api/search.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
from flask import Blueprint, current_app, jsonify, request
1414

1515
from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules
16-
from utils.workspace_path import resolve_workspace_path
16+
from utils.workspace_path import resolve_workspace_path, get_cli_chats_path
1717
from utils.path_helpers import normalize_file_path, get_workspace_folder_paths, to_epoch_ms
1818
from utils.text_extract import extract_text_from_bubble
19+
from utils.cli_chat_reader import list_cli_projects, traverse_blobs, messages_to_bubbles
1920

2021
bp = Blueprint("search", __name__)
2122

@@ -343,6 +344,88 @@ def search():
343344
except Exception:
344345
pass
345346

347+
# ---------------------------------------------------------------
348+
# Search Cursor CLI sessions (only for type=all)
349+
# ---------------------------------------------------------------
350+
if search_type == "all":
351+
try:
352+
cli_projects = list_cli_projects(get_cli_chats_path())
353+
for cp in cli_projects:
354+
ws_name = cp["workspace_name"] or cp["project_id"][:12]
355+
for session in cp["sessions"]:
356+
meta = session.get("meta", {})
357+
session_id = session["session_id"]
358+
created_ms: int = meta.get("createdAt") or int(datetime.now().timestamp() * 1000)
359+
session_name = meta.get("name") or f"Session {session_id[:8]}"
360+
361+
try:
362+
messages = traverse_blobs(session["db_path"])
363+
except Exception:
364+
continue
365+
366+
bubbles = messages_to_bubbles(messages, created_ms)
367+
if not bubbles:
368+
continue
369+
370+
# Derive title
371+
title = session_name
372+
if not title or title.startswith("New Agent"):
373+
for b in bubbles:
374+
if b["type"] == "user" and b.get("text"):
375+
first_lines = [ln for ln in b["text"].split("\n") if ln.strip()]
376+
if first_lines:
377+
title = first_lines[0][:100]
378+
break
379+
380+
bubble_texts = [b["text"] for b in bubbles if b.get("text")]
381+
tool_payloads = [
382+
tc.get("input") or tc.get("summary") or ""
383+
for b in bubbles
384+
for tc in (b.get("metadata") or {}).get("toolCalls") or []
385+
]
386+
exclusion_text = _build_exclusion_searchable(
387+
project_name=ws_name,
388+
chat_title=title,
389+
content_parts=bubble_texts + tool_payloads,
390+
)
391+
if is_excluded_by_rules(rules, exclusion_text):
392+
continue
393+
394+
has_match = False
395+
matching_text = ""
396+
397+
if title and query_lower in title.lower():
398+
has_match = True
399+
matching_text = title
400+
401+
if not has_match:
402+
for text in bubble_texts:
403+
if text and query_lower in text.lower():
404+
has_match = True
405+
idx = text.lower().find(query_lower)
406+
start = max(0, idx - 80)
407+
end = min(len(text), idx + len(query) + 120)
408+
matching_text = (
409+
("..." if start > 0 else "")
410+
+ text[start:end]
411+
+ ("..." if end < len(text) else "")
412+
)
413+
break
414+
415+
if has_match:
416+
results.append({
417+
"workspaceId": f"cli:{cp['project_id']}",
418+
"workspaceFolder": cp.get("workspace_path"),
419+
"chatId": session_id,
420+
"chatTitle": title,
421+
"timestamp": created_ms,
422+
"matchingText": matching_text,
423+
"type": "cli_agent",
424+
"source": "cli",
425+
})
426+
except Exception as e:
427+
print(f"Error searching CLI sessions: {e}")
428+
346429
# Sort by timestamp descending
347430
def _ts(r):
348431
t = r.get("timestamp", 0)

api/workspaces.py

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717

1818
from flask import Blueprint, current_app, jsonify
1919

20-
from utils.workspace_path import resolve_workspace_path
20+
from utils.workspace_path import resolve_workspace_path, get_cli_chats_path
21+
from utils.cli_chat_reader import (
22+
list_cli_projects,
23+
traverse_blobs,
24+
messages_to_bubbles,
25+
)
2126
from utils.path_helpers import (
2227
normalize_file_path,
2328
get_workspace_folder_paths,
@@ -712,6 +717,39 @@ def list_workspaces():
712717
),
713718
})
714719

720+
# --- Cursor CLI projects ---
721+
try:
722+
cli_projects = list_cli_projects(get_cli_chats_path())
723+
for cp in cli_projects:
724+
ws_name = cp["workspace_name"] or cp["project_id"][:12]
725+
if is_excluded_by_rules(rules, ws_name):
726+
continue
727+
convos = []
728+
for s in cp["sessions"]:
729+
session_name = s["meta"].get("name") or f"Session {s['session_id'][:8]}"
730+
searchable = build_searchable_text(
731+
project_name=ws_name,
732+
chat_title=session_name,
733+
)
734+
if not is_excluded_by_rules(rules, searchable):
735+
convos.append(session_name)
736+
if not convos:
737+
continue
738+
last_ms = cp["last_updated_ms"]
739+
projects.append({
740+
"id": f"cli:{cp['project_id']}",
741+
"name": ws_name,
742+
"conversationCount": len(convos),
743+
"lastModified": (
744+
datetime.fromtimestamp(last_ms / 1000, tz=timezone.utc).isoformat()
745+
if last_ms
746+
else datetime.now(tz=timezone.utc).isoformat()
747+
),
748+
"source": "cli",
749+
})
750+
except Exception as e:
751+
print(f"Failed to load CLI projects: {e}")
752+
715753
projects.sort(key=lambda p: p["lastModified"], reverse=True)
716754
return jsonify(projects)
717755

@@ -736,6 +774,26 @@ def get_workspace(workspace_id):
736774
"lastModified": datetime.now(tz=timezone.utc).isoformat(),
737775
})
738776

777+
if workspace_id.startswith("cli:"):
778+
project_id = workspace_id[4:]
779+
cli_projects = list_cli_projects(get_cli_chats_path())
780+
for cp in cli_projects:
781+
if cp["project_id"] == project_id:
782+
last_ms = cp["last_updated_ms"]
783+
return jsonify({
784+
"id": workspace_id,
785+
"name": cp["workspace_name"] or project_id[:12],
786+
"path": cp["workspace_path"],
787+
"folder": cp["workspace_path"],
788+
"lastModified": (
789+
datetime.fromtimestamp(last_ms / 1000, tz=timezone.utc).isoformat()
790+
if last_ms
791+
else datetime.now(tz=timezone.utc).isoformat()
792+
),
793+
"source": "cli",
794+
})
795+
return jsonify({"error": "CLI project not found"}), 404
796+
739797
workspace_path = resolve_workspace_path()
740798
db_path = os.path.join(workspace_path, workspace_id, "state.vscdb")
741799
wj_path = os.path.join(workspace_path, workspace_id, "workspace.json")
@@ -782,6 +840,97 @@ def get_workspace(workspace_id):
782840
from utils.tool_parser import parse_tool_call as _parse_tool_call
783841

784842

843+
def _get_cli_workspace_tabs(workspace_id: str):
844+
"""Return tabs for a Cursor CLI project (``workspace_id`` starts with ``cli:``)."""
845+
try:
846+
project_id = workspace_id[4:]
847+
cli_projects = list_cli_projects(get_cli_chats_path())
848+
project = next((cp for cp in cli_projects if cp["project_id"] == project_id), None)
849+
if project is None:
850+
return jsonify({"error": "CLI project not found"}), 404
851+
852+
rules = current_app.config.get("EXCLUSION_RULES") or []
853+
ws_name = project["workspace_name"] or project_id[:12]
854+
tabs = []
855+
856+
for session in project["sessions"]:
857+
meta = session.get("meta", {})
858+
session_id = session["session_id"]
859+
created_ms: int = meta.get("createdAt") or int(datetime.now().timestamp() * 1000)
860+
session_name = meta.get("name") or f"Session {session_id[:8]}"
861+
862+
try:
863+
messages = traverse_blobs(session["db_path"])
864+
except Exception as e:
865+
print(f"CLI: could not read session {session_id}: {e}")
866+
continue
867+
868+
bubbles = messages_to_bubbles(messages, created_ms)
869+
if not bubbles:
870+
continue
871+
872+
# Derive title from first user bubble when name is generic
873+
title = session_name
874+
if not title or title.startswith("New Agent"):
875+
for b in bubbles:
876+
if b["type"] == "user" and b.get("text"):
877+
first_lines = [l for l in b["text"].split("\n") if l.strip()]
878+
if first_lines:
879+
title = first_lines[0][:100]
880+
if len(title) == 100:
881+
title += "..."
882+
break
883+
884+
searchable = build_searchable_text(project_name=ws_name, chat_title=title)
885+
if is_excluded_by_rules(rules, searchable):
886+
continue
887+
888+
# Aggregate metadata
889+
total_tool_calls = 0
890+
tool_breakdown: dict = {}
891+
for b in bubbles:
892+
tcs = (b.get("metadata") or {}).get("toolCalls") or []
893+
total_tool_calls += len(tcs)
894+
for tc in tcs:
895+
tn = tc.get("name", "unknown")
896+
tool_breakdown[tn] = tool_breakdown.get(tn, 0) + 1
897+
898+
tab_meta: dict | None = None
899+
if total_tool_calls or tool_breakdown:
900+
tab_meta = {"totalToolCalls": total_tool_calls or None}
901+
if tool_breakdown:
902+
tab_meta["toolBreakdown"] = tool_breakdown
903+
904+
tab = {
905+
"id": session_id,
906+
"title": title,
907+
"timestamp": created_ms,
908+
"bubbles": [
909+
{
910+
"type": b["type"],
911+
"text": b.get("text", ""),
912+
"timestamp": b.get("timestamp", created_ms),
913+
**({"metadata": b["metadata"]} if b.get("metadata") else {}),
914+
}
915+
for b in bubbles
916+
],
917+
"source": "cli",
918+
}
919+
if tab_meta:
920+
tab_meta_clean = {k: v for k, v in tab_meta.items() if v is not None}
921+
if tab_meta_clean:
922+
tab["metadata"] = tab_meta_clean
923+
924+
tabs.append(tab)
925+
926+
tabs.sort(key=lambda t: t.get("timestamp") or 0, reverse=True)
927+
return jsonify({"tabs": tabs})
928+
929+
except Exception as e:
930+
print(f"Failed to get CLI workspace tabs: {e}")
931+
return jsonify({"error": "Failed to get CLI workspace tabs"}), 500
932+
933+
785934
def _extract_chat_id_from_bubble_key(key: str) -> str | None:
786935
m = re.match(r"^bubbleId:([^:]+):", key)
787936
return m.group(1) if m else None
@@ -794,6 +943,9 @@ def _extract_chat_id_from_code_block_diff_key(key: str) -> str | None:
794943

795944
@bp.route("/api/workspaces/<workspace_id>/tabs")
796945
def get_workspace_tabs(workspace_id):
946+
if workspace_id.startswith("cli:"):
947+
return _get_cli_workspace_tabs(workspace_id)
948+
797949
global_db = None
798950
try:
799951
workspace_path = resolve_workspace_path()

0 commit comments

Comments
 (0)