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.
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/PKG-INFO +3 -2
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/PLAN.md +1 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/README.md +1 -0
- context_forge_cli-0.4.12/contextforge/adapters/cursor.py +341 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/registry.py +2 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/session_detail.py +2 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/session_table.py +5 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/stats_panel.py +3 -1
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/status_bar.py +2 -1
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/tokens_panel.py +1 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/transfer_panel.py +1 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/pyproject.toml +1 -1
- context_forge_cli-0.4.12/tests/adapters/test_cursor.py +308 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/uv.lock +1 -1
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/.contextforge.toml +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/.github/workflows/publish.yml +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/.gitignore +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/.python-version +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/AGENTS.md +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/altimate_code.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/antigravity.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/base.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/claude_code.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/claude_desktop.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/codex.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/gemini.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/cli.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/analytics.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/compactor.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/db.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/injector.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/scanner.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/summarizer.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/core/token_analyzer.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/mcp_server.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/models/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/models/config.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/models/session.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/app.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/clipboard.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/styles.tcss +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/utils/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/utils/display.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/utils/tokens.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/adapters/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/adapters/test_antigravity.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/adapters/test_claude_code.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/conftest.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/core/__init__.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/core/test_compactor.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/core/test_db.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/core/test_injector.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/core/test_summarizer.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/core/test_token_analyzer.py +0 -0
- {context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/fixtures/claude_session_sample.jsonl +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: context-forge-cli
|
|
3
|
-
Version: 0.4.
|
|
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
|
|
{context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/session_detail.py
RENAMED
|
@@ -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
|
|
{context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/session_table.py
RENAMED
|
@@ -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:
|
{context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/stats_panel.py
RENAMED
|
@@ -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",
|
{context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/tui/widgets/status_bar.py
RENAMED
|
@@ -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(
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/altimate_code.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/contextforge/adapters/claude_desktop.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{context_forge_cli-0.4.11 → context_forge_cli-0.4.12}/tests/fixtures/claude_session_sample.jsonl
RENAMED
|
File without changes
|
|
File without changes
|