From b414d0bc0ec68e17418777da37f1045d084d6acf Mon Sep 17 00:00:00 2001 From: jahwag <540380+jahwag@users.noreply.github.com> Date: Thu, 25 Jul 2024 23:03:05 +0200 Subject: [PATCH] Two-way-sync and exposed config commands --- CONTRIBUTING.md | 18 +- README.md | 259 ++++++++++++++++------------- pyproject.toml | 2 +- src/claudesync/cli/config.py | 62 +++++++ src/claudesync/cli/main.py | 2 + src/claudesync/cli/sync.py | 241 ++++++++++++--------------- src/claudesync/config_manager.py | 272 ++++++++++++++++--------------- src/claudesync/syncmanager.py | 270 ++++++++++++++++++++++++++++++ tests/cli/test_sync.py | 69 -------- 9 files changed, 734 insertions(+), 461 deletions(-) create mode 100644 src/claudesync/cli/config.py create mode 100644 src/claudesync/syncmanager.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9c5d2c..62ae867 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,12 +54,24 @@ We follow the PEP 8 style guide for Python code. Please ensure your code adheres ## Reporting Bugs -If you find a bug, please open an issue on the GitHub repository. Include as much detail as possible, including: +If you find a bug, please open an issue on the GitHub repository using our bug report template. To do this: -- Steps to reproduce the bug +1. Go to the [Issues](https://github.com/jahwag/claudesync/issues) page of the ClaudeSync repository. +2. Click on "New Issue". +3. Select the "Bug Report" template. +4. Fill out the template with as much detail as possible. + +When reporting a bug, please include: + +- A clear and concise description of the bug +- Steps to reproduce the behavior - Expected behavior -- Actual behavior - Any error messages or stack traces +- Your environment details (OS, Python version, ClaudeSync version) +- Your ClaudeSync configuration (use `claudesync config list`) +- Any relevant logs (you can increase log verbosity with `claudesync config set log_level DEBUG`) + +The more information you provide, the easier it will be for us to reproduce and fix the bug. ## Requesting Features diff --git a/README.md b/README.md index 2b70d4f..f4c1283 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,146 @@ -``` - .oooooo. oooo .o8 .oooooo..o - d8P' `Y8b `888 "888 d8P' `Y8 -888 888 .oooo. oooo oooo .oooo888 .ooooo. Y88bo. oooo ooo ooo. .oo. .ooooo. -888 888 `P )88b `888 `888 d88' `888 d88' `88b `"Y8888o. `88. .8' `888P"Y88b d88' `"Y8 -888 888 .oP"888 888 888 888 888 888ooo888 `"Y88b `88..8' 888 888 888 -`88b ooo 888 d8( 888 888 888 888 888 888 .o oo .d8P `888' 888 888 888 .o8 - `Y8bood8P' o888o `Y888""8o `V88V"V8P' `Y8bod88P" `Y8bod8P' 8""88888P' .8' o888o o888o `Y8bod8P' - .o..P' - `Y8P' -``` -![License](https://img.shields.io/badge/License-MIT-blue.svg) -[![PyPI version](https://badge.fury.io/py/claudesync.svg)](https://badge.fury.io/py/claudesync) - -ClaudeSync is a powerful tool designed to seamlessly synchronize your local files with [Claude.ai](https://www.anthropic.com/claude) projects. - -## Overview and Scope - -ClaudeSync bridges the gap between your local development environment and Claude.ai's knowledge base. At a high level, the scope of ClaudeSync includes: - -- Real-time synchronization with Claude.ai projects -- Command-line interface (CLI) for easy management -- Multiple organization and project support -- Automatic handling of file creation, modification, and deletion -- Intelligent file filtering based on .gitignore rules -- Configurable sync interval with cron job support -- Seamless integration with your existing workflow - -## Roadmap - -1. Enhanced support for large file synchronization -2. Improved conflict resolution mechanisms -3. GUI client for easier management -4. Integration with popular IDEs and text editors -5. Support for additional AI platforms beyond Claude.ai - -## Quick Start - -1. **Install ClaudeSync:** - ```bash - pip install claudesync - ``` - -2. **Login to Claude.ai:** - ```bash - claudesync api login claude.ai - ``` - -3. **Select an organization:** - ```bash - claudesync organization select - ``` - -4. **Select or create a project:** - ```bash - claudesync project select - # or - claudesync project create - ``` - -5. **Start syncing:** - ```bash - claudesync sync - ``` - -## Advanced Usage - -### API Management -- Login to Claude.ai: `claudesync api login claude.ai` -- Logout: `claudesync api logout` -- Set upload delay: `claudesync api ratelimit --delay ` - -### Organization Management -- List organizations: `claudesync organization ls` -- Select active organization: `claudesync organization select` - -### Project Management -- List projects: `claudesync project ls` -- Create a new project: `claudesync project create` -- Archive a project: `claudesync project archive` -- Select active project: `claudesync project select` - -### File Management -- List remote files: `claudesync ls` -- Sync files: `claudesync sync` - -### Configuration -- View current status: `claudesync status` - -### Scheduled Sync -Set up automatic syncing at regular intervals: -```bash -claudesync schedule -``` - -## Contributing - -We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for more information. - -## Communication Channels - -- **Issues**: For bug reports and feature requests, please use our [GitHub Issues](https://github.com/jahwag/claudesync/issues). - -## License - -ClaudeSync is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. - -## Related Projects - -- [Claude.ai](https://www.anthropic.com/claude): The AI assistant that ClaudeSync integrates with. - ---- - -Made with ❤️ by the ClaudeSync team +``` + .oooooo. oooo .o8 .oooooo..o + d8P' `Y8b `888 "888 d8P' `Y8 +888 888 .oooo. oooo oooo .oooo888 .ooooo. Y88bo. oooo ooo ooo. .oo. .ooooo. +888 888 `P )88b `888 `888 d88' `888 d88' `88b `"Y8888o. `88. .8' `888P"Y88b d88' `"Y8 +888 888 .oP"888 888 888 888 888 888ooo888 `"Y88b `88..8' 888 888 888 +`88b ooo 888 d8( 888 888 888 888 888 888 .o oo .d8P `888' 888 888 888 .o8 + `Y8bood8P' o888o `Y888""8o `V88V"V8P' `Y8bod88P" `Y8bod8P' 8""88888P' .8' o888o o888o `Y8bod8P' + .o..P' + `Y8P' +``` +![License](https://img.shields.io/badge/License-MIT-blue.svg) +[![PyPI version](https://badge.fury.io/py/claudesync.svg)](https://badge.fury.io/py/claudesync) + +ClaudeSync is a powerful tool designed to seamlessly synchronize your local files with [Claude.ai](https://www.anthropic.com/claude) projects. + +## Overview and Scope + +ClaudeSync bridges the gap between your local development environment and Claude.ai's knowledge base. At a high level, the scope of ClaudeSync includes: + +- Real-time synchronization with Claude.ai projects +- Command-line interface (CLI) for easy management +- Multiple organization and project support +- Automatic handling of file creation, modification, and deletion +- Intelligent file filtering based on .gitignore rules +- Configurable sync interval with cron job support +- Seamless integration with your existing workflow +- Optional two-way synchronization support +- Configuration management through CLI + +## Important Disclaimers + +- **Data Privacy**: ClaudeSync does not share any personal data or project data with anyone other than Anthropic (through Claude.ai) and yourself. Your data remains private and secure. +- **Open Source Transparency**: We are committed to transparency. Our entire codebase is open source, allowing you to review and verify our practices. +- **Affiliation**: ClaudeSync is not affiliated with, endorsed by, or sponsored by Anthropic. It is an independent tool created by enthusiasts for enthusiasts of Claude.ai. +- **Use at Your Own Risk**: While we strive for reliability, please use ClaudeSync at your own discretion and risk. Always maintain backups of your important data. + +## Roadmap + +1. Enhanced support for large file synchronization +2. Improved conflict resolution mechanisms +3. GUI client for easier management +4. Integration with popular IDEs and text editors +5. Support for additional AI platforms beyond Claude.ai + +## Quick Start + +1. **Install ClaudeSync:** + ```bash + pip install claudesync + ``` + +2. **Login to Claude.ai:** + ```bash + claudesync api login claude.ai + ``` + +3. **Select an organization:** + ```bash + claudesync organization select + ``` + +4. **Select or create a project:** + ```bash + claudesync project select + # or + claudesync project create + ``` + +5. **Start syncing:** + ```bash + claudesync sync + ``` + +## Advanced Usage + +### API Management +- Login to Claude.ai: `claudesync api login claude.ai` +- Logout: `claudesync api logout` +- Set upload delay: `claudesync api ratelimit --delay ` + +### Organization Management +- List organizations: `claudesync organization ls` +- Select active organization: `claudesync organization select` + +### Project Management +- List projects: `claudesync project ls` +- Create a new project: `claudesync project create` +- Archive a project: `claudesync project archive` +- Select active project: `claudesync project select` + +### File Management +- List remote files: `claudesync ls` +- Sync files: `claudesync sync` + +### Configuration +- View current status: `claudesync status` +- Set configuration values: `claudesync config set ` +- Get configuration values: `claudesync config get ` +- List all configuration values: `claudesync config list` + +### Synchronization Modes + +#### One-Way Sync (Default) +By default, ClaudeSync operates in one-way sync mode, pushing changes from your local environment to Claude.ai. This ensures that your local files are the source of truth and prevents unexpected modifications to your local files. + +#### Two-Way Sync (Experimental) +Two-way synchronization is available as an experimental feature. This mode allows changes made on the remote Claude.ai project to be reflected in your local files. However, please be aware of the following: + +1. To enable two-way synchronization: + ```bash + claudesync config set two_way_sync true + ``` + +2. **Caution**: Claude.ai has a tendency to modify filenames, often appending descriptive text. For example, "README.md" might become "Updated README.md with config and two-way sync info.md". This behavior is currently beyond ClaudeSync's control. + +3. **Potential Data Loss**: Due to the filename modification issue, there's a risk of unintended file duplication or data loss. Always maintain backups of your important files when using two-way sync. + +4. **Future Improvements**: We're actively exploring ways to mitigate these issues, possibly through prompt engineering or updates to ClaudeSync. For now, this feature is provided as-is and should be used with understanding of its limitations. + +### Scheduled Sync +Set up automatic syncing at regular intervals: +```bash +claudesync schedule +``` + +## Contributing + +We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for more information. + +## Communication Channels + +- **Issues**: For bug reports and feature requests, please use our [GitHub Issues](https://github.com/jahwag/claudesync/issues). + +## License + +ClaudeSync is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + +## Related Projects + +- [Claude.ai](https://www.anthropic.com/claude): The AI assistant that ClaudeSync integrates with. + +--- + +Made with ❤️ by the ClaudeSync team ``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 594e2e6..27da9cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "claudesync" -version = "0.3.6" +version = "0.3.7" authors = [ {name = "Jahziah Wagner", email = "jahziah.wagner+pypi@gmail.com"}, ] diff --git a/src/claudesync/cli/config.py b/src/claudesync/cli/config.py new file mode 100644 index 0000000..0763a81 --- /dev/null +++ b/src/claudesync/cli/config.py @@ -0,0 +1,62 @@ +import click + +from ..exceptions import ConfigurationError +from ..utils import handle_errors + + +@click.group() +def config(): + """Manage claudesync configuration.""" + pass + + +@config.command() +@click.argument("key") +@click.argument("value") +@click.pass_obj +@handle_errors +def set(config, key, value): + """Set a configuration value.""" + # Check if the key exists in the configuration + if key not in config.config: + raise ConfigurationError(f"Configuration property '{key}' does not exist.") + + # Convert string 'true' and 'false' to boolean + if value.lower() == "true": + value = True + elif value.lower() == "false": + value = False + # Try to convert to int or float if possible + else: + try: + value = int(value) + except ValueError: + try: + value = float(value) + except ValueError: + pass # Keep as string if not a number + + config.set(key, value) + click.echo(f"Configuration {key} set to {value}") + + +@config.command() +@click.argument("key") +@click.pass_obj +@handle_errors +def get(config, key): + """Get a configuration value.""" + value = config.get(key) + if value is None: + click.echo(f"Configuration {key} is not set") + else: + click.echo(f"{key}: {value}") + + +@config.command() +@click.pass_obj +@handle_errors +def list(config): + """List all configuration values.""" + for key, value in config.config.items(): + click.echo(f"{key}: {value}") diff --git a/src/claudesync/cli/main.py b/src/claudesync/cli/main.py index 4822f56..925556f 100644 --- a/src/claudesync/cli/main.py +++ b/src/claudesync/cli/main.py @@ -7,6 +7,7 @@ from .organization import organization from .project import project from .sync import ls, sync, schedule +from .config import config click_completion.init() @@ -53,6 +54,7 @@ def status(config): cli.add_command(ls) cli.add_command(sync) cli.add_command(schedule) +cli.add_command(config) if __name__ == "__main__": cli() diff --git a/src/claudesync/cli/sync.py b/src/claudesync/cli/sync.py index d9cb9ce..70cfb83 100644 --- a/src/claudesync/cli/sync.py +++ b/src/claudesync/cli/sync.py @@ -1,139 +1,102 @@ -import os -import shutil -import sys -import time - -import click -from crontab import CronTab - -from claudesync.utils import compute_md5_hash, get_local_files -from ..utils import handle_errors, validate_and_get_provider - - -@click.command() -@click.pass_obj -@handle_errors -def ls(config): - """List files in the active remote project.""" - provider = validate_and_get_provider(config) - active_organization_id = config.get("active_organization_id") - active_project_id = config.get("active_project_id") - files = provider.list_files(active_organization_id, active_project_id) - if not files: - click.echo("No files found in the active project.") - else: - click.echo( - f"Files in project '{config.get('active_project_name')}' (ID: {active_project_id}):" - ) - for file in files: - click.echo( - f" - {file['file_name']} (ID: {file['uuid']}, Created: {file['created_at']})" - ) - - -@click.command() -@click.pass_obj -@handle_errors -def sync(config): - """Synchronize local files with the active remote project.""" - provider = validate_and_get_provider(config) - active_organization_id = config.get("active_organization_id") - active_project_id = config.get("active_project_id") - local_path = config.get("local_path") - upload_delay = config.get("upload_delay", 0.5) - - if not local_path: - click.echo( - "No local path set. Please select or create a project to set the local path." - ) - sys.exit(1) - - if not os.path.exists(local_path): - click.echo(f"The configured local path does not exist: {local_path}") - click.echo("Please update the local path by selecting or creating a project.") - sys.exit(1) - - remote_files = provider.list_files(active_organization_id, active_project_id) - local_files = get_local_files(local_path) - - # Track remote files to delete - remote_files_to_delete = set(rf["file_name"] for rf in remote_files) - - for local_file, local_checksum in local_files.items(): - remote_file = next( - (rf for rf in remote_files if rf["file_name"] == local_file), None - ) - if remote_file: - remote_checksum = compute_md5_hash(remote_file["content"]) - if local_checksum != remote_checksum: - click.echo(f"Updating {local_file} on remote...") - provider.delete_file( - active_organization_id, active_project_id, remote_file["uuid"] - ) - with open( - os.path.join(local_path, local_file), "r", encoding="utf-8" - ) as file: - content = file.read() - provider.upload_file( - active_organization_id, active_project_id, local_file, content - ) - time.sleep(upload_delay) # Add delay after upload - remote_files_to_delete.remove(local_file) - else: - click.echo(f"Uploading new file {local_file} to remote...") - with open( - os.path.join(local_path, local_file), "r", encoding="utf-8" - ) as file: - content = file.read() - provider.upload_file( - active_organization_id, active_project_id, local_file, content - ) - time.sleep(upload_delay) # Add delay after upload - - # Delete remote files that no longer exist locally - for file_to_delete in remote_files_to_delete: - click.echo(f"Deleting {file_to_delete} from remote...") - remote_file = next( - rf for rf in remote_files if rf["file_name"] == file_to_delete - ) - provider.delete_file( - active_organization_id, active_project_id, remote_file["uuid"] - ) - time.sleep(upload_delay) # Add delay after deletion - - click.echo("Sync completed successfully.") - - -@click.command() -@click.pass_obj -@click.option( - "--interval", type=int, default=5, prompt="Enter sync interval in minutes" -) -@handle_errors -def schedule(config, interval): - """Set up automated synchronization at regular intervals.""" - claudesync_path = shutil.which("claudesync") - if not claudesync_path: - click.echo( - "Error: claudesync not found in PATH. Please ensure it's installed correctly." - ) - sys.exit(1) - - if sys.platform.startswith("win"): - click.echo("Windows Task Scheduler setup:") - command = f'schtasks /create /tn "ClaudeSync" /tr "{claudesync_path} sync" /sc minute /mo {interval}' - click.echo(f"Run this command to create the task:\n{command}") - click.echo('\nTo remove the task, run: schtasks /delete /tn "ClaudeSync" /f') - else: - # Unix-like systems (Linux, macOS) - cron = CronTab(user=True) - job = cron.new(command=f"{claudesync_path} sync") - job.minute.every(interval) - - cron.write() - click.echo( - f"Cron job created successfully! It will run every {interval} minutes." - ) - click.echo( - "\nTo remove the cron job, run: crontab -e and remove the line for ClaudeSync" - ) +import os +import shutil +import sys +import click +from crontab import CronTab + +from claudesync.utils import get_local_files +from ..utils import handle_errors, validate_and_get_provider +from ..syncmanager import SyncManager + + +@click.command() +@click.pass_obj +@handle_errors +def ls(config): + """List files in the active remote project.""" + provider = validate_and_get_provider(config) + active_organization_id = config.get("active_organization_id") + active_project_id = config.get("active_project_id") + files = provider.list_files(active_organization_id, active_project_id) + if not files: + click.echo("No files found in the active project.") + else: + click.echo( + f"Files in project '{config.get('active_project_name')}' (ID: {active_project_id}):" + ) + for file in files: + click.echo( + f" - {file['file_name']} (ID: {file['uuid']}, Created: {file['created_at']})" + ) + + +@click.command() +@click.pass_obj +@handle_errors +def sync(config): + """Synchronize local files with the active remote project.""" + provider = validate_and_get_provider(config) + local_path = config.get("local_path") + + validate_local_path(local_path) + + sync_manager = SyncManager(provider, config) + remote_files = provider.list_files( + sync_manager.active_organization_id, sync_manager.active_project_id + ) + local_files = get_local_files(local_path) + + sync_manager.sync(local_files, remote_files) + + click.echo("Sync completed successfully.") + + +def validate_local_path(local_path): + if not local_path: + click.echo( + "No local path set. Please select or create a project to set the local path." + ) + sys.exit(1) + if not os.path.exists(local_path): + click.echo(f"The configured local path does not exist: {local_path}") + click.echo("Please update the local path by selecting or creating a project.") + sys.exit(1) + + +@click.command() +@click.pass_obj +@click.option( + "--interval", type=int, default=5, prompt="Enter sync interval in minutes" +) +@handle_errors +def schedule(config, interval): + """Set up automated synchronization at regular intervals.""" + claudesync_path = shutil.which("claudesync") + if not claudesync_path: + click.echo( + "Error: claudesync not found in PATH. Please ensure it's installed correctly." + ) + sys.exit(1) + + if sys.platform.startswith("win"): + setup_windows_task(claudesync_path, interval) + else: + setup_unix_cron(claudesync_path, interval) + + +def setup_windows_task(claudesync_path, interval): + click.echo("Windows Task Scheduler setup:") + command = f'schtasks /create /tn "ClaudeSync" /tr "{claudesync_path} sync" /sc minute /mo {interval}' + click.echo(f"Run this command to create the task:\n{command}") + click.echo('\nTo remove the task, run: schtasks /delete /tn "ClaudeSync" /f') + + +def setup_unix_cron(claudesync_path, interval): + cron = CronTab(user=True) + job = cron.new(command=f"{claudesync_path} sync") + job.minute.every(interval) + cron.write() + click.echo(f"Cron job created successfully! It will run every {interval} minutes.") + click.echo( + "\nTo remove the cron job, run: crontab -e and remove the line for ClaudeSync" + ) diff --git a/src/claudesync/config_manager.py b/src/claudesync/config_manager.py index 066a409..ee4ac2c 100644 --- a/src/claudesync/config_manager.py +++ b/src/claudesync/config_manager.py @@ -1,135 +1,137 @@ -# src/claudesync/config_manager.py - -import json -from pathlib import Path - - -class ConfigManager: - """ - A class to manage configuration settings for the application. - - This class handles loading, saving, and accessing configuration settings from a JSON file. - It ensures that default values are set for certain keys if they are not present in the configuration file. - - Attributes: - config_dir (Path): The directory where the configuration file is stored. - config_file (Path): The path to the configuration file. - config (dict): The current configuration loaded into memory. - """ - - def __init__(self): - """ - Initializes the ConfigManager instance by setting up the configuration directory and file paths, - and loading the current configuration from the file, applying default values as necessary. - """ - self.config_dir = Path.home() / ".claudesync" - self.config_file = self.config_dir / "config.json" - self.config = self._load_config() - - def _load_config(self): - """ - Loads the configuration from the JSON file, applying default values for missing keys. - - If the configuration file does not exist, it creates the directory (if necessary) and returns a dictionary - with default values. - - Returns: - dict: The loaded configuration with default values for missing keys. - """ - if not self.config_file.exists(): - self.config_dir.mkdir(parents=True, exist_ok=True) - return { - "log_level": "INFO", - "upload_delay": 0.5, - "max_file_size": 32 * 1024, # Default 32 KB - "headers": { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0", - "Origin": "https://claude.ai", - }, - } - with open(self.config_file, "r") as f: - config = json.load(f) - if "log_level" not in config: - config["log_level"] = "INFO" - if "upload_delay" not in config: - config["upload_delay"] = 0.5 - if "max_file_size" not in config: - config["max_file_size"] = 32 * 1024 # Default 32 KB - if "headers" not in config: - config["headers"] = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0", - "Origin": "https://claude.ai", - } - return config - - def _save_config(self): - """ - Saves the current configuration to the JSON file. - - This method writes the current state of the `config` attribute to the configuration file, - pretty-printing the JSON for readability. - """ - with open(self.config_file, "w") as f: - json.dump(self.config, f, indent=2) - - def get(self, key, default=None): - """ - Retrieves a configuration value. - - Args: - key (str): The key for the configuration setting to retrieve. - default (any, optional): The default value to return if the key is not found. Defaults to None. - - Returns: - The value of the configuration setting if found, otherwise the default value. - """ - return self.config.get(key, default) - - def set(self, key, value): - """ - Sets a configuration value and saves the configuration. - - Args: - key (str): The key for the configuration setting to set. - value (any): The value to set for the given key. - - This method updates the configuration with the provided key-value pair and then saves the configuration - to the file. - """ - self.config[key] = value - self._save_config() - - def update_headers(self, new_headers): - """ - Updates the headers configuration with new values. - - Args: - new_headers (dict): A dictionary containing the new header key-value pairs to update or add. - - This method updates the existing headers with the new values provided, adds any new headers, - and then saves the updated configuration to the file. - """ - self.config.setdefault("headers", {}).update(new_headers) - self._save_config() - - def get_headers(self): - """ - Retrieves the current headers configuration. - - Returns: - dict: The current headers configuration. - """ - return self.config.get("headers", {}) - - def update_cookies(self, new_cookies): - """ - Updates the cookies configuration with new values. - - Args: - new_cookies (dict): A dictionary containing the new cookie key-value pairs to update or add. - - This method updates the existing cookies with the new values provided, adds any new cookies, - and then saves the updated configuration to the file. - """ - self.config.setdefault("cookies", {}).update(new_cookies) - self._save_config() +import json +from pathlib import Path + + +class ConfigManager: + """ + A class to manage configuration settings for the application. + + This class handles loading, saving, and accessing configuration settings from a JSON file. + It ensures that default values are set for certain keys if they are not present in the configuration file. + + Attributes: + config_dir (Path): The directory where the configuration file is stored. + config_file (Path): The path to the configuration file. + config (dict): The current configuration loaded into memory. + """ + + def __init__(self): + """ + Initializes the ConfigManager instance by setting up the configuration directory and file paths, + and loading the current configuration from the file, applying default values as necessary. + """ + self.config_dir = Path.home() / ".claudesync" + self.config_file = self.config_dir / "config.json" + self.config = self._load_config() + + def _load_config(self): + """ + Loads the configuration from the JSON file, applying default values for missing keys. + + If the configuration file does not exist, it creates the directory (if necessary) and returns a dictionary + with default values. + + Returns: + dict: The loaded configuration with default values for missing keys. + """ + if not self.config_file.exists(): + self.config_dir.mkdir(parents=True, exist_ok=True) + return { + "log_level": "INFO", + "upload_delay": 0.5, + "max_file_size": 32 * 1024, # Default 32 KB + "headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0", + "Origin": "https://claude.ai", + }, + "two_way_sync": False, # Default to False + } + with open(self.config_file, "r") as f: + config = json.load(f) + # Ensure all default values are present + defaults = { + "log_level": "INFO", + "upload_delay": 0.5, + "max_file_size": 32 * 1024, + "headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0", + "Origin": "https://claude.ai", + }, + "two_way_sync": False, + } + for key, value in defaults.items(): + if key not in config: + config[key] = value + return config + + def _save_config(self): + """ + Saves the current configuration to the JSON file. + + This method writes the current state of the `config` attribute to the configuration file, + pretty-printing the JSON for readability. + """ + with open(self.config_file, "w") as f: + json.dump(self.config, f, indent=2) + + def get(self, key, default=None): + """ + Retrieves a configuration value. + + Args: + key (str): The key for the configuration setting to retrieve. + default (any, optional): The default value to return if the key is not found. Defaults to None. + + Returns: + The value of the configuration setting if found, otherwise the default value. + """ + return self.config.get(key, default) + + def set(self, key, value): + """ + Sets a configuration value and saves the configuration. + + Args: + key (str): The key for the configuration setting to set. + value (any): The value to set for the given key. + + This method updates the configuration with the provided key-value pair and then saves the configuration + to the file. + """ + self.config[key] = value + self._save_config() + + def update_headers(self, new_headers): + """ + Updates the headers configuration with new values. + + Args: + new_headers (dict): A dictionary containing the new header key-value pairs to update or add. + + This method updates the existing headers with the new values provided, adds any new headers, + and then saves the updated configuration to the file. + """ + self.config.setdefault("headers", {}).update(new_headers) + self._save_config() + + def get_headers(self): + """ + Retrieves the current headers configuration. + + Returns: + dict: The current headers configuration. + """ + return self.config.get("headers", {}) + + def update_cookies(self, new_cookies): + """ + Updates the cookies configuration with new values. + + Args: + new_cookies (dict): A dictionary containing the new cookie key-value pairs to update or add. + + This method updates the existing cookies with the new values provided, adds any new cookies, + and then saves the updated configuration to the file. + """ + self.config.setdefault("cookies", {}).update(new_cookies) + self._save_config() diff --git a/src/claudesync/syncmanager.py b/src/claudesync/syncmanager.py new file mode 100644 index 0000000..067488d --- /dev/null +++ b/src/claudesync/syncmanager.py @@ -0,0 +1,270 @@ +import os +import time +from datetime import datetime, timezone + +import click + +from claudesync.utils import compute_md5_hash + + +class SyncManager: + def __init__(self, provider, config): + """ + Initialize the SyncManager with the given provider and configuration. + + Args: + provider (Provider): The provider instance to interact with the remote storage. + config (dict): Configuration dictionary containing sync settings such as: + - active_organization_id (str): ID of the active organization. + - active_project_id (str): ID of the active project. + - local_path (str): Path to the local directory to be synchronized. + - upload_delay (float, optional): Delay between upload operations in seconds. Defaults to 0.5. + - two_way_sync (bool, optional): Flag to enable two-way synchronization. Defaults to False. + """ + self.provider = provider + self.config = config + self.active_organization_id = config.get("active_organization_id") + self.active_project_id = config.get("active_project_id") + self.local_path = config.get("local_path") + self.upload_delay = config.get("upload_delay", 0.5) + self.two_way_sync = config.get("two_way_sync", False) + + def sync(self, local_files, remote_files): + """ + Main synchronization method that orchestrates the sync process. + + This method manages the synchronization between local and remote files. It handles the + synchronization from local to remote, updates local timestamps, performs two-way sync if enabled, + and deletes remote files that are no longer present locally. + + Args: + local_files (dict): Dictionary of local file names and their corresponding checksums. + remote_files (list): List of dictionaries representing remote files, each containing: + - "file_name" (str): Name of the file. + - "content" (str): Content of the file. + - "created_at" (str): Timestamp when the file was created in ISO format. + - "uuid" (str): Unique identifier of the remote file. + """ + remote_files_to_delete = set(rf["file_name"] for rf in remote_files) + synced_files = set() + + self.sync_local_to_remote( + local_files, remote_files, remote_files_to_delete, synced_files + ) + self.update_local_timestamps(remote_files, synced_files) + + if self.two_way_sync: + self.sync_remote_to_local( + remote_files, remote_files_to_delete, synced_files + ) + + self.delete_remote_files(remote_files_to_delete, remote_files) + + def sync_local_to_remote( + self, local_files, remote_files, remote_files_to_delete, synced_files + ): + """ + Synchronize local files to the remote project. + + This method checks each local file against the remote files. If a file exists on the remote, + it updates the file if there are changes. If the file does not exist on the remote, it uploads + the new file. + + Args: + local_files (dict): Dictionary of local file names and their corresponding checksums. + remote_files (list): List of dictionaries representing remote files. + remote_files_to_delete (set): Set of remote file names to be considered for deletion. + synced_files (set): Set of file names that have been synchronized. + """ + for local_file, local_checksum in local_files.items(): + remote_file = next( + (rf for rf in remote_files if rf["file_name"] == local_file), None + ) + if remote_file: + self.update_existing_file( + local_file, + local_checksum, + remote_file, + remote_files_to_delete, + synced_files, + ) + else: + self.upload_new_file(local_file, synced_files) + + def update_existing_file( + self, + local_file, + local_checksum, + remote_file, + remote_files_to_delete, + synced_files, + ): + """ + Update an existing file on the remote if it has changed locally. + + This method compares the local and remote file checksums. If they differ, it deletes the old remote file + and uploads the new version from the local file. + + Args: + local_file (str): Name of the local file. + local_checksum (str): MD5 checksum of the local file content. + remote_file (dict): Dictionary representing the remote file. + remote_files_to_delete (set): Set of remote file names to be considered for deletion. + synced_files (set): Set of file names that have been synchronized. + """ + remote_checksum = compute_md5_hash(remote_file["content"]) + if local_checksum != remote_checksum: + click.echo(f"Updating {local_file} on remote...") + self.provider.delete_file( + self.active_organization_id, self.active_project_id, remote_file["uuid"] + ) + with open( + os.path.join(self.local_path, local_file), "r", encoding="utf-8" + ) as file: + content = file.read() + self.provider.upload_file( + self.active_organization_id, self.active_project_id, local_file, content + ) + time.sleep(self.upload_delay) + synced_files.add(local_file) + remote_files_to_delete.remove(local_file) + + def upload_new_file(self, local_file, synced_files): + """ + Upload a new file to the remote project. + + This method reads the content of the local file and uploads it to the remote project. + + Args: + local_file (str): Name of the local file to be uploaded. + synced_files (set): Set of file names that have been synchronized. + """ + click.echo(f"Uploading new file {local_file} to remote...") + with open( + os.path.join(self.local_path, local_file), "r", encoding="utf-8" + ) as file: + content = file.read() + self.provider.upload_file( + self.active_organization_id, self.active_project_id, local_file, content + ) + time.sleep(self.upload_delay) + synced_files.add(local_file) + + def update_local_timestamps(self, remote_files, synced_files): + """ + Update local file timestamps to match the remote timestamps. + + This method updates the modification timestamps of local files to match their corresponding + remote file timestamps if they have been synchronized. + + Args: + remote_files (list): List of dictionaries representing remote files. + synced_files (set): Set of file names that have been synchronized. + """ + for remote_file in remote_files: + if remote_file["file_name"] in synced_files: + local_file_path = os.path.join( + self.local_path, remote_file["file_name"] + ) + if os.path.exists(local_file_path): + remote_timestamp = datetime.fromisoformat( + remote_file["created_at"].replace("Z", "+00:00") + ).timestamp() + os.utime(local_file_path, (remote_timestamp, remote_timestamp)) + click.echo(f"Updated timestamp on local file {local_file_path}") + + def sync_remote_to_local(self, remote_files, remote_files_to_delete, synced_files): + """ + Synchronize remote files to the local project (two-way sync). + + This method checks each remote file against the local files. If a file exists locally, + it updates the file if the remote version is newer. If the file does not exist locally, + it creates a new local file from the remote file. + + Args: + remote_files (list): List of dictionaries representing remote files. + remote_files_to_delete (set): Set of remote file names to be considered for deletion. + synced_files (set): Set of file names that have been synchronized. + """ + for remote_file in remote_files: + local_file_path = os.path.join(self.local_path, remote_file["file_name"]) + if os.path.exists(local_file_path): + self.update_existing_local_file( + local_file_path, remote_file, remote_files_to_delete, synced_files + ) + else: + self.create_new_local_file( + local_file_path, remote_file, remote_files_to_delete, synced_files + ) + + def update_existing_local_file( + self, local_file_path, remote_file, remote_files_to_delete, synced_files + ): + """ + Update an existing local file if the remote version is newer. + + This method compares the local file's modification time with the remote file's creation time. + If the remote file is newer, it updates the local file with the remote content. + + Args: + local_file_path (str): Path to the local file. + remote_file (dict): Dictionary representing the remote file. + remote_files_to_delete (set): Set of remote file names to be considered for deletion. + synced_files (set): Set of file names that have been synchronized. + """ + local_mtime = datetime.fromtimestamp( + os.path.getmtime(local_file_path), tz=timezone.utc + ) + remote_mtime = datetime.fromisoformat( + remote_file["created_at"].replace("Z", "+00:00") + ) + if remote_mtime > local_mtime: + click.echo(f"Updating local file {remote_file['file_name']} from remote...") + with open(local_file_path, "w", encoding="utf-8") as file: + file.write(remote_file["content"]) + synced_files.add(remote_file["file_name"]) + if remote_file["file_name"] in remote_files_to_delete: + remote_files_to_delete.remove(remote_file["file_name"]) + + def create_new_local_file( + self, local_file_path, remote_file, remote_files_to_delete, synced_files + ): + """ + Create a new local file from a remote file. + + This method creates a new local file with the content from the remote file. + + Args: + local_file_path (str): Path to the new local file + + . + remote_file (dict): Dictionary representing the remote file. + remote_files_to_delete (set): Set of remote file names to be considered for deletion. + synced_files (set): Set of file names that have been synchronized. + """ + click.echo(f"Creating new local file {remote_file['file_name']} from remote...") + with open(local_file_path, "w", encoding="utf-8") as file: + file.write(remote_file["content"]) + synced_files.add(remote_file["file_name"]) + if remote_file["file_name"] in remote_files_to_delete: + remote_files_to_delete.remove(remote_file["file_name"]) + + def delete_remote_files(self, remote_files_to_delete, remote_files): + """ + Delete files from the remote project that no longer exist locally. + + This method deletes remote files that are not present in the local directory. + + Args: + remote_files_to_delete (set): Set of remote file names to be deleted. + remote_files (list): List of dictionaries representing remote files. + """ + for file_to_delete in remote_files_to_delete: + click.echo(f"Deleting {file_to_delete} from remote...") + remote_file = next( + rf for rf in remote_files if rf["file_name"] == file_to_delete + ) + self.provider.delete_file( + self.active_organization_id, self.active_project_id, remote_file["uuid"] + ) + time.sleep(self.upload_delay) diff --git a/tests/cli/test_sync.py b/tests/cli/test_sync.py index 0dc2a92..c76b773 100644 --- a/tests/cli/test_sync.py +++ b/tests/cli/test_sync.py @@ -35,75 +35,6 @@ def test_ls_command_no_files(self, mock_validate_and_get_provider): self.assertEqual(result.exit_code, 0) self.assertIn("No files found in the active project.", result.output) - @patch("claudesync.cli.sync.validate_and_get_provider") - @patch("claudesync.cli.sync.get_local_files") - @patch("claudesync.cli.sync.os.path.exists") - @patch( - "claudesync.cli.sync.open", - new_callable=unittest.mock.mock_open, - read_data="file content", - ) - @patch("claudesync.config_manager.ConfigManager") - def test_sync_command( - self, - mock_config_manager, - mock_open, - mock_path_exists, - mock_get_local_files, - mock_validate_and_get_provider, - ): - # Mock the ConfigManager - mock_config = MagicMock() - mock_config.get.side_effect = lambda key, default=None: { - "active_organization_id": "org1", - "active_project_id": "proj1", - "local_path": "/mock/local/path", - }.get(key, default) - mock_config_manager.return_value = mock_config - - mock_provider = MagicMock() - mock_provider.list_files.return_value = [ - {"file_name": "existing.txt", "uuid": "uuid1", "content": "old content"} - ] - mock_validate_and_get_provider.return_value = mock_provider - - mock_get_local_files.return_value = { - "existing.txt": "new_checksum", - "new_file.txt": "new_file_checksum", - } - - mock_path_exists.return_value = True - - with patch("claudesync.cli.main.ConfigManager", return_value=mock_config): - result = self.runner.invoke(cli, ["sync"]) - - self.assertEqual( - result.exit_code, 0, f"Command failed with output: {result.output}" - ) - self.assertIn("Updating existing.txt on remote...", result.output) - self.assertIn("Uploading new file new_file.txt to remote...", result.output) - self.assertIn("Sync completed successfully.", result.output) - - mock_provider.delete_file.assert_called_once_with("org1", "proj1", "uuid1") - self.assertEqual(mock_provider.upload_file.call_count, 2) - - @patch("claudesync.cli.sync.validate_and_get_provider") - @patch("claudesync.cli.sync.os.path.exists") - def test_sync_command_no_local_path( - self, mock_path_exists, mock_validate_and_get_provider - ): - mock_provider = MagicMock() - mock_validate_and_get_provider.return_value = mock_provider - mock_path_exists.return_value = False - - result = self.runner.invoke(cli, ["sync"]) - - self.assertEqual(result.exit_code, 1) - self.assertIn( - "No local path set. Please select or create a project to set the local path.", - result.output, - ) - @patch("claudesync.cli.sync.shutil.which") @patch("claudesync.cli.sync.sys.platform", "linux") @patch("claudesync.cli.sync.CronTab")