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/cli/session.py ADDED
@@ -0,0 +1,626 @@
1
+ import click
2
+ from datetime import datetime
3
+ from ..utils import handle_errors, validate_and_get_provider
4
+ from ..exceptions import ProviderError
5
+
6
+
7
+ @click.group()
8
+ def session():
9
+ """Manage Claude Code web sessions."""
10
+ pass
11
+
12
+
13
+ @session.group()
14
+ def environment():
15
+ """Manage Claude Code environments."""
16
+ pass
17
+
18
+
19
+ @session.group()
20
+ def branch():
21
+ """Manage Claude Code repository branches."""
22
+ pass
23
+
24
+
25
+ @branch.command(name="ls")
26
+ @click.option(
27
+ "--json",
28
+ "json_output",
29
+ is_flag=True,
30
+ help="Output in JSON format",
31
+ )
32
+ @click.option(
33
+ "-s",
34
+ "--search",
35
+ help="Filter repositories by name",
36
+ )
37
+ @click.pass_obj
38
+ @handle_errors
39
+ def branch_ls(config, json_output, search):
40
+ """List available repositories for Claude Code sessions."""
41
+ import json as json_module
42
+
43
+ provider = validate_and_get_provider(config)
44
+ active_organization_id = config.get("active_organization_id")
45
+
46
+ try:
47
+ repos_data = provider.get_code_repos(active_organization_id)
48
+
49
+ if not repos_data or not repos_data.get("repos"):
50
+ click.echo("No repositories found.")
51
+ return
52
+
53
+ repos = repos_data["repos"]
54
+
55
+ # Filter by search term if provided
56
+ if search:
57
+ repos = [
58
+ r
59
+ for r in repos
60
+ if search.lower() in r.get("repo", {}).get("name", "").lower()
61
+ ]
62
+
63
+ if not repos:
64
+ click.echo(f"No repositories found matching '{search}'.")
65
+ return
66
+
67
+ if json_output:
68
+ click.echo(json_module.dumps(repos, indent=2))
69
+ return
70
+
71
+ # Display repositories in a formatted way
72
+ click.echo(f"Found {len(repos)} repository(ies):")
73
+
74
+ for idx, repo_data in enumerate(repos, 1):
75
+ repo = repo_data.get("repo", {})
76
+ name = repo.get("name", "Unknown")
77
+ owner = repo.get("owner", {}).get("login", "Unknown")
78
+ default_branch = repo.get("default_branch", "N/A")
79
+
80
+ click.echo(f"\n{idx}. {owner}/{name}")
81
+ click.echo(f" Default branch: {default_branch}")
82
+
83
+ except ProviderError as e:
84
+ click.echo(f"Failed to list repositories: {str(e)}")
85
+
86
+
87
+ @environment.command(name="ls")
88
+ @click.option(
89
+ "--json",
90
+ "json_output",
91
+ is_flag=True,
92
+ help="Output in JSON format",
93
+ )
94
+ @click.pass_obj
95
+ @handle_errors
96
+ def environment_ls(config, json_output):
97
+ """List all Claude Code environments."""
98
+ import json as json_module
99
+
100
+ provider = validate_and_get_provider(config)
101
+ active_organization_id = config.get("active_organization_id")
102
+
103
+ try:
104
+ environments_data = provider.get_environments(active_organization_id)
105
+
106
+ if not environments_data or not environments_data.get("environments"):
107
+ click.echo("No environments found.")
108
+ return
109
+
110
+ environments = environments_data["environments"]
111
+
112
+ if json_output:
113
+ click.echo(json_module.dumps(environments, indent=2))
114
+ return
115
+
116
+ # Display environments in a formatted way
117
+ click.echo(f"Found {len(environments)} environment(s):")
118
+
119
+ for idx, env in enumerate(environments, 1):
120
+ env_id = env.get("environment_id", "N/A")
121
+ name = env.get("name", "Unnamed Environment")
122
+ kind = env.get("kind", "N/A")
123
+ state = env.get("state", "unknown")
124
+
125
+ click.echo(f"\n{idx}. {name}")
126
+ click.echo(f" ID: {env_id}")
127
+ click.echo(f" Kind: {kind}")
128
+ click.echo(f" State: {state}")
129
+
130
+ except ProviderError as e:
131
+ click.echo(f"Failed to list environments: {str(e)}")
132
+
133
+
134
+ @session.command()
135
+ @click.option(
136
+ "-a",
137
+ "--all",
138
+ "archive_all",
139
+ is_flag=True,
140
+ help="Archive all active sessions",
141
+ )
142
+ @click.option(
143
+ "-y",
144
+ "--yes",
145
+ is_flag=True,
146
+ help="Skip confirmation prompt",
147
+ )
148
+ @click.pass_obj
149
+ @handle_errors
150
+ def archive(config, archive_all, yes):
151
+ """Archive existing sessions."""
152
+ provider = validate_and_get_provider(config)
153
+ active_organization_id = config.get("active_organization_id")
154
+ sessions_data = provider.get_sessions(active_organization_id)
155
+
156
+ if not sessions_data or not sessions_data.get("data"):
157
+ click.echo("No sessions found.")
158
+ return
159
+
160
+ # Filter for active sessions (not archived)
161
+ sessions = [
162
+ s
163
+ for s in sessions_data["data"]
164
+ if s.get("session_status") in ["running", "idle"]
165
+ ]
166
+
167
+ if not sessions:
168
+ click.echo("No active sessions found.")
169
+ return
170
+
171
+ if archive_all:
172
+ if not yes:
173
+ click.echo("The following sessions will be archived:")
174
+ for sess in sessions:
175
+ title = sess.get("title", "Untitled")
176
+ sess_id = sess.get("id", "N/A")
177
+ click.echo(f" - {title} (ID: {sess_id})")
178
+ if not click.confirm("Are you sure you want to archive all sessions?"):
179
+ click.echo("Operation cancelled.")
180
+ return
181
+
182
+ success_count = 0
183
+ failure_count = 0
184
+ with click.progressbar(
185
+ sessions,
186
+ label="Archiving sessions",
187
+ item_show_func=lambda s: s.get("title", "Untitled") if s else "",
188
+ ) as bar:
189
+ for sess in bar:
190
+ try:
191
+ provider.archive_session(active_organization_id, sess.get("id"))
192
+ success_count += 1
193
+ except ProviderError as e:
194
+ failure_count += 1
195
+ title = sess.get("title", "Untitled")
196
+ click.echo(f"\nFailed to archive session '{title}': {str(e)}")
197
+
198
+ click.echo(
199
+ f"\nArchive operation completed. "
200
+ f"Successfully archived: {success_count}, Failed: {failure_count}"
201
+ )
202
+ return
203
+
204
+ single_session_archival(sessions, yes, provider, active_organization_id)
205
+
206
+
207
+ def single_session_archival(sessions, yes, provider, organization_id):
208
+ """Archive a single session selected by the user."""
209
+ click.echo("Available sessions to archive:")
210
+ for idx, sess in enumerate(sessions, 1):
211
+ title = sess.get("title", "Untitled")
212
+ sess_id = sess.get("id", "N/A")
213
+ click.echo(f" {idx}. {title} (ID: {sess_id})")
214
+
215
+ selection = click.prompt("Enter the number of the session to archive", type=int)
216
+ if 1 <= selection <= len(sessions):
217
+ selected_session = sessions[selection - 1]
218
+ title = selected_session.get("title", "Untitled")
219
+ if yes or click.confirm(
220
+ f"Are you sure you want to archive the session '{title}'? "
221
+ f"Archived sessions cannot be modified but can still be viewed."
222
+ ):
223
+ try:
224
+ provider.archive_session(organization_id, selected_session.get("id"))
225
+ click.echo(f"Session '{title}' has been archived.")
226
+ except ProviderError as e:
227
+ click.echo(f"Failed to archive session '{title}': {str(e)}")
228
+ else:
229
+ click.echo("Invalid selection. Please try again.")
230
+
231
+
232
+ @session.command()
233
+ @click.argument("title", required=False)
234
+ @click.option(
235
+ "-e",
236
+ "--environment-id",
237
+ help="Environment ID (if not provided, will try to use active environment)",
238
+ )
239
+ @click.option(
240
+ "-m",
241
+ "--model",
242
+ default="claude-sonnet-4-5-20250929",
243
+ help="Model to use (default: claude-sonnet-4-5-20250929)",
244
+ )
245
+ @click.option(
246
+ "-b",
247
+ "--branch",
248
+ help="Branch name to create (auto-generated if not provided)",
249
+ )
250
+ @click.option(
251
+ "--json",
252
+ "json_output",
253
+ is_flag=True,
254
+ help="Output in JSON format",
255
+ )
256
+ @click.pass_obj
257
+ @handle_errors
258
+ def create(config, title, environment_id, model, branch, json_output): # noqa: C901
259
+ """Create a new Claude Code web session.
260
+
261
+ Provide a title for the session. If no title is provided, you will be prompted.
262
+ If the current directory is a git repository, it will be automatically linked to the session.
263
+ """
264
+ import json as json_module
265
+ import subprocess
266
+ import re
267
+
268
+ provider = validate_and_get_provider(config)
269
+ active_organization_id = config.get("active_organization_id")
270
+
271
+ # Get the title from user if not provided
272
+ if not title:
273
+ title = click.prompt("Enter the session title")
274
+
275
+ if not title.strip():
276
+ click.echo("Error: Title cannot be empty.")
277
+ return
278
+
279
+ # Get environment_id from config or parameter
280
+ if not environment_id:
281
+ environment_id = config.get("active_environment_id")
282
+ if not environment_id:
283
+ # Try to get environments and let user select
284
+ try:
285
+ environments_data = provider.get_environments(active_organization_id)
286
+ environments = environments_data.get("environments", [])
287
+
288
+ if not environments:
289
+ click.echo("Error: No environments found.")
290
+ click.echo(
291
+ "Please create an environment first or use -e flag to specify one."
292
+ )
293
+ return
294
+
295
+ # Show available environments
296
+ click.echo("Available environments:")
297
+ for idx, env in enumerate(environments, 1):
298
+ env_id = env.get("environment_id", "N/A")
299
+ name = env.get("name", "Unnamed")
300
+ state = env.get("state", "unknown")
301
+ click.echo(f" {idx}. {name} ({state}) - {env_id}")
302
+
303
+ # Prompt user to select
304
+ selection = click.prompt(
305
+ "Select an environment number", type=int, default=1
306
+ )
307
+
308
+ if 1 <= selection <= len(environments):
309
+ environment_id = environments[selection - 1].get("environment_id")
310
+ if not json_output:
311
+ click.echo(
312
+ f"Using environment: {environments[selection - 1].get('name')}"
313
+ )
314
+ else:
315
+ click.echo("Invalid selection.")
316
+ return
317
+
318
+ except ProviderError as e:
319
+ click.echo(f"Error: Could not retrieve environments: {str(e)}")
320
+ click.echo("Please use -e flag to specify an environment ID.")
321
+ return
322
+
323
+ # Try to detect git repository context and verify it's available
324
+ git_repo_url = None
325
+ git_repo_owner = None
326
+ git_repo_name = None
327
+ local_repo_detected = False
328
+ local_owner = None
329
+ local_name = None
330
+
331
+ try:
332
+ # Get git remote URL
333
+ result = subprocess.run(
334
+ ["git", "remote", "get-url", "origin"],
335
+ capture_output=True,
336
+ text=True,
337
+ check=True,
338
+ )
339
+ git_remote = result.stdout.strip()
340
+
341
+ # Parse GitHub URL (supports both SSH and HTTPS)
342
+ # SSH: git@github.com:owner/repo.git
343
+ # HTTPS: https://github.com/owner/repo.git
344
+ github_ssh_pattern = r"git@github\.com:([^/]+)/(.+?)(?:\.git)?$"
345
+ github_https_pattern = r"https://github\.com/([^/]+)/(.+?)(?:\.git)?$"
346
+
347
+ match = re.match(github_ssh_pattern, git_remote) or re.match(
348
+ github_https_pattern, git_remote
349
+ )
350
+ if match:
351
+ local_owner = match.group(1)
352
+ local_name = match.group(2)
353
+ local_repo_detected = True
354
+ except (subprocess.CalledProcessError, FileNotFoundError):
355
+ # Not in a git repository or git not available
356
+ local_repo_detected = False
357
+
358
+ # Get available repos to verify local repo is connected
359
+ repo_available = False
360
+ if local_repo_detected:
361
+ try:
362
+ repos_data = provider.get_code_repos(active_organization_id)
363
+ repos = repos_data.get("repos", [])
364
+
365
+ # Check if local repo is in available repos
366
+ for repo_data in repos:
367
+ repo = repo_data.get("repo", {})
368
+ owner = repo.get("owner", {}).get("login")
369
+ name = repo.get("name")
370
+ if owner == local_owner and name == local_name:
371
+ git_repo_owner = local_owner
372
+ git_repo_name = local_name
373
+ git_repo_url = (
374
+ f"https://github.com/{git_repo_owner}/{git_repo_name}"
375
+ )
376
+ repo_available = True
377
+ if not json_output:
378
+ click.echo(
379
+ f"Using detected repository: {git_repo_owner}/{git_repo_name}"
380
+ )
381
+ break
382
+
383
+ if not repo_available and not json_output:
384
+ click.echo(
385
+ f"\nDetected local repository {local_owner}/{local_name}, but it's not connected to Claude Code."
386
+ )
387
+ click.echo(
388
+ "You need to connect this repository via GitHub OAuth first."
389
+ )
390
+ click.echo("Available repositories:")
391
+ except ProviderError:
392
+ repos = []
393
+
394
+ # If no valid repo, prompt user to select one
395
+ if not repo_available and not json_output:
396
+ try:
397
+ # If we haven't fetched repos yet, fetch them now
398
+ if not local_repo_detected or not repos:
399
+ repos_data = provider.get_code_repos(active_organization_id)
400
+ repos = repos_data.get("repos", [])
401
+
402
+ if repos:
403
+ for idx, repo_data in enumerate(repos, 1):
404
+ repo = repo_data.get("repo", {})
405
+ name = repo.get("name", "Unknown")
406
+ owner = repo.get("owner", {}).get("login", "Unknown")
407
+ click.echo(f" {idx}. {owner}/{name}")
408
+ click.echo(
409
+ f" {len(repos) + 1}. Skip (create session without repository)"
410
+ )
411
+
412
+ selection = click.prompt(
413
+ "Select a repository number", type=int, default=len(repos) + 1
414
+ )
415
+
416
+ if 1 <= selection <= len(repos):
417
+ selected_repo = repos[selection - 1].get("repo", {})
418
+ git_repo_owner = selected_repo.get("owner", {}).get("login")
419
+ git_repo_name = selected_repo.get("name")
420
+ if git_repo_owner and git_repo_name:
421
+ git_repo_url = (
422
+ f"https://github.com/{git_repo_owner}/{git_repo_name}"
423
+ )
424
+ click.echo(
425
+ f"Using repository: {git_repo_owner}/{git_repo_name}"
426
+ )
427
+ elif selection == len(repos) + 1:
428
+ click.echo("Creating session without git repository context")
429
+ else:
430
+ click.echo(
431
+ "Invalid selection. Creating session without repository."
432
+ )
433
+
434
+ except ProviderError:
435
+ # If we can't get repos, just continue without repo context
436
+ if not json_output:
437
+ click.echo("Creating session without git repository context")
438
+
439
+ try:
440
+ result = provider.create_session(
441
+ organization_id=active_organization_id,
442
+ title=title,
443
+ environment_id=environment_id,
444
+ git_repo_url=git_repo_url,
445
+ git_repo_owner=git_repo_owner,
446
+ git_repo_name=git_repo_name,
447
+ branch_name=branch,
448
+ model=model,
449
+ )
450
+
451
+ session_id = result.get("id", "N/A")
452
+ session_title = result.get("title", "N/A")
453
+ session_status = result.get("session_status", "N/A")
454
+
455
+ # Extract branch name from outcomes
456
+ branch_info = "N/A"
457
+ try:
458
+ outcomes = result.get("session_context", {}).get("outcomes", [])
459
+ for outcome in outcomes:
460
+ if outcome.get("type") == "git_repository":
461
+ branches = outcome.get("git_info", {}).get("branches", [])
462
+ if branches:
463
+ branch_info = branches[0]
464
+ except (AttributeError, TypeError, KeyError):
465
+ pass
466
+
467
+ if json_output:
468
+ click.echo(json_module.dumps(result, indent=2))
469
+ else:
470
+ click.echo("Session created successfully!")
471
+ click.echo(f"ID: {session_id}")
472
+ click.echo(f"Title: {session_title}")
473
+ click.echo(f"Status: {session_status}")
474
+ if branch_info != "N/A":
475
+ click.echo(f"Branch: {branch_info}")
476
+
477
+ click.echo(f"\nView session at: https://claude.ai/code/{session_id}")
478
+ click.echo(
479
+ "\nNote: Session starts idle. Send a message through the web UI to begin."
480
+ )
481
+ click.echo("\n--- Streaming session events (Ctrl+C to stop) ---\n")
482
+ click.echo("Connecting to event stream...")
483
+
484
+ # Stream session events
485
+ try:
486
+ event_count = 0
487
+ for event in provider.stream_session_events(
488
+ active_organization_id, session_id
489
+ ):
490
+ event_count += 1
491
+
492
+ # Debug: show raw events for now
493
+ click.echo(f"[Event {event_count}] {json_module.dumps(event)}")
494
+
495
+ if "error" in event:
496
+ click.echo(f"Error: {event['error']}")
497
+ break
498
+
499
+ # Handle different event types
500
+ event_type = event.get("type")
501
+ if event_type == "message":
502
+ content = event.get("content", "")
503
+ if content:
504
+ click.echo(f"Claude: {content}")
505
+ elif event_type == "session_status":
506
+ status = event.get("status", "")
507
+ if status:
508
+ click.echo(f"Status: {status}")
509
+
510
+ if event_count == 0:
511
+ click.echo("\nNo events received from session.")
512
+ click.echo("The session may still be initializing.")
513
+ except KeyboardInterrupt:
514
+ click.echo("\n\nSession streaming stopped by user.")
515
+ click.echo(f"Session {session_id} continues running in the background.")
516
+ click.echo(
517
+ "You can view it at: https://claude.ai/code/session_{session_id}"
518
+ )
519
+ except Exception as e:
520
+ click.echo(f"\nError streaming events: {str(e)}")
521
+ click.echo(
522
+ f"Session {session_id} is still running. View at: https://claude.ai/code/{session_id}"
523
+ )
524
+
525
+ except ProviderError as e:
526
+ click.echo(f"Failed to create session: {str(e)}")
527
+
528
+
529
+ @session.command()
530
+ @click.option(
531
+ "-a",
532
+ "--all",
533
+ "show_all",
534
+ is_flag=True,
535
+ help="Show all sessions including archived (default shows only running and idle)",
536
+ )
537
+ @click.option(
538
+ "--json",
539
+ "json_output",
540
+ is_flag=True,
541
+ help="Output in JSON format",
542
+ )
543
+ @click.pass_obj
544
+ @handle_errors
545
+ def ls(config, show_all, json_output): # noqa: C901
546
+ """List all web sessions."""
547
+ import json as json_module
548
+
549
+ provider = validate_and_get_provider(config)
550
+ active_organization_id = config.get("active_organization_id")
551
+ sessions_data = provider.get_sessions(active_organization_id)
552
+
553
+ if not sessions_data or not sessions_data.get("data"):
554
+ click.echo("No sessions found.")
555
+ return
556
+
557
+ sessions = sessions_data["data"]
558
+
559
+ # Filter sessions if not showing all
560
+ if not show_all:
561
+ sessions = [
562
+ s for s in sessions if s.get("session_status") in ["running", "idle"]
563
+ ]
564
+
565
+ if not sessions:
566
+ click.echo("No active sessions found. Use --all to show archived sessions.")
567
+ return
568
+
569
+ if json_output:
570
+ click.echo(json_module.dumps(sessions, indent=2))
571
+ return
572
+
573
+ # Display sessions in a formatted way
574
+ click.echo(f"Found {len(sessions)} session(s):")
575
+
576
+ for idx, sess in enumerate(sessions, 1):
577
+ session_id = sess.get("id", "N/A")
578
+ title = sess.get("title", "Untitled")
579
+ status = sess.get("session_status", "unknown")
580
+ created_at = sess.get("created_at", "N/A")
581
+ updated_at = sess.get("updated_at", "N/A")
582
+
583
+ # Parse and format timestamps
584
+ try:
585
+ created_dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
586
+ created_str = created_dt.strftime("%Y-%m-%d %H:%M:%S")
587
+ except (ValueError, AttributeError):
588
+ created_str = created_at
589
+
590
+ try:
591
+ updated_dt = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
592
+ updated_str = updated_dt.strftime("%Y-%m-%d %H:%M:%S")
593
+ except (ValueError, AttributeError):
594
+ updated_str = updated_at
595
+
596
+ # Get repository info if available
597
+ repo_info = ""
598
+ try:
599
+ context = sess.get("session_context", {})
600
+ outcomes = context.get("outcomes", [])
601
+ for outcome in outcomes:
602
+ if outcome.get("type") == "git_repository":
603
+ git_info = outcome.get("git_info", {})
604
+ repo = git_info.get("repo", "")
605
+ branches = git_info.get("branches", [])
606
+ if repo:
607
+ repo_info = f"\n Repository: {repo}"
608
+ if branches:
609
+ repo_info += f"\n Branch: {branches[0]}"
610
+ except (AttributeError, TypeError, KeyError):
611
+ # Skip repo info if structure is unexpected
612
+ pass
613
+
614
+ # Status text
615
+ status_text = status.capitalize()
616
+
617
+ click.echo(f"\n{idx}. {title}")
618
+ click.echo(f" ID: {session_id}")
619
+ click.echo(f" Status: {status_text}")
620
+ click.echo(f" Created: {created_str}")
621
+ click.echo(f" Updated: {updated_str}")
622
+ if repo_info:
623
+ click.echo(repo_info)
624
+
625
+
626
+ __all__ = ["session"]