ctxsync 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,255 @@
1
+ from abc import ABC, abstractmethod
2
+ import copy
3
+
4
+
5
+ class BaseConfigManager(ABC):
6
+ """
7
+ Abstract base class for managing configuration settings.
8
+
9
+ This class defines the interface for configuration management and includes
10
+ common functionality that can be shared across different implementations,
11
+ such as file-based and in-memory configurations.
12
+ """
13
+
14
+ def __init__(self):
15
+ """
16
+ Initializes the configuration manager with empty global and local configurations.
17
+
18
+ - `global_config`: A dictionary to store global configuration settings that apply
19
+ universally across all environments.
20
+ - `local_config`: A dictionary to store local configuration settings specific to
21
+ the current environment or project.
22
+ """
23
+ self.global_config = {}
24
+ self.local_config = {}
25
+
26
+ def _get_default_config(self):
27
+ """
28
+ Returns the default configuration dictionary.
29
+
30
+ This method centralizes the default configuration settings, making it easier to manage and update defaults.
31
+
32
+ Returns:
33
+ dict: The default configuration settings.
34
+ """
35
+ return {
36
+ "log_level": "INFO",
37
+ "upload_delay": 0.5,
38
+ "max_file_size": 32 * 1024,
39
+ "two_way_sync": False,
40
+ "prune_remote_files": True,
41
+ "claude_api_url": "https://claude.ai/api",
42
+ "compression_algorithm": "none",
43
+ "submodule_detect_filenames": [
44
+ "pom.xml",
45
+ "build.gradle",
46
+ "package.json",
47
+ "setup.py",
48
+ "Cargo.toml",
49
+ "go.mod",
50
+ ],
51
+ "file_categories": {
52
+ "all_files": {
53
+ "description": "All files not ignored",
54
+ "patterns": ["*"],
55
+ },
56
+ "all_source_code": {
57
+ "description": "All source code files",
58
+ "patterns": [
59
+ "*.java",
60
+ "*.py",
61
+ "*.js",
62
+ "*.ts",
63
+ "*.c",
64
+ "*.cpp",
65
+ "*.h",
66
+ "*.hpp",
67
+ "*.go",
68
+ "*.rs",
69
+ ],
70
+ },
71
+ "production_code": {
72
+ "description": "Production source code",
73
+ "patterns": [
74
+ "**/src/**/*.java",
75
+ "**/*.py",
76
+ "**/*.js",
77
+ "**/*.ts",
78
+ "**/*.vue",
79
+ ],
80
+ },
81
+ "test_code": {
82
+ "description": "Test source code",
83
+ "patterns": [
84
+ "**/test/**/*.java",
85
+ "**/tests/**/*.py",
86
+ "**/test_*.py",
87
+ "**/*Test.java",
88
+ ],
89
+ },
90
+ "build_config": {
91
+ "description": "Build configuration files",
92
+ "patterns": [
93
+ "**/pom.xml",
94
+ "**/build.gradle",
95
+ "**/package.json",
96
+ "**/setup.py",
97
+ "**/Cargo.toml",
98
+ "**/go.mod",
99
+ "**/pyproject.toml",
100
+ "**/requirements.txt",
101
+ "**/*.tf",
102
+ "**/*.yaml",
103
+ "**/*.yml",
104
+ "**/*.properties",
105
+ ],
106
+ },
107
+ "uberproject_java": {
108
+ "description": "Uberproject Java + Javascript",
109
+ "patterns": [
110
+ "**/src/**/*.java",
111
+ "**/*.py",
112
+ "**/*.js",
113
+ "**/*.ts",
114
+ "**/*.vue",
115
+ "**/pom.xml",
116
+ "**/build.gradle",
117
+ "**/package.json",
118
+ "**/setup.py",
119
+ "**/Cargo.toml",
120
+ "**/go.mod",
121
+ "**/pyproject.toml",
122
+ "**/requirements.txt",
123
+ "**/*.tf",
124
+ "**/*.yaml",
125
+ "**/*.yml",
126
+ "**/*.properties",
127
+ ],
128
+ },
129
+ },
130
+ }
131
+
132
+ @abstractmethod
133
+ def _load_global_config(self):
134
+ """
135
+ Loads the global configuration settings.
136
+
137
+ This method should be implemented by subclasses to load the global configuration
138
+ from the appropriate source (e.g., a file or an in-memory structure).
139
+ """
140
+ pass
141
+
142
+ @abstractmethod
143
+ def _load_local_config(self):
144
+ """
145
+ Loads the local configuration settings.
146
+
147
+ This method should be implemented by subclasses to load the local configuration
148
+ from the appropriate source (e.g., a file or an in-memory structure).
149
+ """
150
+ pass
151
+
152
+ @abstractmethod
153
+ def _save_global_config(self):
154
+ """
155
+ Saves the global configuration settings.
156
+
157
+ This method should be implemented by subclasses to save the global configuration
158
+ to the appropriate destination (e.g., a file or an in-memory structure).
159
+ """
160
+ pass
161
+
162
+ @abstractmethod
163
+ def _save_local_config(self):
164
+ """
165
+ Saves the local configuration settings.
166
+
167
+ This method should be implemented by subclasses to save the local configuration
168
+ to the appropriate destination (e.g., a file or an in-memory structure).
169
+ """
170
+ pass
171
+
172
+ @abstractmethod
173
+ def set(self, key, value, local=False):
174
+ """
175
+ Sets a configuration value.
176
+
177
+ Args:
178
+ key (str): The key of the configuration setting to set.
179
+ value: The value to associate with the given key.
180
+ local (bool): Whether to set the configuration in the local context.
181
+ If False, the setting is stored in the global context.
182
+ Default is False.
183
+
184
+ This method should be implemented by subclasses to handle the setting of
185
+ configuration values, either globally or locally.
186
+ """
187
+ pass
188
+
189
+ @abstractmethod
190
+ def get(self, key, default=None):
191
+ """
192
+ Retrieves a configuration value.
193
+
194
+ Args:
195
+ key (str): The key of the configuration setting to retrieve.
196
+ default: The default value to return if the key is not found.
197
+ Default is None.
198
+
199
+ Returns:
200
+ The value associated with the given key, or the default value if the key
201
+ does not exist.
202
+
203
+ This method should be implemented by subclasses to retrieve configuration
204
+ values, checking the local context first, then the global context.
205
+ """
206
+ pass
207
+
208
+ @abstractmethod
209
+ def _find_local_config_dir(self):
210
+ """
211
+ Finds the local configuration directory.
212
+
213
+ Returns:
214
+ Path: The path to the local configuration directory, or None if no
215
+ directory is found.
216
+
217
+ This method should be implemented by subclasses to locate the directory where
218
+ local configuration files are stored.
219
+ """
220
+ pass
221
+
222
+ # Common methods that are shared between implementations
223
+ def get_default_category(self):
224
+ """
225
+ Retrieves the default synchronization category.
226
+
227
+ Returns:
228
+ str: The default synchronization category, as specified in the configuration.
229
+ """
230
+ return self.get("default_sync_category")
231
+
232
+ def set_default_category(self, category):
233
+ """
234
+ Sets the default synchronization category.
235
+
236
+ Args:
237
+ category (str): The category to set as the default for synchronization.
238
+ """
239
+ self.set("default_sync_category", category, local=True)
240
+
241
+ def copy(self):
242
+ """
243
+ Creates a deep copy of the configuration manager.
244
+
245
+ Returns:
246
+ BaseConfigManager: A new instance of the configuration manager with
247
+ a deep copy of the global and local configurations.
248
+
249
+ This method is useful when you need to duplicate the current state of the
250
+ configuration manager, preserving the settings in a new instance.
251
+ """
252
+ new_instance = self.__class__()
253
+ new_instance.global_config = copy.deepcopy(self.global_config)
254
+ new_instance.local_config = copy.deepcopy(self.local_config)
255
+ return new_instance
@@ -0,0 +1,362 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ import logging
7
+
8
+ from ctxsync.configmanager.base_config_manager import BaseConfigManager
9
+ from ctxsync.session_key_manager import SessionKeyManager
10
+
11
+
12
+ class FileConfigManager(BaseConfigManager):
13
+ """
14
+ Manages the configuration for ctxsync, handling both global and local (project-specific) settings.
15
+
16
+ This class provides methods to load, save, and access configuration settings from both
17
+ a global configuration file (~/.ctxsync/config.json) and a local configuration file
18
+ (.ctxsync/config.local.json) in the project directory. Session keys are stored separately
19
+ in provider-specific files.
20
+
21
+ Configuration created by this tool under its previous name (ClaudeSync) keeps
22
+ working: ~/.claudesync is migrated to ~/.ctxsync on first run, and existing
23
+ project-local .claudesync directories are still recognized.
24
+ """
25
+
26
+ LOCAL_DIR_NAME = ".ctxsync"
27
+ LEGACY_LOCAL_DIR_NAME = ".claudesync"
28
+
29
+ def __init__(self):
30
+ """
31
+ Initialize the ConfigManager.
32
+
33
+ Sets up paths for global and local configuration files and loads both configurations.
34
+ """
35
+ super().__init__()
36
+ self.global_config_dir = Path.home() / ".ctxsync"
37
+ self._migrate_legacy_global_config()
38
+ self.global_config_file = self.global_config_dir / "config.json"
39
+ self.global_config = self._load_global_config()
40
+ self.local_config = {}
41
+ self.local_config_dir = None
42
+ self.local_dir_name = self.LOCAL_DIR_NAME
43
+ self._load_local_config()
44
+
45
+ def _migrate_legacy_global_config(self):
46
+ """
47
+ Copies ~/.claudesync (settings and session keys) to ~/.ctxsync the first
48
+ time the renamed tool runs, so existing users stay authenticated.
49
+ """
50
+ legacy_dir = Path.home() / self.LEGACY_LOCAL_DIR_NAME
51
+ if not self.global_config_dir.exists() and legacy_dir.is_dir():
52
+ try:
53
+ shutil.copytree(legacy_dir, self.global_config_dir)
54
+ logging.info(
55
+ f"Migrated configuration from {legacy_dir} to {self.global_config_dir}"
56
+ )
57
+ except OSError as e:
58
+ logging.warning(
59
+ f"Could not migrate legacy configuration from {legacy_dir}: {e}"
60
+ )
61
+
62
+ def _load_global_config(self):
63
+ """
64
+ Loads the global configuration from the JSON file.
65
+
66
+ If the configuration file doesn't exist, it creates the directory (if necessary)
67
+ and returns the default configuration.
68
+
69
+ Returns:
70
+ dict: The loaded global configuration with default values for missing keys.
71
+ """
72
+ if not self.global_config_file.exists():
73
+ self.global_config_dir.mkdir(parents=True, exist_ok=True)
74
+ return self._get_default_config()
75
+
76
+ with open(self.global_config_file, "r") as f:
77
+ config = json.load(f)
78
+ defaults = self._get_default_config()
79
+ for key, value in defaults.items():
80
+ if key not in config:
81
+ config[key] = value
82
+ return config
83
+
84
+ def _find_local_config_dir(self, max_depth=100):
85
+ """
86
+ Finds the nearest directory containing a .ctxsync folder (or a legacy
87
+ .claudesync folder from before the rename).
88
+
89
+ Searches from the current working directory upwards until it finds a config folder
90
+ or reaches the root directory. Excludes the ~/.ctxsync and ~/.claudesync directories.
91
+
92
+ Returns:
93
+ Path: The path containing the config folder, or None if not found.
94
+ """
95
+ current_dir = Path.cwd()
96
+ root_dir = Path(current_dir.root)
97
+ home_dir = Path.home()
98
+ depth = 0
99
+
100
+ while current_dir != root_dir:
101
+ for dir_name in (self.LOCAL_DIR_NAME, self.LEGACY_LOCAL_DIR_NAME):
102
+ config_dir = current_dir / dir_name
103
+ if config_dir.is_dir() and config_dir != home_dir / dir_name:
104
+ self.local_dir_name = dir_name
105
+ return current_dir
106
+
107
+ current_dir = current_dir.parent
108
+ depth += 1
109
+
110
+ if depth > max_depth:
111
+ return None
112
+
113
+ return None
114
+
115
+ def _load_local_config(self):
116
+ """
117
+ Loads the local configuration from the nearest .ctxsync/config.local.json file.
118
+ Automatically normalizes any Windows-style paths.
119
+ """
120
+ self.local_config_dir = self._find_local_config_dir()
121
+ if self.local_config_dir:
122
+ local_config_file = (
123
+ self.local_config_dir / self.local_dir_name / "config.local.json"
124
+ )
125
+ if local_config_file.exists():
126
+ with open(local_config_file, "r") as f:
127
+ self.local_config = json.load(f)
128
+
129
+ # Check and fix Windows-style paths in submodules
130
+ if "submodules" in self.local_config:
131
+ needs_save = False
132
+ for submodule in self.local_config["submodules"]:
133
+ if "\\" in submodule["relative_path"]:
134
+ submodule["relative_path"] = submodule[
135
+ "relative_path"
136
+ ].replace("\\", "/")
137
+ needs_save = True
138
+
139
+ if needs_save:
140
+ self._save_local_config()
141
+
142
+ def get_local_path(self):
143
+ """
144
+ Retrieves the path of the directory containing the .ctxsync folder.
145
+
146
+ Returns:
147
+ str: The path of the directory containing the .ctxsync folder, or None if not found.
148
+ """
149
+ return str(self.local_config_dir) if self.local_config_dir else None
150
+
151
+ def get(self, key, default=None):
152
+ """
153
+ Retrieves a configuration value.
154
+
155
+ Checks the local configuration first, then falls back to the global configuration.
156
+
157
+ Args:
158
+ key (str): The key for the configuration setting to retrieve.
159
+ default (any, optional): The default value to return if the key is not found. Defaults to None.
160
+
161
+ Returns:
162
+ The value of the configuration setting if found, otherwise the default value.
163
+ """
164
+ return self.local_config.get(key) or self.global_config.get(key, default)
165
+
166
+ def set(self, key, value, local=False):
167
+ """
168
+ Sets a configuration value and saves the configuration.
169
+
170
+ Args:
171
+ key (str): The key for the configuration setting to set.
172
+ value (any): The value to set for the given key.
173
+ local (bool): If True, sets the value in the local configuration. Otherwise, sets it in the global configuration.
174
+ """
175
+ if local:
176
+ # Update local_config_dir when setting local_path
177
+ if key == "local_path":
178
+ self.local_config_dir = Path(value)
179
+ # Create .ctxsync directory in the specified path
180
+ self.local_dir_name = self.LOCAL_DIR_NAME
181
+ (self.local_config_dir / self.local_dir_name).mkdir(exist_ok=True)
182
+
183
+ self.local_config[key] = value
184
+ self._save_local_config()
185
+ else:
186
+ self.global_config[key] = value
187
+ self._save_global_config()
188
+
189
+ def _save_global_config(self):
190
+ """
191
+ Saves the current global configuration to the JSON file.
192
+
193
+ This method writes the current state of the `global_config` attribute to the configuration file,
194
+ pretty-printing the JSON for readability.
195
+ """
196
+ with open(self.global_config_file, "w") as f:
197
+ json.dump(self.global_config, f, indent=2)
198
+
199
+ def _save_local_config(self):
200
+ """
201
+ Saves the current local configuration to the .ctxsync/config.local.json file.
202
+ """
203
+ if self.local_config_dir:
204
+ local_config_file = (
205
+ self.local_config_dir / self.local_dir_name / "config.local.json"
206
+ )
207
+ local_config_file.parent.mkdir(exist_ok=True)
208
+ with open(local_config_file, "w") as f:
209
+ json.dump(self.local_config, f, indent=2)
210
+
211
+ def set_session_key(self, provider, session_key, expiry):
212
+ """
213
+ Sets the session key and its expiry for a specific provider.
214
+
215
+ Args:
216
+ provider (str): The name of the provider.
217
+ session_key (str): The session key to set.
218
+ expiry (datetime): The expiry datetime for the session key.
219
+ """
220
+ try:
221
+ session_key_manager = SessionKeyManager(self.get("ssh_key_path"))
222
+ encrypted_session_key, encryption_method = (
223
+ session_key_manager.encrypt_session_key(provider, session_key)
224
+ )
225
+
226
+ self.global_config_dir.mkdir(parents=True, exist_ok=True)
227
+ provider_key_file = self.global_config_dir / f"{provider}.key"
228
+ with open(provider_key_file, "w") as f:
229
+ json.dump(
230
+ {
231
+ "session_key": encrypted_session_key,
232
+ "session_key_encryption_method": encryption_method,
233
+ "session_key_expiry": expiry.isoformat(),
234
+ },
235
+ f,
236
+ )
237
+ except RuntimeError as e:
238
+ logging.error(f"Failed to encrypt session key: {str(e)}")
239
+ raise
240
+
241
+ def get_session_key(self, provider):
242
+ """
243
+ Retrieves the session key for the specified provider if it's still valid.
244
+
245
+ Args:
246
+ provider (str): The name of the provider.
247
+
248
+ Returns:
249
+ tuple: A tuple containing the session key and expiry if valid, (None, None) otherwise.
250
+ """
251
+ provider_key_file = self.global_config_dir / f"{provider}.key"
252
+ if not provider_key_file.exists():
253
+ return None, None
254
+
255
+ with open(provider_key_file, "r") as f:
256
+ data = json.load(f)
257
+
258
+ encrypted_key = data.get("session_key")
259
+ encryption_method = data.get("session_key_encryption_method")
260
+ expiry_str = data.get("session_key_expiry")
261
+
262
+ if not encrypted_key or not expiry_str:
263
+ return None, None
264
+
265
+ expiry = datetime.fromisoformat(expiry_str)
266
+ if datetime.now() > expiry:
267
+ return None, None
268
+
269
+ try:
270
+ session_key_manager = SessionKeyManager(self.get("ssh_key_path"))
271
+ session_key = session_key_manager.decrypt_session_key(
272
+ provider, encryption_method, encrypted_key
273
+ )
274
+ return session_key, expiry
275
+ except RuntimeError as e:
276
+ logging.error(f"Failed to decrypt session key: {str(e)}")
277
+ return None, None
278
+
279
+ def add_file_category(self, category_name, description, patterns):
280
+ """
281
+ Adds a new file category to the global configuration.
282
+
283
+ Args:
284
+ category_name (str): The name of the category to add.
285
+ description (str): A description of the category.
286
+ patterns (list): A list of file patterns for the category.
287
+ """
288
+ if "file_categories" not in self.global_config:
289
+ self.global_config["file_categories"] = {}
290
+ self.global_config["file_categories"][category_name] = {
291
+ "description": description,
292
+ "patterns": patterns,
293
+ }
294
+ self._save_global_config()
295
+
296
+ def remove_file_category(self, category_name):
297
+ """
298
+ Removes a file category from the global configuration.
299
+
300
+ Args:
301
+ category_name (str): The name of the category to remove.
302
+ """
303
+ if (
304
+ "file_categories" in self.global_config
305
+ and category_name in self.global_config["file_categories"]
306
+ ):
307
+ del self.global_config["file_categories"][category_name]
308
+ self._save_global_config()
309
+
310
+ def update_file_category(self, category_name, description=None, patterns=None):
311
+ """
312
+ Updates an existing file category in the global configuration.
313
+
314
+ Args:
315
+ category_name (str): The name of the category to update.
316
+ description (str, optional): The new description for the category. If None, the description is not updated.
317
+ patterns (list, optional): The new list of file patterns for the category. If None, the patterns are not updated.
318
+ """
319
+ if (
320
+ "file_categories" in self.global_config
321
+ and category_name in self.global_config["file_categories"]
322
+ ):
323
+ if description is not None:
324
+ self.global_config["file_categories"][category_name][
325
+ "description"
326
+ ] = description
327
+ if patterns is not None:
328
+ self.global_config["file_categories"][category_name][
329
+ "patterns"
330
+ ] = patterns
331
+ self._save_global_config()
332
+
333
+ def clear_all_session_keys(self):
334
+ """
335
+ Removes all stored session keys.
336
+ """
337
+ for file in self.global_config_dir.glob("*.key"):
338
+ os.remove(file)
339
+
340
+ def get_active_provider(self):
341
+ """
342
+ Retrieves the active provider from the local configuration.
343
+
344
+ Returns:
345
+ str: The name of the active provider, or None if not set.
346
+ """
347
+ return self.local_config.get("active_provider")
348
+
349
+ def get_providers_with_session_keys(self):
350
+ """
351
+ Retrieves a list of providers that have valid session keys.
352
+
353
+ Returns:
354
+ list: A list of provider names with valid session keys.
355
+ """
356
+ providers = []
357
+ for file in self.global_config_dir.glob("*.key"):
358
+ provider = file.stem
359
+ session_key, expiry = self.get_session_key(provider)
360
+ if session_key and expiry > datetime.now():
361
+ providers.append(provider)
362
+ return providers