claudesync 0.3.2__tar.gz → 0.3.3__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.3.2/src/claudesync.egg-info → claudesync-0.3.3}/PKG-INFO +1 -1
- {claudesync-0.3.2 → claudesync-0.3.3}/pyproject.toml +2 -2
- {claudesync-0.3.2 → claudesync-0.3.3}/src/claudesync/cli/api.py +1 -0
- {claudesync-0.3.2 → claudesync-0.3.3}/src/claudesync/cli/organization.py +1 -0
- {claudesync-0.3.2 → claudesync-0.3.3}/src/claudesync/cli/project.py +2 -1
- {claudesync-0.3.2 → claudesync-0.3.3}/src/claudesync/cli/sync.py +9 -7
- claudesync-0.3.3/src/claudesync/config_manager.py +89 -0
- claudesync-0.3.3/src/claudesync/exceptions.py +22 -0
- claudesync-0.3.3/src/claudesync/provider_factory.py +38 -0
- claudesync-0.3.3/src/claudesync/providers/claude_ai.py +333 -0
- claudesync-0.3.3/src/claudesync/utils.py +264 -0
- {claudesync-0.3.2 → claudesync-0.3.3/src/claudesync.egg-info}/PKG-INFO +1 -1
- {claudesync-0.3.2 → claudesync-0.3.3}/tests/test_utils.py +2 -2
- claudesync-0.3.2/src/claudesync/config_manager.py +0 -38
- claudesync-0.3.2/src/claudesync/exceptions.py +0 -10
- claudesync-0.3.2/src/claudesync/provider_factory.py +0 -19
- claudesync-0.3.2/src/claudesync/providers/claude_ai.py +0 -166
- claudesync-0.3.2/src/claudesync/utils.py +0 -138
- {claudesync-0.3.2 → claudesync-0.3.3}/LICENSE +0 -0
- {claudesync-0.3.2 → claudesync-0.3.3}/README.md +0 -0
- {claudesync-0.3.2 → claudesync-0.3.3}/setup.cfg +0 -0
- {claudesync-0.3.2 → claudesync-0.3.3}/setup.py +0 -0
- {claudesync-0.3.2 → claudesync-0.3.3}/src/claudesync/__init__.py +0 -0
- {claudesync-0.3.2 → claudesync-0.3.3}/src/claudesync/cli/__init__.py +0 -0
- {claudesync-0.3.2 → claudesync-0.3.3}/src/claudesync/cli/main.py +1 -1
- {claudesync-0.3.2 → claudesync-0.3.3}/src/claudesync/providers/__init__.py +0 -0
- {claudesync-0.3.2 → claudesync-0.3.3}/src/claudesync.egg-info/SOURCES.txt +0 -0
- {claudesync-0.3.2 → claudesync-0.3.3}/src/claudesync.egg-info/dependency_links.txt +0 -0
- {claudesync-0.3.2 → claudesync-0.3.3}/src/claudesync.egg-info/entry_points.txt +0 -0
- {claudesync-0.3.2 → claudesync-0.3.3}/src/claudesync.egg-info/requires.txt +0 -0
- {claudesync-0.3.2 → claudesync-0.3.3}/src/claudesync.egg-info/top_level.txt +0 -0
- {claudesync-0.3.2 → claudesync-0.3.3}/tests/test_config_manager.py +0 -0
- {claudesync-0.3.2 → claudesync-0.3.3}/tests/test_provider_factory.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "claudesync"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.3"
|
|
8
8
|
authors = [
|
|
9
9
|
{name = "Jahziah Wagner", email = "jahziah.wagner+pypi@gmail.com"},
|
|
10
10
|
]
|
|
@@ -43,4 +43,4 @@ include = ["claudesync*"]
|
|
|
43
43
|
[tool.pytest.ini_options]
|
|
44
44
|
testpaths = ["tests"]
|
|
45
45
|
python_files = "test_*.py"
|
|
46
|
-
addopts = "-v --cov=claudesync --cov-report=term-missing"
|
|
46
|
+
addopts = "-v --cov=claudesync --cov-report=term-missing"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import click
|
|
2
|
+
|
|
2
3
|
from claudesync.exceptions import ProviderError
|
|
3
4
|
from ..utils import (
|
|
4
5
|
handle_errors,
|
|
@@ -62,7 +63,7 @@ def archive(config):
|
|
|
62
63
|
if 1 <= selection <= len(projects):
|
|
63
64
|
selected_project = projects[selection - 1]
|
|
64
65
|
if click.confirm(
|
|
65
|
-
|
|
66
|
+
f"Are you sure you want to archive '{selected_project['name']}'?"
|
|
66
67
|
):
|
|
67
68
|
provider.archive_project(active_organization_id, selected_project["id"])
|
|
68
69
|
click.echo(f"Project '{selected_project['name']}' has been archived.")
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import click
|
|
2
|
-
import sys
|
|
3
1
|
import os
|
|
4
|
-
import time
|
|
5
2
|
import shutil
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import click
|
|
6
7
|
from crontab import CronTab
|
|
7
|
-
|
|
8
|
+
|
|
9
|
+
from claudesync.utils import compute_md5_hash, get_local_files
|
|
8
10
|
from ..utils import handle_errors, validate_and_get_provider
|
|
9
11
|
|
|
10
12
|
|
|
@@ -62,14 +64,14 @@ def sync(config):
|
|
|
62
64
|
(rf for rf in remote_files if rf["file_name"] == local_file), None
|
|
63
65
|
)
|
|
64
66
|
if remote_file:
|
|
65
|
-
remote_checksum =
|
|
67
|
+
remote_checksum = compute_md5_hash(remote_file["content"])
|
|
66
68
|
if local_checksum != remote_checksum:
|
|
67
69
|
click.echo(f"Updating {local_file} on remote...")
|
|
68
70
|
provider.delete_file(
|
|
69
71
|
active_organization_id, active_project_id, remote_file["uuid"]
|
|
70
72
|
)
|
|
71
73
|
with open(
|
|
72
|
-
|
|
74
|
+
os.path.join(local_path, local_file), "r", encoding="utf-8"
|
|
73
75
|
) as file:
|
|
74
76
|
content = file.read()
|
|
75
77
|
provider.upload_file(
|
|
@@ -80,7 +82,7 @@ def sync(config):
|
|
|
80
82
|
else:
|
|
81
83
|
click.echo(f"Uploading new file {local_file} to remote...")
|
|
82
84
|
with open(
|
|
83
|
-
|
|
85
|
+
os.path.join(local_path, local_file), "r", encoding="utf-8"
|
|
84
86
|
) as file:
|
|
85
87
|
content = file.read()
|
|
86
88
|
provider.upload_file(
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigManager:
|
|
6
|
+
"""
|
|
7
|
+
A class to manage configuration settings for the application.
|
|
8
|
+
|
|
9
|
+
This class handles loading, saving, and accessing configuration settings from a JSON file.
|
|
10
|
+
It ensures that default values are set for certain keys if they are not present in the configuration file.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
config_dir (Path): The directory where the configuration file is stored.
|
|
14
|
+
config_file (Path): The path to the configuration file.
|
|
15
|
+
config (dict): The current configuration loaded into memory.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
"""
|
|
20
|
+
Initializes the ConfigManager instance by setting up the configuration directory and file paths,
|
|
21
|
+
and loading the current configuration from the file, applying default values as necessary.
|
|
22
|
+
"""
|
|
23
|
+
self.config_dir = Path.home() / ".claudesync"
|
|
24
|
+
self.config_file = self.config_dir / "config.json"
|
|
25
|
+
self.config = self._load_config()
|
|
26
|
+
|
|
27
|
+
def _load_config(self):
|
|
28
|
+
"""
|
|
29
|
+
Loads the configuration from the JSON file, applying default values for missing keys.
|
|
30
|
+
|
|
31
|
+
If the configuration file does not exist, it creates the directory (if necessary) and returns a dictionary
|
|
32
|
+
with default values.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
dict: The loaded configuration with default values for missing keys.
|
|
36
|
+
"""
|
|
37
|
+
if not self.config_file.exists():
|
|
38
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
return {
|
|
40
|
+
"log_level": "INFO",
|
|
41
|
+
"upload_delay": 0.5,
|
|
42
|
+
"max_file_size": 32 * 1024, # Default 32 KB
|
|
43
|
+
}
|
|
44
|
+
with open(self.config_file, "r") as f:
|
|
45
|
+
config = json.load(f)
|
|
46
|
+
if "log_level" not in config:
|
|
47
|
+
config["log_level"] = "INFO"
|
|
48
|
+
if "upload_delay" not in config:
|
|
49
|
+
config["upload_delay"] = 0.5
|
|
50
|
+
if "max_file_size" not in config:
|
|
51
|
+
config["max_file_size"] = 32 * 1024 # Default 32 KB
|
|
52
|
+
return config
|
|
53
|
+
|
|
54
|
+
def _save_config(self):
|
|
55
|
+
"""
|
|
56
|
+
Saves the current configuration to the JSON file.
|
|
57
|
+
|
|
58
|
+
This method writes the current state of the `config` attribute to the configuration file,
|
|
59
|
+
pretty-printing the JSON for readability.
|
|
60
|
+
"""
|
|
61
|
+
with open(self.config_file, "w") as f:
|
|
62
|
+
json.dump(self.config, f, indent=2)
|
|
63
|
+
|
|
64
|
+
def get(self, key, default=None):
|
|
65
|
+
"""
|
|
66
|
+
Retrieves a configuration value.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
key (str): The key for the configuration setting to retrieve.
|
|
70
|
+
default (any, optional): The default value to return if the key is not found. Defaults to None.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The value of the configuration setting if found, otherwise the default value.
|
|
74
|
+
"""
|
|
75
|
+
return self.config.get(key, default)
|
|
76
|
+
|
|
77
|
+
def set(self, key, value):
|
|
78
|
+
"""
|
|
79
|
+
Sets a configuration value and saves the configuration.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
key (str): The key for the configuration setting to set.
|
|
83
|
+
value (any): The value to set for the given key.
|
|
84
|
+
|
|
85
|
+
This method updates the configuration with the provided key-value pair and then saves the configuration
|
|
86
|
+
to the file.
|
|
87
|
+
"""
|
|
88
|
+
self.config[key] = value
|
|
89
|
+
self._save_config()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class ConfigurationError(Exception):
|
|
2
|
+
"""
|
|
3
|
+
Exception raised when there's an issue with the application's configuration.
|
|
4
|
+
|
|
5
|
+
This exception should be raised to indicate problems such as missing required configuration options,
|
|
6
|
+
invalid values, or issues loading configuration files. It helps in distinguishing configuration-related
|
|
7
|
+
errors from other types of exceptions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProviderError(Exception):
|
|
14
|
+
"""
|
|
15
|
+
Exception raised when there's an issue with a provider operation.
|
|
16
|
+
|
|
17
|
+
This exception is used to signal failures in operations related to external service providers,
|
|
18
|
+
such as authentication failures, data retrieval errors, or actions that cannot be completed as requested.
|
|
19
|
+
It allows for more granular error handling that is specific to provider interactions.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
pass
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from .providers.claude_ai import ClaudeAIProvider
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# Import other providers here as they are added
|
|
5
|
+
|
|
6
|
+
def get_provider(provider_name=None, session_key=None):
|
|
7
|
+
"""
|
|
8
|
+
Retrieve an instance of a provider class based on the provider name and session key.
|
|
9
|
+
|
|
10
|
+
This function serves as a factory to instantiate provider classes. It maintains a registry of available
|
|
11
|
+
providers. If a provider name is not specified, it returns a list of available provider names. If a provider
|
|
12
|
+
name is specified but not found in the registry, it raises a ValueError. If a session key is provided, it
|
|
13
|
+
is passed to the provider class constructor.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
provider_name (str, optional): The name of the provider to retrieve. If None, returns a list of available provider names.
|
|
17
|
+
session_key (str, optional): The session key to be used by the provider for authentication. Defaults to None.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
object: An instance of the requested provider class if both provider_name and session_key are provided.
|
|
21
|
+
list: A list of available provider names if provider_name is None.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
ValueError: If the specified provider_name is not found in the registry of providers.
|
|
25
|
+
"""
|
|
26
|
+
providers = {
|
|
27
|
+
"claude.ai": ClaudeAIProvider,
|
|
28
|
+
# Add other providers here as they are implemented
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if provider_name is None:
|
|
32
|
+
return list(providers.keys())
|
|
33
|
+
|
|
34
|
+
provider_class = providers.get(provider_name)
|
|
35
|
+
if provider_class is None:
|
|
36
|
+
raise ValueError(f"Unsupported provider: {provider_name}")
|
|
37
|
+
|
|
38
|
+
return provider_class(session_key) if session_key else provider_class()
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from ..config_manager import ConfigManager
|
|
8
|
+
from ..exceptions import ProviderError
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClaudeAIProvider:
|
|
14
|
+
"""
|
|
15
|
+
A provider class for interacting with the Claude AI API.
|
|
16
|
+
|
|
17
|
+
This class encapsulates methods for performing API operations such as logging in, retrieving organizations,
|
|
18
|
+
projects, and files, as well as uploading and deleting files. It uses a session key for authentication,
|
|
19
|
+
which can be obtained through the login method.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
BASE_URL (str): The base URL for the Claude AI API.
|
|
23
|
+
session_key (str, optional): The session key used for authentication with the API.
|
|
24
|
+
config (ConfigManager): An instance of ConfigManager to manage application configuration.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
BASE_URL = "https://claude.ai/api"
|
|
28
|
+
|
|
29
|
+
def __init__(self, session_key=None):
|
|
30
|
+
"""
|
|
31
|
+
Initializes the ClaudeAIProvider instance.
|
|
32
|
+
|
|
33
|
+
Sets up the session key if provided, initializes the configuration manager, and configures logging
|
|
34
|
+
based on the configuration.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
session_key (str, optional): The session key used for authentication. Defaults to None.
|
|
38
|
+
"""
|
|
39
|
+
self.session_key = session_key
|
|
40
|
+
self.config = ConfigManager()
|
|
41
|
+
self._configure_logging()
|
|
42
|
+
|
|
43
|
+
def _configure_logging(self):
|
|
44
|
+
"""
|
|
45
|
+
Configures the logging level for the application based on the configuration.
|
|
46
|
+
This method sets the global logging configuration to the level specified in the application's configuration.
|
|
47
|
+
If the log level is not specified in the configuration, it defaults to "INFO".
|
|
48
|
+
It ensures that all log messages across the application are handled at the configured log level.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
log_level = self.config.get("log_level", "INFO") # Retrieve log level from config, default to "INFO"
|
|
52
|
+
logging.basicConfig(level=getattr(logging, log_level)) # Set global logging configuration
|
|
53
|
+
logger.setLevel(getattr(logging, log_level)) # Set logger instance to the specified log level
|
|
54
|
+
|
|
55
|
+
def login(self):
|
|
56
|
+
"""
|
|
57
|
+
Guides the user through obtaining a session key from the Claude AI website.
|
|
58
|
+
|
|
59
|
+
This method provides step-by-step instructions for the user to log in to the Claude AI website,
|
|
60
|
+
access the developer tools of their browser, navigate to the cookies section, and retrieve the
|
|
61
|
+
'sessionKey' cookie value. It then prompts the user to enter this session key, which is stored
|
|
62
|
+
in the instance for future requests.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
str: The session key entered by the user.
|
|
66
|
+
"""
|
|
67
|
+
click.echo("To obtain your session key, please follow these steps:")
|
|
68
|
+
click.echo("1. Open your web browser and go to https://claude.ai")
|
|
69
|
+
click.echo("2. Log in to your Claude account if you haven't already")
|
|
70
|
+
click.echo("3. Once logged in, open your browser's developer tools:")
|
|
71
|
+
click.echo(" - Chrome/Edge: Press F12 or Ctrl+Shift+I (Cmd+Option+I on Mac)")
|
|
72
|
+
click.echo(" - Firefox: Press F12 or Ctrl+Shift+I (Cmd+Option+I on Mac)")
|
|
73
|
+
click.echo(
|
|
74
|
+
" - Safari: Enable developer tools in Preferences > Advanced, then press Cmd+Option+I"
|
|
75
|
+
)
|
|
76
|
+
click.echo(
|
|
77
|
+
"4. In the developer tools, go to the 'Application' tab (Chrome/Edge) or 'Storage' tab (Firefox)"
|
|
78
|
+
)
|
|
79
|
+
click.echo(
|
|
80
|
+
"5. In the left sidebar, expand 'Cookies' and select 'https://claude.ai'"
|
|
81
|
+
)
|
|
82
|
+
click.echo("6. Find the cookie named 'sessionKey' and copy its value")
|
|
83
|
+
self.session_key = click.prompt("Please enter your sessionKey", type=str)
|
|
84
|
+
return self.session_key
|
|
85
|
+
|
|
86
|
+
def get_organizations(self):
|
|
87
|
+
"""
|
|
88
|
+
Retrieves a list of organizations the user is a member of.
|
|
89
|
+
|
|
90
|
+
This method sends a GET request to the '/bootstrap' endpoint to fetch account information,
|
|
91
|
+
including memberships in organizations. It parses the response to extract and return
|
|
92
|
+
organization IDs and names.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ProviderError: If the account information does not contain 'account' or 'memberships' keys,
|
|
96
|
+
indicating an issue with retrieving organization information.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
list of dict: A list of dictionaries, each containing the 'id' and 'name' of an organization.
|
|
100
|
+
"""
|
|
101
|
+
account_info = self._make_request("GET", "/bootstrap")
|
|
102
|
+
if (
|
|
103
|
+
"account" not in account_info
|
|
104
|
+
or "memberships" not in account_info["account"]
|
|
105
|
+
):
|
|
106
|
+
raise ProviderError("Unable to retrieve organization information")
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
{
|
|
110
|
+
"id": membership["organization"]["uuid"],
|
|
111
|
+
"name": membership["organization"]["name"],
|
|
112
|
+
}
|
|
113
|
+
for membership in account_info["account"]["memberships"]
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
def get_projects(self, organization_id, include_archived=False):
|
|
117
|
+
"""
|
|
118
|
+
Retrieves a list of projects for a specified organization.
|
|
119
|
+
|
|
120
|
+
This method sends a GET request to fetch all projects associated with a given organization ID.
|
|
121
|
+
It then filters these projects based on the `include_archived` parameter. If `include_archived`
|
|
122
|
+
is False (default), only active projects are returned. If True, both active and archived projects
|
|
123
|
+
are returned.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
organization_id (str): The unique identifier for the organization.
|
|
127
|
+
include_archived (bool, optional): Flag to include archived projects in the result. Defaults to False.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
list of dict: A list of dictionaries, each representing a project with its ID, name, and archival status.
|
|
131
|
+
"""
|
|
132
|
+
projects = self._make_request(
|
|
133
|
+
"GET", f"/organizations/{organization_id}/projects"
|
|
134
|
+
)
|
|
135
|
+
filtered_projects = [
|
|
136
|
+
{
|
|
137
|
+
"id": project["uuid"],
|
|
138
|
+
"name": project["name"],
|
|
139
|
+
"archived_at": project.get("archived_at"),
|
|
140
|
+
}
|
|
141
|
+
for project in projects
|
|
142
|
+
if include_archived or project.get("archived_at") is None
|
|
143
|
+
]
|
|
144
|
+
return filtered_projects
|
|
145
|
+
|
|
146
|
+
def list_files(self, organization_id, project_id):
|
|
147
|
+
"""
|
|
148
|
+
Lists all files within a specified project and organization.
|
|
149
|
+
|
|
150
|
+
This method sends a GET request to the Claude AI API to retrieve all documents associated with a given project
|
|
151
|
+
within an organization. It then formats the response into a list of dictionaries, each representing a file with
|
|
152
|
+
its unique identifier, file name, content, and creation date.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
organization_id (str): The unique identifier for the organization.
|
|
156
|
+
project_id (str): The unique identifier for the project within the organization.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
list of dict: A list of dictionaries, each containing details of a file such as its UUID, file name,
|
|
160
|
+
content, and the date it was created.
|
|
161
|
+
"""
|
|
162
|
+
files = self._make_request(
|
|
163
|
+
"GET", f"/organizations/{organization_id}/projects/{project_id}/docs"
|
|
164
|
+
)
|
|
165
|
+
return [
|
|
166
|
+
{
|
|
167
|
+
"uuid": file["uuid"],
|
|
168
|
+
"file_name": file["file_name"],
|
|
169
|
+
"content": file["content"],
|
|
170
|
+
"created_at": file["created_at"],
|
|
171
|
+
}
|
|
172
|
+
for file in files
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
def upload_file(self, organization_id, project_id, file_name, content):
|
|
176
|
+
"""
|
|
177
|
+
Uploads a file to a specified project within an organization.
|
|
178
|
+
|
|
179
|
+
This method sends a POST request to the Claude AI API to upload a file with the given name and content
|
|
180
|
+
to a specified project within an organization. The file's metadata, including its name and content,
|
|
181
|
+
is sent as JSON in the request body.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
organization_id (str): The unique identifier for the organization.
|
|
185
|
+
project_id (str): The unique identifier for the project within the organization.
|
|
186
|
+
file_name (str): The name of the file to be uploaded.
|
|
187
|
+
content (str): The content of the file to be uploaded.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
dict: The response from the server after the file upload operation, typically including details
|
|
191
|
+
about the uploaded file such as its ID, name, and a confirmation of the upload status.
|
|
192
|
+
"""
|
|
193
|
+
return self._make_request(
|
|
194
|
+
"POST",
|
|
195
|
+
f"/organizations/{organization_id}/projects/{project_id}/docs",
|
|
196
|
+
json={"file_name": file_name, "content": content},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def delete_file(self, organization_id, project_id, file_uuid):
|
|
200
|
+
"""
|
|
201
|
+
Deletes a file from a specified project within an organization.
|
|
202
|
+
|
|
203
|
+
This method sends a DELETE request to the Claude AI API to remove a file, identified by its UUID,
|
|
204
|
+
from a specified project within an organization. The organization and project are identified by their
|
|
205
|
+
respective unique identifiers.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
organization_id (str): The unique identifier for the organization.
|
|
209
|
+
project_id (str): The unique identifier for the project within the organization.
|
|
210
|
+
file_uuid (str): The unique identifier (UUID) of the file to be deleted.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
dict: The response from the server after the file deletion operation, typically confirming the deletion.
|
|
214
|
+
"""
|
|
215
|
+
return self._make_request(
|
|
216
|
+
"DELETE",
|
|
217
|
+
f"/organizations/{organization_id}/projects/{project_id}/docs/{file_uuid}",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def _make_request(self, method, endpoint, **kwargs):
|
|
221
|
+
"""
|
|
222
|
+
Sends a request to the specified endpoint using the given HTTP method.
|
|
223
|
+
|
|
224
|
+
This method constructs a request to the Claude AI API, appending the specified endpoint to the base URL.
|
|
225
|
+
It sets up common headers and cookies for the request, including a session key for authentication.
|
|
226
|
+
Additional headers can be provided through `kwargs`. The method logs the request and response details
|
|
227
|
+
and handles common HTTP errors and JSON parsing errors.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
method (str): The HTTP method to use for the request (e.g., 'GET', 'POST').
|
|
231
|
+
endpoint (str): The API endpoint to which the request is sent.
|
|
232
|
+
**kwargs: Arbitrary keyword arguments. Can include 'headers' to add or override default headers,
|
|
233
|
+
and any other request parameters.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
dict or None: The parsed JSON response from the API if the response contains content; otherwise, None.
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
ProviderError: If the request fails, if the response status code indicates an error,
|
|
240
|
+
or if the response cannot be parsed as JSON.
|
|
241
|
+
"""
|
|
242
|
+
url = f"{self.BASE_URL}{endpoint}"
|
|
243
|
+
headers = {
|
|
244
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
|
|
245
|
+
"Accept": "*/*",
|
|
246
|
+
"Accept-Language": "en-US,en;q=0.5",
|
|
247
|
+
"Referer": "https://claude.ai/",
|
|
248
|
+
"Origin": "https://claude.ai",
|
|
249
|
+
"Connection": "keep-alive",
|
|
250
|
+
}
|
|
251
|
+
cookies = {"sessionKey": self.session_key}
|
|
252
|
+
|
|
253
|
+
if "headers" in kwargs:
|
|
254
|
+
headers.update(kwargs.pop("headers"))
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
logger.debug(f"Making {method} request to {url}")
|
|
258
|
+
logger.debug(f"Headers: {headers}")
|
|
259
|
+
logger.debug(f"Cookies: {cookies}")
|
|
260
|
+
if "data" in kwargs:
|
|
261
|
+
logger.debug(f"Request data: {kwargs['data']}")
|
|
262
|
+
|
|
263
|
+
response = requests.request(
|
|
264
|
+
method, url, headers=headers, cookies=cookies, **kwargs
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
logger.debug(f"Response status code: {response.status_code}")
|
|
268
|
+
logger.debug(f"Response headers: {response.headers}")
|
|
269
|
+
logger.debug(
|
|
270
|
+
f"Response content: {response.text[:1000]}..."
|
|
271
|
+
) # Log first 1000 characters of response
|
|
272
|
+
|
|
273
|
+
response.raise_for_status()
|
|
274
|
+
|
|
275
|
+
if not response.content:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
return response.json()
|
|
280
|
+
except json.JSONDecodeError as json_err:
|
|
281
|
+
logger.error(f"Failed to parse JSON response: {str(json_err)}")
|
|
282
|
+
logger.error(f"Response content: {response.text}")
|
|
283
|
+
raise ProviderError(f"Invalid JSON response from API: {str(json_err)}")
|
|
284
|
+
|
|
285
|
+
except requests.RequestException as e:
|
|
286
|
+
logger.error(f"Request failed: {str(e)}")
|
|
287
|
+
if hasattr(e, "response") and e.response is not None:
|
|
288
|
+
logger.error(f"Response status code: {e.response.status_code}")
|
|
289
|
+
logger.error(f"Response headers: {e.response.headers}")
|
|
290
|
+
logger.error(f"Response content: {e.response.text}")
|
|
291
|
+
raise ProviderError(f"API request failed: {str(e)}")
|
|
292
|
+
|
|
293
|
+
def archive_project(self, organization_id, project_id):
|
|
294
|
+
"""
|
|
295
|
+
Archives a specified project within an organization.
|
|
296
|
+
|
|
297
|
+
This method sends a PUT request to the Claude AI API to change the archival status of a specified project
|
|
298
|
+
to archive. The project and organization are identified by their respective unique identifiers.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
organization_id (str): The unique identifier for the organization.
|
|
302
|
+
project_id (str): The unique identifier for the project within the organization.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
dict: The response from the server after the archival operation, typically confirming the archival status.
|
|
306
|
+
"""
|
|
307
|
+
return self._make_request(
|
|
308
|
+
"PUT",
|
|
309
|
+
f"/organizations/{organization_id}/projects/{project_id}",
|
|
310
|
+
json={"is_archived": True},
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def create_project(self, organization_id, name, description=""):
|
|
314
|
+
"""
|
|
315
|
+
Creates a new project within a specified organization.
|
|
316
|
+
|
|
317
|
+
This method sends a POST request to the Claude AI API to create a new project with the given name,
|
|
318
|
+
description, and sets it as private within the specified organization. The project's name, description,
|
|
319
|
+
and privacy status are sent as JSON in the request body.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
organization_id (str): The unique identifier for the organization.
|
|
323
|
+
name (str): The name of the project to be created.
|
|
324
|
+
description (str, optional): A description of the project. Defaults to an empty string.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
dict: The response from the server after the project creation operation, typically including details
|
|
328
|
+
about the created project such as its ID, name, and a confirmation of the creation status.
|
|
329
|
+
"""
|
|
330
|
+
data = {"name": name, "description": description, "is_private": True}
|
|
331
|
+
return self._make_request(
|
|
332
|
+
"POST", f"/organizations/{organization_id}/projects", json=data
|
|
333
|
+
)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import hashlib
|
|
3
|
+
from functools import wraps
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import pathspec
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from claudesync.exceptions import ConfigurationError, ProviderError
|
|
10
|
+
from claudesync.provider_factory import get_provider
|
|
11
|
+
from claudesync.config_manager import ConfigManager
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
config_manager = ConfigManager()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_and_calculate_md5(content):
|
|
19
|
+
"""
|
|
20
|
+
Calculate the MD5 checksum of the given content after normalizing line endings.
|
|
21
|
+
|
|
22
|
+
This function normalizes the line endings of the input content to Unix-style (\n),
|
|
23
|
+
strips leading and trailing whitespace, and then calculates the MD5 checksum of the
|
|
24
|
+
normalized content. This is useful for ensuring consistent checksums across different
|
|
25
|
+
operating systems and environments where line ending styles may vary.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
content (str): The content for which to calculate the checksum.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
str: The hexadecimal MD5 checksum of the normalized content.
|
|
32
|
+
"""
|
|
33
|
+
normalized_content = content.replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
34
|
+
return hashlib.md5(normalized_content.encode("utf-8")).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_gitignore(base_path):
|
|
38
|
+
"""
|
|
39
|
+
Loads and parses the .gitignore file from the specified base path.
|
|
40
|
+
|
|
41
|
+
This function attempts to find a .gitignore file in the given base path. If found,
|
|
42
|
+
it reads the file and creates a PathSpec object that can be used to match paths
|
|
43
|
+
against the patterns defined in the .gitignore file. This is useful for filtering
|
|
44
|
+
out files that should be ignored based on the project's .gitignore settings.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
base_path (str): The base directory path where the .gitignore file is located.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
pathspec.PathSpec or None: A PathSpec object containing the patterns from the .gitignore file
|
|
51
|
+
if the file exists; otherwise, None.
|
|
52
|
+
"""
|
|
53
|
+
gitignore_path = os.path.join(base_path, ".gitignore")
|
|
54
|
+
if os.path.exists(gitignore_path):
|
|
55
|
+
with open(gitignore_path, "r") as f:
|
|
56
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", f)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_text_file(file_path, sample_size=8192):
|
|
61
|
+
"""
|
|
62
|
+
Determines if a file is a text file by checking for the absence of null bytes.
|
|
63
|
+
|
|
64
|
+
This function reads a sample of the file (default 8192 bytes) and checks if it contains
|
|
65
|
+
any null byte (\x00). The presence of a null byte is often indicative of a binary file.
|
|
66
|
+
This is a heuristic method and may not be 100% accurate for all file types.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
file_path (str): The path to the file to be checked.
|
|
70
|
+
sample_size (int, optional): The number of bytes to read from the file for checking.
|
|
71
|
+
Defaults to 8192.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
bool: True if the file is likely a text file, False if it is likely binary or an error occurred.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
with open(file_path, "rb") as file:
|
|
78
|
+
return b"\x00" not in file.read(sample_size)
|
|
79
|
+
except IOError:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compute_md5_hash(content):
|
|
84
|
+
"""
|
|
85
|
+
Computes the MD5 hash of the given content.
|
|
86
|
+
|
|
87
|
+
This function takes a string as input, encodes it into UTF-8, and then computes the MD5 hash of the encoded string.
|
|
88
|
+
The result is a hexadecimal representation of the hash, which is commonly used for creating a quick and simple
|
|
89
|
+
fingerprint of a piece of data.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
content (str): The content for which to compute the MD5 hash.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
str: The hexadecimal MD5 hash of the input content.
|
|
96
|
+
"""
|
|
97
|
+
return hashlib.md5(content.encode("utf-8")).hexdigest()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_local_files(local_path):
|
|
101
|
+
"""
|
|
102
|
+
Retrieves a dictionary of local files within a specified path, applying various filters.
|
|
103
|
+
|
|
104
|
+
This function walks through the directory specified by `local_path`, applying several filters to each file:
|
|
105
|
+
- Excludes files in directories like .git, .svn, etc.
|
|
106
|
+
- Skips files larger than a specified maximum size (default 200KB, configurable).
|
|
107
|
+
- Ignores temporary editor files (ending with '~').
|
|
108
|
+
- Applies .gitignore rules if a .gitignore file is present in the `local_path`.
|
|
109
|
+
- Checks if the file is a text file before processing.
|
|
110
|
+
Each file that passes these filters is read, and its content is hashed using MD5. The function returns a dictionary
|
|
111
|
+
where each key is the relative path of a file from `local_path`, and its value is the MD5 hash of the file's content.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
local_path (str): The base directory path to search for files.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
dict: A dictionary where keys are relative file paths, and values are MD5 hashes of the file contents.
|
|
118
|
+
"""
|
|
119
|
+
gitignore = load_gitignore(local_path)
|
|
120
|
+
files = {}
|
|
121
|
+
|
|
122
|
+
# List of directories to exclude
|
|
123
|
+
exclude_dirs = {".git", ".svn", ".hg", ".bzr", "_darcs", "CVS"}
|
|
124
|
+
|
|
125
|
+
for root, dirs, filenames in os.walk(local_path):
|
|
126
|
+
# Remove excluded directories
|
|
127
|
+
dirs[:] = [d for d in dirs if d not in exclude_dirs]
|
|
128
|
+
|
|
129
|
+
rel_root = os.path.relpath(root, local_path)
|
|
130
|
+
if rel_root == ".":
|
|
131
|
+
rel_root = ""
|
|
132
|
+
|
|
133
|
+
for filename in filenames:
|
|
134
|
+
rel_path = os.path.join(rel_root, filename)
|
|
135
|
+
full_path = os.path.join(root, filename)
|
|
136
|
+
|
|
137
|
+
# Skip files larger than 200KB
|
|
138
|
+
max_file_size = config_manager.get("max_file_size", 32 * 1024)
|
|
139
|
+
if os.path.getsize(full_path) > max_file_size:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
# Skip temporary editor files
|
|
143
|
+
if filename.endswith("~"):
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
# Use gitignore rules if available
|
|
147
|
+
if gitignore and gitignore.match_file(rel_path):
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
# Check if it's a text file
|
|
151
|
+
if not is_text_file(full_path):
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
with open(full_path, "r", encoding="utf-8") as file:
|
|
156
|
+
content = file.read()
|
|
157
|
+
files[rel_path] = compute_md5_hash(content)
|
|
158
|
+
except UnicodeDecodeError:
|
|
159
|
+
# If UTF-8 decoding fails, it's likely not a text file we can handle
|
|
160
|
+
logger.debug(f"Unable to read {full_path} as UTF-8 text. Skipping.")
|
|
161
|
+
continue
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.error(f"Error reading file {full_path}: {str(e)}")
|
|
164
|
+
|
|
165
|
+
return files
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def handle_errors(func):
|
|
169
|
+
"""
|
|
170
|
+
A decorator that wraps a function to catch and handle specific exceptions.
|
|
171
|
+
|
|
172
|
+
This decorator catches exceptions of type ConfigurationError and ProviderError
|
|
173
|
+
that are raised within the decorated function. When such an exception is caught,
|
|
174
|
+
it prints an error message to the console using click's echo function. This is
|
|
175
|
+
useful for CLI applications where a friendly error message is preferred over a
|
|
176
|
+
full traceback for known error conditions.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
func (Callable): The function to be decorated.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Callable: The wrapper function that includes exception handling.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
@wraps(func)
|
|
186
|
+
def wrapper(*args, **kwargs):
|
|
187
|
+
try:
|
|
188
|
+
return func(*args, **kwargs)
|
|
189
|
+
except (ConfigurationError, ProviderError) as e:
|
|
190
|
+
click.echo(f"Error: {str(e)}")
|
|
191
|
+
|
|
192
|
+
return wrapper
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def validate_and_get_provider(config, require_org=True):
|
|
196
|
+
"""
|
|
197
|
+
Validates the configuration for the presence of an active provider and session key,
|
|
198
|
+
and optionally checks for an active organization ID. If validation passes, it retrieves
|
|
199
|
+
the provider instance based on the active provider name.
|
|
200
|
+
|
|
201
|
+
This function ensures that the necessary configuration settings are present before
|
|
202
|
+
attempting to interact with a provider. It raises a ConfigurationError if the required
|
|
203
|
+
settings are missing, guiding the user to perform necessary setup steps.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
config (ConfigManager): The configuration manager instance containing settings.
|
|
207
|
+
require_org (bool, optional): Flag to indicate whether an active organization ID
|
|
208
|
+
is required. Defaults to True.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
object: An instance of the provider specified in the configuration.
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
ConfigurationError: If the active provider or session key is missing, or if
|
|
215
|
+
require_org is True and no active organization ID is set.
|
|
216
|
+
"""
|
|
217
|
+
active_provider = config.get("active_provider")
|
|
218
|
+
session_key = config.get("session_key")
|
|
219
|
+
if not active_provider or not session_key:
|
|
220
|
+
raise ConfigurationError(
|
|
221
|
+
"No active provider or session key. Please login first."
|
|
222
|
+
)
|
|
223
|
+
if require_org and not config.get("active_organization_id"):
|
|
224
|
+
raise ConfigurationError(
|
|
225
|
+
"No active organization set. Please select an organization."
|
|
226
|
+
)
|
|
227
|
+
return get_provider(active_provider, session_key)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def validate_and_store_local_path(config):
|
|
231
|
+
"""
|
|
232
|
+
Prompts the user for the absolute path to their local project directory and stores it in the configuration.
|
|
233
|
+
|
|
234
|
+
This function repeatedly prompts the user to enter the absolute path to their local project directory until
|
|
235
|
+
a valid absolute path is provided. The path is validated to ensure it exists, is a directory, and is an absolute path.
|
|
236
|
+
Once a valid path is provided, it is stored in the configuration using the `set` method of the `ConfigManager` object.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
config (ConfigManager): The configuration manager instance to store the local path setting.
|
|
240
|
+
|
|
241
|
+
Note:
|
|
242
|
+
This function uses `click.prompt` to interact with the user, providing a default path (the current working directory)
|
|
243
|
+
and validating the user's input to ensure it meets the criteria for an absolute path to a directory.
|
|
244
|
+
"""
|
|
245
|
+
def get_default_path():
|
|
246
|
+
return os.getcwd()
|
|
247
|
+
|
|
248
|
+
while True:
|
|
249
|
+
default_path = get_default_path()
|
|
250
|
+
local_path = click.prompt(
|
|
251
|
+
"Enter the absolute path to your local project directory",
|
|
252
|
+
type=click.Path(
|
|
253
|
+
exists=True, file_okay=False, dir_okay=True, resolve_path=True
|
|
254
|
+
),
|
|
255
|
+
default=default_path,
|
|
256
|
+
show_default=True,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if os.path.isabs(local_path):
|
|
260
|
+
config.set("local_path", local_path)
|
|
261
|
+
click.echo(f"Local path set to: {local_path}")
|
|
262
|
+
break
|
|
263
|
+
else:
|
|
264
|
+
click.echo("Please enter an absolute path.")
|
|
@@ -3,7 +3,7 @@ import os
|
|
|
3
3
|
import tempfile
|
|
4
4
|
|
|
5
5
|
from claudesync.utils import (
|
|
6
|
-
|
|
6
|
+
compute_md5_hash,
|
|
7
7
|
load_gitignore,
|
|
8
8
|
get_local_files,
|
|
9
9
|
)
|
|
@@ -14,7 +14,7 @@ class TestUtils(unittest.TestCase):
|
|
|
14
14
|
def test_calculate_checksum(self):
|
|
15
15
|
content = "Hello, World!"
|
|
16
16
|
expected_checksum = "65a8e27d8879283831b664bd8b7f0ad4"
|
|
17
|
-
self.assertEqual(
|
|
17
|
+
self.assertEqual(compute_md5_hash(content), expected_checksum)
|
|
18
18
|
|
|
19
19
|
def test_load_gitignore(self):
|
|
20
20
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
class ConfigManager:
|
|
6
|
-
def __init__(self):
|
|
7
|
-
self.config_dir = Path.home() / ".claudesync"
|
|
8
|
-
self.config_file = self.config_dir / "config.json"
|
|
9
|
-
self.config = self._load_config()
|
|
10
|
-
|
|
11
|
-
def _load_config(self):
|
|
12
|
-
if not self.config_file.exists():
|
|
13
|
-
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
14
|
-
return {
|
|
15
|
-
"log_level": "INFO",
|
|
16
|
-
"upload_delay": 0.5,
|
|
17
|
-
"max_file_size": 32 * 1024, # Default 32 KB
|
|
18
|
-
}
|
|
19
|
-
with open(self.config_file, "r") as f:
|
|
20
|
-
config = json.load(f)
|
|
21
|
-
if "log_level" not in config:
|
|
22
|
-
config["log_level"] = "INFO"
|
|
23
|
-
if "upload_delay" not in config:
|
|
24
|
-
config["upload_delay"] = 0.5
|
|
25
|
-
if "max_file_size" not in config:
|
|
26
|
-
config["max_file_size"] = 32 * 1024 # Default 32 KB
|
|
27
|
-
return config
|
|
28
|
-
|
|
29
|
-
def _save_config(self):
|
|
30
|
-
with open(self.config_file, "w") as f:
|
|
31
|
-
json.dump(self.config, f, indent=2)
|
|
32
|
-
|
|
33
|
-
def get(self, key, default=None):
|
|
34
|
-
return self.config.get(key, default)
|
|
35
|
-
|
|
36
|
-
def set(self, key, value):
|
|
37
|
-
self.config[key] = value
|
|
38
|
-
self._save_config()
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
from .providers.claude_ai import ClaudeAIProvider
|
|
2
|
-
|
|
3
|
-
# Import other providers here as they are added
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def get_provider(provider_name=None, session_key=None):
|
|
7
|
-
providers = {
|
|
8
|
-
"claude.ai": ClaudeAIProvider,
|
|
9
|
-
# Add other providers here as they are implemented
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
if provider_name is None:
|
|
13
|
-
return list(providers.keys())
|
|
14
|
-
|
|
15
|
-
provider_class = providers.get(provider_name)
|
|
16
|
-
if provider_class is None:
|
|
17
|
-
raise ValueError(f"Unsupported provider: {provider_name}")
|
|
18
|
-
|
|
19
|
-
return provider_class(session_key) if session_key else provider_class()
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
import requests
|
|
2
|
-
import json
|
|
3
|
-
import click
|
|
4
|
-
import logging
|
|
5
|
-
from ..exceptions import ProviderError
|
|
6
|
-
from ..config_manager import ConfigManager
|
|
7
|
-
|
|
8
|
-
logger = logging.getLogger(__name__)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class ClaudeAIProvider:
|
|
12
|
-
BASE_URL = "https://claude.ai/api"
|
|
13
|
-
|
|
14
|
-
def __init__(self, session_key=None):
|
|
15
|
-
self.session_key = session_key
|
|
16
|
-
self.config = ConfigManager()
|
|
17
|
-
self._configure_logging()
|
|
18
|
-
|
|
19
|
-
def _configure_logging(self):
|
|
20
|
-
log_level = self.config.get("log_level", "INFO")
|
|
21
|
-
logging.basicConfig(level=getattr(logging, log_level))
|
|
22
|
-
logger.setLevel(getattr(logging, log_level))
|
|
23
|
-
|
|
24
|
-
def login(self):
|
|
25
|
-
click.echo("To obtain your session key, please follow these steps:")
|
|
26
|
-
click.echo("1. Open your web browser and go to https://claude.ai")
|
|
27
|
-
click.echo("2. Log in to your Claude account if you haven't already")
|
|
28
|
-
click.echo("3. Once logged in, open your browser's developer tools:")
|
|
29
|
-
click.echo(" - Chrome/Edge: Press F12 or Ctrl+Shift+I (Cmd+Option+I on Mac)")
|
|
30
|
-
click.echo(" - Firefox: Press F12 or Ctrl+Shift+I (Cmd+Option+I on Mac)")
|
|
31
|
-
click.echo(
|
|
32
|
-
" - Safari: Enable developer tools in Preferences > Advanced, then press Cmd+Option+I"
|
|
33
|
-
)
|
|
34
|
-
click.echo(
|
|
35
|
-
"4. In the developer tools, go to the 'Application' tab (Chrome/Edge) or 'Storage' tab (Firefox)"
|
|
36
|
-
)
|
|
37
|
-
click.echo(
|
|
38
|
-
"5. In the left sidebar, expand 'Cookies' and select 'https://claude.ai'"
|
|
39
|
-
)
|
|
40
|
-
click.echo("6. Find the cookie named 'sessionKey' and copy its value")
|
|
41
|
-
|
|
42
|
-
self.session_key = click.prompt("Please enter your sessionKey", type=str)
|
|
43
|
-
return self.session_key
|
|
44
|
-
|
|
45
|
-
def get_organizations(self):
|
|
46
|
-
account_info = self._make_request("GET", "/bootstrap")
|
|
47
|
-
if (
|
|
48
|
-
"account" not in account_info
|
|
49
|
-
or "memberships" not in account_info["account"]
|
|
50
|
-
):
|
|
51
|
-
raise ProviderError("Unable to retrieve organization information")
|
|
52
|
-
|
|
53
|
-
return [
|
|
54
|
-
{
|
|
55
|
-
"id": membership["organization"]["uuid"],
|
|
56
|
-
"name": membership["organization"]["name"],
|
|
57
|
-
}
|
|
58
|
-
for membership in account_info["account"]["memberships"]
|
|
59
|
-
]
|
|
60
|
-
|
|
61
|
-
def get_projects(self, organization_id, include_archived=False):
|
|
62
|
-
projects = self._make_request(
|
|
63
|
-
"GET", f"/organizations/{organization_id}/projects"
|
|
64
|
-
)
|
|
65
|
-
filtered_projects = [
|
|
66
|
-
{
|
|
67
|
-
"id": project["uuid"],
|
|
68
|
-
"name": project["name"],
|
|
69
|
-
"archived_at": project.get("archived_at"),
|
|
70
|
-
}
|
|
71
|
-
for project in projects
|
|
72
|
-
if include_archived or project.get("archived_at") is None
|
|
73
|
-
]
|
|
74
|
-
return filtered_projects
|
|
75
|
-
|
|
76
|
-
def list_files(self, organization_id, project_id):
|
|
77
|
-
files = self._make_request(
|
|
78
|
-
"GET", f"/organizations/{organization_id}/projects/{project_id}/docs"
|
|
79
|
-
)
|
|
80
|
-
return [
|
|
81
|
-
{
|
|
82
|
-
"uuid": file["uuid"],
|
|
83
|
-
"file_name": file["file_name"],
|
|
84
|
-
"content": file["content"],
|
|
85
|
-
"created_at": file["created_at"],
|
|
86
|
-
}
|
|
87
|
-
for file in files
|
|
88
|
-
]
|
|
89
|
-
|
|
90
|
-
def upload_file(self, organization_id, project_id, file_name, content):
|
|
91
|
-
return self._make_request(
|
|
92
|
-
"POST",
|
|
93
|
-
f"/organizations/{organization_id}/projects/{project_id}/docs",
|
|
94
|
-
json={"file_name": file_name, "content": content},
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
def delete_file(self, organization_id, project_id, file_uuid):
|
|
98
|
-
return self._make_request(
|
|
99
|
-
"DELETE",
|
|
100
|
-
f"/organizations/{organization_id}/projects/{project_id}/docs/{file_uuid}",
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
def _make_request(self, method, endpoint, **kwargs):
|
|
104
|
-
url = f"{self.BASE_URL}{endpoint}"
|
|
105
|
-
headers = {
|
|
106
|
-
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
|
|
107
|
-
"Accept": "*/*",
|
|
108
|
-
"Accept-Language": "en-US,en;q=0.5",
|
|
109
|
-
"Referer": "https://claude.ai/",
|
|
110
|
-
"Origin": "https://claude.ai",
|
|
111
|
-
"Connection": "keep-alive",
|
|
112
|
-
}
|
|
113
|
-
cookies = {"sessionKey": self.session_key}
|
|
114
|
-
|
|
115
|
-
if "headers" in kwargs:
|
|
116
|
-
headers.update(kwargs.pop("headers"))
|
|
117
|
-
|
|
118
|
-
try:
|
|
119
|
-
logger.debug(f"Making {method} request to {url}")
|
|
120
|
-
logger.debug(f"Headers: {headers}")
|
|
121
|
-
logger.debug(f"Cookies: {cookies}")
|
|
122
|
-
if "data" in kwargs:
|
|
123
|
-
logger.debug(f"Request data: {kwargs['data']}")
|
|
124
|
-
|
|
125
|
-
response = requests.request(
|
|
126
|
-
method, url, headers=headers, cookies=cookies, **kwargs
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
logger.debug(f"Response status code: {response.status_code}")
|
|
130
|
-
logger.debug(f"Response headers: {response.headers}")
|
|
131
|
-
logger.debug(
|
|
132
|
-
f"Response content: {response.text[:1000]}..."
|
|
133
|
-
) # Log first 1000 characters of response
|
|
134
|
-
|
|
135
|
-
response.raise_for_status()
|
|
136
|
-
|
|
137
|
-
if not response.content:
|
|
138
|
-
return None
|
|
139
|
-
|
|
140
|
-
try:
|
|
141
|
-
return response.json()
|
|
142
|
-
except json.JSONDecodeError as json_err:
|
|
143
|
-
logger.error(f"Failed to parse JSON response: {str(json_err)}")
|
|
144
|
-
logger.error(f"Response content: {response.text}")
|
|
145
|
-
raise ProviderError(f"Invalid JSON response from API: {str(json_err)}")
|
|
146
|
-
|
|
147
|
-
except requests.RequestException as e:
|
|
148
|
-
logger.error(f"Request failed: {str(e)}")
|
|
149
|
-
if hasattr(e, "response") and e.response is not None:
|
|
150
|
-
logger.error(f"Response status code: {e.response.status_code}")
|
|
151
|
-
logger.error(f"Response headers: {e.response.headers}")
|
|
152
|
-
logger.error(f"Response content: {e.response.text}")
|
|
153
|
-
raise ProviderError(f"API request failed: {str(e)}")
|
|
154
|
-
|
|
155
|
-
def archive_project(self, organization_id, project_id):
|
|
156
|
-
return self._make_request(
|
|
157
|
-
"PUT",
|
|
158
|
-
f"/organizations/{organization_id}/projects/{project_id}",
|
|
159
|
-
json={"is_archived": True},
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
def create_project(self, organization_id, name, description=""):
|
|
163
|
-
data = {"name": name, "description": description, "is_private": True}
|
|
164
|
-
return self._make_request(
|
|
165
|
-
"POST", f"/organizations/{organization_id}/projects", json=data
|
|
166
|
-
)
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import hashlib
|
|
3
|
-
from functools import wraps
|
|
4
|
-
|
|
5
|
-
import click
|
|
6
|
-
import pathspec
|
|
7
|
-
import logging
|
|
8
|
-
|
|
9
|
-
from claudesync.exceptions import ConfigurationError, ProviderError
|
|
10
|
-
from claudesync.provider_factory import get_provider
|
|
11
|
-
from claudesync.config_manager import ConfigManager
|
|
12
|
-
|
|
13
|
-
logger = logging.getLogger(__name__)
|
|
14
|
-
|
|
15
|
-
config_manager = ConfigManager()
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def calculate_checksum(content):
|
|
19
|
-
normalized_content = content.replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
20
|
-
return hashlib.md5(normalized_content.encode("utf-8")).hexdigest()
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def load_gitignore(base_path):
|
|
24
|
-
gitignore_path = os.path.join(base_path, ".gitignore")
|
|
25
|
-
if os.path.exists(gitignore_path):
|
|
26
|
-
with open(gitignore_path, "r") as f:
|
|
27
|
-
return pathspec.PathSpec.from_lines("gitwildmatch", f)
|
|
28
|
-
return None
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def is_text_file(file_path, sample_size=8192):
|
|
32
|
-
try:
|
|
33
|
-
with open(file_path, "rb") as file:
|
|
34
|
-
return b"\x00" not in file.read(sample_size)
|
|
35
|
-
except IOError:
|
|
36
|
-
return False
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def calculate_checksum(content):
|
|
40
|
-
return hashlib.md5(content.encode("utf-8")).hexdigest()
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def get_local_files(local_path):
|
|
44
|
-
gitignore = load_gitignore(local_path)
|
|
45
|
-
files = {}
|
|
46
|
-
|
|
47
|
-
# List of directories to exclude
|
|
48
|
-
exclude_dirs = {".git", ".svn", ".hg", ".bzr", "_darcs", "CVS"}
|
|
49
|
-
|
|
50
|
-
for root, dirs, filenames in os.walk(local_path):
|
|
51
|
-
# Remove excluded directories
|
|
52
|
-
dirs[:] = [d for d in dirs if d not in exclude_dirs]
|
|
53
|
-
|
|
54
|
-
rel_root = os.path.relpath(root, local_path)
|
|
55
|
-
if rel_root == ".":
|
|
56
|
-
rel_root = ""
|
|
57
|
-
|
|
58
|
-
for filename in filenames:
|
|
59
|
-
rel_path = os.path.join(rel_root, filename)
|
|
60
|
-
full_path = os.path.join(root, filename)
|
|
61
|
-
|
|
62
|
-
# Skip files larger than 200KB
|
|
63
|
-
max_file_size = config_manager.get("max_file_size", 32 * 1024)
|
|
64
|
-
if os.path.getsize(full_path) > max_file_size:
|
|
65
|
-
continue
|
|
66
|
-
|
|
67
|
-
# Skip temporary editor files
|
|
68
|
-
if filename.endswith("~"):
|
|
69
|
-
continue
|
|
70
|
-
|
|
71
|
-
# Use gitignore rules if available
|
|
72
|
-
if gitignore and gitignore.match_file(rel_path):
|
|
73
|
-
continue
|
|
74
|
-
|
|
75
|
-
# Check if it's a text file
|
|
76
|
-
if not is_text_file(full_path):
|
|
77
|
-
continue
|
|
78
|
-
|
|
79
|
-
try:
|
|
80
|
-
with open(full_path, "r", encoding="utf-8") as file:
|
|
81
|
-
content = file.read()
|
|
82
|
-
files[rel_path] = calculate_checksum(content)
|
|
83
|
-
except UnicodeDecodeError:
|
|
84
|
-
# If UTF-8 decoding fails, it's likely not a text file we can handle
|
|
85
|
-
logger.debug(f"Unable to read {full_path} as UTF-8 text. Skipping.")
|
|
86
|
-
continue
|
|
87
|
-
except Exception as e:
|
|
88
|
-
logger.error(f"Error reading file {full_path}: {str(e)}")
|
|
89
|
-
|
|
90
|
-
return files
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def handle_errors(func):
|
|
94
|
-
@wraps(func)
|
|
95
|
-
def wrapper(*args, **kwargs):
|
|
96
|
-
try:
|
|
97
|
-
return func(*args, **kwargs)
|
|
98
|
-
except (ConfigurationError, ProviderError) as e:
|
|
99
|
-
click.echo(f"Error: {str(e)}")
|
|
100
|
-
|
|
101
|
-
return wrapper
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def validate_and_get_provider(config, require_org=True):
|
|
105
|
-
active_provider = config.get("active_provider")
|
|
106
|
-
session_key = config.get("session_key")
|
|
107
|
-
if not active_provider or not session_key:
|
|
108
|
-
raise ConfigurationError(
|
|
109
|
-
"No active provider or session key. Please login first."
|
|
110
|
-
)
|
|
111
|
-
if require_org and not config.get("active_organization_id"):
|
|
112
|
-
raise ConfigurationError(
|
|
113
|
-
"No active organization set. Please select an organization."
|
|
114
|
-
)
|
|
115
|
-
return get_provider(active_provider, session_key)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def validate_and_store_local_path(config):
|
|
119
|
-
def get_default_path():
|
|
120
|
-
return os.getcwd()
|
|
121
|
-
|
|
122
|
-
while True:
|
|
123
|
-
default_path = get_default_path()
|
|
124
|
-
local_path = click.prompt(
|
|
125
|
-
"Enter the absolute path to your local project directory",
|
|
126
|
-
type=click.Path(
|
|
127
|
-
exists=True, file_okay=False, dir_okay=True, resolve_path=True
|
|
128
|
-
),
|
|
129
|
-
default=default_path,
|
|
130
|
-
show_default=True,
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
if os.path.isabs(local_path):
|
|
134
|
-
config.set("local_path", local_path)
|
|
135
|
-
click.echo(f"Local path set to: {local_path}")
|
|
136
|
-
break
|
|
137
|
-
else:
|
|
138
|
-
click.echo("Please enter an absolute path.")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import click
|
|
2
|
-
from claudesync.config_manager import ConfigManager
|
|
3
2
|
import click_completion
|
|
4
3
|
import click_completion.core
|
|
5
4
|
|
|
5
|
+
from claudesync.config_manager import ConfigManager
|
|
6
6
|
from .api import api
|
|
7
7
|
from .organization import organization
|
|
8
8
|
from .project import project
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|