ctxsync 0.8.0__py3-none-any.whl
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.
- ctxsync/__init__.py +0 -0
- ctxsync/chat_sync.py +186 -0
- ctxsync/cli/__init__.py +3 -0
- ctxsync/cli/auth.py +77 -0
- ctxsync/cli/category.py +71 -0
- ctxsync/cli/chat.py +357 -0
- ctxsync/cli/config.py +72 -0
- ctxsync/cli/file.py +29 -0
- ctxsync/cli/main.py +257 -0
- ctxsync/cli/organization.py +98 -0
- ctxsync/cli/project.py +422 -0
- ctxsync/cli/session.py +626 -0
- ctxsync/cli/submodule.py +148 -0
- ctxsync/cli/sync.py +79 -0
- ctxsync/compression.py +302 -0
- ctxsync/configmanager/__init__.py +5 -0
- ctxsync/configmanager/base_config_manager.py +255 -0
- ctxsync/configmanager/file_config_manager.py +362 -0
- ctxsync/configmanager/inmemory_config_manager.py +134 -0
- ctxsync/exceptions.py +22 -0
- ctxsync/provider_factory.py +38 -0
- ctxsync/providers/__init__.py +0 -0
- ctxsync/providers/base_claude_ai.py +537 -0
- ctxsync/providers/base_provider.py +109 -0
- ctxsync/providers/claude_ai.py +192 -0
- ctxsync/session_key_manager.py +129 -0
- ctxsync/syncmanager.py +328 -0
- ctxsync/utils.py +416 -0
- ctxsync-0.8.0.dist-info/METADATA +151 -0
- ctxsync-0.8.0.dist-info/RECORD +34 -0
- ctxsync-0.8.0.dist-info/WHEEL +5 -0
- ctxsync-0.8.0.dist-info/entry_points.txt +2 -0
- ctxsync-0.8.0.dist-info/licenses/LICENSE +21 -0
- ctxsync-0.8.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import urllib
|
|
5
|
+
import sseclient
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from .base_provider import BaseProvider
|
|
9
|
+
from ..configmanager import FileConfigManager, InMemoryConfigManager
|
|
10
|
+
from ..exceptions import ProviderError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_url_encoded(s):
|
|
14
|
+
decoded_s = urllib.parse.unquote(s)
|
|
15
|
+
return decoded_s != s
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_session_key_expiry():
|
|
19
|
+
while True:
|
|
20
|
+
date_format = "%a, %d %b %Y %H:%M:%S %Z"
|
|
21
|
+
default_expires = datetime.datetime.now(
|
|
22
|
+
datetime.timezone.utc
|
|
23
|
+
) + datetime.timedelta(days=30)
|
|
24
|
+
formatted_expires = default_expires.strftime(date_format).strip()
|
|
25
|
+
expires = click.prompt(
|
|
26
|
+
"Please enter the expires time for the sessionKey (optional)",
|
|
27
|
+
default=formatted_expires,
|
|
28
|
+
type=str,
|
|
29
|
+
).strip()
|
|
30
|
+
try:
|
|
31
|
+
expires_on = datetime.datetime.strptime(expires, date_format)
|
|
32
|
+
return expires_on
|
|
33
|
+
except ValueError:
|
|
34
|
+
click.echo(
|
|
35
|
+
"The entered date does not match the required format. Please try again."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BaseClaudeAIProvider(BaseProvider):
|
|
40
|
+
def __init__(self, config=None):
|
|
41
|
+
self.config = config
|
|
42
|
+
if self.config is None:
|
|
43
|
+
self.config = InMemoryConfigManager()
|
|
44
|
+
self.config.load_from_file_config(
|
|
45
|
+
FileConfigManager()
|
|
46
|
+
) # a provider may not edit the config
|
|
47
|
+
self.logger = logging.getLogger(__name__)
|
|
48
|
+
self._configure_logging()
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def base_url(self):
|
|
52
|
+
return self.config.get("claude_api_url", "https://claude.ai/api")
|
|
53
|
+
|
|
54
|
+
def _configure_logging(self):
|
|
55
|
+
log_level = self.config.get("log_level", "INFO")
|
|
56
|
+
logging.basicConfig(level=getattr(logging, log_level))
|
|
57
|
+
self.logger.setLevel(getattr(logging, log_level))
|
|
58
|
+
|
|
59
|
+
def login(self):
|
|
60
|
+
"""
|
|
61
|
+
Handle login with support for direct session key and auto-approve options.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
tuple: (session_key, expires) where session_key is the authenticated key
|
|
65
|
+
and expires is the datetime when the key expires
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ProviderError: If authentication fails or the session key is invalid
|
|
69
|
+
"""
|
|
70
|
+
if hasattr(self, "_provided_session_key"):
|
|
71
|
+
return self._handle_provided_session_key()
|
|
72
|
+
return self._handle_interactive_login()
|
|
73
|
+
|
|
74
|
+
def _handle_provided_session_key(self):
|
|
75
|
+
"""Handle login with a pre-provided session key."""
|
|
76
|
+
session_key = self._provided_session_key
|
|
77
|
+
|
|
78
|
+
if not session_key.startswith("sk-ant"):
|
|
79
|
+
raise ProviderError("Invalid sessionKey format. Must start with 'sk-ant'")
|
|
80
|
+
|
|
81
|
+
expires = self._get_session_expiry()
|
|
82
|
+
|
|
83
|
+
# Validate the session key
|
|
84
|
+
try:
|
|
85
|
+
self.config.set_session_key("claude.ai", session_key, expires)
|
|
86
|
+
organizations = self.get_organizations()
|
|
87
|
+
if organizations:
|
|
88
|
+
return session_key, expires
|
|
89
|
+
except ProviderError as e:
|
|
90
|
+
raise ProviderError(f"Invalid session key: {str(e)}")
|
|
91
|
+
|
|
92
|
+
def _handle_interactive_login(self):
|
|
93
|
+
"""Handle interactive login flow with user prompts."""
|
|
94
|
+
self._display_login_instructions()
|
|
95
|
+
|
|
96
|
+
while True:
|
|
97
|
+
session_key = self._get_valid_session_key()
|
|
98
|
+
expires = self._get_session_expiry()
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
self.config.set_session_key("claude.ai", session_key, expires)
|
|
102
|
+
organizations = self.get_organizations()
|
|
103
|
+
if organizations:
|
|
104
|
+
return session_key, expires
|
|
105
|
+
except ProviderError as e:
|
|
106
|
+
click.echo(e)
|
|
107
|
+
click.echo(
|
|
108
|
+
"Failed to retrieve organizations. Please enter a valid sessionKey."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _get_session_expiry(self):
|
|
112
|
+
"""Get session expiry time, either auto-approved or user-specified."""
|
|
113
|
+
if hasattr(self, "_auto_approve_expiry") and self._auto_approve_expiry:
|
|
114
|
+
return self._get_default_expiry()
|
|
115
|
+
return _get_session_key_expiry()
|
|
116
|
+
|
|
117
|
+
def _get_default_expiry(self):
|
|
118
|
+
"""Get default expiry time (30 days from now)."""
|
|
119
|
+
expires = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
|
|
120
|
+
days=30
|
|
121
|
+
)
|
|
122
|
+
date_format = "%a, %d %b %Y %H:%M:%S %Z"
|
|
123
|
+
expires = expires.strftime(date_format).strip()
|
|
124
|
+
return datetime.datetime.strptime(expires, date_format)
|
|
125
|
+
|
|
126
|
+
def _display_login_instructions(self):
|
|
127
|
+
"""Display instructions for obtaining a session key."""
|
|
128
|
+
click.echo(
|
|
129
|
+
f"A session key is required to call: {self.config.get('claude_api_url')}"
|
|
130
|
+
)
|
|
131
|
+
click.echo("To obtain your session key, please follow these steps:")
|
|
132
|
+
click.echo("1. Open your web browser and go to https://claude.ai")
|
|
133
|
+
click.echo("2. Log in to your Claude account if you haven't already")
|
|
134
|
+
click.echo("3. Once logged in, open your browser's developer tools:")
|
|
135
|
+
click.echo(" - Chrome/Edge: Press F12 or Ctrl+Shift+I (Cmd+Option+I on Mac)")
|
|
136
|
+
click.echo(" - Firefox: Press F12 or Ctrl+Shift+I (Cmd+Option+I on Mac)")
|
|
137
|
+
click.echo(
|
|
138
|
+
" - Safari: Enable developer tools in Preferences > Advanced, then press Cmd+Option+I"
|
|
139
|
+
)
|
|
140
|
+
click.echo(
|
|
141
|
+
"4. In the developer tools, go to the 'Application' tab (Chrome/Edge) or 'Storage' tab (Firefox)"
|
|
142
|
+
)
|
|
143
|
+
click.echo(
|
|
144
|
+
"5. In the left sidebar, expand 'Cookies' and select 'https://claude.ai'"
|
|
145
|
+
)
|
|
146
|
+
click.echo(
|
|
147
|
+
"6. Locate the cookie named 'sessionKey' and copy its value. Ensure that the value is not URL-encoded."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def _get_valid_session_key(self):
|
|
151
|
+
"""Get and validate a session key from user input."""
|
|
152
|
+
while True:
|
|
153
|
+
session_key = click.prompt(
|
|
154
|
+
"Please enter your sessionKey", type=str, hide_input=True
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if not session_key.startswith("sk-ant"):
|
|
158
|
+
click.echo(
|
|
159
|
+
"Invalid sessionKey format. Please make sure it starts with 'sk-ant'."
|
|
160
|
+
)
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if is_url_encoded(session_key):
|
|
164
|
+
click.echo(
|
|
165
|
+
"The session key appears to be URL-encoded. Please provide the decoded version."
|
|
166
|
+
)
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
return session_key
|
|
170
|
+
|
|
171
|
+
def get_organizations(self):
|
|
172
|
+
response = self._make_request("GET", "/organizations")
|
|
173
|
+
if not response:
|
|
174
|
+
raise ProviderError("Unable to retrieve organization information")
|
|
175
|
+
return [
|
|
176
|
+
{"id": org["uuid"], "name": org["name"]}
|
|
177
|
+
for org in response
|
|
178
|
+
if (
|
|
179
|
+
{"chat", "claude_pro"}.issubset(set(org.get("capabilities", [])))
|
|
180
|
+
or {"chat", "raven"}.issubset(set(org.get("capabilities", [])))
|
|
181
|
+
or {"chat", "claude_max"}.issubset(set(org.get("capabilities", [])))
|
|
182
|
+
)
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
def get_projects(self, organization_id, include_archived=False):
|
|
186
|
+
response = self._make_request(
|
|
187
|
+
"GET", f"/organizations/{organization_id}/projects"
|
|
188
|
+
)
|
|
189
|
+
projects = [
|
|
190
|
+
{
|
|
191
|
+
"id": project["uuid"],
|
|
192
|
+
"name": project["name"],
|
|
193
|
+
"archived_at": project.get("archived_at"),
|
|
194
|
+
}
|
|
195
|
+
for project in response
|
|
196
|
+
if include_archived or project.get("archived_at") is None
|
|
197
|
+
]
|
|
198
|
+
return projects
|
|
199
|
+
|
|
200
|
+
def list_files(self, organization_id, project_id):
|
|
201
|
+
response = self._make_request(
|
|
202
|
+
"GET", f"/organizations/{organization_id}/projects/{project_id}/docs"
|
|
203
|
+
)
|
|
204
|
+
return [
|
|
205
|
+
{
|
|
206
|
+
"uuid": file["uuid"],
|
|
207
|
+
"file_name": file["file_name"],
|
|
208
|
+
"content": file["content"],
|
|
209
|
+
"created_at": file["created_at"],
|
|
210
|
+
}
|
|
211
|
+
for file in response
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
def upload_file(self, organization_id, project_id, file_name, content):
|
|
215
|
+
data = {"file_name": file_name, "content": content}
|
|
216
|
+
return self._make_request(
|
|
217
|
+
"POST", f"/organizations/{organization_id}/projects/{project_id}/docs", data
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def delete_file(self, organization_id, project_id, file_uuid):
|
|
221
|
+
return self._make_request(
|
|
222
|
+
"DELETE",
|
|
223
|
+
f"/organizations/{organization_id}/projects/{project_id}/docs/{file_uuid}",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def archive_project(self, organization_id, project_id):
|
|
227
|
+
data = {"is_archived": True}
|
|
228
|
+
return self._make_request(
|
|
229
|
+
"PUT", f"/organizations/{organization_id}/projects/{project_id}", data
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def create_project(self, organization_id, name, description=""):
|
|
233
|
+
data = {"name": name, "description": description, "is_private": True}
|
|
234
|
+
return self._make_request(
|
|
235
|
+
"POST", f"/organizations/{organization_id}/projects", data
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def get_chat_conversations(self, organization_id):
|
|
239
|
+
return self._make_request(
|
|
240
|
+
"GET", f"/organizations/{organization_id}/chat_conversations"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def get_published_artifacts(self, organization_id):
|
|
244
|
+
return self._make_request(
|
|
245
|
+
"GET", f"/organizations/{organization_id}/published_artifacts"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def get_chat_conversation(self, organization_id, conversation_id):
|
|
249
|
+
return self._make_request(
|
|
250
|
+
"GET",
|
|
251
|
+
f"/organizations/{organization_id}/chat_conversations/{conversation_id}?rendering_mode=raw",
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def get_artifact_content(self, organization_id, artifact_uuid):
|
|
255
|
+
artifacts = self._make_request(
|
|
256
|
+
"GET", f"/organizations/{organization_id}/published_artifacts"
|
|
257
|
+
)
|
|
258
|
+
for artifact in artifacts:
|
|
259
|
+
if artifact["published_artifact_uuid"] == artifact_uuid:
|
|
260
|
+
return artifact.get("artifact_content", "")
|
|
261
|
+
raise ProviderError(f"Artifact with UUID {artifact_uuid} not found")
|
|
262
|
+
|
|
263
|
+
def delete_chat(self, organization_id, conversation_uuids):
|
|
264
|
+
endpoint = f"/organizations/{organization_id}/chat_conversations/delete_many"
|
|
265
|
+
data = {"conversation_uuids": conversation_uuids}
|
|
266
|
+
return self._make_request("POST", endpoint, data)
|
|
267
|
+
|
|
268
|
+
def get_sessions(self, organization_id):
|
|
269
|
+
"""Get all web sessions from the v1 API endpoint."""
|
|
270
|
+
# The sessions endpoint is at /v1/sessions, not under /api
|
|
271
|
+
# Requires x-organization-uuid header
|
|
272
|
+
return self._make_request_v1(
|
|
273
|
+
"GET", "/v1/sessions", organization_id=organization_id
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def get_environments(self, organization_id):
|
|
277
|
+
"""Get all environments from the v1 API endpoint."""
|
|
278
|
+
# Get environments for the organization
|
|
279
|
+
endpoint = f"/v1/environment_providers/private/organizations/{organization_id}/environments"
|
|
280
|
+
return self._make_request_v1("GET", endpoint, organization_id=organization_id)
|
|
281
|
+
|
|
282
|
+
def get_code_repos(self, organization_id, skip_status=True):
|
|
283
|
+
"""Get all code repositories available for Claude Code sessions.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
organization_id: The organization UUID
|
|
287
|
+
skip_status: Whether to skip fetching repository status (default: True)
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
dict: Contains 'repos' array with repository information
|
|
291
|
+
"""
|
|
292
|
+
endpoint = f"/organizations/{organization_id}/code/repos"
|
|
293
|
+
params = "?skip_status=true" if skip_status else ""
|
|
294
|
+
return self._make_request("GET", f"{endpoint}{params}")
|
|
295
|
+
|
|
296
|
+
def archive_session(self, organization_id, session_id):
|
|
297
|
+
"""Archive a session by its ID."""
|
|
298
|
+
# Requires x-organization-uuid header
|
|
299
|
+
return self._make_request_v1(
|
|
300
|
+
"POST",
|
|
301
|
+
f"/v1/sessions/{session_id}/archive",
|
|
302
|
+
organization_id=organization_id,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def create_session(
|
|
306
|
+
self,
|
|
307
|
+
organization_id,
|
|
308
|
+
title,
|
|
309
|
+
environment_id,
|
|
310
|
+
git_repo_url=None,
|
|
311
|
+
git_repo_owner=None,
|
|
312
|
+
git_repo_name=None,
|
|
313
|
+
branch_name=None,
|
|
314
|
+
model="claude-sonnet-4-5-20250929",
|
|
315
|
+
):
|
|
316
|
+
"""Create a new Claude Code web session.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
organization_id: The organization UUID
|
|
320
|
+
title: Session title
|
|
321
|
+
environment_id: Environment UUID (e.g., env_011CUPDTyMiRVMf18tfu2VUa)
|
|
322
|
+
git_repo_url: Optional git repository URL
|
|
323
|
+
git_repo_owner: Optional git repository owner (e.g., "Bytelope")
|
|
324
|
+
git_repo_name: Optional git repository name (e.g., "uppdragsradarn3")
|
|
325
|
+
branch_name: Optional branch name to create
|
|
326
|
+
model: Model to use (default: claude-sonnet-4-5-20250929)
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
dict: Created session with id, title, session_context, etc.
|
|
330
|
+
"""
|
|
331
|
+
endpoint = "/v1/sessions"
|
|
332
|
+
|
|
333
|
+
data = {
|
|
334
|
+
"title": title,
|
|
335
|
+
"environment_id": environment_id,
|
|
336
|
+
"session_context": {"model": model},
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
# Add git repository source if URL provided
|
|
340
|
+
if git_repo_url:
|
|
341
|
+
data["session_context"]["sources"] = [
|
|
342
|
+
{"type": "git_repository", "url": git_repo_url}
|
|
343
|
+
]
|
|
344
|
+
|
|
345
|
+
# Add git repository outcome if repo details provided
|
|
346
|
+
if git_repo_owner and git_repo_name:
|
|
347
|
+
# If no branch name specified, generate a simple one
|
|
348
|
+
# The API will append the session ID automatically
|
|
349
|
+
if not branch_name:
|
|
350
|
+
# Generate from title: lowercase, replace spaces/special chars with hyphens
|
|
351
|
+
import re
|
|
352
|
+
|
|
353
|
+
safe_title = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
|
354
|
+
# Limit to reasonable length
|
|
355
|
+
safe_title = safe_title[:50]
|
|
356
|
+
branch_name = f"claude/{safe_title}"
|
|
357
|
+
|
|
358
|
+
git_info = {
|
|
359
|
+
"type": "github",
|
|
360
|
+
"repo": f"{git_repo_owner}/{git_repo_name}",
|
|
361
|
+
"branches": [branch_name],
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
data["session_context"]["outcomes"] = [
|
|
365
|
+
{"type": "git_repository", "git_info": git_info}
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
return self._make_request_v1(
|
|
369
|
+
"POST", endpoint, data=data, organization_id=organization_id
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def _make_request_v1(self, method, endpoint, data=None, organization_id=None):
|
|
373
|
+
"""Make a request to the v1 API (not under /api prefix).
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
method: HTTP method (GET, POST, etc.)
|
|
377
|
+
endpoint: API endpoint path
|
|
378
|
+
data: Optional request data
|
|
379
|
+
organization_id: Optional organization UUID for x-organization-uuid header
|
|
380
|
+
"""
|
|
381
|
+
# This method should be implemented by subclasses to handle v1 API requests
|
|
382
|
+
# that are not under the /api prefix
|
|
383
|
+
raise NotImplementedError("This method should be implemented by subclasses")
|
|
384
|
+
|
|
385
|
+
def _make_request(self, method, endpoint, data=None):
|
|
386
|
+
raise NotImplementedError("This method should be implemented by subclasses")
|
|
387
|
+
|
|
388
|
+
def create_chat(self, organization_id, chat_name="", project_uuid=None, model=None):
|
|
389
|
+
data = {
|
|
390
|
+
"uuid": self._generate_uuid(),
|
|
391
|
+
"name": chat_name,
|
|
392
|
+
"project_uuid": project_uuid,
|
|
393
|
+
}
|
|
394
|
+
if model is not None:
|
|
395
|
+
data["model"] = model
|
|
396
|
+
|
|
397
|
+
return self._make_request(
|
|
398
|
+
"POST", f"/organizations/{organization_id}/chat_conversations", data
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def _generate_uuid(self):
|
|
402
|
+
"""Generate a UUID for the chat conversation."""
|
|
403
|
+
import uuid
|
|
404
|
+
|
|
405
|
+
return str(uuid.uuid4())
|
|
406
|
+
|
|
407
|
+
def _make_request_stream(self, method, endpoint, data=None):
|
|
408
|
+
# This method should be implemented by subclasses to return a response object
|
|
409
|
+
# that can be used with sseclient
|
|
410
|
+
raise NotImplementedError("This method should be implemented by subclasses")
|
|
411
|
+
|
|
412
|
+
def _make_request_stream_v1(self, method, endpoint, organization_id=None):
|
|
413
|
+
"""Make a streaming request to the v1 API."""
|
|
414
|
+
# This method should be implemented by subclasses
|
|
415
|
+
raise NotImplementedError("This method should be implemented by subclasses")
|
|
416
|
+
|
|
417
|
+
def send_message(
|
|
418
|
+
self, organization_id, chat_id, prompt, timezone="UTC", model=None
|
|
419
|
+
):
|
|
420
|
+
endpoint = (
|
|
421
|
+
f"/organizations/{organization_id}/chat_conversations/{chat_id}/completion"
|
|
422
|
+
)
|
|
423
|
+
data = {
|
|
424
|
+
"prompt": prompt,
|
|
425
|
+
"timezone": timezone,
|
|
426
|
+
"rendering_mode": "messages",
|
|
427
|
+
"attachments": [],
|
|
428
|
+
"files": [],
|
|
429
|
+
}
|
|
430
|
+
if model is not None:
|
|
431
|
+
data["model"] = model
|
|
432
|
+
|
|
433
|
+
response = self._make_request_stream("POST", endpoint, data)
|
|
434
|
+
client = sseclient.SSEClient(response)
|
|
435
|
+
for event in client.events():
|
|
436
|
+
if event.data:
|
|
437
|
+
try:
|
|
438
|
+
yield json.loads(event.data)
|
|
439
|
+
except json.JSONDecodeError:
|
|
440
|
+
yield {"error": "Failed to parse JSON"}
|
|
441
|
+
if event.event == "error":
|
|
442
|
+
yield {"error": event.data}
|
|
443
|
+
if event.event == "done":
|
|
444
|
+
break
|
|
445
|
+
|
|
446
|
+
def _parse_sse_event(self, event):
|
|
447
|
+
"""Parse a single SSE event and return the data."""
|
|
448
|
+
if not event.data or event.data.strip() == "":
|
|
449
|
+
return None
|
|
450
|
+
try:
|
|
451
|
+
return json.loads(event.data)
|
|
452
|
+
except json.JSONDecodeError:
|
|
453
|
+
self.logger.warning(f"Failed to parse event data: {event.data}")
|
|
454
|
+
return {"error": "Failed to parse JSON", "raw_data": event.data}
|
|
455
|
+
|
|
456
|
+
def stream_session_events(self, organization_id, session_id):
|
|
457
|
+
"""Stream events from a Claude Code session.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
organization_id: The organization UUID
|
|
461
|
+
session_id: The session ID to stream events from
|
|
462
|
+
|
|
463
|
+
Yields:
|
|
464
|
+
dict: Event data from the session stream
|
|
465
|
+
"""
|
|
466
|
+
import signal
|
|
467
|
+
|
|
468
|
+
endpoint = f"/v1/sessions/{session_id}/events"
|
|
469
|
+
self.logger.debug(f"Opening SSE stream to {endpoint}")
|
|
470
|
+
|
|
471
|
+
def timeout_handler(signum, frame):
|
|
472
|
+
raise TimeoutError("No events received within timeout period")
|
|
473
|
+
|
|
474
|
+
response = self._make_request_stream_v1("GET", endpoint, organization_id)
|
|
475
|
+
client = sseclient.SSEClient(response)
|
|
476
|
+
|
|
477
|
+
# Set timeout for first event
|
|
478
|
+
old_handler = signal.signal(signal.SIGALRM, timeout_handler)
|
|
479
|
+
signal.alarm(30)
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
for event_num, event in enumerate(client.events()):
|
|
483
|
+
if event_num == 0:
|
|
484
|
+
signal.alarm(0)
|
|
485
|
+
signal.signal(signal.SIGALRM, old_handler)
|
|
486
|
+
|
|
487
|
+
parsed_data = self._parse_sse_event(event)
|
|
488
|
+
if parsed_data:
|
|
489
|
+
yield parsed_data
|
|
490
|
+
|
|
491
|
+
if event.event in ("error", "done"):
|
|
492
|
+
if event.event == "error":
|
|
493
|
+
yield {"error": event.data}
|
|
494
|
+
break
|
|
495
|
+
except TimeoutError:
|
|
496
|
+
yield {
|
|
497
|
+
"error": "timeout",
|
|
498
|
+
"message": "No events received from session within 30 seconds",
|
|
499
|
+
}
|
|
500
|
+
finally:
|
|
501
|
+
signal.alarm(0)
|
|
502
|
+
signal.signal(signal.SIGALRM, old_handler)
|
|
503
|
+
|
|
504
|
+
def send_session_input(self, organization_id, session_id, prompt):
|
|
505
|
+
"""Send input/prompt to a Claude Code session.
|
|
506
|
+
|
|
507
|
+
This is used to send an initial prompt or user input to a session.
|
|
508
|
+
The session will process the input and emit events through the event stream.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
organization_id: The organization UUID
|
|
512
|
+
session_id: The session ID
|
|
513
|
+
prompt: The text prompt to send to Claude
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
dict: Response from the API (typically session state or acknowledgment)
|
|
517
|
+
"""
|
|
518
|
+
# Try different possible endpoints - the actual endpoint is not documented
|
|
519
|
+
possible_endpoints = [
|
|
520
|
+
(f"/v1/sessions/{session_id}/prompt", {"prompt": prompt}),
|
|
521
|
+
(f"/v1/sessions/{session_id}/message", {"message": prompt}),
|
|
522
|
+
(f"/v1/sessions/{session_id}/messages", {"content": prompt}),
|
|
523
|
+
(f"/v1/sessions/{session_id}/input", {"input": prompt}),
|
|
524
|
+
]
|
|
525
|
+
|
|
526
|
+
last_error = None
|
|
527
|
+
for endpoint, data in possible_endpoints:
|
|
528
|
+
try:
|
|
529
|
+
return self._make_request_v1("POST", endpoint, data, organization_id)
|
|
530
|
+
except Exception as e:
|
|
531
|
+
last_error = e
|
|
532
|
+
# Try next endpoint
|
|
533
|
+
continue
|
|
534
|
+
|
|
535
|
+
# If all endpoints failed, raise the last error
|
|
536
|
+
if last_error:
|
|
537
|
+
raise last_error
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# src/ctxsync/providers/base_provider.py
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseProvider(ABC):
|
|
7
|
+
@abstractmethod
|
|
8
|
+
def login(self):
|
|
9
|
+
"""Authenticate with the provider and return a session key."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def get_organizations(self):
|
|
14
|
+
"""Retrieve a list of organizations the user is a member of."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def get_projects(self, organization_id, include_archived=False):
|
|
19
|
+
"""Retrieve a list of projects for a specified organization."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def list_files(self, organization_id, project_id):
|
|
24
|
+
"""List all files within a specified project and organization."""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def upload_file(self, organization_id, project_id, file_name, content):
|
|
29
|
+
"""Upload a file to a specified project within an organization."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def delete_file(self, organization_id, project_id, file_uuid):
|
|
34
|
+
"""Delete a file from a specified project within an organization."""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def archive_project(self, organization_id, project_id):
|
|
39
|
+
"""Archive a specified project within an organization."""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def create_project(self, organization_id, name, description=""):
|
|
44
|
+
"""Create a new project within a specified organization."""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def get_chat_conversations(self, organization_id):
|
|
49
|
+
"""Retrieve a list of chat conversations for a specified organization."""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def get_published_artifacts(self, organization_id):
|
|
54
|
+
"""Retrieve a list of published artifacts for a specified organization."""
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def get_chat_conversation(self, organization_id, conversation_id):
|
|
59
|
+
"""Retrieve the full content of a specific chat conversation."""
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def get_artifact_content(self, organization_id, artifact_uuid):
|
|
64
|
+
"""Retrieve the full content of a specific published artifact."""
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def delete_chat(self, organization_id, conversation_uuids):
|
|
69
|
+
"""Delete specified chats for a given organization."""
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def create_chat(self, organization_id, chat_name="", project_uuid=None, model=None):
|
|
74
|
+
"""
|
|
75
|
+
Create a new chat conversation in the specified organization.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
organization_id (str): The UUID of the organization.
|
|
79
|
+
chat_name (str, optional): The name of the chat. Defaults to an empty string.
|
|
80
|
+
project_uuid (str, optional): The UUID of the project to associate the chat with. Defaults to None.
|
|
81
|
+
model (str, optional): The chat model to use. Defaults to None.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
dict: The created chat conversation data.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ProviderError: If the chat creation fails.
|
|
88
|
+
"""
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def send_message(
|
|
93
|
+
self, organization_id, chat_id, prompt, timezone="UTC", model=None
|
|
94
|
+
):
|
|
95
|
+
"""Send a message to a specified chat conversation.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
organization_id (str): The organization ID
|
|
99
|
+
chat_id (str): The chat conversation ID
|
|
100
|
+
prompt (str): The message to send
|
|
101
|
+
timezone (str, optional): The timezone. Defaults to "UTC"
|
|
102
|
+
model (str, optional): The model to use. If None, uses the default model.
|
|
103
|
+
Available models:
|
|
104
|
+
- None (default)
|
|
105
|
+
- claude-3-5-haiku-20241022
|
|
106
|
+
- claude-3-opus-20240229
|
|
107
|
+
- custom string entry
|
|
108
|
+
"""
|
|
109
|
+
pass
|