context-forge-cli 0.4.11__tar.gz → 0.4.12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/PKG-INFO +3 -2
  2. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/PLAN.md +1 -0
  3. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/README.md +1 -0
  4. context_forge_cli-0.4.12/contextforge/adapters/cursor.py +341 -0
  5. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/registry.py +2 -0
  6. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/session_detail.py +2 -0
  7. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/session_table.py +5 -0
  8. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/stats_panel.py +3 -1
  9. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/status_bar.py +2 -1
  10. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/tokens_panel.py +1 -0
  11. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/transfer_panel.py +1 -0
  12. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/pyproject.toml +1 -1
  13. context_forge_cli-0.4.12/tests/adapters/test_cursor.py +308 -0
  14. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/uv.lock +1 -1
  15. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/.contextforge.toml +0 -0
  16. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/.github/workflows/publish.yml +0 -0
  17. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/.gitignore +0 -0
  18. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/.python-version +0 -0
  19. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/AGENTS.md +0 -0
  20. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/__init__.py +0 -0
  21. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/__init__.py +0 -0
  22. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/altimate_code.py +0 -0
  23. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/antigravity.py +0 -0
  24. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/base.py +0 -0
  25. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/claude_code.py +0 -0
  26. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/claude_desktop.py +0 -0
  27. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/codex.py +0 -0
  28. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/gemini.py +0 -0
  29. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/cli.py +0 -0
  30. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/__init__.py +0 -0
  31. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/analytics.py +0 -0
  32. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/compactor.py +0 -0
  33. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/db.py +0 -0
  34. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/injector.py +0 -0
  35. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/scanner.py +0 -0
  36. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/summarizer.py +0 -0
  37. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/token_analyzer.py +0 -0
  38. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/mcp_server.py +0 -0
  39. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/models/__init__.py +0 -0
  40. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/models/config.py +0 -0
  41. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/models/session.py +0 -0
  42. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/__init__.py +0 -0
  43. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/app.py +0 -0
  44. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/clipboard.py +0 -0
  45. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/styles.tcss +0 -0
  46. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/__init__.py +0 -0
  47. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/utils/__init__.py +0 -0
  48. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/utils/display.py +0 -0
  49. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/utils/tokens.py +0 -0
  50. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/__init__.py +0 -0
  51. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/adapters/__init__.py +0 -0
  52. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/adapters/test_antigravity.py +0 -0
  53. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/adapters/test_claude_code.py +0 -0
  54. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/conftest.py +0 -0
  55. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/core/__init__.py +0 -0
  56. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/core/test_compactor.py +0 -0
  57. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/core/test_db.py +0 -0
  58. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/core/test_injector.py +0 -0
  59. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/core/test_summarizer.py +0 -0
  60. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/core/test_token_analyzer.py +0 -0
  61. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/fixtures/claude_session_sample.jsonl +0 -0
  62. {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/test_mcp_server.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: context-forge-cli
3
- Version: 0.4.11
3
+ Version: 0.4.12
4
4
  Summary: Session manager and context bridge for agentic CLI tools
5
5
  Project-URL: Homepage, https://github.com/emmver/contextforge
6
6
  Project-URL: Repository, https://github.com/emmver/contextforge
@@ -171,6 +171,7 @@ The method is chosen automatically based on token count and whether a target ses
171
171
  | Gemini | `~/.gemini/tmp/<project_hash>/chats/` JSON | New session / `/resume` |
172
172
  | Antigravity | `~/.gemini/antigravity-cli/history.jsonl` | `-i` / `--conversation` |
173
173
  | altimate-code | `~/.local/share/altimate-code/opencode.db` | `run -s` / `import` |
174
+ | Cursor | `~/.config/Cursor/User/globalStorage/state.vscdb` | `cursor-agent -p` / `--resume` |
174
175
 
175
176
  ## Session Summaries
176
177
 
@@ -88,6 +88,7 @@ Agents and contributors: update this file as you work.
88
88
  - [ ] GitHub Actions CI: `uv run pytest --cov=contextforge`
89
89
  - [ ] `open-claw` adapter (if/when CLI interface is documented)
90
90
  - [x] Antigravity adapter (`adapters/antigravity.py`)
91
+ - [x] Cursor adapter (`adapters/cursor.py`)
91
92
  - [ ] Session search: `cf ls --search <query>`
92
93
  - [ ] Session archiving: `cf archive <id>`
93
94
 
@@ -142,6 +142,7 @@ The method is chosen automatically based on token count and whether a target ses
142
142
  | Gemini | `~/.gemini/tmp/<project_hash>/chats/` JSON | New session / `/resume` |
143
143
  | Antigravity | `~/.gemini/antigravity-cli/history.jsonl` | `-i` / `--conversation` |
144
144
  | altimate-code | `~/.local/share/altimate-code/opencode.db` | `run -s` / `import` |
145
+ | Cursor | `~/.config/Cursor/User/globalStorage/state.vscdb` | `cursor-agent -p` / `--resume` |
145
146
 
146
147
  ## Session Summaries
147
148
 
@@ -0,0 +1,341 @@
1
+ """Adapter for Cursor IDE chat/composer sessions.
2
+
3
+ Cursor stores conversation data in SQLite ("state.vscdb") files under its
4
+ user data directory:
5
+
6
+ - ``globalStorage/state.vscdb`` — authoritative store for full conversation
7
+ content, shared across all workspaces. Conversations ("composers") live
8
+ in the ``cursorDiskKV`` table as ``composerData:<composerId>`` rows, and
9
+ individual chat turns ("bubbles") as ``bubbleId:<composerId>:<bubbleId>``
10
+ rows.
11
+ - ``workspaceStorage/<hash>/state.vscdb`` + ``workspace.json`` — per-workspace
12
+ metadata used here only to recover the project cwd for a composer (older
13
+ Cursor versions also kept a workspace-local composer index here).
14
+
15
+ Cursor's storage schema has drifted across versions (schema version is
16
+ tracked via a ``_v`` field on composer objects), so this adapter reads
17
+ fields defensively with ``.get()`` and falls back to scanning bubbles
18
+ directly when the ``fullConversationHeadersOnly`` index is absent.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import shlex
25
+ import sqlite3
26
+ import sys
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from urllib.parse import unquote, urlparse
30
+
31
+ import tiktoken
32
+
33
+ from contextforge.adapters.base import ToolAdapter
34
+ from contextforge.models.session import Message, Session
35
+
36
+
37
+ def _cursor_user_dir() -> Path:
38
+ """Return Cursor's per-OS "User" data directory."""
39
+ if sys.platform == "darwin":
40
+ return Path.home() / "Library" / "Application Support" / "Cursor" / "User"
41
+ if sys.platform.startswith("win"):
42
+ appdata = os.environ.get("APPDATA")
43
+ base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
44
+ return base / "Cursor" / "User"
45
+ return Path.home() / ".config" / "Cursor" / "User"
46
+
47
+
48
+ _CURSOR_USER_DIR = _cursor_user_dir()
49
+ _GLOBAL_DB = _CURSOR_USER_DIR / "globalStorage" / "state.vscdb"
50
+ _WORKSPACE_STORAGE_DIR = _CURSOR_USER_DIR / "workspaceStorage"
51
+
52
+
53
+ def _count_tokens(text: str) -> int:
54
+ """Count tokens in text using tiktoken for Claude models."""
55
+ try:
56
+ enc = tiktoken.encoding_for_model("claude-3-5-sonnet-20241022")
57
+ return len(enc.encode(text))
58
+ except Exception:
59
+ # Fallback: rough estimate (~4 chars per token)
60
+ return len(text) // 4
61
+
62
+
63
+ def _ms_to_dt(ts) -> datetime | None:
64
+ """Convert a millisecond timestamp to UTC datetime, or None if unusable."""
65
+ if not ts:
66
+ return None
67
+ try:
68
+ return datetime.fromtimestamp(int(ts) / 1000, tz=timezone.utc)
69
+ except (TypeError, ValueError, OSError, OverflowError):
70
+ return None
71
+
72
+
73
+ def _uri_to_path(uri: str) -> str | None:
74
+ """Convert a workspace.json ``file://`` URI to a plain filesystem path."""
75
+ if not uri:
76
+ return None
77
+ try:
78
+ parsed = urlparse(uri)
79
+ except ValueError:
80
+ return None
81
+ if parsed.scheme == "file":
82
+ return unquote(parsed.path)
83
+ return uri
84
+
85
+
86
+ def _load_json_blob(value) -> dict | list | None:
87
+ """Decode a BLOB/TEXT value from an ItemTable/cursorDiskKV row into JSON."""
88
+ if value is None:
89
+ return None
90
+ if isinstance(value, bytes):
91
+ try:
92
+ value = value.decode("utf-8")
93
+ except UnicodeDecodeError:
94
+ return None
95
+ try:
96
+ return json.loads(value)
97
+ except (json.JSONDecodeError, TypeError):
98
+ return None
99
+
100
+
101
+ class CursorAdapter(ToolAdapter):
102
+ tool_name = "cursor"
103
+ default_paths = [_GLOBAL_DB]
104
+
105
+ def _workspace_paths(self) -> dict[str, str]:
106
+ """Map workspaceStorage hash-dir names to their project path."""
107
+ mapping: dict[str, str] = {}
108
+ if not _WORKSPACE_STORAGE_DIR.exists():
109
+ return mapping
110
+ for ws_dir in _WORKSPACE_STORAGE_DIR.iterdir():
111
+ if not ws_dir.is_dir():
112
+ continue
113
+ meta_file = ws_dir / "workspace.json"
114
+ if not meta_file.exists():
115
+ continue
116
+ try:
117
+ with meta_file.open() as f:
118
+ meta = json.load(f)
119
+ except (json.JSONDecodeError, OSError):
120
+ continue
121
+ uri = meta.get("folder") or meta.get("workspace")
122
+ path = _uri_to_path(uri) if isinstance(uri, str) else None
123
+ if path:
124
+ mapping[ws_dir.name] = path
125
+ return mapping
126
+
127
+ def _composer_workspace_map(self) -> dict[str, str]:
128
+ """Map composerId -> project cwd via each workspace's local state.vscdb.
129
+
130
+ Only populated on Cursor <=2.6, which kept a per-workspace composer
131
+ index (``composer.composerData`` in ``ItemTable``) before the 3.0+
132
+ global-index refactor. Newer versions simply won't match here, and
133
+ callers should treat a missing entry as "cwd unknown".
134
+ """
135
+ composer_to_ws: dict[str, str] = {}
136
+ if not _WORKSPACE_STORAGE_DIR.exists():
137
+ return composer_to_ws
138
+ ws_paths = self._workspace_paths()
139
+ for ws_dir in _WORKSPACE_STORAGE_DIR.iterdir():
140
+ cwd = ws_paths.get(ws_dir.name)
141
+ db_path = ws_dir / "state.vscdb"
142
+ if not cwd or not db_path.exists():
143
+ continue
144
+ try:
145
+ conn = sqlite3.connect(str(db_path))
146
+ cur = conn.cursor()
147
+ cur.execute(
148
+ "SELECT value FROM ItemTable WHERE key = 'composer.composerData'"
149
+ )
150
+ row = cur.fetchone()
151
+ conn.close()
152
+ except sqlite3.Error:
153
+ continue
154
+ data = _load_json_blob(row[0]) if row else None
155
+ if not isinstance(data, dict):
156
+ continue
157
+ for entry in data.get("allComposers") or []:
158
+ cid = entry.get("composerId") if isinstance(entry, dict) else None
159
+ if cid:
160
+ composer_to_ws[cid] = cwd
161
+ return composer_to_ws
162
+
163
+ def discover_sessions(self) -> list[Session]:
164
+ if not _GLOBAL_DB.exists():
165
+ return []
166
+
167
+ try:
168
+ conn = sqlite3.connect(str(_GLOBAL_DB))
169
+ conn.row_factory = sqlite3.Row
170
+ cur = conn.cursor()
171
+ cur.execute(
172
+ "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'"
173
+ )
174
+ rows = cur.fetchall()
175
+ except sqlite3.Error:
176
+ return []
177
+
178
+ composer_to_ws = self._composer_workspace_map()
179
+
180
+ sessions: list[Session] = []
181
+ for row in rows:
182
+ key = row["key"]
183
+ composer_id = key.split(":", 1)[1] if ":" in key else key
184
+ data = _load_json_blob(row["value"])
185
+ if not isinstance(data, dict):
186
+ continue
187
+
188
+ headers = data.get("fullConversationHeadersOnly") or []
189
+ created_at = _ms_to_dt(data.get("createdAt"))
190
+ updated_at = _ms_to_dt(data.get("lastUpdatedAt")) or created_at
191
+
192
+ title = data.get("name") or None
193
+ if not title:
194
+ title = self._first_bubble_text(cur, composer_id, headers)
195
+
196
+ if created_at is None and updated_at is None and not headers and not title:
197
+ # Nothing usable — likely an empty/never-started composer.
198
+ continue
199
+
200
+ now = datetime.now(timezone.utc)
201
+ sessions.append(
202
+ Session(
203
+ id=composer_id,
204
+ tool=self.tool_name,
205
+ title=title[:200] if title else None,
206
+ cwd=composer_to_ws.get(composer_id),
207
+ created_at=created_at or now,
208
+ updated_at=updated_at or created_at or now,
209
+ raw_path=str(_GLOBAL_DB),
210
+ status="unknown",
211
+ )
212
+ )
213
+
214
+ conn.close()
215
+ return sessions
216
+
217
+ def _first_bubble_text(
218
+ self, cur: sqlite3.Cursor, composer_id: str, headers: list
219
+ ) -> str | None:
220
+ """Best-effort title fallback: text of the first bubble in the conversation."""
221
+ first_id = None
222
+ for h in headers:
223
+ if isinstance(h, dict) and h.get("bubbleId"):
224
+ first_id = h["bubbleId"]
225
+ break
226
+ if first_id is None:
227
+ return None
228
+ try:
229
+ cur.execute(
230
+ "SELECT value FROM cursorDiskKV WHERE key = ?",
231
+ (f"bubbleId:{composer_id}:{first_id}",),
232
+ )
233
+ row = cur.fetchone()
234
+ except sqlite3.Error:
235
+ return None
236
+ bubble = _load_json_blob(row[0]) if row else None
237
+ if isinstance(bubble, dict):
238
+ text = bubble.get("text")
239
+ if isinstance(text, str) and text.strip():
240
+ return text.strip()
241
+ return None
242
+
243
+ def load_messages(self, session_id: str) -> list[Message]:
244
+ if not _GLOBAL_DB.exists():
245
+ return []
246
+
247
+ try:
248
+ conn = sqlite3.connect(str(_GLOBAL_DB))
249
+ cur = conn.cursor()
250
+ cur.execute(
251
+ "SELECT value FROM cursorDiskKV WHERE key = ?",
252
+ (f"composerData:{session_id}",),
253
+ )
254
+ row = cur.fetchone()
255
+ composer = _load_json_blob(row[0]) if row else None
256
+ headers = (
257
+ composer.get("fullConversationHeadersOnly")
258
+ if isinstance(composer, dict)
259
+ else None
260
+ )
261
+
262
+ if headers:
263
+ ordered_ids = [
264
+ h.get("bubbleId")
265
+ for h in headers
266
+ if isinstance(h, dict) and h.get("bubbleId")
267
+ ]
268
+ keys = [f"bubbleId:{session_id}:{bid}" for bid in ordered_ids]
269
+ value_by_key: dict[str, object] = {}
270
+ if keys:
271
+ placeholders = ",".join("?" for _ in keys)
272
+ cur.execute(
273
+ f"SELECT key, value FROM cursorDiskKV WHERE key IN ({placeholders})",
274
+ keys,
275
+ )
276
+ value_by_key = dict(cur.fetchall())
277
+ ordered_bubbles = []
278
+ for key in keys:
279
+ parsed = _load_json_blob(value_by_key.get(key))
280
+ if isinstance(parsed, dict):
281
+ ordered_bubbles.append(parsed)
282
+ else:
283
+ # No header index (older/partial data) — scan bubbles directly
284
+ # and fall back to sorting by creation time.
285
+ cur.execute(
286
+ "SELECT value FROM cursorDiskKV WHERE key LIKE ?",
287
+ (f"bubbleId:{session_id}:%",),
288
+ )
289
+ ordered_bubbles = sorted(
290
+ (
291
+ parsed
292
+ for (v,) in cur.fetchall()
293
+ if isinstance(parsed := _load_json_blob(v), dict)
294
+ ),
295
+ key=lambda b: b.get("createdAt") or 0,
296
+ )
297
+
298
+ conn.close()
299
+ except sqlite3.Error:
300
+ return []
301
+
302
+ messages: list[Message] = []
303
+ for bubble in ordered_bubbles:
304
+ text = bubble.get("text")
305
+ if not isinstance(text, str) or not text.strip():
306
+ continue
307
+ # type: 1 = user, 2 = assistant.
308
+ role = "user" if bubble.get("type") == 1 else "assistant"
309
+ messages.append(
310
+ Message(
311
+ role=role,
312
+ content=text,
313
+ timestamp=_ms_to_dt(bubble.get("createdAt")),
314
+ token_count=_count_tokens(text),
315
+ )
316
+ )
317
+
318
+ return messages
319
+
320
+ def build_inject_command(
321
+ self,
322
+ context: str,
323
+ target_session_id: str | None = None,
324
+ cwd: str | None = None,
325
+ method: str = "system_prompt",
326
+ ) -> str:
327
+ """Build a shell command to start/resume a Cursor CLI (``cursor-agent``) session.
328
+
329
+ ``cursor-agent -p <prompt>`` runs a one-shot non-interactive turn;
330
+ ``cursor-agent --resume <id> -p <prompt>`` continues an existing chat.
331
+ """
332
+ safe_ctx = shlex.quote(context)
333
+
334
+ if method == "resume" and target_session_id:
335
+ cmd = f"cursor-agent --resume {shlex.quote(target_session_id)} -p {safe_ctx}"
336
+ else:
337
+ cmd = f"cursor-agent -p {safe_ctx}"
338
+
339
+ if cwd:
340
+ cmd = f"cd {shlex.quote(cwd)} && {cmd}"
341
+ return cmd
@@ -7,6 +7,7 @@ from contextforge.adapters.claude_code import ClaudeCodeAdapter
7
7
  from contextforge.adapters.claude_desktop import ClaudeDesktopAdapter
8
8
  from contextforge.adapters.codex import CodexAdapter
9
9
  from contextforge.adapters.antigravity import AntigravityAdapter
10
+ from contextforge.adapters.cursor import CursorAdapter
10
11
  from contextforge.adapters.gemini import GeminiAdapter
11
12
 
12
13
  ADAPTERS: dict[str, type[ToolAdapter]] = {
@@ -16,6 +17,7 @@ ADAPTERS: dict[str, type[ToolAdapter]] = {
16
17
  "altimate_code": AltimateCodeAdapter,
17
18
  "gemini": GeminiAdapter,
18
19
  "antigravity": AntigravityAdapter,
20
+ "cursor": CursorAdapter,
19
21
  }
20
22
 
21
23
 
@@ -17,6 +17,7 @@ TOOL_COLORS = {
17
17
  "claude_desktop": "yellow",
18
18
  "gemini": "blue",
19
19
  "antigravity": "white",
20
+ "cursor": "bright_white",
20
21
  }
21
22
  TOOL_LABELS = {
22
23
  "claude_code": "◆ Claude Code",
@@ -25,6 +26,7 @@ TOOL_LABELS = {
25
26
  "claude_desktop": "◇ Claude Desktop",
26
27
  "gemini": "✦ Gemini",
27
28
  "antigravity": "◉ Antigravity",
29
+ "cursor": "▲ Cursor",
28
30
  }
29
31
 
30
32
 
@@ -20,6 +20,7 @@ TOOL_ICON = {
20
20
  "claude_desktop": "◇", # Claude Desktop — open diamond
21
21
  "gemini": "✦", # Google Gemini — four-pointed star
22
22
  "antigravity": "◉", # Antigravity — circle
23
+ "cursor": "▲", # Cursor — triangle
23
24
  }
24
25
 
25
26
  TOOL_MARKUP = {
@@ -29,6 +30,7 @@ TOOL_MARKUP = {
29
30
  "claude_desktop": "[yellow]◇ Desktop[/yellow]",
30
31
  "gemini": "[blue]✦ Gemini[/blue]",
31
32
  "antigravity": "[white]◉ Agy[/white]",
33
+ "cursor": "[bright_white]▲ Cursor[/bright_white]",
32
34
  }
33
35
 
34
36
  TOOL_DISPLAY = {
@@ -38,6 +40,7 @@ TOOL_DISPLAY = {
38
40
  "claude_desktop": "◇ Desktop",
39
41
  "gemini": "✦ Gemini",
40
42
  "antigravity": "◉ Agy",
43
+ "cursor": "▲ Cursor",
41
44
  }
42
45
 
43
46
  _FTOOL_MAP = {
@@ -47,6 +50,7 @@ _FTOOL_MAP = {
47
50
  "ftool-gem": "gemini",
48
51
  "ftool-alt": "altimate_code",
49
52
  "ftool-agy": "antigravity",
53
+ "ftool-cur": "cursor",
50
54
  }
51
55
 
52
56
 
@@ -164,6 +168,7 @@ class SessionTable(Widget):
164
168
  yield Button("✦ Gem", id="ftool-gem", classes="filter-tool-btn")
165
169
  yield Button("⚡ Alt", id="ftool-alt", classes="filter-tool-btn")
166
170
  yield Button("◉ Agy", id="ftool-agy", classes="filter-tool-btn")
171
+ yield Button("▲ Cursor", id="ftool-cur", classes="filter-tool-btn")
167
172
  yield DataTable(cursor_type="row", zebra_stripes=True, id="sessions-datatable")
168
173
 
169
174
  def on_mount(self) -> None:
@@ -22,6 +22,7 @@ TOOL_COLORS = {
22
22
  "claude_desktop": "yellow",
23
23
  "gemini": "blue",
24
24
  "antigravity": "white",
25
+ "cursor": "bright_white",
25
26
  }
26
27
  TOOL_LABELS = {
27
28
  "claude_code": "◆ Claude Code",
@@ -30,9 +31,10 @@ TOOL_LABELS = {
30
31
  "claude_desktop": "◇ Desktop",
31
32
  "gemini": "✦ Gemini",
32
33
  "antigravity": "◉ Antigravity",
34
+ "cursor": "▲ Cursor",
33
35
  }
34
36
 
35
- _TOOL_ORDER = ["claude_code", "codex", "gemini", "altimate_code", "claude_desktop", "antigravity"]
37
+ _TOOL_ORDER = ["claude_code", "codex", "gemini", "altimate_code", "claude_desktop", "antigravity", "cursor"]
36
38
 
37
39
  WINDOW_LABELS = {
38
40
  "7d": "7 days",
@@ -70,6 +70,7 @@ class StatusBar(Widget):
70
70
  "claude_desktop": "◇ Desktop",
71
71
  "gemini": "✦ Gemini",
72
72
  "antigravity": "◉ Agy",
73
+ "cursor": "▲ Cursor",
73
74
  }
74
75
  for tool, label in labels.items():
75
76
  n = counts.get(tool, 0)
@@ -96,7 +97,7 @@ class StatusBar(Widget):
96
97
  if text:
97
98
  parts.append(f'"{text}"')
98
99
  if tool:
99
- short = {"claude_code": "◆ CC", "codex": "⬡ Codex", "altimate_code": "⚡ Alt", "claude_desktop": "◇ Desktop", "gemini": "✦ Gemini", "antigravity": "◉ Agy"}.get(tool, tool)
100
+ short = {"claude_code": "◆ CC", "codex": "⬡ Codex", "altimate_code": "⚡ Alt", "claude_desktop": "◇ Desktop", "gemini": "✦ Gemini", "antigravity": "◉ Agy", "cursor": "▲ Cursor"}.get(tool, tool)
100
101
  parts.append(short)
101
102
 
102
103
  self.query_one("#status-filter", Static).update(
@@ -25,6 +25,7 @@ _TOOL_LABELS = {
25
25
  "claude_desktop": "◇ Claude Desktop",
26
26
  "gemini": "✦ Gemini",
27
27
  "antigravity": "◉ Antigravity",
28
+ "cursor": "▲ Cursor",
28
29
  }
29
30
 
30
31
 
@@ -13,6 +13,7 @@ TOOLS = [
13
13
  ("codex", "Codex"),
14
14
  ("altimate_code", "altimate-code"),
15
15
  ("antigravity", "Antigravity"),
16
+ ("cursor", "Cursor"),
16
17
  ]
17
18
 
18
19
  STRATEGIES = [
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "context-forge-cli"
3
- version = "0.4.11"
3
+ version = "0.4.12"
4
4
  description = "Session manager and context bridge for agentic CLI tools"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -0,0 +1,308 @@
1
+ """Tests for the Cursor adapter using synthetic SQLite fixtures."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sqlite3
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from contextforge.adapters.cursor import CursorAdapter, _ms_to_dt, _uri_to_path, _count_tokens
12
+
13
+
14
+ def _make_global_db(path: Path, rows: dict[str, dict]) -> None:
15
+ path.parent.mkdir(parents=True, exist_ok=True)
16
+ conn = sqlite3.connect(str(path))
17
+ conn.execute("CREATE TABLE cursorDiskKV (key TEXT UNIQUE, value BLOB)")
18
+ for key, value in rows.items():
19
+ conn.execute(
20
+ "INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)",
21
+ (key, json.dumps(value)),
22
+ )
23
+ conn.commit()
24
+ conn.close()
25
+
26
+
27
+ def test_ms_to_dt():
28
+ dt = _ms_to_dt(1700000000000)
29
+ assert dt is not None
30
+ assert dt.tzinfo is not None
31
+
32
+
33
+ def test_ms_to_dt_none():
34
+ assert _ms_to_dt(None) is None
35
+ assert _ms_to_dt(0) is None
36
+
37
+
38
+ def test_uri_to_path_file_uri():
39
+ assert _uri_to_path("file:///Users/alice/myproject") == "/Users/alice/myproject"
40
+
41
+
42
+ def test_uri_to_path_passthrough():
43
+ assert _uri_to_path("/plain/path") == "/plain/path"
44
+
45
+
46
+ def test_count_tokens():
47
+ n = _count_tokens("hello world")
48
+ assert isinstance(n, int) and n > 0
49
+
50
+
51
+ def test_discover_sessions_with_headers():
52
+ adapter = CursorAdapter()
53
+
54
+ with tempfile.TemporaryDirectory() as tmpdir:
55
+ global_db = Path(tmpdir) / "User" / "globalStorage" / "state.vscdb"
56
+ composer_id = "comp-1"
57
+ _make_global_db(
58
+ global_db,
59
+ {
60
+ f"composerData:{composer_id}": {
61
+ "composerId": composer_id,
62
+ "name": "Fix the bug",
63
+ "createdAt": 1700000000000,
64
+ "lastUpdatedAt": 1700000100000,
65
+ "fullConversationHeadersOnly": [
66
+ {"bubbleId": "b1", "type": 1},
67
+ {"bubbleId": "b2", "type": 2},
68
+ ],
69
+ },
70
+ f"bubbleId:{composer_id}:b1": {"text": "hi", "type": 1, "createdAt": 1700000000000},
71
+ f"bubbleId:{composer_id}:b2": {"text": "hello", "type": 2, "createdAt": 1700000050000},
72
+ },
73
+ )
74
+
75
+ import contextforge.adapters.cursor as mod
76
+
77
+ original_global = mod._GLOBAL_DB
78
+ original_ws = mod._WORKSPACE_STORAGE_DIR
79
+ mod._GLOBAL_DB = global_db
80
+ mod._WORKSPACE_STORAGE_DIR = Path(tmpdir) / "User" / "workspaceStorage"
81
+ try:
82
+ sessions = adapter.discover_sessions()
83
+ finally:
84
+ mod._GLOBAL_DB = original_global
85
+ mod._WORKSPACE_STORAGE_DIR = original_ws
86
+
87
+ assert len(sessions) == 1
88
+ session = sessions[0]
89
+ assert session.id == composer_id
90
+ assert session.title == "Fix the bug"
91
+ assert session.tool == "cursor"
92
+ assert session.created_at == _ms_to_dt(1700000000000)
93
+ assert session.updated_at == _ms_to_dt(1700000100000)
94
+
95
+
96
+ def test_discover_sessions_title_fallback_to_first_bubble():
97
+ adapter = CursorAdapter()
98
+
99
+ with tempfile.TemporaryDirectory() as tmpdir:
100
+ global_db = Path(tmpdir) / "User" / "globalStorage" / "state.vscdb"
101
+ composer_id = "comp-2"
102
+ _make_global_db(
103
+ global_db,
104
+ {
105
+ f"composerData:{composer_id}": {
106
+ "composerId": composer_id,
107
+ "createdAt": 1700000000000,
108
+ "fullConversationHeadersOnly": [
109
+ {"bubbleId": "b1", "type": 1},
110
+ ],
111
+ },
112
+ f"bubbleId:{composer_id}:b1": {
113
+ "text": "Please refactor this function",
114
+ "type": 1,
115
+ "createdAt": 1700000000000,
116
+ },
117
+ },
118
+ )
119
+
120
+ import contextforge.adapters.cursor as mod
121
+
122
+ original_global = mod._GLOBAL_DB
123
+ original_ws = mod._WORKSPACE_STORAGE_DIR
124
+ mod._GLOBAL_DB = global_db
125
+ mod._WORKSPACE_STORAGE_DIR = Path(tmpdir) / "User" / "workspaceStorage"
126
+ try:
127
+ sessions = adapter.discover_sessions()
128
+ finally:
129
+ mod._GLOBAL_DB = original_global
130
+ mod._WORKSPACE_STORAGE_DIR = original_ws
131
+
132
+ assert len(sessions) == 1
133
+ assert sessions[0].title == "Please refactor this function"
134
+
135
+
136
+ def test_discover_sessions_resolves_cwd_from_workspace():
137
+ adapter = CursorAdapter()
138
+
139
+ with tempfile.TemporaryDirectory() as tmpdir:
140
+ user_dir = Path(tmpdir) / "User"
141
+ global_db = user_dir / "globalStorage" / "state.vscdb"
142
+ composer_id = "comp-3"
143
+ _make_global_db(
144
+ global_db,
145
+ {
146
+ f"composerData:{composer_id}": {
147
+ "composerId": composer_id,
148
+ "name": "Session with cwd",
149
+ "createdAt": 1700000000000,
150
+ "fullConversationHeadersOnly": [],
151
+ },
152
+ },
153
+ )
154
+
155
+ ws_dir = user_dir / "workspaceStorage" / "abc123"
156
+ ws_dir.mkdir(parents=True)
157
+ (ws_dir / "workspace.json").write_text(
158
+ json.dumps({"folder": "file:///home/alice/project"})
159
+ )
160
+ ws_state_db = ws_dir / "state.vscdb"
161
+ conn = sqlite3.connect(str(ws_state_db))
162
+ conn.execute("CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)")
163
+ conn.execute(
164
+ "INSERT INTO ItemTable (key, value) VALUES (?, ?)",
165
+ (
166
+ "composer.composerData",
167
+ json.dumps({"allComposers": [{"composerId": composer_id}]}),
168
+ ),
169
+ )
170
+ conn.commit()
171
+ conn.close()
172
+
173
+ import contextforge.adapters.cursor as mod
174
+
175
+ original_global = mod._GLOBAL_DB
176
+ original_ws = mod._WORKSPACE_STORAGE_DIR
177
+ mod._GLOBAL_DB = global_db
178
+ mod._WORKSPACE_STORAGE_DIR = user_dir / "workspaceStorage"
179
+ try:
180
+ sessions = adapter.discover_sessions()
181
+ finally:
182
+ mod._GLOBAL_DB = original_global
183
+ mod._WORKSPACE_STORAGE_DIR = original_ws
184
+
185
+ assert len(sessions) == 1
186
+ assert sessions[0].cwd == "/home/alice/project"
187
+
188
+
189
+ def test_load_messages_with_headers():
190
+ adapter = CursorAdapter()
191
+
192
+ with tempfile.TemporaryDirectory() as tmpdir:
193
+ global_db = Path(tmpdir) / "User" / "globalStorage" / "state.vscdb"
194
+ composer_id = "comp-4"
195
+ _make_global_db(
196
+ global_db,
197
+ {
198
+ f"composerData:{composer_id}": {
199
+ "composerId": composer_id,
200
+ "fullConversationHeadersOnly": [
201
+ {"bubbleId": "b1", "type": 1},
202
+ {"bubbleId": "b2", "type": 2},
203
+ ],
204
+ },
205
+ f"bubbleId:{composer_id}:b1": {
206
+ "text": "What does this do?",
207
+ "type": 1,
208
+ "createdAt": 1700000000000,
209
+ },
210
+ f"bubbleId:{composer_id}:b2": {
211
+ "text": "It does X.",
212
+ "type": 2,
213
+ "createdAt": 1700000010000,
214
+ },
215
+ },
216
+ )
217
+
218
+ import contextforge.adapters.cursor as mod
219
+
220
+ original_global = mod._GLOBAL_DB
221
+ mod._GLOBAL_DB = global_db
222
+ try:
223
+ messages = adapter.load_messages(composer_id)
224
+ finally:
225
+ mod._GLOBAL_DB = original_global
226
+
227
+ assert len(messages) == 2
228
+ assert messages[0].role == "user"
229
+ assert messages[0].content == "What does this do?"
230
+ assert messages[1].role == "assistant"
231
+ assert messages[1].content == "It does X."
232
+
233
+
234
+ def test_load_messages_without_headers_sorts_by_created_at():
235
+ adapter = CursorAdapter()
236
+
237
+ with tempfile.TemporaryDirectory() as tmpdir:
238
+ global_db = Path(tmpdir) / "User" / "globalStorage" / "state.vscdb"
239
+ composer_id = "comp-5"
240
+ _make_global_db(
241
+ global_db,
242
+ {
243
+ f"composerData:{composer_id}": {"composerId": composer_id},
244
+ f"bubbleId:{composer_id}:b2": {
245
+ "text": "second",
246
+ "type": 2,
247
+ "createdAt": 1700000020000,
248
+ },
249
+ f"bubbleId:{composer_id}:b1": {
250
+ "text": "first",
251
+ "type": 1,
252
+ "createdAt": 1700000010000,
253
+ },
254
+ },
255
+ )
256
+
257
+ import contextforge.adapters.cursor as mod
258
+
259
+ original_global = mod._GLOBAL_DB
260
+ mod._GLOBAL_DB = global_db
261
+ try:
262
+ messages = adapter.load_messages(composer_id)
263
+ finally:
264
+ mod._GLOBAL_DB = original_global
265
+
266
+ assert [m.content for m in messages] == ["first", "second"]
267
+
268
+
269
+ def test_load_messages_missing_session_returns_empty():
270
+ adapter = CursorAdapter()
271
+
272
+ with tempfile.TemporaryDirectory() as tmpdir:
273
+ global_db = Path(tmpdir) / "User" / "globalStorage" / "state.vscdb"
274
+ _make_global_db(global_db, {})
275
+
276
+ import contextforge.adapters.cursor as mod
277
+
278
+ original_global = mod._GLOBAL_DB
279
+ mod._GLOBAL_DB = global_db
280
+ try:
281
+ messages = adapter.load_messages("does-not-exist")
282
+ finally:
283
+ mod._GLOBAL_DB = original_global
284
+
285
+ assert messages == []
286
+
287
+
288
+ def test_build_inject_command_new_session():
289
+ adapter = CursorAdapter()
290
+ cmd = adapter.build_inject_command("some context", method="system_prompt")
291
+ assert "cursor-agent" in cmd
292
+ assert "-p" in cmd
293
+ assert "some context" in cmd
294
+
295
+
296
+ def test_build_inject_command_resume():
297
+ adapter = CursorAdapter()
298
+ cmd = adapter.build_inject_command("ctx", target_session_id="abc-123", method="resume")
299
+ assert "cursor-agent" in cmd
300
+ assert "--resume" in cmd
301
+ assert "abc-123" in cmd
302
+
303
+
304
+ def test_build_inject_command_with_cwd():
305
+ adapter = CursorAdapter()
306
+ cmd = adapter.build_inject_command("ctx", cwd="/tmp/proj")
307
+ assert cmd.startswith("cd /tmp/proj")
308
+ assert "cursor-agent" in cmd
@@ -206,7 +206,7 @@ wheels = [
206
206
 
207
207
  [[package]]
208
208
  name = "context-forge-cli"
209
- version = "0.4.10"
209
+ version = "0.4.12"
210
210
  source = { editable = "." }
211
211
  dependencies = [
212
212
  { name = "anthropic" },