From c1a140b9cf3190ce53a7e500cddaf5a8f7dd275e Mon Sep 17 00:00:00 2001 From: Jahziah Wagner Date: Tue, 6 Aug 2024 01:02:26 +0200 Subject: [PATCH] Fix newline --- .github/ISSUE_TEMPLATE/bug_report.md | 94 +-- .github/workflows/python-publish.yml | 78 +-- CONTRIBUTING.md | 166 ++--- LICENSE | 42 +- README.md | 106 ++-- pyproject.toml | 134 ++--- pytest.ini | 6 +- requirements.txt | 12 +- setup.py | 12 +- src/claudesync/chat_sync.py | 372 ++++++------ src/claudesync/cli/__init__.py | 6 +- src/claudesync/cli/api.py | 168 +++--- src/claudesync/cli/config.py | 124 ++-- src/claudesync/cli/main.py | 134 ++--- src/claudesync/cli/organization.py | 106 ++-- src/claudesync/cli/project.py | 292 ++++----- src/claudesync/cli/sync.py | 204 +++---- src/claudesync/config_manager.py | 260 ++++---- src/claudesync/exceptions.py | 44 +- src/claudesync/provider_factory.py | 102 ++-- src/claudesync/providers/claude_ai_curl.py | 258 ++++---- src/claudesync/syncmanager.py | 550 ++++++++--------- src/claudesync/utils.py | 668 ++++++++++----------- tests/cli/test_main.py | 58 +- tests/cli/test_organization.py | 206 +++---- tests/test_config_manager.py | 152 ++--- 26 files changed, 2177 insertions(+), 2177 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 22f45fd..d72d205 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 52e80f1..060d2df 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 0b13a47..b2c89d2 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 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. - +# 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 0779f74..d0cf252 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 69aa7ab..378a8bd 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,53 @@ -# ClaudeSync - -[![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) - -ClaudeSync is a powerful tool that bridges your local development environment with Claude.ai projects, enabling seamless synchronization and enhancing your AI-powered workflow. - -![ClaudeSync example](claudesync.gif "ClaudeSync") - -## 🌟 Key Features - -- 🔄 **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 - -1. **Install ClaudeSync:** -```shell -pip install claudesync -``` - -2. **Log in to your Claude.ai account:** -```shell -claudesync api login claude.ai -``` - -3. **Start syncing:** -```shell -claudesync sync -``` - -📚 Need more details? Check our [Wiki](https://github.com/jahwag/claudesync/wiki) for comprehensive guides and FAQs. - -## 🤝 Support ClaudeSync - -Love ClaudeSync? Here's how you can contribute: - -- ⭐ [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 - -Your support fuels ClaudeSync's growth and improvement! - ---- - -[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=) +# ClaudeSync + +[![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) + +ClaudeSync is a powerful tool that bridges your local development environment with Claude.ai projects, enabling seamless synchronization and enhancing your AI-powered workflow. + +![ClaudeSync example](claudesync.gif "ClaudeSync") + +## 🌟 Key Features + +- 🔄 **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 + +1. **Install ClaudeSync:** +```shell +pip install claudesync +``` + +2. **Log in to your Claude.ai account:** +```shell +claudesync api login claude.ai +``` + +3. **Start syncing:** +```shell +claudesync sync +``` + +📚 Need more details? Check our [Wiki](https://github.com/jahwag/claudesync/wiki) for comprehensive guides and FAQs. + +## 🤝 Support ClaudeSync + +Love ClaudeSync? Here's how you can contribute: + +- ⭐ [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 + +Your support fuels ClaudeSync's growth and improvement! + +--- + +[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/pyproject.toml b/pyproject.toml index e01dab4..f307ea0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,67 +1,67 @@ -[project] -name = "claudesync" -version = "0.4.8" -authors = [ - {name = "Jahziah Wagner", email = "jahziah.wagner+pypi@gmail.com"}, -] -description = "A tool to synchronize local files with Claude.ai projects" -license = {file = "LICENSE"} -readme = "README.md" -requires-python = ">=3.7" -classifiers = [ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", -] -dependencies = [ - "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", -] -keywords = [ - "sync", - "files", - "Claude.ai", - "automation", - "synchronization", - "project management", - "file management", - "cloud sync", - "cli tool", - "command line", - "productivity", - "development tools", - "file synchronization", - "continuous integration", - "devops", - "version control" -] - -[project.optional-dependencies] -test = [ - "pytest>=8.2.2", - "pytest-cov>=5.0.0", -] - -[project.urls] -"Homepage" = "https://github.com/jahwag/claudesync" -"Bug Tracker" = "https://github.com/jahwag/claudesync/issues" - -[project.scripts] -claudesync = "claudesync.cli.main:cli" - -[build-system] -requires = ["setuptools>=42", "wheel"] -build-backend = "setuptools.build_meta" - -[tool.setuptools.packages.find] -where = ["src"] -include = ["claudesync*"] - -[tool.pytest.ini_options] -testpaths = ["tests"] -python_files = "test_*.py" -addopts = "-v --cov=claudesync --cov-report=term-missing" +[project] +name = "claudesync" +version = "0.4.8" +authors = [ + {name = "Jahziah Wagner", email = "jahziah.wagner+pypi@gmail.com"}, +] +description = "A tool to synchronize local files with Claude.ai projects" +license = {file = "LICENSE"} +readme = "README.md" +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +dependencies = [ + "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", +] +keywords = [ + "sync", + "files", + "Claude.ai", + "automation", + "synchronization", + "project management", + "file management", + "cloud sync", + "cli tool", + "command line", + "productivity", + "development tools", + "file synchronization", + "continuous integration", + "devops", + "version control" +] + +[project.optional-dependencies] +test = [ + "pytest>=8.2.2", + "pytest-cov>=5.0.0", +] + +[project.urls] +"Homepage" = "https://github.com/jahwag/claudesync" +"Bug Tracker" = "https://github.com/jahwag/claudesync/issues" + +[project.scripts] +claudesync = "claudesync.cli.main:cli" + +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] +include = ["claudesync*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" +addopts = "-v --cov=claudesync --cov-report=term-missing" diff --git a/pytest.ini b/pytest.ini index 1d09ea7..2f65e0b 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 5d8d0d8..b5f5e25 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 dc62080..b97fb10 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 ded77eb..c195b06 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 9e31c66..ef86501 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 b5d8237..6d274a9 100644 --- a/src/claudesync/cli/api.py +++ b/src/claudesync/cli/api.py @@ -1,84 +1,84 @@ -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.") +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/config.py b/src/claudesync/cli/config.py index effcc18..9be24c3 100644 --- a/src/claudesync/cli/config.py +++ b/src/claudesync/cli/config.py @@ -1,62 +1,62 @@ -import click - -from ..exceptions import ConfigurationError -from ..utils import handle_errors - - -@click.group() -def config(): - """Manage claudesync configuration.""" - pass - - -@config.command() -@click.argument("key") -@click.argument("value") -@click.pass_obj -@handle_errors -def set(config, key, value): - """Set a configuration value.""" - # Check if the key exists in the configuration - if key not in config.config: - raise ConfigurationError(f"Configuration property '{key}' does not exist.") - - # Convert string 'true' and 'false' to boolean - if value.lower() == "true": - value = True - elif value.lower() == "false": - value = False - # Try to convert to int or float if possible - else: - try: - value = int(value) - except ValueError: - try: - value = float(value) - except ValueError: - pass # Keep as string if not a number - - config.set(key, value) - click.echo(f"Configuration {key} set to {value}") - - -@config.command() -@click.argument("key") -@click.pass_obj -@handle_errors -def get(config, key): - """Get a configuration value.""" - value = config.get(key) - if value is None: - click.echo(f"Configuration {key} is not set") - else: - click.echo(f"{key}: {value}") - - -@config.command() -@click.pass_obj -@handle_errors -def ls(config): - """List all configuration values.""" - for key, value in config.config.items(): - click.echo(f"{key}: {value}") +import click + +from ..exceptions import ConfigurationError +from ..utils import handle_errors + + +@click.group() +def config(): + """Manage claudesync configuration.""" + pass + + +@config.command() +@click.argument("key") +@click.argument("value") +@click.pass_obj +@handle_errors +def set(config, key, value): + """Set a configuration value.""" + # Check if the key exists in the configuration + if key not in config.config: + raise ConfigurationError(f"Configuration property '{key}' does not exist.") + + # Convert string 'true' and 'false' to boolean + if value.lower() == "true": + value = True + elif value.lower() == "false": + value = False + # Try to convert to int or float if possible + else: + try: + value = int(value) + except ValueError: + try: + value = float(value) + except ValueError: + pass # Keep as string if not a number + + config.set(key, value) + click.echo(f"Configuration {key} set to {value}") + + +@config.command() +@click.argument("key") +@click.pass_obj +@handle_errors +def get(config, key): + """Get a configuration value.""" + value = config.get(key) + if value is None: + click.echo(f"Configuration {key} is not set") + else: + click.echo(f"{key}: {value}") + + +@config.command() +@click.pass_obj +@handle_errors +def ls(config): + """List all configuration values.""" + for key, value in config.config.items(): + click.echo(f"{key}: {value}") diff --git a/src/claudesync/cli/main.py b/src/claudesync/cli/main.py index 4aff09d..769e389 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 4e31b13..c479549 100644 --- a/src/claudesync/cli/organization.py +++ b/src/claudesync/cli/organization.py @@ -1,53 +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_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.") +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 0cf441d..98a771a 100644 --- a/src/claudesync/cli/project.py +++ b/src/claudesync/cli/project.py @@ -1,146 +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_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.") +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/cli/sync.py b/src/claudesync/cli/sync.py index 54c1b23..f6f5c08 100644 --- a/src/claudesync/cli/sync.py +++ b/src/claudesync/cli/sync.py @@ -1,102 +1,102 @@ -import os -import shutil -import sys -import click -from crontab import CronTab - -from claudesync.utils import get_local_files -from ..utils import handle_errors, validate_and_get_provider -from ..syncmanager import SyncManager -from ..chat_sync import sync_chats - - -@click.command() -@click.pass_obj -@handle_errors -def ls(config): - """List files in the active remote project.""" - provider = validate_and_get_provider(config, require_project=True) - active_organization_id = config.get("active_organization_id") - active_project_id = config.get("active_project_id") - files = provider.list_files(active_organization_id, active_project_id) - if not files: - click.echo("No files found in the active project.") - else: - click.echo( - f"Files in project '{config.get('active_project_name')}' (ID: {active_project_id}):" - ) - for file in files: - click.echo( - f" - {file['file_name']} (ID: {file['uuid']}, Created: {file['created_at']})" - ) - - -@click.command() -@click.pass_obj -@handle_errors -def sync(config): - """Synchronize both projects and chats.""" - provider = validate_and_get_provider(config, require_project=True) - - # Sync projects - 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) - - # Sync chats - sync_chats(provider, config) - click.echo("Project and chat sync completed successfully.") - - -def validate_local_path(local_path): - if not local_path: - click.echo( - "No local path set. Please select or create a project to set the local path." - ) - sys.exit(1) - if not os.path.exists(local_path): - click.echo(f"The configured local path does not exist: {local_path}") - click.echo("Please update the local path by selecting or creating a project.") - sys.exit(1) - - -@click.command() -@click.pass_obj -@click.option( - "--interval", type=int, default=5, prompt="Enter sync interval in minutes" -) -@handle_errors -def schedule(config, interval): - """Set up automated synchronization at regular intervals.""" - claudesync_path = shutil.which("claudesync") - if not claudesync_path: - click.echo( - "Error: claudesync not found in PATH. Please ensure it's installed correctly." - ) - sys.exit(1) - - if sys.platform.startswith("win"): - setup_windows_task(claudesync_path, interval) - else: - setup_unix_cron(claudesync_path, interval) - - -def setup_windows_task(claudesync_path, interval): - click.echo("Windows Task Scheduler setup:") - command = f'schtasks /create /tn "ClaudeSync" /tr "{claudesync_path} sync" /sc minute /mo {interval}' - click.echo(f"Run this command to create the task:\n{command}") - click.echo('\nTo remove the task, run: schtasks /delete /tn "ClaudeSync" /f') - - -def setup_unix_cron(claudesync_path, interval): - cron = CronTab(user=True) - job = cron.new(command=f"{claudesync_path} sync") - job.minute.every(interval) - cron.write() - click.echo(f"Cron job created successfully! It will run every {interval} minutes.") - click.echo( - "\nTo remove the cron job, run: crontab -e and remove the line for ClaudeSync" - ) +import os +import shutil +import sys +import click +from crontab import CronTab + +from claudesync.utils import get_local_files +from ..utils import handle_errors, validate_and_get_provider +from ..syncmanager import SyncManager +from ..chat_sync import sync_chats + + +@click.command() +@click.pass_obj +@handle_errors +def ls(config): + """List files in the active remote project.""" + provider = validate_and_get_provider(config, require_project=True) + active_organization_id = config.get("active_organization_id") + active_project_id = config.get("active_project_id") + files = provider.list_files(active_organization_id, active_project_id) + if not files: + click.echo("No files found in the active project.") + else: + click.echo( + f"Files in project '{config.get('active_project_name')}' (ID: {active_project_id}):" + ) + for file in files: + click.echo( + f" - {file['file_name']} (ID: {file['uuid']}, Created: {file['created_at']})" + ) + + +@click.command() +@click.pass_obj +@handle_errors +def sync(config): + """Synchronize both projects and chats.""" + provider = validate_and_get_provider(config, require_project=True) + + # Sync projects + 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) + + # Sync chats + sync_chats(provider, config) + click.echo("Project and chat sync completed successfully.") + + +def validate_local_path(local_path): + if not local_path: + click.echo( + "No local path set. Please select or create a project to set the local path." + ) + sys.exit(1) + if not os.path.exists(local_path): + click.echo(f"The configured local path does not exist: {local_path}") + click.echo("Please update the local path by selecting or creating a project.") + sys.exit(1) + + +@click.command() +@click.pass_obj +@click.option( + "--interval", type=int, default=5, prompt="Enter sync interval in minutes" +) +@handle_errors +def schedule(config, interval): + """Set up automated synchronization at regular intervals.""" + claudesync_path = shutil.which("claudesync") + if not claudesync_path: + click.echo( + "Error: claudesync not found in PATH. Please ensure it's installed correctly." + ) + sys.exit(1) + + if sys.platform.startswith("win"): + setup_windows_task(claudesync_path, interval) + else: + setup_unix_cron(claudesync_path, interval) + + +def setup_windows_task(claudesync_path, interval): + click.echo("Windows Task Scheduler setup:") + command = f'schtasks /create /tn "ClaudeSync" /tr "{claudesync_path} sync" /sc minute /mo {interval}' + click.echo(f"Run this command to create the task:\n{command}") + click.echo('\nTo remove the task, run: schtasks /delete /tn "ClaudeSync" /f') + + +def setup_unix_cron(claudesync_path, interval): + cron = CronTab(user=True) + job = cron.new(command=f"{claudesync_path} sync") + job.minute.every(interval) + cron.write() + click.echo(f"Cron job created successfully! It will run every {interval} minutes.") + click.echo( + "\nTo remove the cron job, run: crontab -e and remove the line for ClaudeSync" + ) diff --git a/src/claudesync/config_manager.py b/src/claudesync/config_manager.py index 5a10053..1be2da5 100644 --- a/src/claudesync/config_manager.py +++ b/src/claudesync/config_manager.py @@ -1,130 +1,130 @@ -import datetime -import json -from pathlib import Path - - -class ConfigManager: - """ - A class to manage configuration settings for the ClaudeSync application. - - This class handles loading, saving, and accessing configuration settings from a JSON file. - It ensures that default values are set for certain keys if they are not present in the configuration file, - and handles the expansion of user home directory paths. - - Attributes: - config_dir (Path): The directory where the configuration file is stored. - config_file (Path): The path to the configuration file. - config (dict): The current configuration loaded into memory. - """ - - def __init__(self): - """ - Initializes the ConfigManager instance. - - Sets up the configuration directory and file paths, and loads the current configuration from the file. - """ - self.config_dir = Path.home() / ".claudesync" - self.config_file = self.config_dir / "config.json" - self.config = self._load_config() - - def _get_default_config(self): - """ - Returns the default configuration dictionary. - - This method centralizes the default configuration settings, making it easier to manage and update defaults. - - Returns: - dict: The default configuration settings. - """ - return { - "log_level": "INFO", - "upload_delay": 0.5, - "max_file_size": 32 * 1024, # Default 32 KB - "two_way_sync": False, # Default to False - "curl_use_file_input": False, - } - - def _load_config(self): - """ - Loads the configuration from the JSON file, applying default values for missing keys. - - If the configuration file does not exist, - it creates the directory (if necessary) and returns the default configuration. - For existing configurations, it ensures all default values are present and expands user home directory paths. - - Returns: - dict: The loaded configuration with default values for missing keys and expanded paths. - """ - if not self.config_file.exists(): - self.config_dir.mkdir(parents=True, exist_ok=True) - return self._get_default_config() - - with open(self.config_file, "r") as f: - config = json.load(f) - defaults = self._get_default_config() - for key, value in defaults.items(): - if key not in config: - config[key] = value - elif key == "chat_destination": - # Expand user home directory for path-based settings - config[key] = str(Path(config[key]).expanduser()) - return config - - def _save_config(self): - """ - Saves the current configuration to the JSON file. - - This method writes the current state of the `config` attribute to the configuration file, - pretty-printing the JSON for readability. - """ - with open(self.config_file, "w") as f: - json.dump(self.config, f, indent=2) - - def get(self, key, default=None): - """ - Retrieves a configuration value. - - Args: - key (str): The key for the configuration setting to retrieve. - default (any, optional): The default value to return if the key is not found. Defaults to None. - - Returns: - The value of the configuration setting if found, otherwise the default value. - """ - return self.config.get(key, default) - - def set(self, key, value): - """ - Sets a configuration value and saves the configuration. - - For path-based settings (chat_destination), this method expands the user's home directory. - - Args: - key (str): The key for the configuration setting to set. - value (any): The value to set for the given key. - - This method updates the configuration with the provided key-value pair and then saves the configuration to the file. - """ - if key == "chat_destination": - # Expand user home directory for path-based settings - value = str(Path(value).expanduser()) - self.config[key] = value - self._save_config() - - def set_session_key(self, session_key, expiry: datetime): - self.config["session_key"] = session_key - self.config["session_key_expiry"] = expiry.isoformat() - self._save_config() - - def get_session_key(self): - session_key = self.config.get("session_key") - expiry_str = self.config.get("session_key_expiry") - - if not session_key or not expiry_str: - return None - - expiry = datetime.datetime.fromisoformat(expiry_str) - if datetime.datetime.now() > expiry: - return None - - return session_key +import datetime +import json +from pathlib import Path + + +class ConfigManager: + """ + A class to manage configuration settings for the ClaudeSync application. + + This class handles loading, saving, and accessing configuration settings from a JSON file. + It ensures that default values are set for certain keys if they are not present in the configuration file, + and handles the expansion of user home directory paths. + + Attributes: + config_dir (Path): The directory where the configuration file is stored. + config_file (Path): The path to the configuration file. + config (dict): The current configuration loaded into memory. + """ + + def __init__(self): + """ + Initializes the ConfigManager instance. + + Sets up the configuration directory and file paths, and loads the current configuration from the file. + """ + self.config_dir = Path.home() / ".claudesync" + self.config_file = self.config_dir / "config.json" + self.config = self._load_config() + + def _get_default_config(self): + """ + Returns the default configuration dictionary. + + This method centralizes the default configuration settings, making it easier to manage and update defaults. + + Returns: + dict: The default configuration settings. + """ + return { + "log_level": "INFO", + "upload_delay": 0.5, + "max_file_size": 32 * 1024, # Default 32 KB + "two_way_sync": False, # Default to False + "curl_use_file_input": False, + } + + def _load_config(self): + """ + Loads the configuration from the JSON file, applying default values for missing keys. + + If the configuration file does not exist, + it creates the directory (if necessary) and returns the default configuration. + For existing configurations, it ensures all default values are present and expands user home directory paths. + + Returns: + dict: The loaded configuration with default values for missing keys and expanded paths. + """ + if not self.config_file.exists(): + self.config_dir.mkdir(parents=True, exist_ok=True) + return self._get_default_config() + + with open(self.config_file, "r") as f: + config = json.load(f) + defaults = self._get_default_config() + for key, value in defaults.items(): + if key not in config: + config[key] = value + elif key == "chat_destination": + # Expand user home directory for path-based settings + config[key] = str(Path(config[key]).expanduser()) + return config + + def _save_config(self): + """ + Saves the current configuration to the JSON file. + + This method writes the current state of the `config` attribute to the configuration file, + pretty-printing the JSON for readability. + """ + with open(self.config_file, "w") as f: + json.dump(self.config, f, indent=2) + + def get(self, key, default=None): + """ + Retrieves a configuration value. + + Args: + key (str): The key for the configuration setting to retrieve. + default (any, optional): The default value to return if the key is not found. Defaults to None. + + Returns: + The value of the configuration setting if found, otherwise the default value. + """ + return self.config.get(key, default) + + def set(self, key, value): + """ + Sets a configuration value and saves the configuration. + + For path-based settings (chat_destination), this method expands the user's home directory. + + Args: + key (str): The key for the configuration setting to set. + value (any): The value to set for the given key. + + This method updates the configuration with the provided key-value pair and then saves the configuration to the file. + """ + if key == "chat_destination": + # Expand user home directory for path-based settings + value = str(Path(value).expanduser()) + self.config[key] = value + self._save_config() + + def set_session_key(self, session_key, expiry: datetime): + self.config["session_key"] = session_key + self.config["session_key_expiry"] = expiry.isoformat() + self._save_config() + + def get_session_key(self): + session_key = self.config.get("session_key") + expiry_str = self.config.get("session_key_expiry") + + if not session_key or not expiry_str: + return None + + expiry = datetime.datetime.fromisoformat(expiry_str) + if datetime.datetime.now() > expiry: + return None + + return session_key diff --git a/src/claudesync/exceptions.py b/src/claudesync/exceptions.py index 2a75960..4b9767b 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/provider_factory.py b/src/claudesync/provider_factory.py index d8dad32..98d608f 100644 --- a/src/claudesync/provider_factory.py +++ b/src/claudesync/provider_factory.py @@ -1,51 +1,51 @@ -# src/claudesync/provider_factory.py - -from .providers.base_provider import BaseProvider -from .providers.claude_ai import ClaudeAIProvider -from .providers.claude_ai_curl import ClaudeAICurlProvider - - -def get_provider( - provider_name=None, session_key=None, session_key_expiry=None -) -> BaseProvider: - """ - Retrieve an instance of a provider class based on the provider name and session key. - - This function serves as a factory to instantiate provider classes. It maintains a registry of available - providers. If a provider name is not specified, it returns a list of available provider names. If a provider - name is specified but not found in the registry, it raises a ValueError. If a session key is provided, it - is passed to the provider class constructor. - - Args: - provider_name (str, optional): The name of the provider to retrieve. If None, returns a list of available - provider names. - session_key (str, optional): The session key to be used by the provider for authentication. - Defaults to None. - session_key_expiry (str, optional): The session key expiry time. - Defaults to None. - - Returns: - BaseProvider: An instance of the requested provider class if both provider_name and session_key are provided. - list: A list of available provider names if provider_name is None. - - Raises: - ValueError: If the specified provider_name is not found in the registry of providers. - """ - providers = { - "claude.ai": ClaudeAIProvider, - "claude.ai-curl": ClaudeAICurlProvider, - # Add other providers here as they are implemented - } - - if provider_name is None: - return list(providers.keys()) - - provider_class = providers.get(provider_name) - if provider_class is None: - raise ValueError(f"Unsupported provider: {provider_name}") - - return ( - provider_class(session_key, session_key_expiry) - if session_key and session_key_expiry - else provider_class() - ) +# src/claudesync/provider_factory.py + +from .providers.base_provider import BaseProvider +from .providers.claude_ai import ClaudeAIProvider +from .providers.claude_ai_curl import ClaudeAICurlProvider + + +def get_provider( + provider_name=None, session_key=None, session_key_expiry=None +) -> BaseProvider: + """ + Retrieve an instance of a provider class based on the provider name and session key. + + This function serves as a factory to instantiate provider classes. It maintains a registry of available + providers. If a provider name is not specified, it returns a list of available provider names. If a provider + name is specified but not found in the registry, it raises a ValueError. If a session key is provided, it + is passed to the provider class constructor. + + Args: + provider_name (str, optional): The name of the provider to retrieve. If None, returns a list of available + provider names. + session_key (str, optional): The session key to be used by the provider for authentication. + Defaults to None. + session_key_expiry (str, optional): The session key expiry time. + Defaults to None. + + Returns: + BaseProvider: An instance of the requested provider class if both provider_name and session_key are provided. + list: A list of available provider names if provider_name is None. + + Raises: + ValueError: If the specified provider_name is not found in the registry of providers. + """ + providers = { + "claude.ai": ClaudeAIProvider, + "claude.ai-curl": ClaudeAICurlProvider, + # Add other providers here as they are implemented + } + + if provider_name is None: + return list(providers.keys()) + + provider_class = providers.get(provider_name) + if provider_class is None: + raise ValueError(f"Unsupported provider: {provider_name}") + + return ( + provider_class(session_key, session_key_expiry) + if session_key and session_key_expiry + else provider_class() + ) diff --git a/src/claudesync/providers/claude_ai_curl.py b/src/claudesync/providers/claude_ai_curl.py index c5a3a03..bbe8769 100644 --- a/src/claudesync/providers/claude_ai_curl.py +++ b/src/claudesync/providers/claude_ai_curl.py @@ -1,129 +1,129 @@ -import json -import subprocess -import tempfile -import os -from .base_claude_ai import BaseClaudeAIProvider -from ..exceptions import ProviderError -from ..config_manager import ConfigManager - - -class ClaudeAICurlProvider(BaseClaudeAIProvider): - def __init__(self, session_key=None, session_key_expiry=None): - super().__init__(session_key, session_key_expiry) - self.config = ConfigManager() - self.use_file_input = self.config.get("curl_use_file_input", False) - - def _make_request(self, method, endpoint, data=None): - url = f"{self.BASE_URL}{endpoint}" - headers = self._prepare_headers() - - command = self._build_curl_command(method, url, headers, data) - - self.logger.debug(f"Executing command: {' '.join(command)}") - - try: - result = subprocess.run( - command, capture_output=True, text=True, check=True, encoding="utf-8" - ) - - if self.use_file_input and data: - os.unlink(command[-1][1:]) # Remove the temporary file - - return self._process_result(result, headers) - except subprocess.CalledProcessError as e: - if self.use_file_input and data: - os.unlink(command[-1][1:]) # Remove the temporary file - self._handle_called_process_error(e, headers) - except UnicodeDecodeError as e: - if self.use_file_input and data: - os.unlink(command[-1][1:]) # Remove the temporary file - self._handle_unicode_decode_error(e, headers) - - def _prepare_headers(self): - return [ - "-H", - "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0", - "-H", - f"Cookie: sessionKey={self.session_key}", - "-H", - "Content-Type: application/json", - ] - - def _write_data_to_temp_file(self, data): - with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file: - json.dump(data, temp_file) - return temp_file.name - - def _build_curl_command(self, method, url, headers, data): - command = ["curl", url, "--compressed", "-s", "-S", "-w", "%{http_code}"] - command.extend(headers) - - if method != "GET": - command.extend(["-X", method]) - - if data: - if self.use_file_input: - temp_file_name = self._write_data_to_temp_file(data) - command.extend(["--data", f"@{temp_file_name}"]) - else: - json_data = json.dumps(data) - command.extend(["-d", json_data]) - - return command - - def _process_result(self, result, headers): - if not result.stdout: - raise ProviderError( - f"Empty response from the server. Request headers: {headers}" - ) - - http_status_code = result.stdout[-3:] - response_body = result.stdout[:-3].strip() - self.logger.debug(f"Got HTTP {http_status_code}") - - if http_status_code.startswith("2"): - if http_status_code == "204": - # HTTP 204 No Content - return None - if not response_body: - self.logger.warn(f"Got HTTP {http_status_code} but empty response") - return None - - try: - return json.loads(response_body) - except json.JSONDecodeError as e: - error_message = ( - f"Failed to parse JSON response: {response_body}. Reason: {e}. Response content: {response_body}. Request " - f"headers: {headers}" - ) - self.logger.error(error_message) - raise ProviderError(error_message) - else: - error_message = ( - f"HTTP request failed with status code {http_status_code}. " - f"Response content: {response_body}. Request headers: {headers}" - ) - self.logger.error(error_message) - raise ProviderError(error_message) - - def _handle_called_process_error(self, e, headers): - if e.returncode == 1: - error_message = ( - f"cURL command failed due to an unsupported protocol or a failed initialization. " - f"Request headers: {headers}" - ) - else: - error_message = ( - f"cURL command failed with return code {e.returncode}. stdout: {e.stdout}, " - f"stderr: {e.stderr}. Request headers: {headers}" - ) - self.logger.error(error_message) - raise ProviderError(error_message) - - def _handle_unicode_decode_error(self, e, headers): - error_message = ( - f"Failed to decode cURL output: {e}. This might be due to non-UTF-8 characters in the " - f"response. Request headers: {headers}" - ) - self.logger.error(error_message) - raise ProviderError(error_message) +import json +import subprocess +import tempfile +import os +from .base_claude_ai import BaseClaudeAIProvider +from ..exceptions import ProviderError +from ..config_manager import ConfigManager + + +class ClaudeAICurlProvider(BaseClaudeAIProvider): + def __init__(self, session_key=None, session_key_expiry=None): + super().__init__(session_key, session_key_expiry) + self.config = ConfigManager() + self.use_file_input = self.config.get("curl_use_file_input", False) + + def _make_request(self, method, endpoint, data=None): + url = f"{self.BASE_URL}{endpoint}" + headers = self._prepare_headers() + + command = self._build_curl_command(method, url, headers, data) + + self.logger.debug(f"Executing command: {' '.join(command)}") + + try: + result = subprocess.run( + command, capture_output=True, text=True, check=True, encoding="utf-8" + ) + + if self.use_file_input and data: + os.unlink(command[-1][1:]) # Remove the temporary file + + return self._process_result(result, headers) + except subprocess.CalledProcessError as e: + if self.use_file_input and data: + os.unlink(command[-1][1:]) # Remove the temporary file + self._handle_called_process_error(e, headers) + except UnicodeDecodeError as e: + if self.use_file_input and data: + os.unlink(command[-1][1:]) # Remove the temporary file + self._handle_unicode_decode_error(e, headers) + + def _prepare_headers(self): + return [ + "-H", + "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0", + "-H", + f"Cookie: sessionKey={self.session_key}", + "-H", + "Content-Type: application/json", + ] + + def _write_data_to_temp_file(self, data): + with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file: + json.dump(data, temp_file) + return temp_file.name + + def _build_curl_command(self, method, url, headers, data): + command = ["curl", url, "--compressed", "-s", "-S", "-w", "%{http_code}"] + command.extend(headers) + + if method != "GET": + command.extend(["-X", method]) + + if data: + if self.use_file_input: + temp_file_name = self._write_data_to_temp_file(data) + command.extend(["--data", f"@{temp_file_name}"]) + else: + json_data = json.dumps(data) + command.extend(["-d", json_data]) + + return command + + def _process_result(self, result, headers): + if not result.stdout: + raise ProviderError( + f"Empty response from the server. Request headers: {headers}" + ) + + http_status_code = result.stdout[-3:] + response_body = result.stdout[:-3].strip() + self.logger.debug(f"Got HTTP {http_status_code}") + + if http_status_code.startswith("2"): + if http_status_code == "204": + # HTTP 204 No Content + return None + if not response_body: + self.logger.warn(f"Got HTTP {http_status_code} but empty response") + return None + + try: + return json.loads(response_body) + except json.JSONDecodeError as e: + error_message = ( + f"Failed to parse JSON response: {response_body}. Reason: {e}. Response content: {response_body}. Request " + f"headers: {headers}" + ) + self.logger.error(error_message) + raise ProviderError(error_message) + else: + error_message = ( + f"HTTP request failed with status code {http_status_code}. " + f"Response content: {response_body}. Request headers: {headers}" + ) + self.logger.error(error_message) + raise ProviderError(error_message) + + def _handle_called_process_error(self, e, headers): + if e.returncode == 1: + error_message = ( + f"cURL command failed due to an unsupported protocol or a failed initialization. " + f"Request headers: {headers}" + ) + else: + error_message = ( + f"cURL command failed with return code {e.returncode}. stdout: {e.stdout}, " + f"stderr: {e.stderr}. Request headers: {headers}" + ) + self.logger.error(error_message) + raise ProviderError(error_message) + + def _handle_unicode_decode_error(self, e, headers): + error_message = ( + f"Failed to decode cURL output: {e}. This might be due to non-UTF-8 characters in the " + f"response. Request headers: {headers}" + ) + self.logger.error(error_message) + raise ProviderError(error_message) diff --git a/src/claudesync/syncmanager.py b/src/claudesync/syncmanager.py index c8f66dd..8c2e262 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="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) +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 cc1069a..512796d 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_main.py b/tests/cli/test_main.py index 9d14536..b443735 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 e12e42e..96ca54f 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 62b20e8..7877f96 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()