Skip to content

Commit

Permalink
Enhanced login (#51)
Browse files Browse the repository at this point in the history
  • Loading branch information
jahwag authored Aug 22, 2024
1 parent fbf9d1b commit 4ec53d7
Show file tree
Hide file tree
Showing 12 changed files with 200 additions and 433 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ ClaudeSync is a powerful tool that bridges your local development environment wi

**Claude Plan Requirements:**

- **Supported:** Pro
- **Not Supported:** Free, Team
- **Supported:** Pro, Team
- **Not Supported:** Free

## 🚀 Quick Start

Expand All @@ -53,7 +53,7 @@ claudesync api login claude.ai

3. **Start syncing:**
```shell
claudesync sync
claudesync project sync
```
*Note: This performs a one-way sync. Any files not present locally will be deleted from the Claude.ai Project.

Expand Down
47 changes: 41 additions & 6 deletions src/claudesync/cli/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from ..utils import handle_errors
from ..cli.organization import select as org_select
from ..cli.project import select as proj_select
from ..cli.submodule import create as submodule_create
from ..cli.project import create as project_create


@click.group()
Expand All @@ -29,16 +31,49 @@ def login(ctx, provider):
)
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.")

# Check for existing valid session key
existing_session_key = config.get_session_key()
existing_session_key_expiry = config.get("session_key_expiry")

if existing_session_key and existing_session_key_expiry:
use_existing = click.confirm(
"An existing session key was found. Would you like to use it?", default=True
)
if use_existing:
config.set("active_provider", provider)
click.echo("Logged in successfully using existing session key.")
else:
session = provider_instance.login()
config.set_session_key(session[0], session[1])
config.set("active_provider", provider)
click.echo("Logged in successfully with new session key.")
else:
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)
use_existing_project = click.confirm(
"Would you like to select an existing project, or create a new one? (Selecting 'No' will prompt you to create "
"a new project)",
default=True,
)
if use_existing_project:
ctx.invoke(proj_select)
else:
ctx.invoke(project_create)
ctx.invoke(submodule_create)

delete_remote_files = click.confirm(
"Do you want ClaudeSync to automatically delete remote files that are not present in your local workspace? ("
"You can change this setting later with claudesync config set prune_remote_files=True|False)",
default=True,
)
config.set("prune_remote_files", delete_remote_files)


@api.command()
Expand Down
2 changes: 1 addition & 1 deletion src/claudesync/cli/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def ls(config):
@click.pass_obj
@handle_errors
def rm(config, delete_all):
"""Delete chats. Use -a to delete all chats, or select a chat to delete."""
"""Delete chat conversations. Use -a to delete all chats, or run without -a to select specific chats to delete."""
provider = validate_and_get_provider(config)
organization_id = config.get("active_organization_id")

Expand Down
6 changes: 5 additions & 1 deletion src/claudesync/cli/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ def select(ctx):
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)
selection = click.prompt(
"Enter the number of the organization you want to work with",
type=int,
default=1,
)
if 1 <= selection <= len(organizations):
selected_org = organizations[selection - 1]
config.set("active_organization_id", selected_org["id"])
Expand Down
14 changes: 10 additions & 4 deletions src/claudesync/cli/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def create(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)
title = click.prompt("Enter a title for your new project", default=default_name)
description = click.prompt("Enter the project description (optional)", default="")

try:
Expand Down Expand Up @@ -69,7 +69,8 @@ def archive(config):
if 1 <= selection <= len(projects):
selected_project = projects[selection - 1]
if click.confirm(
f"Are you sure you want to archive '{selected_project['name']}'?"
f"Are you sure you want to archive the project '{selected_project['name']}'?"
f"Archived projects cannot be modified but can still be viewed."
):
provider.archive_project(active_organization_id, selected_project["id"])
click.echo(f"Project '{selected_project['name']}' has been archived.")
Expand Down Expand Up @@ -114,7 +115,9 @@ def select(ctx, show_all):
)
click.echo(f" {idx}. {project['name']} (ID: {project['id']}) - {project_type}")

selection = click.prompt("Enter the number of the project to select", type=int)
selection = click.prompt(
"Enter the number of the project to select", type=int, default=1
)
if 1 <= selection <= len(selectable_projects):
selected_project = selectable_projects[selection - 1]
config.set("active_project_id", selected_project["id"])
Expand Down Expand Up @@ -166,7 +169,10 @@ def sync(config, category):
local_path = config.get("local_path")

if not local_path:
click.echo("No local path set. Please select or create a project first.")
click.echo(
"No local path set for this project. Please select an existing project or create a new one using "
"'claudesync project select' or 'claudesync project create'."
)
return

# Detect local submodules
Expand Down
10 changes: 8 additions & 2 deletions src/claudesync/cli/submodule.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ def ls(config):
"""List all detected submodules in the current project."""
local_path = config.get("local_path")
if not local_path:
click.echo("No local path set. Please select or create a project first.")
click.echo(
"No local path set for this project. Please select an existing project or create a new one using "
"'claudesync project select' or 'claudesync project create'."
)
return

submodule_detect_filenames = config.get("submodule_detect_filenames", [])
Expand All @@ -48,7 +51,10 @@ def create(config):
local_path = config.get("local_path")

if not local_path:
click.echo("No local path set. Please select or create a project first.")
click.echo(
"No local path set for this project. Please select an existing project or create a new one using "
"'claudesync project select' or 'claudesync project create'."
)
return

submodule_detect_filenames = config.get("submodule_detect_filenames", [])
Expand Down
1 change: 1 addition & 0 deletions src/claudesync/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ def _get_default_config(self):
"go.mod",
],
},
"prune_remote_files": True,
},
}

Expand Down
89 changes: 53 additions & 36 deletions src/claudesync/syncmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,27 @@
import logging
from datetime import datetime, timezone

import click
from tqdm import tqdm

from claudesync.utils import compute_md5_hash
from claudesync.exceptions import ProviderError

logger = logging.getLogger(__name__)


class SyncManager:
"""
Manages the synchronization process between local and remote files.
"""

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.
config (dict): Configuration dictionary containing sync settings.
"""
self.provider = provider
self.config = config
Expand All @@ -31,22 +32,39 @@ def __init__(self, provider, config):
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)
self.max_retries = 3 # Maximum number of retries for 403 errors
self.retry_delay = 1 # Delay between retries in seconds

def retry_on_403(func):
"""
Decorator to retry a function on 403 Forbidden error.
This decorator will retry the wrapped function up to max_retries times
if a ProviderError with a 403 Forbidden message is encountered.
"""

def wrapper(self, *args, **kwargs):
for attempt in range(self.max_retries):
try:
return func(self, *args, **kwargs)
except ProviderError as e:
if "403 Forbidden" in str(e) and attempt < self.max_retries - 1:
logger.warning(
f"Received 403 error. Retrying in {self.retry_delay} seconds..."
)
time.sleep(self.retry_delay)
else:
raise

return wrapper

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 (list): List of dictionaries representing remote files.
"""
remote_files_to_delete = set(rf["file_name"] for rf in remote_files)
synced_files = set()
Expand Down Expand Up @@ -77,10 +95,10 @@ def sync(self, local_files, remote_files):
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)

self.prune_remote_files(remote_files, remote_files_to_delete)

@retry_on_403
def update_existing_file(
self,
local_file,
Expand All @@ -92,9 +110,6 @@ def update_existing_file(
"""
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.
Expand Down Expand Up @@ -127,12 +142,11 @@ def update_existing_file(
synced_files.add(local_file)
remote_files_to_delete.remove(local_file)

@retry_on_403
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.
Expand All @@ -154,9 +168,6 @@ 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.
Expand All @@ -177,9 +188,6 @@ 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.
Expand All @@ -201,9 +209,6 @@ def update_existing_local_file(
"""
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.
Expand Down Expand Up @@ -232,8 +237,6 @@ def create_new_local_file(
"""
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.
Expand All @@ -253,12 +256,26 @@ def create_new_local_file(
if remote_file["file_name"] in remote_files_to_delete:
remote_files_to_delete.remove(remote_file["file_name"])

def prune_remote_files(self, remote_files, remote_files_to_delete):
"""
Delete remote files that no longer exist locally.
Args:
remote_files (list): List of dictionaries representing remote files.
remote_files_to_delete (set): Set of remote file names to be deleted.
"""
if not self.config.get("prune_remote_files"):
click.echo("Remote pruning is not enabled.")
return

for file_to_delete in list(remote_files_to_delete):
self.delete_remote_files(file_to_delete, remote_files)

@retry_on_403
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.
Expand Down
Loading

0 comments on commit 4ec53d7

Please sign in to comment.