-
-
Notifications
You must be signed in to change notification settings - Fork 40
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
- Loading branch information
Showing
18 changed files
with
894 additions
and
983 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -168,4 +168,5 @@ __pycache__ | |
# claude | ||
claude.sync | ||
config.json | ||
claudesync.log | ||
claudesync.log | ||
chats |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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]"}, | ||
] | ||
|
@@ -26,6 +26,7 @@ dependencies = [ | |
"pytest", | ||
"pytest-cov", | ||
"click_completion", | ||
"tqdm", | ||
] | ||
|
||
[project.urls] | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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')}") |
Oops, something went wrong.