Skip to content

Commit

Permalink
#22 chat and artifact sync to a sub folder of the project (#23)
Browse files Browse the repository at this point in the history
  • Loading branch information
jahwag authored Aug 1, 2024
1 parent d8399e1 commit b2f4b79
Show file tree
Hide file tree
Showing 18 changed files with 894 additions and 983 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,5 @@ __pycache__
# claude
claude.sync
config.json
claudesync.log
claudesync.log
chats
26 changes: 17 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
.o..P'
`Y8P'
```
[![Python package](https://github.com/jahwag/ClaudeSync/actions/workflows/python-package.yml/badge.svg)](https://github.com/jahwag/ClaudeSync/actions/workflows/python-package.yml)
![License](https://img.shields.io/badge/License-MIT-blue.svg)
[![PyPI version](https://badge.fury.io/py/claudesync.svg)](https://badge.fury.io/py/claudesync)

Expand All @@ -27,6 +28,9 @@ ClaudeSync bridges the gap between your local development environment and Claude
- Seamless integration with your existing workflow
- Optional two-way synchronization support
- Configuration management through CLI
- Chat and artifact synchronization and management

**Important Note**: ClaudeSync requires a Claude.ai Professional plan to function properly. Make sure you have an active Professional subscription before using this tool.

## Important Disclaimers

Expand Down Expand Up @@ -85,6 +89,12 @@ ClaudeSync bridges the gap between your local development environment and Claude
- List remote files: `claudesync ls`
- Sync files: `claudesync sync`

### Chat Management
- List chats: `claudesync chat ls`
- Sync chats and artifacts: `claudesync chat sync`
- Delete chats: `claudesync chat rm`
- Delete all chats: `claudesync chat rm -a`

### Configuration
- View current status: `claudesync status`
- Set configuration values: `claudesync config set <key> <value>`
Expand Down Expand Up @@ -139,23 +149,21 @@ ClaudeSync offers two providers for interacting with the Claude.ai API:
### Troubleshooting

#### 403 Forbidden Error
If you encounter a 403 Forbidden error when using ClaudeSync, it might be due to an issue with the session key or API access. As a workaround, you can try using the `claude.ai-curl` provider:
If you encounter a 403 Forbidden error when using ClaudeSync, it might be due to an issue with the session key or API access. Here are some steps to resolve this:

1. Ensure cURL is installed on your system (see note above for Windows users).

2. Logout from your current session:
1. Ensure you have an active Claude.ai Professional plan subscription.
2. Try logging out and logging in again:
```bash
claudesync api logout
claudesync api login claude.ai
```

3. Login using the claude.ai-curl provider:
3. If the issue persists, you can try using the claude.ai-curl provider as a workaround:
```bash
claudesync api logout
claudesync api login claude.ai-curl
```

4. Try your operation again.

If the issue persists, please check your network connection and ensure that you have the necessary permissions to access Claude.ai.
If you continue to experience issues, please check your network connection and ensure that you have the necessary permissions to access Claude.ai.

## Contributing

Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "claudesync"
version = "0.3.8"
version = "0.3.9"
authors = [
{name = "Jahziah Wagner", email = "[email protected]"},
]
Expand All @@ -26,6 +26,7 @@ dependencies = [
"pytest",
"pytest-cov",
"click_completion",
"tqdm",
]

[project.urls]
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ crontab>=1.0.1
setuptools>=65.5.1
pytest>=8.2.2
pytest-cov>=5.0.0
click_completion>=0.5.2
click_completion>=0.5.2
tqdm>=4.66.4
162 changes: 162 additions & 0 deletions src/claudesync/chat_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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, "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="Syncing chats"):
# 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.info(f"Processing chat {chat['uuid']}")
chat_folder = os.path.join(chat_destination, chat["uuid"])
os.makedirs(chat_folder, exist_ok=True)

# Save chat metadata
with open(os.path.join(chat_folder, "metadata.json"), "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"]:
# Save the message
message_file = os.path.join(chat_folder, f"{message['uuid']}.json")
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:
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'])}",
)
with open(artifact_file, "w") as f:
f.write(artifact["content"])
else:
logger.debug(
f"Skipping chat {chat['uuid']} as it doesn't belong to the active project"
)

logger.debug(f"Chats and artifacts synchronized to {chat_destination}")


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 <antArtifact> tags and extract their attributes and content
pattern = re.compile(
r'<antArtifact\s+identifier="([^"]+)"\s+type="([^"]+)"\s+title="([^"]+)">([\s\S]*?)</antArtifact>',
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
138 changes: 138 additions & 0 deletions src/claudesync/cli/chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import click
import logging
from ..exceptions import ProviderError
from ..utils import handle_errors, validate_and_get_provider
from ..chat_sync import sync_chats

logger = logging.getLogger(__name__)


@click.group()
def chat():
"""Manage and synchronize chats."""
pass


@chat.command()
@click.pass_obj
@handle_errors
def sync(config):
"""Synchronize chats and their artifacts from the remote source."""
provider = validate_and_get_provider(config)
sync_chats(provider, config)


@chat.command()
@click.pass_obj
@handle_errors
def ls(config):
"""List all chats."""
provider = validate_and_get_provider(config)
organization_id = config.get("active_organization_id")
chats = provider.get_chat_conversations(organization_id)

for chat in chats:
project = chat.get("project")
project_name = project.get("name") if project else ""
click.echo(
f"UUID: {chat.get('uuid', 'Unknown')}, "
f"Name: {chat.get('name', 'Unnamed')}, "
f"Project: {project_name}, "
f"Updated: {chat.get('updated_at', 'Unknown')}"
)


@chat.command()
@click.option("-a", "--all", "delete_all", is_flag=True, help="Delete all chats")
@click.pass_obj
@handle_errors
def rm(config, delete_all):
"""Delete chats. Use -a to delete all chats, or select a chat to delete."""
provider = validate_and_get_provider(config)
organization_id = config.get("active_organization_id")

if delete_all:
delete_all_chats(provider, organization_id)
else:
delete_single_chat(provider, organization_id)


def delete_chats(provider, organization_id, uuids):
"""Delete a list of chats by their UUIDs."""
try:
result = provider.delete_chat(organization_id, uuids)
return len(result), 0
except ProviderError as e:
logger.error(f"Error deleting chats: {str(e)}")
click.echo(f"Error occurred while deleting chats: {str(e)}")
return 0, len(uuids)


def delete_all_chats(provider, organization_id):
"""Delete all chats for the given organization."""
if click.confirm("Are you sure you want to delete all chats?"):
total_deleted = 0
with click.progressbar(length=100, label="Deleting chats") as bar:
while True:
chats = provider.get_chat_conversations(organization_id)
if not chats:
break
uuids_to_delete = [chat["uuid"] for chat in chats[:50]]
deleted, _ = delete_chats(provider, organization_id, uuids_to_delete)
total_deleted += deleted
bar.update(len(uuids_to_delete))
click.echo(f"Chat deletion complete. Total chats deleted: {total_deleted}")


def delete_single_chat(provider, organization_id):
"""Delete a single chat selected by the user."""
chats = provider.get_chat_conversations(organization_id)
if not chats:
click.echo("No chats found.")
return

display_chat_list(chats)
selected_chat = get_chat_selection(chats)
if selected_chat:
confirm_and_delete_chat(provider, organization_id, selected_chat)


def display_chat_list(chats):
"""Display a list of chats to the user."""
click.echo("Available chats:")
for idx, chat in enumerate(chats, 1):
project = chat.get("project")
project_name = project.get("name") if project else ""
click.echo(
f"{idx}. Name: {chat.get('name', 'Unnamed')}, "
f"Project: {project_name}, Updated: {chat.get('updated_at', 'Unknown')}"
)


def get_chat_selection(chats):
"""Get a valid chat selection from the user."""
while True:
selection = click.prompt(
"Enter the number of the chat to delete (or 'q' to quit)", type=str
)
if selection.lower() == "q":
return None
try:
selection = int(selection)
if 1 <= selection <= len(chats):
return chats[selection - 1]
click.echo("Invalid selection. Please try again.")
except ValueError:
click.echo("Invalid input. Please enter a number or 'q' to quit.")


def confirm_and_delete_chat(provider, organization_id, chat):
"""Confirm deletion with the user and delete the selected chat."""
if click.confirm(
f"Are you sure you want to delete the chat '{chat.get('name', 'Unnamed')}'?"
):
deleted, _ = delete_chats(provider, organization_id, [chat["uuid"]])
if deleted:
click.echo(f"Successfully deleted chat: {chat.get('name', 'Unnamed')}")
else:
click.echo(f"Failed to delete chat: {chat.get('name', 'Unnamed')}")
Loading

0 comments on commit b2f4b79

Please sign in to comment.