claudesync 0.7.2__tar.gz → 0.7.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {claudesync-0.7.2/src/claudesync.egg-info → claudesync-0.7.4}/PKG-INFO +1 -1
  2. {claudesync-0.7.2 → claudesync-0.7.4}/pyproject.toml +1 -1
  3. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/main.py +14 -3
  4. claudesync-0.7.4/src/claudesync/cli/session.py +626 -0
  5. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/providers/base_claude_ai.py +215 -0
  6. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/providers/claude_ai.py +56 -2
  7. {claudesync-0.7.2 → claudesync-0.7.4/src/claudesync.egg-info}/PKG-INFO +1 -1
  8. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync.egg-info/SOURCES.txt +1 -0
  9. {claudesync-0.7.2 → claudesync-0.7.4}/tests/test_claude_ai.py +89 -0
  10. {claudesync-0.7.2 → claudesync-0.7.4}/LICENSE +0 -0
  11. {claudesync-0.7.2 → claudesync-0.7.4}/README.md +0 -0
  12. {claudesync-0.7.2 → claudesync-0.7.4}/setup.cfg +0 -0
  13. {claudesync-0.7.2 → claudesync-0.7.4}/setup.py +0 -0
  14. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/__init__.py +0 -0
  15. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/chat_sync.py +0 -0
  16. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/__init__.py +0 -0
  17. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/auth.py +0 -0
  18. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/category.py +0 -0
  19. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/chat.py +0 -0
  20. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/config.py +0 -0
  21. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/file.py +0 -0
  22. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/organization.py +0 -0
  23. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/project.py +0 -0
  24. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/submodule.py +0 -0
  25. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/sync.py +0 -0
  26. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/compression.py +0 -0
  27. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/configmanager/__init__.py +0 -0
  28. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/configmanager/base_config_manager.py +0 -0
  29. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/configmanager/file_config_manager.py +0 -0
  30. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/configmanager/inmemory_config_manager.py +0 -0
  31. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/exceptions.py +0 -0
  32. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/provider_factory.py +0 -0
  33. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/providers/__init__.py +0 -0
  34. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/providers/base_provider.py +0 -0
  35. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/session_key_manager.py +0 -0
  36. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/syncmanager.py +0 -0
  37. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/utils.py +0 -0
  38. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync.egg-info/dependency_links.txt +0 -0
  39. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync.egg-info/entry_points.txt +0 -0
  40. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync.egg-info/requires.txt +0 -0
  41. {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync.egg-info/top_level.txt +0 -0
  42. {claudesync-0.7.2 → claudesync-0.7.4}/tests/test_chat_happy_path.py +0 -0
  43. {claudesync-0.7.2 → claudesync-0.7.4}/tests/test_happy_path.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claudesync
3
- Version: 0.7.2
3
+ Version: 0.7.4
4
4
  Summary: A tool to synchronize local files with Claude.ai projects
5
5
  Author-email: Jahziah Wagner <540380+jahwag@users.noreply.github.com>
6
6
  License: MIT License
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "claudesync"
3
- version = "0.7.2"
3
+ version = "0.7.4"
4
4
  authors = [
5
5
  {name = "Jahziah Wagner", email = "540380+jahwag@users.noreply.github.com"},
6
6
  ]
@@ -6,7 +6,7 @@ import click_completion.core
6
6
  import json
7
7
  import subprocess
8
8
  import urllib.request
9
- from pkg_resources import get_distribution
9
+ import importlib.metadata
10
10
 
11
11
  from claudesync.cli.chat import chat
12
12
  from claudesync.configmanager import FileConfigManager, InMemoryConfigManager
@@ -21,6 +21,7 @@ from .organization import organization
21
21
  from .project import project
22
22
  from .sync import schedule
23
23
  from .config import config
24
+ from .session import session
24
25
  import logging
25
26
 
26
27
  logging.basicConfig(
@@ -55,7 +56,7 @@ def install_completion(shell):
55
56
  @click.pass_context
56
57
  def upgrade(ctx):
57
58
  """Upgrade ClaudeSync to the latest version and reset configuration, preserving sessionKey."""
58
- current_version = get_distribution("claudesync").version
59
+ current_version = importlib.metadata.version("claudesync")
59
60
 
60
61
  # Check for the latest version
61
62
  try:
@@ -100,9 +101,12 @@ def upgrade(ctx):
100
101
  @click.option(
101
102
  "--uberproject", is_flag=True, help="Include submodules in the parent project sync"
102
103
  )
104
+ @click.option(
105
+ "--dryrun", is_flag=True, default=False, help="Just show what files would be sent"
106
+ )
103
107
  @click.pass_obj
104
108
  @handle_errors
105
- def push(config, category, uberproject):
109
+ def push(config, category, uberproject, dryrun):
106
110
  """Synchronize the project files, optionally including submodules in the parent project."""
107
111
  provider = validate_and_get_provider(config, require_project=True)
108
112
 
@@ -157,6 +161,12 @@ def push(config, category, uberproject):
157
161
  config, local_path, category, include_submodules=False
158
162
  )
159
163
 
164
+ if dryrun:
165
+ for file in local_files.keys():
166
+ click.echo(f"Would send file: {file}")
167
+ click.echo("Not sending files due to dry run mode.")
168
+ return
169
+
160
170
  sync_manager.sync(local_files, remote_files)
161
171
  click.echo(
162
172
  f"Main project '{active_project_name}' synced successfully: https://claude.ai/project/{active_project_id}"
@@ -243,6 +253,7 @@ cli.add_command(project)
243
253
  cli.add_command(schedule)
244
254
  cli.add_command(config)
245
255
  cli.add_command(chat)
256
+ cli.add_command(session)
246
257
 
247
258
  if __name__ == "__main__":
248
259
  cli()
@@ -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"]
@@ -265,6 +265,123 @@ class BaseClaudeAIProvider(BaseProvider):
265
265
  data = {"conversation_uuids": conversation_uuids}
266
266
  return self._make_request("POST", endpoint, data)
267
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
+
268
385
  def _make_request(self, method, endpoint, data=None):
269
386
  raise NotImplementedError("This method should be implemented by subclasses")
270
387
 
@@ -292,6 +409,11 @@ class BaseClaudeAIProvider(BaseProvider):
292
409
  # that can be used with sseclient
293
410
  raise NotImplementedError("This method should be implemented by subclasses")
294
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
+
295
417
  def send_message(
296
418
  self, organization_id, chat_id, prompt, timezone="UTC", model=None
297
419
  ):
@@ -319,3 +441,96 @@ class BaseClaudeAIProvider(BaseProvider):
319
441
  yield {"error": event.data}
320
442
  if event.event == "done":
321
443
  break
444
+
445
+ def _parse_sse_event(self, event):
446
+ """Parse a single SSE event and return the data."""
447
+ if not event.data or event.data.strip() == "":
448
+ return None
449
+ try:
450
+ return json.loads(event.data)
451
+ except json.JSONDecodeError:
452
+ self.logger.warning(f"Failed to parse event data: {event.data}")
453
+ return {"error": "Failed to parse JSON", "raw_data": event.data}
454
+
455
+ def stream_session_events(self, organization_id, session_id):
456
+ """Stream events from a Claude Code session.
457
+
458
+ Args:
459
+ organization_id: The organization UUID
460
+ session_id: The session ID to stream events from
461
+
462
+ Yields:
463
+ dict: Event data from the session stream
464
+ """
465
+ import signal
466
+
467
+ endpoint = f"/v1/sessions/{session_id}/events"
468
+ self.logger.debug(f"Opening SSE stream to {endpoint}")
469
+
470
+ def timeout_handler(signum, frame):
471
+ raise TimeoutError("No events received within timeout period")
472
+
473
+ response = self._make_request_stream_v1("GET", endpoint, organization_id)
474
+ client = sseclient.SSEClient(response)
475
+
476
+ # Set timeout for first event
477
+ old_handler = signal.signal(signal.SIGALRM, timeout_handler)
478
+ signal.alarm(30)
479
+
480
+ try:
481
+ for event_num, event in enumerate(client.events()):
482
+ if event_num == 0:
483
+ signal.alarm(0)
484
+ signal.signal(signal.SIGALRM, old_handler)
485
+
486
+ parsed_data = self._parse_sse_event(event)
487
+ if parsed_data:
488
+ yield parsed_data
489
+
490
+ if event.event in ("error", "done"):
491
+ if event.event == "error":
492
+ yield {"error": event.data}
493
+ break
494
+ except TimeoutError:
495
+ yield {
496
+ "error": "timeout",
497
+ "message": "No events received from session within 30 seconds",
498
+ }
499
+ finally:
500
+ signal.alarm(0)
501
+ signal.signal(signal.SIGALRM, old_handler)
502
+
503
+ def send_session_input(self, organization_id, session_id, prompt):
504
+ """Send input/prompt to a Claude Code session.
505
+
506
+ This is used to send an initial prompt or user input to a session.
507
+ The session will process the input and emit events through the event stream.
508
+
509
+ Args:
510
+ organization_id: The organization UUID
511
+ session_id: The session ID
512
+ prompt: The text prompt to send to Claude
513
+
514
+ Returns:
515
+ dict: Response from the API (typically session state or acknowledgment)
516
+ """
517
+ # Try different possible endpoints - the actual endpoint is not documented
518
+ possible_endpoints = [
519
+ (f"/v1/sessions/{session_id}/prompt", {"prompt": prompt}),
520
+ (f"/v1/sessions/{session_id}/message", {"message": prompt}),
521
+ (f"/v1/sessions/{session_id}/messages", {"content": prompt}),
522
+ (f"/v1/sessions/{session_id}/input", {"input": prompt}),
523
+ ]
524
+
525
+ last_error = None
526
+ for endpoint, data in possible_endpoints:
527
+ try:
528
+ return self._make_request_v1("POST", endpoint, data, organization_id)
529
+ except Exception as e:
530
+ last_error = e
531
+ # Try next endpoint
532
+ continue
533
+
534
+ # If all endpoints failed, raise the last error
535
+ if last_error:
536
+ raise last_error
@@ -12,14 +12,20 @@ class ClaudeAIProvider(BaseClaudeAIProvider):
12
12
  def __init__(self, config=None):
13
13
  super().__init__(config)
14
14
 
15
- def _make_request(self, method, endpoint, data=None):
16
- url = f"{self.base_url}{endpoint}"
15
+ def _make_request_internal( # noqa: C901
16
+ self, method, endpoint, data, base_url, extra_headers=None
17
+ ):
18
+ """Internal method to make HTTP requests with specified base URL."""
19
+ url = f"{base_url}{endpoint}"
17
20
  headers = {
18
21
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko/20100101 Firefox/129.0",
19
22
  "Content-Type": "application/json",
20
23
  "Accept-Encoding": "gzip",
21
24
  }
22
25
 
26
+ if extra_headers:
27
+ headers.update(extra_headers)
28
+
23
29
  session_key, expiry = self.config.get_session_key("claude.ai")
24
30
  cookies = {
25
31
  "sessionKey": session_key,
@@ -75,6 +81,9 @@ class ClaudeAIProvider(BaseClaudeAIProvider):
75
81
  self.logger.error(f"Response content: {content_str}")
76
82
  raise ProviderError(f"Invalid JSON response from API: {str(json_err)}")
77
83
 
84
+ def _make_request(self, method, endpoint, data=None):
85
+ return self._make_request_internal(method, endpoint, data, self.base_url)
86
+
78
87
  def handle_http_error(self, e):
79
88
  self.logger.debug(f"Request failed: {str(e)}")
80
89
  self.logger.debug(f"Response status code: {e.code}")
@@ -116,6 +125,24 @@ class ClaudeAIProvider(BaseClaudeAIProvider):
116
125
  self.logger.error(error_msg)
117
126
  raise ProviderError(error_msg)
118
127
 
128
+ def _make_request_v1(self, method, endpoint, data=None, organization_id=None):
129
+ """Make a request to the v1 API (not under /api prefix)."""
130
+ # For v1 endpoints, we use the root URL without the /api prefix
131
+ base_url = self.base_url.replace("/api", "")
132
+
133
+ # Add required Anthropic headers for v1 API
134
+ extra_headers = {
135
+ "anthropic-version": "2023-06-01",
136
+ }
137
+
138
+ # Add organization header if provided
139
+ if organization_id:
140
+ extra_headers["x-organization-uuid"] = organization_id
141
+
142
+ return self._make_request_internal(
143
+ method, endpoint, data, base_url, extra_headers
144
+ )
145
+
119
146
  def _make_request_stream(self, method, endpoint, data=None):
120
147
  url = f"{self.base_url}{endpoint}"
121
148
  session_key, _ = self.config.get_session_key("claude.ai")
@@ -136,3 +163,30 @@ class ClaudeAIProvider(BaseClaudeAIProvider):
136
163
  self.handle_http_error(e)
137
164
  except urllib.error.URLError as e:
138
165
  raise ProviderError(f"API request failed: {str(e)}")
166
+
167
+ def _make_request_stream_v1(self, method, endpoint, organization_id=None):
168
+ """Make a streaming request to the v1 API."""
169
+ # For v1 endpoints, use root URL without /api prefix
170
+ base_url = self.base_url.replace("/api", "")
171
+ url = f"{base_url}{endpoint}"
172
+
173
+ session_key, _ = self.config.get_session_key("claude.ai")
174
+ headers = {
175
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko/20100101 Firefox/129.0",
176
+ "Accept": "text/event-stream",
177
+ "anthropic-version": "2023-06-01",
178
+ "Cookie": f"sessionKey={session_key}",
179
+ }
180
+
181
+ # Add organization header if provided
182
+ if organization_id:
183
+ headers["x-organization-uuid"] = organization_id
184
+
185
+ req = urllib.request.Request(url, method=method, headers=headers)
186
+
187
+ try:
188
+ return urllib.request.urlopen(req)
189
+ except urllib.error.HTTPError as e:
190
+ self.handle_http_error(e)
191
+ except urllib.error.URLError as e:
192
+ raise ProviderError(f"API request failed: {str(e)}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claudesync
3
- Version: 0.7.2
3
+ Version: 0.7.4
4
4
  Summary: A tool to synchronize local files with Claude.ai projects
5
5
  Author-email: Jahziah Wagner <540380+jahwag@users.noreply.github.com>
6
6
  License: MIT License
@@ -25,6 +25,7 @@ src/claudesync/cli/file.py
25
25
  src/claudesync/cli/main.py
26
26
  src/claudesync/cli/organization.py
27
27
  src/claudesync/cli/project.py
28
+ src/claudesync/cli/session.py
28
29
  src/claudesync/cli/submodule.py
29
30
  src/claudesync/cli/sync.py
30
31
  src/claudesync/configmanager/__init__.py
@@ -159,6 +159,95 @@ class TestClaudeAIProvider(unittest.TestCase):
159
159
  self.provider.handle_http_error(mock_error)
160
160
  self.assertIn("403 Forbidden error", str(context.exception))
161
161
 
162
+ def test_create_session_with_branch(self):
163
+ result = self.provider.create_session(
164
+ organization_id="org1",
165
+ title="Test Session",
166
+ environment_id="env_test",
167
+ git_repo_url="https://github.com/test/repo",
168
+ git_repo_owner="test",
169
+ git_repo_name="repo",
170
+ branch_name="claude/test-branch",
171
+ )
172
+ self.assertIn("id", result)
173
+ self.assertIn("title", result)
174
+ self.assertIn("session_status", result)
175
+ self.assertEqual(result["title"], "Test Session")
176
+ self.assertEqual(result["session_status"], "running")
177
+ self.assertEqual(result["environment_id"], "env_test")
178
+
179
+ # Check git repository context
180
+ outcomes = result.get("session_context", {}).get("outcomes", [])
181
+ self.assertTrue(len(outcomes) > 0)
182
+ git_outcome = outcomes[0]
183
+ self.assertEqual(git_outcome["type"], "git_repository")
184
+ self.assertIn("branches", git_outcome["git_info"])
185
+ self.assertEqual(git_outcome["git_info"]["branches"][0], "claude/test-branch")
186
+
187
+ def test_create_session_auto_branch(self):
188
+ # Test session creation with auto-generated branch name
189
+ result = self.provider.create_session(
190
+ organization_id="org1",
191
+ title="Auto Branch Session",
192
+ environment_id="env_test",
193
+ git_repo_url="https://github.com/test/repo",
194
+ git_repo_owner="test",
195
+ git_repo_name="repo",
196
+ # No branch_name specified - should auto-generate
197
+ )
198
+ self.assertEqual(result["title"], "Auto Branch Session")
199
+ self.assertEqual(result["session_status"], "running")
200
+
201
+ # Check that branch was auto-generated
202
+ outcomes = result.get("session_context", {}).get("outcomes", [])
203
+ self.assertTrue(len(outcomes) > 0)
204
+ git_outcome = outcomes[0]
205
+ self.assertEqual(git_outcome["type"], "git_repository")
206
+ self.assertIn("branches", git_outcome["git_info"])
207
+ # Branch should be auto-generated with session ID
208
+ self.assertTrue(git_outcome["git_info"]["branches"][0].startswith("claude/"))
209
+
210
+ def test_create_session_minimal(self):
211
+ # Test session creation with minimal parameters (no git context)
212
+ result = self.provider.create_session(
213
+ organization_id="org1",
214
+ title="Minimal Session",
215
+ environment_id="env_test",
216
+ )
217
+ self.assertEqual(result["title"], "Minimal Session")
218
+ self.assertEqual(result["session_status"], "running")
219
+ self.assertIn("session_context", result)
220
+ # Should not have outcomes or sources without git context
221
+ session_context = result.get("session_context", {})
222
+ self.assertNotIn("sources", session_context)
223
+ self.assertNotIn("outcomes", session_context)
224
+
225
+ def test_stream_session_events(self):
226
+ # Test streaming events from a session
227
+ events = list(
228
+ self.provider.stream_session_events(
229
+ organization_id="org1", session_id="session_test123"
230
+ )
231
+ )
232
+ # Should receive at least 3 events from mock server
233
+ self.assertGreaterEqual(len(events), 3)
234
+ # Check first event
235
+ self.assertEqual(events[0]["type"], "session_status")
236
+ self.assertEqual(events[0]["status"], "running")
237
+ # Check subsequent events
238
+ self.assertEqual(events[1]["type"], "message")
239
+ self.assertIn("Starting Claude Code", events[1]["content"])
240
+
241
+ def test_send_session_input(self):
242
+ # Test sending input/prompt to a session
243
+ result = self.provider.send_session_input(
244
+ organization_id="org1",
245
+ session_id="session_test123",
246
+ prompt="Hello, please help me fix a bug",
247
+ )
248
+ self.assertEqual(result["status"], "accepted")
249
+ self.assertEqual(result["input_received"], "Hello, please help me fix a bug")
250
+
162
251
 
163
252
  if __name__ == "__main__":
164
253
  unittest.main()
File without changes
File without changes
File without changes
File without changes