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.
- {claudesync-0.7.2/src/claudesync.egg-info → claudesync-0.7.4}/PKG-INFO +1 -1
- {claudesync-0.7.2 → claudesync-0.7.4}/pyproject.toml +1 -1
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/main.py +14 -3
- claudesync-0.7.4/src/claudesync/cli/session.py +626 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/providers/base_claude_ai.py +215 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/providers/claude_ai.py +56 -2
- {claudesync-0.7.2 → claudesync-0.7.4/src/claudesync.egg-info}/PKG-INFO +1 -1
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync.egg-info/SOURCES.txt +1 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/tests/test_claude_ai.py +89 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/LICENSE +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/README.md +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/setup.cfg +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/setup.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/__init__.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/chat_sync.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/__init__.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/auth.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/category.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/chat.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/config.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/file.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/organization.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/project.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/submodule.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/cli/sync.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/compression.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/configmanager/__init__.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/configmanager/base_config_manager.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/configmanager/file_config_manager.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/configmanager/inmemory_config_manager.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/exceptions.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/provider_factory.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/providers/__init__.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/providers/base_provider.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/session_key_manager.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/syncmanager.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/utils.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync.egg-info/dependency_links.txt +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync.egg-info/entry_points.txt +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync.egg-info/requires.txt +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync.egg-info/top_level.txt +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/tests/test_chat_happy_path.py +0 -0
- {claudesync-0.7.2 → claudesync-0.7.4}/tests/test_happy_path.py +0 -0
|
@@ -6,7 +6,7 @@ import click_completion.core
|
|
|
6
6
|
import json
|
|
7
7
|
import subprocess
|
|
8
8
|
import urllib.request
|
|
9
|
-
|
|
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 =
|
|
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
|
|
16
|
-
|
|
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)}")
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{claudesync-0.7.2 → claudesync-0.7.4}/src/claudesync/configmanager/inmemory_config_manager.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|