diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d72d205..22f45fd 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,47 +1,47 @@ ---- -name: Bug report -about: Create a report to help us improve ClaudeSync -title: '' -labels: bug -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Run command '....' -3. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Error messages** -If applicable, provide the full error message or stack trace. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Environment (please complete the following information):** - - OS: [e.g. Windows 10, macOS 11.4, Ubuntu 20.04] - - Python version: [e.g. 3.8.10] - - ClaudeSync version: [e.g. 0.3.7] - -**Configuration** -Please provide the output of `claudesync config list` (make sure to remove any sensitive information). - -**Additional context** -Add any other context about the problem here. For example: -- Are you using any custom configurations? -- Did this issue start happening recently, or has it always been a problem? -- Can you reliably reproduce this issue? If not, how often does it occur? - -**Logs** -If possible, please provide relevant logs. You can increase the log verbosity by running: -``` -claudesync config set log_level DEBUG -``` -Then reproduce the issue and provide the logs (make sure to remove any sensitive information). +--- +name: Bug report +about: Create a report to help us improve ClaudeSync +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Run command '....' +3. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Error messages** +If applicable, provide the full error message or stack trace. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Environment (please complete the following information):** + - OS: [e.g. Windows 10, macOS 11.4, Ubuntu 20.04] + - Python version: [e.g. 3.8.10] + - ClaudeSync version: [e.g. 0.3.7] + +**Configuration** +Please provide the output of `claudesync config list` (make sure to remove any sensitive information). + +**Additional context** +Add any other context about the problem here. For example: +- Are you using any custom configurations? +- Did this issue start happening recently, or has it always been a problem? +- Can you reliably reproduce this issue? If not, how often does it occur? + +**Logs** +If possible, please provide relevant logs. You can increase the log verbosity by running: +``` +claudesync config set log_level DEBUG +``` +Then reproduce the issue and provide the logs (make sure to remove any sensitive information). diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 060d2df..52e80f1 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,39 +1,39 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -name: Upload Python Package - -on: - release: - types: [created] - -permissions: - contents: read - -jobs: - deploy: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [created] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62ae867..0b13a47 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,84 +1,84 @@ -# Contributing to ClaudeSync - -We're excited that you're interested in contributing to ClaudeSync! This document outlines the process for contributing to this project. - -## Getting Started - -1. Fork the repository on GitHub. -2. Clone your fork locally: - ``` - git clone https://github.com/your-username/claudesync.git - ``` -3. Create a new branch for your feature or bug fix: - ``` - git checkout -b feature/your-feature-name - ``` - -## Setting Up the Development Environment - -1. Ensure you have Python 3.6 or later installed. -2. Install the development dependencies: - ``` - pip install -r requirements.txt - ``` -3. Install the package in editable mode: - ``` - pip install -e . - ``` - -## Making Changes - -1. Make your changes in your feature branch. -2. Add or update tests as necessary. -3. Run the tests to ensure they pass: - ``` - python -m unittest discover tests - ``` -4. Update the documentation if you've made changes to the API or added new features. - -## Submitting Changes - -1. Commit your changes: - ``` - git commit -am "Add a brief description of your changes" - ``` -2. Push to your fork: - ``` - git push origin feature/your-feature-name - ``` -3. Submit a pull request through the GitHub website. - -## Code Style - -We follow the PEP 8 style guide for Python code. Please ensure your code adheres to this style. - -## Reporting Bugs - -If you find a bug, please open an issue on the GitHub repository using our bug report template. To do this: - -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 -- 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 - -If you have an idea for a new feature, please open an issue on the GitHub repository. Describe the feature and why you think it would be useful for the project. - -## Questions - -If you have any questions about contributing, feel free to open an issue for discussion. - +# Contributing to ClaudeSync + +We're excited that you're interested in contributing to ClaudeSync! This document outlines the process for contributing to this project. + +## Getting Started + +1. Fork the repository on GitHub. +2. Clone your fork locally: + ``` + git clone https://github.com/your-username/claudesync.git + ``` +3. Create a new branch for your feature or bug fix: + ``` + git checkout -b feature/your-feature-name + ``` + +## Setting Up the Development Environment + +1. Ensure you have Python 3.6 or later installed. +2. Install the development dependencies: + ``` + pip install -r requirements.txt + ``` +3. Install the package in editable mode: + ``` + pip install -e . + ``` + +## Making Changes + +1. Make your changes in your feature branch. +2. Add or update tests as necessary. +3. Run the tests to ensure they pass: + ``` + python -m unittest discover tests + ``` +4. Update the documentation if you've made changes to the API or added new features. + +## Submitting Changes + +1. Commit your changes: + ``` + git commit -am "Add a brief description of your changes" + ``` +2. Push to your fork: + ``` + git push origin feature/your-feature-name + ``` +3. Submit a pull request through the GitHub website. + +## Code Style + +We follow the black style guide for Python code. Please ensure your code adheres to this style. + +## Reporting Bugs + +If you find a bug, please open an issue on the GitHub repository using our bug report template. To do this: + +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 +- 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 + +If you have an idea for a new feature, please open an issue on the GitHub repository. Describe the feature and why you think it would be useful for the project. + +## Questions + +If you have any questions about contributing, feel free to open an issue for discussion. + Thank you for your interest in improving ClaudeSync! \ No newline at end of file diff --git a/LICENSE b/LICENSE index d0cf252..0779f74 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2024 Jahziah Wagner - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2024 Jahziah Wagner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 6a7fdc9..69aa7ab 100644 --- a/README.md +++ b/README.md @@ -1,202 +1,53 @@ -``` - .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' -``` -[![Python package](https://github.com/jahwag/ClaudeSync/actions/workflows/python-package.yml/badge.svg)](https://github.com/jahwag/ClaudeSync/actions/workflows/python-package.yml) -![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 -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: +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) +[![Issues](https://img.shields.io/github/issues/jahwag/claudesync)](https://github.com/jahwag/claudesync/issues) +[![PyPI version](https://badge.fury.io/py/claudesync.svg)](https://badge.fury.io/py/claudesync) +[![Release date](https://img.shields.io/github/release-date/jahwag/claudesync)](https://github.com/jahwag/claudesync/releases) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -- 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 -- Chat and artifact synchronization and management +ClaudeSync is a powerful tool that bridges your local development environment with Claude.ai projects, enabling seamless synchronization and enhancing your AI-powered workflow. -**Important Note**: ClaudeSync requires a Claude.ai Professional plan to function properly. Make sure you have an active Professional subscription before using this tool. +![ClaudeSync example](claudesync.gif "ClaudeSync") -## Important Disclaimers +## 🌟 Key Features -- **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. +- 🔄 **Real-time Synchronization**: Automatically keep your local files in sync with Claude.ai projects. +- 🚀 **Productivity Boost**: Streamline your workflow with efficient file management and reduced manual updates. +- 💻 **Cross-Platform Compatibility**: Works flawlessly on Windows, macOS, and Linux. +- 🔒 **Secure**: Prioritizes your data privacy and security. +- 🛠 **Customizable**: Configurable sync intervals, file filtering, and more. -## Quick Start +## 🚀 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 ` -- Set maximum file size: `claudesync api max-filesize --size ` - -### 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` -- Sync projects: `claudesync project sync` - -### File Management -- List remote files: `claudesync ls` -- Sync files: `claudesync sync` - -### Chat Management -- List chats: `claudesync chat ls` -- Sync chats and artifacts: `claudesync chat sync` -- Delete chats: `claudesync chat rm` -- Delete all chats: `claudesync chat rm -a` - -### Configuration -- View current status: `claudesync status` -- Set configuration values: `claudesync config set ` -- Get configuration values: `claudesync config get ` -- List all configuration values: `claudesync config ls` - -### 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. - -**Warning:** During synchronization, any files present on the remote server that are not available in your local environment will be automatically deleted from the remote server. Ensure that all important files are present locally before syncing to avoid unintentional data loss. - -#### 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 +```shell +pip install claudesync ``` -### Providers - -ClaudeSync offers two providers for interacting with the Claude.ai API: - -1. **claude.ai (Default)**: - - Uses built-in Python libraries to make API requests. - - No additional dependencies required. - - Recommended for most users. - -2. **claude.ai-curl**: - - Uses cURL to make API requests. - - Requires cURL to be installed on your system. - - Can be used as a workaround for certain issues, such as 403 Forbidden errors. - - **Note for Windows Users**: To use the claude.ai-curl provider on Windows, you need to have cURL installed. This can be done by: - - Installing [Git for Windows](https://git-scm.com/download/win) (which includes cURL) - - Installing [Windows Subsystem for Linux (WSL)](https://learn.microsoft.com/en-us/windows/wsl/install) - - Make sure cURL is accessible from your command line before using this provider. - -### Custom Ignore File - -ClaudeSync supports a custom ignore file named .claudeignore for specifying patterns of files to exclude from syncing, using the same syntax as .gitignore. - -### Troubleshooting - -#### 403 Forbidden Error -If you encounter a 403 Forbidden error when using ClaudeSync, it might be due to an issue with the session key or API access. Here are some steps to resolve this: - -1. Ensure you have an active Claude.ai Professional plan subscription. -2. Try logging out and logging in again: - ```bash - claudesync api logout - claudesync api login claude.ai - ``` -3. If the issue persists, you can try using the claude.ai-curl provider as a workaround: - ```bash - claudesync api logout - claudesync api login claude.ai-curl - ``` - -#### WinError 206 when syncing large files -If you encounter WinError 206 when syncing files larger than 32 KB, you can try using file input for curl data: - -```bash -claudesync config set curl_use_file_input True +2. **Log in to your Claude.ai account:** +```shell +claudesync api login claude.ai ``` -If you continue to experience issues, please check your network connection and ensure that you have the necessary permissions to access Claude.ai. - -## Contributing - -We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for more information. - -## Communication Channels +3. **Start syncing:** +```shell +claudesync sync +``` -- **Issues**: For bug reports and feature requests, please use our [GitHub Issues](https://github.com/jahwag/claudesync/issues). -- **Discord**: For general development discussions, visit our [Discord server](https://discord.gg/pR4qeMH4u4). +📚 Need more details? Check our [Wiki](https://github.com/jahwag/claudesync/wiki) for comprehensive guides and FAQs. -## License +## 🤝 Support ClaudeSync -ClaudeSync is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +Love ClaudeSync? Here's how you can contribute: -## Related Projects +- ⭐ [Star us on GitHub](https://github.com/jahwag/claudesync) +- 🐛 [Report bugs or request features](https://github.com/jahwag/claudesync/issues) +- 🌍 [Contribute to the project](CONTRIBUTING.md) +- 💬 Join our [Discord community](https://discord.gg/pR4qeMH4u4) for discussions and support -- [Claude.ai](https://www.anthropic.com/claude): The AI assistant that ClaudeSync integrates with. +Your support fuels ClaudeSync's growth and improvement! --- -Made with ❤️ by the ClaudeSync team +[Contributors](https://github.com/jahwag/claudesync/graphs/contributors) • [License](https://github.com/jahwag/claudesync/blob/master/LICENSE) • [Report Bug](https://github.com/jahwag/claudesync/issues) • [Request Feature](https://github.com/jahwag/ClaudeSync/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.md&title=) diff --git a/claudesync.gif b/claudesync.gif new file mode 100644 index 0000000..1fd571e Binary files /dev/null and b/claudesync.gif differ diff --git a/pyproject.toml b/pyproject.toml index 2e39124..e01dab4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "claudesync" -version = "0.4.7" +version = "0.4.8" authors = [ {name = "Jahziah Wagner", email = "jahziah.wagner+pypi@gmail.com"}, ] diff --git a/pytest.ini b/pytest.ini index 2f65e0b..1d09ea7 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,4 @@ -[pytest] -testpaths = tests -python_files = test_*.py +[pytest] +testpaths = tests +python_files = test_*.py addopts = -v --cov=claudesync --cov-report=term-missing \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b5f5e25..5d8d0d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -Click>=8.1.7 -requests>=2.32.3 -pathspec>=0.12.1 -crontab>=1.0.1 -click_completion>=0.5.2 -tqdm>=4.66.4 +Click>=8.1.7 +requests>=2.32.3 +pathspec>=0.12.1 +crontab>=1.0.1 +click_completion>=0.5.2 +tqdm>=4.66.4 pytest-cov>=5.0.0 \ No newline at end of file diff --git a/setup.py b/setup.py index b97fb10..dc62080 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ -from setuptools import setup, find_packages - -setup( - packages=find_packages(where="src"), - package_dir={"": "src"}, -) +from setuptools import setup, find_packages + +setup( + packages=find_packages(where="src"), + package_dir={"": "src"}, +) diff --git a/src/claudesync/chat_sync.py b/src/claudesync/chat_sync.py index c195b06..ded77eb 100644 --- a/src/claudesync/chat_sync.py +++ b/src/claudesync/chat_sync.py @@ -1,186 +1,186 @@ -import json -import logging -import os -import re - -from tqdm import tqdm - -from .exceptions import ConfigurationError - -logger = logging.getLogger(__name__) - - -def sync_chats(provider, config, sync_all=False): - """ - Synchronize chats and their artifacts from the remote source. - - This function fetches all chats for the active organization, saves their metadata, - messages, and extracts any artifacts found in the assistant's messages. - - Args: - provider: The API provider instance. - config: The configuration manager instance. - sync_all (bool): If True, sync all chats regardless of project. If False, only sync chats for the active project. - - Raises: - ConfigurationError: If required configuration settings are missing. - """ - # Get the local_path for chats - local_path = config.get("local_path") - if not local_path: - raise ConfigurationError( - "Local path not set. Use 'claudesync project select' or 'claudesync project create' to set it." - ) - - # Create chats directory within local_path - chat_destination = os.path.join(local_path, "claude_chats") - os.makedirs(chat_destination, exist_ok=True) - - # Get the active organization ID - organization_id = config.get("active_organization_id") - if not organization_id: - raise ConfigurationError( - "No active organization set. Please select an organization." - ) - - # Get the active project ID - active_project_id = config.get("active_project_id") - if not active_project_id and not sync_all: - raise ConfigurationError( - "No active project set. Please select a project or use the -a flag to sync all chats." - ) - - # Fetch all chats for the organization - logger.debug(f"Fetching chats for organization {organization_id}") - chats = provider.get_chat_conversations(organization_id) - logger.debug(f"Found {len(chats)} chats") - - # Process each chat - for chat in tqdm(chats, desc="Chats"): - sync_chat( - active_project_id, - chat, - chat_destination, - organization_id, - provider, - sync_all, - ) - - logger.debug(f"Chats and artifacts synchronized to {chat_destination}") - - -def sync_chat( - active_project_id, chat, chat_destination, organization_id, provider, sync_all -): - # Check if the chat belongs to the active project or if we're syncing all chats - if sync_all or ( - chat.get("project") and chat["project"].get("uuid") == active_project_id - ): - logger.debug(f"Processing chat {chat['uuid']}") - chat_folder = os.path.join(chat_destination, chat["uuid"]) - os.makedirs(chat_folder, exist_ok=True) - - # Save chat metadata - metadata_file = os.path.join(chat_folder, "metadata.json") - if not os.path.exists(metadata_file): - with open(metadata_file, "w") as f: - json.dump(chat, f, indent=2) - - # Fetch full chat conversation - logger.debug(f"Fetching full conversation for chat {chat['uuid']}") - full_chat = provider.get_chat_conversation(organization_id, chat["uuid"]) - - # Process each message in the chat - for message in full_chat["chat_messages"]: - message_file = os.path.join(chat_folder, f"{message['uuid']}.json") - - # Skip processing if the message file already exists - if os.path.exists(message_file): - logger.debug(f"Skipping existing message {message['uuid']}") - continue - - # Save the message - with open(message_file, "w") as f: - json.dump(message, f, indent=2) - - # Handle artifacts in assistant messages - if message["sender"] == "assistant": - artifacts = extract_artifacts(message["text"]) - if artifacts: - save_artifacts(artifacts, chat_folder, message) - else: - logger.debug( - f"Skipping chat {chat['uuid']} as it doesn't belong to the active project" - ) - - -def save_artifacts(artifacts, chat_folder, message): - logger.info(f"Found {len(artifacts)} artifacts in message {message['uuid']}") - artifact_folder = os.path.join(chat_folder, "artifacts") - os.makedirs(artifact_folder, exist_ok=True) - for artifact in artifacts: - # Save each artifact - artifact_file = os.path.join( - artifact_folder, - f"{artifact['identifier']}.{get_file_extension(artifact['type'])}", - ) - if not os.path.exists(artifact_file): - with open(artifact_file, "w") as f: - f.write(artifact["content"]) - - -def get_file_extension(artifact_type): - """ - Get the appropriate file extension for a given artifact type. - - Args: - artifact_type (str): The MIME type of the artifact. - - Returns: - str: The corresponding file extension. - """ - type_to_extension = { - "text/html": "html", - "application/vnd.ant.code": "txt", - "image/svg+xml": "svg", - "application/vnd.ant.mermaid": "mmd", - "application/vnd.ant.react": "jsx", - } - return type_to_extension.get(artifact_type, "txt") - - -def extract_artifacts(text): - """ - Extract artifacts from the given text. - - This function searches for antArtifact tags in the text and extracts - the artifact information, including identifier, type, and content. - - Args: - text (str): The text to search for artifacts. - - Returns: - list: A list of dictionaries containing artifact information. - """ - artifacts = [] - - # Regular expression to match the tags and extract their attributes and content - pattern = re.compile( - r'([\s\S]*?)', - re.MULTILINE, - ) - - # Find all matches in the text - matches = pattern.findall(text) - - for match in matches: - identifier, artifact_type, title, content = match - artifacts.append( - { - "identifier": identifier, - "type": artifact_type, - "content": content.strip(), - } - ) - - return artifacts +import json +import logging +import os +import re + +from tqdm import tqdm + +from .exceptions import ConfigurationError + +logger = logging.getLogger(__name__) + + +def sync_chats(provider, config, sync_all=False): + """ + Synchronize chats and their artifacts from the remote source. + + This function fetches all chats for the active organization, saves their metadata, + messages, and extracts any artifacts found in the assistant's messages. + + Args: + provider: The API provider instance. + config: The configuration manager instance. + sync_all (bool): If True, sync all chats regardless of project. If False, only sync chats for the active project. + + Raises: + ConfigurationError: If required configuration settings are missing. + """ + # Get the local_path for chats + local_path = config.get("local_path") + if not local_path: + raise ConfigurationError( + "Local path not set. Use 'claudesync project select' or 'claudesync project create' to set it." + ) + + # Create chats directory within local_path + chat_destination = os.path.join(local_path, "claude_chats") + os.makedirs(chat_destination, exist_ok=True) + + # Get the active organization ID + organization_id = config.get("active_organization_id") + if not organization_id: + raise ConfigurationError( + "No active organization set. Please select an organization." + ) + + # Get the active project ID + active_project_id = config.get("active_project_id") + if not active_project_id and not sync_all: + raise ConfigurationError( + "No active project set. Please select a project or use the -a flag to sync all chats." + ) + + # Fetch all chats for the organization + logger.debug(f"Fetching chats for organization {organization_id}") + chats = provider.get_chat_conversations(organization_id) + logger.debug(f"Found {len(chats)} chats") + + # Process each chat + for chat in tqdm(chats, desc="Chats"): + sync_chat( + active_project_id, + chat, + chat_destination, + organization_id, + provider, + sync_all, + ) + + logger.debug(f"Chats and artifacts synchronized to {chat_destination}") + + +def sync_chat( + active_project_id, chat, chat_destination, organization_id, provider, sync_all +): + # Check if the chat belongs to the active project or if we're syncing all chats + if sync_all or ( + chat.get("project") and chat["project"].get("uuid") == active_project_id + ): + logger.debug(f"Processing chat {chat['uuid']}") + chat_folder = os.path.join(chat_destination, chat["uuid"]) + os.makedirs(chat_folder, exist_ok=True) + + # Save chat metadata + metadata_file = os.path.join(chat_folder, "metadata.json") + if not os.path.exists(metadata_file): + with open(metadata_file, "w") as f: + json.dump(chat, f, indent=2) + + # Fetch full chat conversation + logger.debug(f"Fetching full conversation for chat {chat['uuid']}") + full_chat = provider.get_chat_conversation(organization_id, chat["uuid"]) + + # Process each message in the chat + for message in full_chat["chat_messages"]: + message_file = os.path.join(chat_folder, f"{message['uuid']}.json") + + # Skip processing if the message file already exists + if os.path.exists(message_file): + logger.debug(f"Skipping existing message {message['uuid']}") + continue + + # Save the message + with open(message_file, "w") as f: + json.dump(message, f, indent=2) + + # Handle artifacts in assistant messages + if message["sender"] == "assistant": + artifacts = extract_artifacts(message["text"]) + if artifacts: + save_artifacts(artifacts, chat_folder, message) + else: + logger.debug( + f"Skipping chat {chat['uuid']} as it doesn't belong to the active project" + ) + + +def save_artifacts(artifacts, chat_folder, message): + logger.info(f"Found {len(artifacts)} artifacts in message {message['uuid']}") + artifact_folder = os.path.join(chat_folder, "artifacts") + os.makedirs(artifact_folder, exist_ok=True) + for artifact in artifacts: + # Save each artifact + artifact_file = os.path.join( + artifact_folder, + f"{artifact['identifier']}.{get_file_extension(artifact['type'])}", + ) + if not os.path.exists(artifact_file): + with open(artifact_file, "w") as f: + f.write(artifact["content"]) + + +def get_file_extension(artifact_type): + """ + Get the appropriate file extension for a given artifact type. + + Args: + artifact_type (str): The MIME type of the artifact. + + Returns: + str: The corresponding file extension. + """ + type_to_extension = { + "text/html": "html", + "application/vnd.ant.code": "txt", + "image/svg+xml": "svg", + "application/vnd.ant.mermaid": "mmd", + "application/vnd.ant.react": "jsx", + } + return type_to_extension.get(artifact_type, "txt") + + +def extract_artifacts(text): + """ + Extract artifacts from the given text. + + This function searches for antArtifact tags in the text and extracts + the artifact information, including identifier, type, and content. + + Args: + text (str): The text to search for artifacts. + + Returns: + list: A list of dictionaries containing artifact information. + """ + artifacts = [] + + # Regular expression to match the tags and extract their attributes and content + pattern = re.compile( + r'([\s\S]*?)', + re.MULTILINE, + ) + + # Find all matches in the text + matches = pattern.findall(text) + + for match in matches: + identifier, artifact_type, title, content = match + artifacts.append( + { + "identifier": identifier, + "type": artifact_type, + "content": content.strip(), + } + ) + + return artifacts diff --git a/src/claudesync/cli/__init__.py b/src/claudesync/cli/__init__.py index ef86501..9e31c66 100644 --- a/src/claudesync/cli/__init__.py +++ b/src/claudesync/cli/__init__.py @@ -1,3 +1,3 @@ -from .main import cli - -__all__ = ["cli"] +from .main import cli + +__all__ = ["cli"] diff --git a/src/claudesync/cli/api.py b/src/claudesync/cli/api.py index 0e765a0..b5d8237 100644 --- a/src/claudesync/cli/api.py +++ b/src/claudesync/cli/api.py @@ -1,76 +1,84 @@ -import click - -from claudesync.provider_factory import get_provider -from ..config_manager import ConfigManager -from ..utils import handle_errors - - -@click.group() -def api(): - """Manage api.""" - pass - - -@api.command() -@click.argument("provider", required=False) -@click.pass_obj -@handle_errors -def login(config: ConfigManager, provider): - """Authenticate with an AI provider.""" - providers = get_provider() - if not provider: - click.echo("Available providers:\n" + "\n".join(f" - {p}" for p in providers)) - return - if provider not in providers: - click.echo( - f"Error: Unknown provider '{provider}'. Available: {', '.join(providers)}" - ) - return - provider_instance = get_provider(provider) - session = provider_instance.login() - config.set_session_key(session[0], session[1]) - config.set("active_provider", provider) - click.echo("Logged in successfully.") - - -@api.command() -@click.pass_obj -def logout(config): - """Log out from the current AI provider.""" - for key in [ - "session_key", - "session_key_expiry", - "active_provider", - "active_organization_id", - "active_project_id", - "active_project_name", - "local_path", - ]: - config.set(key, None) - click.echo("Logged out successfully.") - - -@api.command() -@click.option("--delay", type=float, required=True, help="Upload delay in seconds") -@click.pass_obj -@handle_errors -def ratelimit(config, delay): - """Set the delay between file uploads during sync.""" - if delay < 0: - click.echo("Error: Upload delay must be a non-negative number.") - return - config.set("upload_delay", delay) - click.echo(f"Upload delay set to {delay} seconds.") - - -@api.command() -@click.option("--size", type=int, required=True, help="Maximum file size in bytes") -@click.pass_obj -@handle_errors -def max_filesize(config, size): - """Set the maximum file size for syncing.""" - if size < 0: - click.echo("Error: Maximum file size must be a non-negative number.") - return - config.set("max_file_size", size) - click.echo(f"Maximum file size set to {size} bytes.") +import click + +from claudesync.provider_factory import get_provider +from ..utils import handle_errors +from ..cli.organization import select as org_select +from ..cli.project import select as proj_select + + +@click.group() +def api(): + """Manage api.""" + pass + + +@api.command() +@click.argument("provider", required=False) +@click.pass_context +@handle_errors +def login(ctx, provider): + """Authenticate with an AI provider.""" + config = ctx.obj + providers = get_provider() + if not provider: + click.echo("Available providers:\n" + "\n".join(f" - {p}" for p in providers)) + return + if provider not in providers: + click.echo( + f"Error: Unknown provider '{provider}'. Available: {', '.join(providers)}" + ) + return + provider_instance = get_provider(provider) + session = provider_instance.login() + config.set_session_key(session[0], session[1]) + config.set("active_provider", provider) + click.echo("Logged in successfully.") + + # Automatically run organization select + ctx.invoke(org_select) + + # Automatically run project select + ctx.invoke(proj_select) + + +@api.command() +@click.pass_obj +def logout(config): + """Log out from the current AI provider.""" + for key in [ + "session_key", + "session_key_expiry", + "active_provider", + "active_organization_id", + "active_project_id", + "active_project_name", + "local_path", + ]: + config.set(key, None) + click.echo("Logged out successfully.") + + +@api.command() +@click.option("--delay", type=float, required=True, help="Upload delay in seconds") +@click.pass_obj +@handle_errors +def ratelimit(config, delay): + """Set the delay between file uploads during sync.""" + if delay < 0: + click.echo("Error: Upload delay must be a non-negative number.") + return + config.set("upload_delay", delay) + click.echo(f"Upload delay set to {delay} seconds.") + + +@api.command() +@click.option("--size", type=int, required=True, help="Maximum file size in bytes") +@click.pass_obj +@handle_errors +def max_filesize(config, size): + """Set the maximum file size for syncing.""" + if size < 0: + click.echo("Error: Maximum file size must be a non-negative number.") + return + config.set("max_file_size", size) + click.echo(f"Maximum file size set to {size} bytes.") diff --git a/src/claudesync/cli/main.py b/src/claudesync/cli/main.py index 769e389..4aff09d 100644 --- a/src/claudesync/cli/main.py +++ b/src/claudesync/cli/main.py @@ -1,67 +1,67 @@ -import click -import click_completion -import click_completion.core - -from claudesync.cli.chat import chat -from claudesync.config_manager import ConfigManager -from .api import api -from .organization import organization -from .project import project -from .sync import ls, sync, schedule -from .config import config -import logging - -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" -) - -click_completion.init() - - -@click.group() -@click.pass_context -def cli(ctx): - """ClaudeSync: Synchronize local files with ai projects.""" - ctx.obj = ConfigManager() - - -@cli.command() -@click.argument( - "shell", required=False, type=click.Choice(["bash", "zsh", "fish", "powershell"]) -) -def install_completion(shell): - """Install completion for the specified shell.""" - if shell is None: - shell = click_completion.get_auto_shell() - click.echo("Shell is set to '%s'" % shell) - click_completion.install(shell=shell) - click.echo("Completion installed.") - - -@cli.command() -@click.pass_obj -def status(config): - """Display current configuration status.""" - for key in [ - "active_provider", - "active_organization_id", - "active_project_id", - "active_project_name", - "local_path", - "log_level", - ]: - value = config.get(key) - click.echo(f"{key.replace('_', ' ').capitalize()}: {value or 'Not set'}") - - -cli.add_command(api) -cli.add_command(organization) -cli.add_command(project) -cli.add_command(ls) -cli.add_command(sync) -cli.add_command(schedule) -cli.add_command(config) -cli.add_command(chat) - -if __name__ == "__main__": - cli() +import click +import click_completion +import click_completion.core + +from claudesync.cli.chat import chat +from claudesync.config_manager import ConfigManager +from .api import api +from .organization import organization +from .project import project +from .sync import ls, sync, schedule +from .config import config +import logging + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) + +click_completion.init() + + +@click.group() +@click.pass_context +def cli(ctx): + """ClaudeSync: Synchronize local files with ai projects.""" + ctx.obj = ConfigManager() + + +@cli.command() +@click.argument( + "shell", required=False, type=click.Choice(["bash", "zsh", "fish", "powershell"]) +) +def install_completion(shell): + """Install completion for the specified shell.""" + if shell is None: + shell = click_completion.get_auto_shell() + click.echo("Shell is set to '%s'" % shell) + click_completion.install(shell=shell) + click.echo("Completion installed.") + + +@cli.command() +@click.pass_obj +def status(config): + """Display current configuration status.""" + for key in [ + "active_provider", + "active_organization_id", + "active_project_id", + "active_project_name", + "local_path", + "log_level", + ]: + value = config.get(key) + click.echo(f"{key.replace('_', ' ').capitalize()}: {value or 'Not set'}") + + +cli.add_command(api) +cli.add_command(organization) +cli.add_command(project) +cli.add_command(ls) +cli.add_command(sync) +cli.add_command(schedule) +cli.add_command(config) +cli.add_command(chat) + +if __name__ == "__main__": + cli() diff --git a/src/claudesync/cli/organization.py b/src/claudesync/cli/organization.py index 5184aa2..4e31b13 100644 --- a/src/claudesync/cli/organization.py +++ b/src/claudesync/cli/organization.py @@ -1,52 +1,53 @@ -import click - -from ..utils import handle_errors, validate_and_get_provider - - -@click.group() -def organization(): - """Manage ai organizations.""" - pass - - -@organization.command() -@click.pass_obj -@handle_errors -def ls(config): - """List all available organizations with required capabilities.""" - provider = validate_and_get_provider(config, require_org=False) - organizations = provider.get_organizations() - if not organizations: - click.echo( - "No organizations with required capabilities (chat and claude_pro) found." - ) - else: - click.echo("Available organizations with required capabilities:") - for idx, org in enumerate(organizations, 1): - click.echo(f" {idx}. {org['name']} (ID: {org['id']})") - - -@organization.command() -@click.pass_obj -@handle_errors -def select(config): - """Set the active organization.""" - provider = validate_and_get_provider(config, require_org=False) - organizations = provider.get_organizations() - if not organizations: - click.echo( - "No organizations with required capabilities (chat and claude_pro) found." - ) - return - click.echo("Available organizations with required capabilities:") - for idx, org in enumerate(organizations, 1): - click.echo(f" {idx}. {org['name']} (ID: {org['id']})") - selection = click.prompt("Enter the number of the organization to select", type=int) - if 1 <= selection <= len(organizations): - selected_org = organizations[selection - 1] - config.set("active_organization_id", selected_org["id"]) - click.echo( - f"Selected organization: {selected_org['name']} (ID: {selected_org['id']})" - ) - else: - click.echo("Invalid selection. Please try again.") +import click + +from ..utils import handle_errors, validate_and_get_provider + + +@click.group() +def organization(): + """Manage ai organizations.""" + pass + + +@organization.command() +@click.pass_obj +@handle_errors +def ls(config): + """List all available organizations with required capabilities.""" + provider = validate_and_get_provider(config, require_org=False) + organizations = provider.get_organizations() + if not organizations: + click.echo( + "No organizations with required capabilities (chat and claude_pro) found." + ) + else: + click.echo("Available organizations with required capabilities:") + for idx, org in enumerate(organizations, 1): + click.echo(f" {idx}. {org['name']} (ID: {org['id']})") + + +@organization.command() +@click.pass_context +@handle_errors +def select(ctx): + """Set the active organization.""" + config = ctx.obj + provider = validate_and_get_provider(config, require_org=False) + organizations = provider.get_organizations() + if not organizations: + click.echo( + "No organizations with required capabilities (chat and claude_pro) found." + ) + return + click.echo("Available organizations with required capabilities:") + for idx, org in enumerate(organizations, 1): + click.echo(f" {idx}. {org['name']} (ID: {org['id']})") + selection = click.prompt("Enter the number of the organization to select", type=int) + if 1 <= selection <= len(organizations): + selected_org = organizations[selection - 1] + config.set("active_organization_id", selected_org["id"]) + click.echo( + f"Selected organization: {selected_org['name']} (ID: {selected_org['id']})" + ) + else: + click.echo("Invalid selection. Please try again.") diff --git a/src/claudesync/cli/project.py b/src/claudesync/cli/project.py index 53d1c5c..0cf441d 100644 --- a/src/claudesync/cli/project.py +++ b/src/claudesync/cli/project.py @@ -1,145 +1,146 @@ -import os - -import click - -from claudesync.exceptions import ProviderError -from ..syncmanager import SyncManager -from ..utils import ( - handle_errors, - validate_and_get_provider, - validate_and_store_local_path, - get_local_files, -) - - -@click.group() -def project(): - """Manage ai projects within the active organization.""" - pass - - -@project.command() -@click.pass_obj -@handle_errors -def create(config): - """Create a new project in the active organization.""" - provider = validate_and_get_provider(config) - active_organization_id = config.get("active_organization_id") - - default_name = os.path.basename(os.getcwd()) - title = click.prompt("Enter the project title", default=default_name) - description = click.prompt("Enter the project description (optional)", default="") - - try: - new_project = provider.create_project( - active_organization_id, title, description - ) - click.echo( - f"Project '{new_project['name']}' (uuid: {new_project['uuid']}) has been created successfully." - ) - - config.set("active_project_id", new_project["uuid"]) - config.set("active_project_name", new_project["name"]) - click.echo( - f"Active project set to: {new_project['name']} (uuid: {new_project['uuid']})" - ) - - validate_and_store_local_path(config) - - except ProviderError as e: - click.echo(f"Failed to create project: {str(e)}") - - -@project.command() -@click.pass_obj -@handle_errors -def archive(config): - """Archive an existing project.""" - provider = validate_and_get_provider(config) - active_organization_id = config.get("active_organization_id") - projects = provider.get_projects(active_organization_id, include_archived=False) - if not projects: - click.echo("No active projects found.") - return - click.echo("Available projects to archive:") - for idx, project in enumerate(projects, 1): - click.echo(f" {idx}. {project['name']} (ID: {project['id']})") - selection = click.prompt("Enter the number of the project to archive", type=int) - if 1 <= selection <= len(projects): - selected_project = projects[selection - 1] - if click.confirm( - f"Are you sure you want to archive '{selected_project['name']}'?" - ): - provider.archive_project(active_organization_id, selected_project["id"]) - click.echo(f"Project '{selected_project['name']}' has been archived.") - else: - click.echo("Invalid selection. Please try again.") - - -@project.command() -@click.pass_obj -@handle_errors -def select(config): - """Set the active project for syncing.""" - provider = validate_and_get_provider(config) - active_organization_id = config.get("active_organization_id") - projects = provider.get_projects(active_organization_id, include_archived=False) - if not projects: - click.echo("No active projects found.") - return - click.echo("Available projects:") - for idx, project in enumerate(projects, 1): - click.echo(f" {idx}. {project['name']} (ID: {project['id']})") - selection = click.prompt("Enter the number of the project to select", type=int) - if 1 <= selection <= len(projects): - selected_project = projects[selection - 1] - config.set("active_project_id", selected_project["id"]) - config.set("active_project_name", selected_project["name"]) - click.echo( - f"Selected project: {selected_project['name']} (ID: {selected_project['id']})" - ) - - validate_and_store_local_path(config) - else: - click.echo("Invalid selection. Please try again.") - - -@project.command() -@click.option( - "-a", - "--all", - "show_all", - is_flag=True, - help="Include archived projects in the list", -) -@click.pass_obj -@handle_errors -def ls(config, show_all): - """List all projects in the active organization.""" - provider = validate_and_get_provider(config) - active_organization_id = config.get("active_organization_id") - projects = provider.get_projects(active_organization_id, include_archived=show_all) - if not projects: - click.echo("No projects found.") - else: - click.echo("Remote projects:") - for project in projects: - status = " (Archived)" if project.get("archived_at") else "" - click.echo(f" - {project['name']} (ID: {project['id']}){status}") - - -@project.command() -@click.pass_obj -@handle_errors -def sync(config): - """Synchronize only the project files.""" - provider = validate_and_get_provider(config, require_project=True) - - 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(config.get("local_path")) - sync_manager.sync(local_files, remote_files) - - click.echo("Project sync completed successfully.") +import os + +import click + +from claudesync.exceptions import ProviderError +from ..syncmanager import SyncManager +from ..utils import ( + handle_errors, + validate_and_get_provider, + validate_and_store_local_path, + get_local_files, +) + + +@click.group() +def project(): + """Manage ai projects within the active organization.""" + pass + + +@project.command() +@click.pass_obj +@handle_errors +def create(config): + """Create a new project in the active organization.""" + provider = validate_and_get_provider(config) + active_organization_id = config.get("active_organization_id") + + default_name = os.path.basename(os.getcwd()) + title = click.prompt("Enter the project title", default=default_name) + description = click.prompt("Enter the project description (optional)", default="") + + try: + new_project = provider.create_project( + active_organization_id, title, description + ) + click.echo( + f"Project '{new_project['name']}' (uuid: {new_project['uuid']}) has been created successfully." + ) + + config.set("active_project_id", new_project["uuid"]) + config.set("active_project_name", new_project["name"]) + click.echo( + f"Active project set to: {new_project['name']} (uuid: {new_project['uuid']})" + ) + + validate_and_store_local_path(config) + + except ProviderError as e: + click.echo(f"Failed to create project: {str(e)}") + + +@project.command() +@click.pass_obj +@handle_errors +def archive(config): + """Archive an existing project.""" + provider = validate_and_get_provider(config) + active_organization_id = config.get("active_organization_id") + projects = provider.get_projects(active_organization_id, include_archived=False) + if not projects: + click.echo("No active projects found.") + return + click.echo("Available projects to archive:") + for idx, project in enumerate(projects, 1): + click.echo(f" {idx}. {project['name']} (ID: {project['id']})") + selection = click.prompt("Enter the number of the project to archive", type=int) + if 1 <= selection <= len(projects): + selected_project = projects[selection - 1] + if click.confirm( + f"Are you sure you want to archive '{selected_project['name']}'?" + ): + provider.archive_project(active_organization_id, selected_project["id"]) + click.echo(f"Project '{selected_project['name']}' has been archived.") + else: + click.echo("Invalid selection. Please try again.") + + +@project.command() +@click.pass_context +@handle_errors +def select(ctx): + """Set the active project for syncing.""" + config = ctx.obj + provider = validate_and_get_provider(config) + active_organization_id = config.get("active_organization_id") + projects = provider.get_projects(active_organization_id, include_archived=False) + if not projects: + click.echo("No active projects found.") + return + click.echo("Available projects:") + for idx, project in enumerate(projects, 1): + click.echo(f" {idx}. {project['name']} (ID: {project['id']})") + selection = click.prompt("Enter the number of the project to select", type=int) + if 1 <= selection <= len(projects): + selected_project = projects[selection - 1] + config.set("active_project_id", selected_project["id"]) + config.set("active_project_name", selected_project["name"]) + click.echo( + f"Selected project: {selected_project['name']} (ID: {selected_project['id']})" + ) + + validate_and_store_local_path(config) + else: + click.echo("Invalid selection. Please try again.") + + +@project.command() +@click.option( + "-a", + "--all", + "show_all", + is_flag=True, + help="Include archived projects in the list", +) +@click.pass_obj +@handle_errors +def ls(config, show_all): + """List all projects in the active organization.""" + provider = validate_and_get_provider(config) + active_organization_id = config.get("active_organization_id") + projects = provider.get_projects(active_organization_id, include_archived=show_all) + if not projects: + click.echo("No projects found.") + else: + click.echo("Remote projects:") + for project in projects: + status = " (Archived)" if project.get("archived_at") else "" + click.echo(f" - {project['name']} (ID: {project['id']}){status}") + + +@project.command() +@click.pass_obj +@handle_errors +def sync(config): + """Synchronize only the project files.""" + provider = validate_and_get_provider(config, require_project=True) + + 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(config.get("local_path")) + sync_manager.sync(local_files, remote_files) + + click.echo("Project sync completed successfully.") diff --git a/src/claudesync/exceptions.py b/src/claudesync/exceptions.py index 4b9767b..2a75960 100644 --- a/src/claudesync/exceptions.py +++ b/src/claudesync/exceptions.py @@ -1,22 +1,22 @@ -class ConfigurationError(Exception): - """ - Exception raised when there's an issue with the application's configuration. - - This exception should be raised to indicate problems such as missing required configuration options, - invalid values, or issues loading configuration files. It helps in distinguishing configuration-related - errors from other types of exceptions. - """ - - pass - - -class ProviderError(Exception): - """ - Exception raised when there's an issue with a provider operation. - - This exception is used to signal failures in operations related to external service providers, - such as authentication failures, data retrieval errors, or actions that cannot be completed as requested. - It allows for more granular error handling that is specific to provider interactions. - """ - - pass +class ConfigurationError(Exception): + """ + Exception raised when there's an issue with the application's configuration. + + This exception should be raised to indicate problems such as missing required configuration options, + invalid values, or issues loading configuration files. It helps in distinguishing configuration-related + errors from other types of exceptions. + """ + + pass + + +class ProviderError(Exception): + """ + Exception raised when there's an issue with a provider operation. + + This exception is used to signal failures in operations related to external service providers, + such as authentication failures, data retrieval errors, or actions that cannot be completed as requested. + It allows for more granular error handling that is specific to provider interactions. + """ + + pass diff --git a/src/claudesync/syncmanager.py b/src/claudesync/syncmanager.py index b7ebf67..c8f66dd 100644 --- a/src/claudesync/syncmanager.py +++ b/src/claudesync/syncmanager.py @@ -1,275 +1,275 @@ -import os -import time -import logging -from datetime import datetime, timezone - -from tqdm import tqdm - -from claudesync.utils import compute_md5_hash - -logger = logging.getLogger(__name__) - - -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() - - with tqdm(total=len(local_files), desc="Local → Remote") as pbar: - 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) - pbar.update(1) - - self.update_local_timestamps(remote_files, synced_files) - - if self.two_way_sync: - with tqdm(total=len(remote_files), desc="Remote → Local") as pbar: - for remote_file in remote_files: - self.sync_remote_to_local( - remote_file, remote_files_to_delete, synced_files - ) - pbar.update(1) - for file_to_delete in list(remote_files_to_delete): - self.delete_remote_files(file_to_delete, remote_files) - pbar.update(1) - - 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: - logger.debug(f"Updating {local_file} on remote...") - with tqdm(total=2, desc=f"Updating {local_file}", leave=False) as pbar: - self.provider.delete_file( - self.active_organization_id, - self.active_project_id, - remote_file["uuid"], - ) - pbar.update(1) - 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, - ) - pbar.update(1) - 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. - """ - logger.debug(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() - with tqdm(total=1, desc=f"Uploading {local_file}", leave=False) as pbar: - self.provider.upload_file( - self.active_organization_id, self.active_project_id, local_file, content - ) - pbar.update(1) - 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)) - logger.debug(f"Updated timestamp on local file {local_file_path}") - - def sync_remote_to_local(self, remote_file, remote_files_to_delete, synced_files): - """ - Synchronize a remote file to the local project (two-way sync). - - This method checks if the remote file exists locally. If it does, it updates the file - if the remote version is newer. If it doesn't exist locally, it creates a new local file. - - Args: - 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_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: - logger.debug( - 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. - """ - logger.debug( - f"Creating new local file {remote_file['file_name']} from remote..." - ) - with tqdm( - total=1, desc=f"Creating {remote_file['file_name']}", leave=False - ) as pbar: - with open(local_file_path, "w", encoding="utf-8") as file: - file.write(remote_file["content"]) - pbar.update(1) - 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, file_to_delete, remote_files): - """ - Delete a file from the remote project that no longer exists locally. - - This method deletes a remote file that is not present in the local directory. - - Args: - file_to_delete (str): Name of the remote file to be deleted. - remote_files (list): List of dictionaries representing remote files. - """ - logger.debug(f"Deleting {file_to_delete} from remote...") - remote_file = next( - rf for rf in remote_files if rf["file_name"] == file_to_delete - ) - with tqdm(total=1, desc=f"Deleting {file_to_delete}", leave=False) as pbar: - self.provider.delete_file( - self.active_organization_id, self.active_project_id, remote_file["uuid"] - ) - pbar.update(1) - time.sleep(self.upload_delay) +import os +import time +import logging +from datetime import datetime, timezone + +from tqdm import tqdm + +from claudesync.utils import compute_md5_hash + +logger = logging.getLogger(__name__) + + +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() + + with tqdm(total=len(local_files), desc="Local → Remote") as pbar: + 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) + pbar.update(1) + + self.update_local_timestamps(remote_files, synced_files) + + if self.two_way_sync: + with tqdm(total=len(remote_files), desc="Local ← Remote") as pbar: + for remote_file in remote_files: + self.sync_remote_to_local( + remote_file, remote_files_to_delete, synced_files + ) + pbar.update(1) + for file_to_delete in list(remote_files_to_delete): + self.delete_remote_files(file_to_delete, remote_files) + pbar.update(1) + + 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: + logger.debug(f"Updating {local_file} on remote...") + with tqdm(total=2, desc=f"Updating {local_file}", leave=False) as pbar: + self.provider.delete_file( + self.active_organization_id, + self.active_project_id, + remote_file["uuid"], + ) + pbar.update(1) + 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, + ) + pbar.update(1) + 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. + """ + logger.debug(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() + with tqdm(total=1, desc=f"Uploading {local_file}", leave=False) as pbar: + self.provider.upload_file( + self.active_organization_id, self.active_project_id, local_file, content + ) + pbar.update(1) + 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)) + logger.debug(f"Updated timestamp on local file {local_file_path}") + + def sync_remote_to_local(self, remote_file, remote_files_to_delete, synced_files): + """ + Synchronize a remote file to the local project (two-way sync). + + This method checks if the remote file exists locally. If it does, it updates the file + if the remote version is newer. If it doesn't exist locally, it creates a new local file. + + Args: + 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_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: + logger.debug( + 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. + """ + logger.debug( + f"Creating new local file {remote_file['file_name']} from remote..." + ) + with tqdm( + total=1, desc=f"Creating {remote_file['file_name']}", leave=False + ) as pbar: + with open(local_file_path, "w", encoding="utf-8") as file: + file.write(remote_file["content"]) + pbar.update(1) + 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, file_to_delete, remote_files): + """ + Delete a file from the remote project that no longer exists locally. + + This method deletes a remote file that is not present in the local directory. + + Args: + file_to_delete (str): Name of the remote file to be deleted. + remote_files (list): List of dictionaries representing remote files. + """ + logger.debug(f"Deleting {file_to_delete} from remote...") + remote_file = next( + rf for rf in remote_files if rf["file_name"] == file_to_delete + ) + with tqdm(total=1, desc=f"Deleting {file_to_delete}", leave=False) as pbar: + self.provider.delete_file( + self.active_organization_id, self.active_project_id, remote_file["uuid"] + ) + pbar.update(1) + time.sleep(self.upload_delay) diff --git a/src/claudesync/utils.py b/src/claudesync/utils.py index 512796d..cc1069a 100644 --- a/src/claudesync/utils.py +++ b/src/claudesync/utils.py @@ -1,334 +1,334 @@ -import os -import hashlib -from functools import wraps -import click -import pathspec -import logging -from claudesync.exceptions import ConfigurationError, ProviderError -from claudesync.provider_factory import get_provider -from claudesync.config_manager import ConfigManager - -logger = logging.getLogger(__name__) -config_manager = ConfigManager() - - -def normalize_and_calculate_md5(content): - """ - Calculate the MD5 checksum of the given content after normalizing line endings. - - This function normalizes the line endings of the input content to Unix-style (\n), - strips leading and trailing whitespace, and then calculates the MD5 checksum of the - normalized content. This is useful for ensuring consistent checksums across different - operating systems and environments where line ending styles may vary. - - Args: - content (str): The content for which to calculate the checksum. - - Returns: - str: The hexadecimal MD5 checksum of the normalized content. - """ - normalized_content = content.replace("\r\n", "\n").replace("\r", "\n").strip() - return hashlib.md5(normalized_content.encode("utf-8")).hexdigest() - - -def load_gitignore(base_path): - """ - Loads and parses the .gitignore file from the specified base path. - - This function attempts to find a .gitignore file in the given base path. If found, - it reads the file and creates a PathSpec object that can be used to match paths - against the patterns defined in the .gitignore file. This is useful for filtering - out files that should be ignored based on the project's .gitignore settings. - - Args: - base_path (str): The base directory path where the .gitignore file is located. - - Returns: - pathspec.PathSpec or None: A PathSpec object containing the patterns from the .gitignore file - if the file exists; otherwise, None. - """ - gitignore_path = os.path.join(base_path, ".gitignore") - if os.path.exists(gitignore_path): - with open(gitignore_path, "r") as f: - return pathspec.PathSpec.from_lines("gitwildmatch", f) - return None - - -def is_text_file(file_path, sample_size=8192): - """ - Determines if a file is a text file by checking for the absence of null bytes. - - This function reads a sample of the file (default 8192 bytes) and checks if it contains - any null byte (\x00). The presence of a null byte is often indicative of a binary file. - This is a heuristic method and may not be 100% accurate for all file types. - - Args: - file_path (str): The path to the file to be checked. - sample_size (int, optional): The number of bytes to read from the file for checking. - Defaults to 8192. - - Returns: - bool: True if the file is likely a text file, False if it is likely binary or an error occurred. - """ - try: - with open(file_path, "rb") as file: - return b"\x00" not in file.read(sample_size) - except IOError: - return False - - -def compute_md5_hash(content): - """ - Computes the MD5 hash of the given content. - - This function takes a string as input, encodes it into UTF-8, and then computes the MD5 hash of the encoded string. - The result is a hexadecimal representation of the hash, which is commonly used for creating a quick and simple - fingerprint of a piece of data. - - Args: - content (str): The content for which to compute the MD5 hash. - - Returns: - str: The hexadecimal MD5 hash of the input content. - """ - return hashlib.md5(content.encode("utf-8")).hexdigest() - - -def should_process_file(file_path, filename, gitignore, base_path, claudeignore): - """ - Determines whether a file should be processed based on various criteria. - - This function checks if a file should be included in the synchronization process by applying - several filters: - - Checks if the file size is within the configured maximum limit. - - Skips temporary editor files (ending with '~'). - - Applies .gitignore rules if a gitignore PathSpec is provided. - - Verifies if the file is a text file. - - Args: - file_path (str): The full path to the file. - filename (str): The name of the file. - gitignore (pathspec.PathSpec or None): A PathSpec object containing .gitignore patterns, if available. - base_path (str): The base directory path of the project. - claudeignore (pathspec.PathSpec or None): A PathSpec object containing .claudeignore patterns, if available. - - Returns: - bool: True if the file should be processed, False otherwise. - """ - # Check file size - max_file_size = config_manager.get("max_file_size", 32 * 1024) - if os.path.getsize(file_path) > max_file_size: - return False - - # Skip temporary editor files - if filename.endswith("~"): - return False - - rel_path = os.path.relpath(file_path, base_path) - - # Use gitignore rules if available - if gitignore and gitignore.match_file(rel_path): - return False - - # Use .claudeignore rules if available - if claudeignore and claudeignore.match_file(rel_path): - return False - - # Check if it's a text file - return is_text_file(file_path) - - -def process_file(file_path): - """ - Reads the content of a file and computes its MD5 hash. - - This function attempts to read the file as UTF-8 text and compute its MD5 hash. - If the file cannot be read as UTF-8 or any other error occurs, it logs the issue - and returns None. - - Args: - file_path (str): The path to the file to be processed. - - Returns: - str or None: The MD5 hash of the file's content if successful, None otherwise. - """ - try: - with open(file_path, "r", encoding="utf-8") as file: - content = file.read() - return compute_md5_hash(content) - except UnicodeDecodeError: - logger.debug(f"Unable to read {file_path} as UTF-8 text. Skipping.") - except Exception as e: - logger.error(f"Error reading file {file_path}: {str(e)}") - return None - - -def get_local_files(local_path): - """ - Retrieves a dictionary of local files within a specified path, applying various filters. - - This function walks through the directory specified by `local_path`, applying several filters to each file: - - Excludes files in directories like .git, .svn, etc. - - Skips files larger than a specified maximum size (default 200KB, configurable). - - Ignores temporary editor files (ending with '~'). - - Applies .gitignore rules if a .gitignore file is present in the `local_path`. - - Applies .gitignore rules if a .claudeignore file is present in the `local_path`. - - Checks if the file is a text file before processing. - Each file that passes these filters is read, and its content is hashed using MD5. The function returns a dictionary - 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. - - Args: - local_path (str): The base directory path to search for files. - - Returns: - dict: A dictionary where keys are relative file paths, and values are MD5 hashes of the file contents. - """ - gitignore = load_gitignore(local_path) - claudeignore = load_claudeignore(local_path) - files = {} - exclude_dirs = {".git", ".svn", ".hg", ".bzr", "_darcs", "CVS", "claude_chats"} - - for root, dirs, filenames in os.walk(local_path): - dirs[:] = [d for d in dirs if d not in exclude_dirs] - rel_root = os.path.relpath(root, local_path) - rel_root = "" if rel_root == "." else rel_root - - for filename in filenames: - rel_path = os.path.join(rel_root, filename) - full_path = os.path.join(root, filename) - - if should_process_file( - full_path, filename, gitignore, local_path, claudeignore - ): - file_hash = process_file(full_path) - if file_hash: - files[rel_path] = file_hash - - return files - - -def handle_errors(func): - """ - A decorator that wraps a function to catch and handle specific exceptions. - - This decorator catches exceptions of type ConfigurationError and ProviderError - that are raised within the decorated function. When such an exception is caught, - it prints an error message to the console using click's echo function. This is - useful for CLI applications where a friendly error message is preferred over a - full traceback for known error conditions. - - Args: - func (Callable): The function to be decorated. - - Returns: - Callable: The wrapper function that includes exception handling. - """ - - @wraps(func) - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except (ConfigurationError, ProviderError) as e: - click.echo(f"Error: {str(e)}") - - return wrapper - - -def validate_and_get_provider(config, require_org=True, require_project=False): - """ - Validates the configuration for the presence of an active provider and session key, - and optionally checks for an active organization ID and project ID. If validation passes, - it retrieves the provider instance based on the active provider name. - - Args: - config (ConfigManager): The configuration manager instance containing settings. - require_org (bool, optional): Flag to indicate whether an active organization ID - is required. Defaults to True. - require_project (bool, optional): Flag to indicate whether an active project ID - is required. Defaults to False. - - Returns: - object: An instance of the provider specified in the configuration. - - Raises: - ConfigurationError: If the active provider or session key is missing, or if - require_org is True and no active organization ID is set, - or if require_project is True and no active project ID is set. - ProviderError: If the session key has expired. - """ - active_provider = config.get("active_provider") - session_key = config.get_session_key() - if not session_key: - raise ProviderError( - f"Session key has expired. Please run `claudesync api login {active_provider}` again." - ) - if not active_provider or not session_key: - raise ConfigurationError( - "No active provider or session key. Please login first." - ) - if require_org and not config.get("active_organization_id"): - raise ConfigurationError( - "No active organization set. Please select an organization." - ) - if require_project and not config.get("active_project_id"): - raise ConfigurationError( - "No active project set. Please select or create a project." - ) - session_key_expiry = config.get("session_key_expiry") - return get_provider(active_provider, session_key, session_key_expiry) - - -def validate_and_store_local_path(config): - """ - Prompts the user for the absolute path to their local project directory and stores it in the configuration. - - This function repeatedly prompts the user to enter the absolute path to their local project directory until - a valid absolute path is provided. The path is validated to ensure it exists, is a directory, and is an absolute path. - Once a valid path is provided, it is stored in the configuration using the `set` method of the `ConfigManager` object. - - Args: - config (ConfigManager): The configuration manager instance to store the local path setting. - - Note: - This function uses `click.prompt` to interact with the user, providing a default path (the current working directory) - and validating the user's input to ensure it meets the criteria for an absolute path to a directory. - """ - - def get_default_path(): - return os.getcwd() - - while True: - default_path = get_default_path() - local_path = click.prompt( - "Enter the absolute path to your local project directory", - type=click.Path( - exists=True, file_okay=False, dir_okay=True, resolve_path=True - ), - default=default_path, - show_default=True, - ) - - if os.path.isabs(local_path): - config.set("local_path", local_path) - click.echo(f"Local path set to: {local_path}") - break - else: - click.echo("Please enter an absolute path.") - - -def load_claudeignore(base_path): - """ - Loads and parses the .claudeignore file from the specified base path. - - Args: - base_path (str): The base directory path where the .claudeignore file is located. - - Returns: - pathspec.PathSpec or None: A PathSpec object containing the patterns from the .claudeignore file - if the file exists; otherwise, None. - """ - claudeignore_path = os.path.join(base_path, ".claudeignore") - if os.path.exists(claudeignore_path): - with open(claudeignore_path, "r") as f: - return pathspec.PathSpec.from_lines("gitwildmatch", f) - return None +import os +import hashlib +from functools import wraps +import click +import pathspec +import logging +from claudesync.exceptions import ConfigurationError, ProviderError +from claudesync.provider_factory import get_provider +from claudesync.config_manager import ConfigManager + +logger = logging.getLogger(__name__) +config_manager = ConfigManager() + + +def normalize_and_calculate_md5(content): + """ + Calculate the MD5 checksum of the given content after normalizing line endings. + + This function normalizes the line endings of the input content to Unix-style (\n), + strips leading and trailing whitespace, and then calculates the MD5 checksum of the + normalized content. This is useful for ensuring consistent checksums across different + operating systems and environments where line ending styles may vary. + + Args: + content (str): The content for which to calculate the checksum. + + Returns: + str: The hexadecimal MD5 checksum of the normalized content. + """ + normalized_content = content.replace("\r\n", "\n").replace("\r", "\n").strip() + return hashlib.md5(normalized_content.encode("utf-8")).hexdigest() + + +def load_gitignore(base_path): + """ + Loads and parses the .gitignore file from the specified base path. + + This function attempts to find a .gitignore file in the given base path. If found, + it reads the file and creates a PathSpec object that can be used to match paths + against the patterns defined in the .gitignore file. This is useful for filtering + out files that should be ignored based on the project's .gitignore settings. + + Args: + base_path (str): The base directory path where the .gitignore file is located. + + Returns: + pathspec.PathSpec or None: A PathSpec object containing the patterns from the .gitignore file + if the file exists; otherwise, None. + """ + gitignore_path = os.path.join(base_path, ".gitignore") + if os.path.exists(gitignore_path): + with open(gitignore_path, "r") as f: + return pathspec.PathSpec.from_lines("gitwildmatch", f) + return None + + +def is_text_file(file_path, sample_size=8192): + """ + Determines if a file is a text file by checking for the absence of null bytes. + + This function reads a sample of the file (default 8192 bytes) and checks if it contains + any null byte (\x00). The presence of a null byte is often indicative of a binary file. + This is a heuristic method and may not be 100% accurate for all file types. + + Args: + file_path (str): The path to the file to be checked. + sample_size (int, optional): The number of bytes to read from the file for checking. + Defaults to 8192. + + Returns: + bool: True if the file is likely a text file, False if it is likely binary or an error occurred. + """ + try: + with open(file_path, "rb") as file: + return b"\x00" not in file.read(sample_size) + except IOError: + return False + + +def compute_md5_hash(content): + """ + Computes the MD5 hash of the given content. + + This function takes a string as input, encodes it into UTF-8, and then computes the MD5 hash of the encoded string. + The result is a hexadecimal representation of the hash, which is commonly used for creating a quick and simple + fingerprint of a piece of data. + + Args: + content (str): The content for which to compute the MD5 hash. + + Returns: + str: The hexadecimal MD5 hash of the input content. + """ + return hashlib.md5(content.encode("utf-8")).hexdigest() + + +def should_process_file(file_path, filename, gitignore, base_path, claudeignore): + """ + Determines whether a file should be processed based on various criteria. + + This function checks if a file should be included in the synchronization process by applying + several filters: + - Checks if the file size is within the configured maximum limit. + - Skips temporary editor files (ending with '~'). + - Applies .gitignore rules if a gitignore PathSpec is provided. + - Verifies if the file is a text file. + + Args: + file_path (str): The full path to the file. + filename (str): The name of the file. + gitignore (pathspec.PathSpec or None): A PathSpec object containing .gitignore patterns, if available. + base_path (str): The base directory path of the project. + claudeignore (pathspec.PathSpec or None): A PathSpec object containing .claudeignore patterns, if available. + + Returns: + bool: True if the file should be processed, False otherwise. + """ + # Check file size + max_file_size = config_manager.get("max_file_size", 32 * 1024) + if os.path.getsize(file_path) > max_file_size: + return False + + # Skip temporary editor files + if filename.endswith("~"): + return False + + rel_path = os.path.relpath(file_path, base_path) + + # Use gitignore rules if available + if gitignore and gitignore.match_file(rel_path): + return False + + # Use .claudeignore rules if available + if claudeignore and claudeignore.match_file(rel_path): + return False + + # Check if it's a text file + return is_text_file(file_path) + + +def process_file(file_path): + """ + Reads the content of a file and computes its MD5 hash. + + This function attempts to read the file as UTF-8 text and compute its MD5 hash. + If the file cannot be read as UTF-8 or any other error occurs, it logs the issue + and returns None. + + Args: + file_path (str): The path to the file to be processed. + + Returns: + str or None: The MD5 hash of the file's content if successful, None otherwise. + """ + try: + with open(file_path, "r", encoding="utf-8") as file: + content = file.read() + return compute_md5_hash(content) + except UnicodeDecodeError: + logger.debug(f"Unable to read {file_path} as UTF-8 text. Skipping.") + except Exception as e: + logger.error(f"Error reading file {file_path}: {str(e)}") + return None + + +def get_local_files(local_path): + """ + Retrieves a dictionary of local files within a specified path, applying various filters. + + This function walks through the directory specified by `local_path`, applying several filters to each file: + - Excludes files in directories like .git, .svn, etc. + - Skips files larger than a specified maximum size (default 200KB, configurable). + - Ignores temporary editor files (ending with '~'). + - Applies .gitignore rules if a .gitignore file is present in the `local_path`. + - Applies .gitignore rules if a .claudeignore file is present in the `local_path`. + - Checks if the file is a text file before processing. + Each file that passes these filters is read, and its content is hashed using MD5. The function returns a dictionary + 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. + + Args: + local_path (str): The base directory path to search for files. + + Returns: + dict: A dictionary where keys are relative file paths, and values are MD5 hashes of the file contents. + """ + gitignore = load_gitignore(local_path) + claudeignore = load_claudeignore(local_path) + files = {} + exclude_dirs = {".git", ".svn", ".hg", ".bzr", "_darcs", "CVS", "claude_chats"} + + for root, dirs, filenames in os.walk(local_path): + dirs[:] = [d for d in dirs if d not in exclude_dirs] + rel_root = os.path.relpath(root, local_path) + rel_root = "" if rel_root == "." else rel_root + + for filename in filenames: + rel_path = os.path.join(rel_root, filename) + full_path = os.path.join(root, filename) + + if should_process_file( + full_path, filename, gitignore, local_path, claudeignore + ): + file_hash = process_file(full_path) + if file_hash: + files[rel_path] = file_hash + + return files + + +def handle_errors(func): + """ + A decorator that wraps a function to catch and handle specific exceptions. + + This decorator catches exceptions of type ConfigurationError and ProviderError + that are raised within the decorated function. When such an exception is caught, + it prints an error message to the console using click's echo function. This is + useful for CLI applications where a friendly error message is preferred over a + full traceback for known error conditions. + + Args: + func (Callable): The function to be decorated. + + Returns: + Callable: The wrapper function that includes exception handling. + """ + + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except (ConfigurationError, ProviderError) as e: + click.echo(f"Error: {str(e)}") + + return wrapper + + +def validate_and_get_provider(config, require_org=True, require_project=False): + """ + Validates the configuration for the presence of an active provider and session key, + and optionally checks for an active organization ID and project ID. If validation passes, + it retrieves the provider instance based on the active provider name. + + Args: + config (ConfigManager): The configuration manager instance containing settings. + require_org (bool, optional): Flag to indicate whether an active organization ID + is required. Defaults to True. + require_project (bool, optional): Flag to indicate whether an active project ID + is required. Defaults to False. + + Returns: + object: An instance of the provider specified in the configuration. + + Raises: + ConfigurationError: If the active provider or session key is missing, or if + require_org is True and no active organization ID is set, + or if require_project is True and no active project ID is set. + ProviderError: If the session key has expired. + """ + active_provider = config.get("active_provider") + session_key = config.get_session_key() + if not session_key: + raise ProviderError( + f"Session key has expired. Please run `claudesync api login {active_provider}` again." + ) + if not active_provider or not session_key: + raise ConfigurationError( + "No active provider or session key. Please login first." + ) + if require_org and not config.get("active_organization_id"): + raise ConfigurationError( + "No active organization set. Please select an organization." + ) + if require_project and not config.get("active_project_id"): + raise ConfigurationError( + "No active project set. Please select or create a project." + ) + session_key_expiry = config.get("session_key_expiry") + return get_provider(active_provider, session_key, session_key_expiry) + + +def validate_and_store_local_path(config): + """ + Prompts the user for the absolute path to their local project directory and stores it in the configuration. + + This function repeatedly prompts the user to enter the absolute path to their local project directory until + a valid absolute path is provided. The path is validated to ensure it exists, is a directory, and is an absolute path. + Once a valid path is provided, it is stored in the configuration using the `set` method of the `ConfigManager` object. + + Args: + config (ConfigManager): The configuration manager instance to store the local path setting. + + Note: + This function uses `click.prompt` to interact with the user, providing a default path (the current working directory) + and validating the user's input to ensure it meets the criteria for an absolute path to a directory. + """ + + def get_default_path(): + return os.getcwd() + + while True: + default_path = get_default_path() + local_path = click.prompt( + "Enter the absolute path to your local project directory", + type=click.Path( + exists=True, file_okay=False, dir_okay=True, resolve_path=True + ), + default=default_path, + show_default=True, + ) + + if os.path.isabs(local_path): + config.set("local_path", local_path) + click.echo(f"Local path set to: {local_path}") + break + else: + click.echo("Please enter an absolute path.") + + +def load_claudeignore(base_path): + """ + Loads and parses the .claudeignore file from the specified base path. + + Args: + base_path (str): The base directory path where the .claudeignore file is located. + + Returns: + pathspec.PathSpec or None: A PathSpec object containing the patterns from the .claudeignore file + if the file exists; otherwise, None. + """ + claudeignore_path = os.path.join(base_path, ".claudeignore") + if os.path.exists(claudeignore_path): + with open(claudeignore_path, "r") as f: + return pathspec.PathSpec.from_lines("gitwildmatch", f) + return None diff --git a/tests/cli/test_api.py b/tests/cli/test_api.py index e7e2627..da37030 100644 --- a/tests/cli/test_api.py +++ b/tests/cli/test_api.py @@ -14,8 +14,15 @@ def setUp(self): @patch("claudesync.providers.base_claude_ai._get_session_key_expiry") @patch("claudesync.cli.api.get_provider") @patch("claudesync.cli.main.ConfigManager") + @patch("claudesync.cli.api.org_select") + @patch("claudesync.cli.api.proj_select") def test_login_success( - self, mock_config_manager, mock_get_provider, mock_get_session_key_expiry + self, + mock_proj_select, + mock_org_select, + mock_config_manager, + mock_get_provider, + mock_get_session_key_expiry, ): mock_config_manager.return_value = self.mock_config mock_get_provider.return_value = self.mock_provider @@ -23,21 +30,20 @@ def test_login_success( ["claude.ai"] if x is None else self.mock_provider ) expiry = "Tue, 03 Sep 2099 06:51:21 UTC" - self.mock_provider.login.return_value = "test_session_key", expiry + self.mock_provider.login.return_value = ("test_session_key", expiry) - mock_get_session_key_expiry = self.mock_config mock_get_session_key_expiry.return_value = expiry result = self.runner.invoke(cli, ["api", "login", "claude.ai"]) - print( - f"Session Key Call Args: {self.mock_config.set_session_key.call_args_list}" - ) - self.assertEqual(result.exit_code, 0) self.assertIn("Logged in successfully.", result.output) - self.mock_config.set_session_key.assert_any_call("test_session_key", expiry) - self.mock_config.set.assert_any_call("active_provider", "claude.ai") + self.mock_config.set_session_key.assert_called_once_with( + "test_session_key", expiry + ) + self.mock_config.set.assert_called_with("active_provider", "claude.ai") + mock_org_select.assert_called_once() + mock_proj_select.assert_called_once() @patch("claudesync.cli.api.get_provider") @patch("claudesync.cli.main.ConfigManager") diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index b443735..9d14536 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -1,29 +1,29 @@ -import unittest -from click.testing import CliRunner -from claudesync.cli.main import cli - - -class TestMainCLI(unittest.TestCase): - def setUp(self): - self.runner = CliRunner() - - def test_cli_group(self): - result = self.runner.invoke(cli, ["--help"]) - self.assertEqual(result.exit_code, 0) - self.assertIn( - "ClaudeSync: Synchronize local files with ai projects.", result.output - ) - - def test_install_completion(self): - result = self.runner.invoke(cli, ["install-completion", "--help"]) - self.assertEqual(result.exit_code, 0) - self.assertIn("Install completion for the specified shell.", result.output) - - def test_status(self): - result = self.runner.invoke(cli, ["status"]) - self.assertEqual(result.exit_code, 0) - self.assertIn("Active provider:", result.output) - - -if __name__ == "__main__": - unittest.main() +import unittest +from click.testing import CliRunner +from claudesync.cli.main import cli + + +class TestMainCLI(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + + def test_cli_group(self): + result = self.runner.invoke(cli, ["--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn( + "ClaudeSync: Synchronize local files with ai projects.", result.output + ) + + def test_install_completion(self): + result = self.runner.invoke(cli, ["install-completion", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("Install completion for the specified shell.", result.output) + + def test_status(self): + result = self.runner.invoke(cli, ["status"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("Active provider:", result.output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_organization.py b/tests/cli/test_organization.py index 96ca54f..e12e42e 100644 --- a/tests/cli/test_organization.py +++ b/tests/cli/test_organization.py @@ -1,103 +1,103 @@ -import unittest -from unittest.mock import patch, MagicMock -from click.testing import CliRunner -from claudesync.cli.main import cli -from claudesync.exceptions import ConfigurationError, ProviderError - - -class TestOrganizationCLI(unittest.TestCase): - def setUp(self): - self.runner = CliRunner() - - @patch("claudesync.cli.organization.validate_and_get_provider") - def test_organization_ls(self, mock_validate_and_get_provider): - # Mock the provider - mock_provider = MagicMock() - mock_provider.get_organizations.return_value = [ - { - "id": "org1", - "name": "Organization 1", - "capabilities": ["chat", "claude_pro"], - }, - { - "id": "org2", - "name": "Organization 2", - "capabilities": ["chat", "claude_pro"], - }, - ] - mock_validate_and_get_provider.return_value = mock_provider - - # Test successful listing - result = self.runner.invoke(cli, ["organization", "ls"]) - self.assertEqual(result.exit_code, 0) - self.assertIn("Organization 1", result.output) - self.assertIn("Organization 2", result.output) - - # Test empty list - mock_provider.get_organizations.return_value = [] - result = self.runner.invoke(cli, ["organization", "ls"]) - self.assertEqual(result.exit_code, 0) - self.assertIn( - "No organizations with required capabilities (chat and claude_pro) found.", - result.output, - ) - - # Test error handling - mock_validate_and_get_provider.side_effect = ConfigurationError( - "Configuration error" - ) - result = self.runner.invoke(cli, ["organization", "ls"]) - self.assertEqual(result.exit_code, 0) - self.assertIn("Error: Configuration error", result.output) - - @patch("claudesync.cli.organization.validate_and_get_provider") - @patch("claudesync.cli.organization.click.prompt") - def test_organization_select(self, mock_prompt, mock_validate_and_get_provider): - # Mock the provider - mock_provider = MagicMock() - mock_provider.get_organizations.return_value = [ - { - "id": "org1", - "name": "Organization 1", - "capabilities": ["chat", "claude_pro"], - }, - { - "id": "org2", - "name": "Organization 2", - "capabilities": ["chat", "claude_pro"], - }, - ] - mock_validate_and_get_provider.return_value = mock_provider - - # Mock user input - mock_prompt.return_value = 1 - - # Test successful selection - result = self.runner.invoke(cli, ["organization", "select"]) - self.assertEqual(result.exit_code, 0) - self.assertIn("Selected organization: Organization 1", result.output) - - # Test invalid selection - mock_prompt.return_value = 3 - result = self.runner.invoke(cli, ["organization", "select"]) - self.assertEqual(result.exit_code, 0) - self.assertIn("Invalid selection. Please try again.", result.output) - - # Test empty list - mock_provider.get_organizations.return_value = [] - result = self.runner.invoke(cli, ["organization", "select"]) - self.assertEqual(result.exit_code, 0) - self.assertIn( - "No organizations with required capabilities (chat and claude_pro) found.", - result.output, - ) - - # Test error handling - mock_validate_and_get_provider.side_effect = ProviderError("Provider error") - result = self.runner.invoke(cli, ["organization", "select"]) - self.assertEqual(result.exit_code, 0) - self.assertIn("Error: Provider error", result.output) - - -if __name__ == "__main__": - unittest.main() +import unittest +from unittest.mock import patch, MagicMock +from click.testing import CliRunner +from claudesync.cli.main import cli +from claudesync.exceptions import ConfigurationError, ProviderError + + +class TestOrganizationCLI(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + + @patch("claudesync.cli.organization.validate_and_get_provider") + def test_organization_ls(self, mock_validate_and_get_provider): + # Mock the provider + mock_provider = MagicMock() + mock_provider.get_organizations.return_value = [ + { + "id": "org1", + "name": "Organization 1", + "capabilities": ["chat", "claude_pro"], + }, + { + "id": "org2", + "name": "Organization 2", + "capabilities": ["chat", "claude_pro"], + }, + ] + mock_validate_and_get_provider.return_value = mock_provider + + # Test successful listing + result = self.runner.invoke(cli, ["organization", "ls"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("Organization 1", result.output) + self.assertIn("Organization 2", result.output) + + # Test empty list + mock_provider.get_organizations.return_value = [] + result = self.runner.invoke(cli, ["organization", "ls"]) + self.assertEqual(result.exit_code, 0) + self.assertIn( + "No organizations with required capabilities (chat and claude_pro) found.", + result.output, + ) + + # Test error handling + mock_validate_and_get_provider.side_effect = ConfigurationError( + "Configuration error" + ) + result = self.runner.invoke(cli, ["organization", "ls"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("Error: Configuration error", result.output) + + @patch("claudesync.cli.organization.validate_and_get_provider") + @patch("claudesync.cli.organization.click.prompt") + def test_organization_select(self, mock_prompt, mock_validate_and_get_provider): + # Mock the provider + mock_provider = MagicMock() + mock_provider.get_organizations.return_value = [ + { + "id": "org1", + "name": "Organization 1", + "capabilities": ["chat", "claude_pro"], + }, + { + "id": "org2", + "name": "Organization 2", + "capabilities": ["chat", "claude_pro"], + }, + ] + mock_validate_and_get_provider.return_value = mock_provider + + # Mock user input + mock_prompt.return_value = 1 + + # Test successful selection + result = self.runner.invoke(cli, ["organization", "select"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("Selected organization: Organization 1", result.output) + + # Test invalid selection + mock_prompt.return_value = 3 + result = self.runner.invoke(cli, ["organization", "select"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("Invalid selection. Please try again.", result.output) + + # Test empty list + mock_provider.get_organizations.return_value = [] + result = self.runner.invoke(cli, ["organization", "select"]) + self.assertEqual(result.exit_code, 0) + self.assertIn( + "No organizations with required capabilities (chat and claude_pro) found.", + result.output, + ) + + # Test error handling + mock_validate_and_get_provider.side_effect = ProviderError("Provider error") + result = self.runner.invoke(cli, ["organization", "select"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("Error: Provider error", result.output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 7877f96..62b20e8 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -1,76 +1,76 @@ -import unittest -import json -import tempfile -from pathlib import Path -from unittest.mock import patch -from claudesync.config_manager import ConfigManager - - -class TestConfigManager(unittest.TestCase): - - def setUp(self): - self.temp_dir = tempfile.mkdtemp() - self.config_dir = Path(self.temp_dir) / ".claudesync" - self.config_file = self.config_dir / "config.json" - - def tearDown(self): - import shutil - - shutil.rmtree(self.temp_dir) - - @patch("pathlib.Path.home") - def test_init_existing_config(self, mock_home): - mock_home.return_value = Path(self.temp_dir) - self.config_dir.mkdir(parents=True) - with open(self.config_file, "w") as f: - json.dump({"existing_key": "value"}, f) - - config = ConfigManager() - self.assertEqual(config.get("existing_key"), "value") - self.assertEqual(config.get("log_level"), "INFO") - self.assertEqual(config.get("upload_delay"), 0.5) - - @patch("pathlib.Path.home") - def test_get_existing_key(self, mock_home): - mock_home.return_value = Path(self.temp_dir) - config = ConfigManager() - config.set("test_key", "test_value") - self.assertEqual(config.get("test_key"), "test_value") - - @patch("pathlib.Path.home") - def test_get_non_existing_key(self, mock_home): - mock_home.return_value = Path(self.temp_dir) - config = ConfigManager() - self.assertIsNone(config.get("non_existing_key")) - self.assertEqual(config.get("non_existing_key", "default"), "default") - - @patch("pathlib.Path.home") - def test_set_and_save(self, mock_home): - mock_home.return_value = Path(self.temp_dir) - config = ConfigManager() - config.set("new_key", "new_value") - - # Check if the new value is in the instance - self.assertEqual(config.get("new_key"), "new_value") - - # Check if the new value was saved to the file - with open(self.config_file, "r") as f: - saved_config = json.load(f) - self.assertEqual(saved_config["new_key"], "new_value") - - @patch("pathlib.Path.home") - def test_update_existing_value(self, mock_home): - mock_home.return_value = Path(self.temp_dir) - config = ConfigManager() - config.set("update_key", "original_value") - config.set("update_key", "updated_value") - - self.assertEqual(config.get("update_key"), "updated_value") - - with open(self.config_file, "r") as f: - saved_config = json.load(f) - self.assertEqual(saved_config["update_key"], "updated_value") - - -if __name__ == "__main__": - unittest.main() +import unittest +import json +import tempfile +from pathlib import Path +from unittest.mock import patch +from claudesync.config_manager import ConfigManager + + +class TestConfigManager(unittest.TestCase): + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.config_dir = Path(self.temp_dir) / ".claudesync" + self.config_file = self.config_dir / "config.json" + + def tearDown(self): + import shutil + + shutil.rmtree(self.temp_dir) + + @patch("pathlib.Path.home") + def test_init_existing_config(self, mock_home): + mock_home.return_value = Path(self.temp_dir) + self.config_dir.mkdir(parents=True) + with open(self.config_file, "w") as f: + json.dump({"existing_key": "value"}, f) + + config = ConfigManager() + self.assertEqual(config.get("existing_key"), "value") + self.assertEqual(config.get("log_level"), "INFO") + self.assertEqual(config.get("upload_delay"), 0.5) + + @patch("pathlib.Path.home") + def test_get_existing_key(self, mock_home): + mock_home.return_value = Path(self.temp_dir) + config = ConfigManager() + config.set("test_key", "test_value") + self.assertEqual(config.get("test_key"), "test_value") + + @patch("pathlib.Path.home") + def test_get_non_existing_key(self, mock_home): + mock_home.return_value = Path(self.temp_dir) + config = ConfigManager() + self.assertIsNone(config.get("non_existing_key")) + self.assertEqual(config.get("non_existing_key", "default"), "default") + + @patch("pathlib.Path.home") + def test_set_and_save(self, mock_home): + mock_home.return_value = Path(self.temp_dir) + config = ConfigManager() + config.set("new_key", "new_value") + + # Check if the new value is in the instance + self.assertEqual(config.get("new_key"), "new_value") + + # Check if the new value was saved to the file + with open(self.config_file, "r") as f: + saved_config = json.load(f) + self.assertEqual(saved_config["new_key"], "new_value") + + @patch("pathlib.Path.home") + def test_update_existing_value(self, mock_home): + mock_home.return_value = Path(self.temp_dir) + config = ConfigManager() + config.set("update_key", "original_value") + config.set("update_key", "updated_value") + + self.assertEqual(config.get("update_key"), "updated_value") + + with open(self.config_file, "r") as f: + saved_config = json.load(f) + self.assertEqual(saved_config["update_key"], "updated_value") + + +if __name__ == "__main__": + unittest.main()