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.
@@ -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