ctxsync 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ctxsync/__init__.py +0 -0
- ctxsync/chat_sync.py +186 -0
- ctxsync/cli/__init__.py +3 -0
- ctxsync/cli/auth.py +77 -0
- ctxsync/cli/category.py +71 -0
- ctxsync/cli/chat.py +357 -0
- ctxsync/cli/config.py +72 -0
- ctxsync/cli/file.py +29 -0
- ctxsync/cli/main.py +257 -0
- ctxsync/cli/organization.py +98 -0
- ctxsync/cli/project.py +422 -0
- ctxsync/cli/session.py +626 -0
- ctxsync/cli/submodule.py +148 -0
- ctxsync/cli/sync.py +79 -0
- ctxsync/compression.py +302 -0
- ctxsync/configmanager/__init__.py +5 -0
- ctxsync/configmanager/base_config_manager.py +255 -0
- ctxsync/configmanager/file_config_manager.py +362 -0
- ctxsync/configmanager/inmemory_config_manager.py +134 -0
- ctxsync/exceptions.py +22 -0
- ctxsync/provider_factory.py +38 -0
- ctxsync/providers/__init__.py +0 -0
- ctxsync/providers/base_claude_ai.py +537 -0
- ctxsync/providers/base_provider.py +109 -0
- ctxsync/providers/claude_ai.py +192 -0
- ctxsync/session_key_manager.py +129 -0
- ctxsync/syncmanager.py +328 -0
- ctxsync/utils.py +416 -0
- ctxsync-0.8.0.dist-info/METADATA +151 -0
- ctxsync-0.8.0.dist-info/RECORD +34 -0
- ctxsync-0.8.0.dist-info/WHEEL +5 -0
- ctxsync-0.8.0.dist-info/entry_points.txt +2 -0
- ctxsync-0.8.0.dist-info/licenses/LICENSE +21 -0
- ctxsync-0.8.0.dist-info/top_level.txt +1 -0
ctxsync/__init__.py
ADDED
|
File without changes
|
ctxsync/chat_sync.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from tqdm import tqdm
|
|
7
|
+
|
|
8
|
+
from .exceptions import ConfigurationError
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def sync_chats(provider, config, sync_all=False):
|
|
14
|
+
"""
|
|
15
|
+
Synchronize chats and their artifacts from the remote source.
|
|
16
|
+
|
|
17
|
+
This function fetches all chats for the active organization, saves their metadata,
|
|
18
|
+
messages, and extracts any artifacts found in the assistant's messages.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
provider: The API provider instance.
|
|
22
|
+
config: The configuration manager instance.
|
|
23
|
+
sync_all (bool): If True, sync all chats regardless of project. If False, only sync chats for the active project.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ConfigurationError: If required configuration settings are missing.
|
|
27
|
+
"""
|
|
28
|
+
# Get the local_path for chats
|
|
29
|
+
local_path = config.get("local_path")
|
|
30
|
+
if not local_path:
|
|
31
|
+
raise ConfigurationError(
|
|
32
|
+
"Local path not set. Use 'ctxsync project set' or 'ctxsync project create' to set it."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Create chats directory within local_path
|
|
36
|
+
chat_destination = os.path.join(local_path, "claude_chats")
|
|
37
|
+
os.makedirs(chat_destination, exist_ok=True)
|
|
38
|
+
|
|
39
|
+
# Get the active organization ID
|
|
40
|
+
organization_id = config.get("active_organization_id")
|
|
41
|
+
if not organization_id:
|
|
42
|
+
raise ConfigurationError(
|
|
43
|
+
"No active organization set. Please set an organization."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Get the active project ID
|
|
47
|
+
active_project_id = config.get("active_project_id")
|
|
48
|
+
if not active_project_id and not sync_all:
|
|
49
|
+
raise ConfigurationError(
|
|
50
|
+
"No active project set. Please set a project or use the -a flag to sync all chats."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Fetch all chats for the organization
|
|
54
|
+
logger.debug(f"Fetching chats for organization {organization_id}")
|
|
55
|
+
chats = provider.get_chat_conversations(organization_id)
|
|
56
|
+
logger.debug(f"Found {len(chats)} chats")
|
|
57
|
+
|
|
58
|
+
# Process each chat
|
|
59
|
+
for chat in tqdm(chats, desc="Chats"):
|
|
60
|
+
sync_chat(
|
|
61
|
+
active_project_id,
|
|
62
|
+
chat,
|
|
63
|
+
chat_destination,
|
|
64
|
+
organization_id,
|
|
65
|
+
provider,
|
|
66
|
+
sync_all,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
logger.debug(f"Chats and artifacts synchronized to {chat_destination}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sync_chat(
|
|
73
|
+
active_project_id, chat, chat_destination, organization_id, provider, sync_all
|
|
74
|
+
):
|
|
75
|
+
# Check if the chat belongs to the active project or if we're syncing all chats
|
|
76
|
+
if sync_all or (
|
|
77
|
+
chat.get("project") and chat["project"].get("uuid") == active_project_id
|
|
78
|
+
):
|
|
79
|
+
logger.debug(f"Processing chat {chat['uuid']}")
|
|
80
|
+
chat_folder = os.path.join(chat_destination, chat["uuid"])
|
|
81
|
+
os.makedirs(chat_folder, exist_ok=True)
|
|
82
|
+
|
|
83
|
+
# Save chat metadata
|
|
84
|
+
metadata_file = os.path.join(chat_folder, "metadata.json")
|
|
85
|
+
if not os.path.exists(metadata_file):
|
|
86
|
+
with open(metadata_file, "w") as f:
|
|
87
|
+
json.dump(chat, f, indent=2)
|
|
88
|
+
|
|
89
|
+
# Fetch full chat conversation
|
|
90
|
+
logger.debug(f"Fetching full conversation for chat {chat['uuid']}")
|
|
91
|
+
full_chat = provider.get_chat_conversation(organization_id, chat["uuid"])
|
|
92
|
+
|
|
93
|
+
# Process each message in the chat
|
|
94
|
+
for message in full_chat["chat_messages"]:
|
|
95
|
+
message_file = os.path.join(chat_folder, f"{message['uuid']}.json")
|
|
96
|
+
|
|
97
|
+
# Skip processing if the message file already exists
|
|
98
|
+
if os.path.exists(message_file):
|
|
99
|
+
logger.debug(f"Skipping existing message {message['uuid']}")
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Save the message
|
|
103
|
+
with open(message_file, "w") as f:
|
|
104
|
+
json.dump(message, f, indent=2)
|
|
105
|
+
|
|
106
|
+
# Handle artifacts in assistant messages
|
|
107
|
+
if message["sender"] == "assistant":
|
|
108
|
+
artifacts = extract_artifacts(message["text"])
|
|
109
|
+
if artifacts:
|
|
110
|
+
save_artifacts(artifacts, chat_folder, message)
|
|
111
|
+
else:
|
|
112
|
+
logger.debug(
|
|
113
|
+
f"Skipping chat {chat['uuid']} as it doesn't belong to the active project"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def save_artifacts(artifacts, chat_folder, message):
|
|
118
|
+
logger.info(f"Found {len(artifacts)} artifacts in message {message['uuid']}")
|
|
119
|
+
artifact_folder = os.path.join(chat_folder, "artifacts")
|
|
120
|
+
os.makedirs(artifact_folder, exist_ok=True)
|
|
121
|
+
for artifact in artifacts:
|
|
122
|
+
# Save each artifact
|
|
123
|
+
artifact_file = os.path.join(
|
|
124
|
+
artifact_folder,
|
|
125
|
+
f"{artifact['identifier']}.{get_file_extension(artifact['type'])}",
|
|
126
|
+
)
|
|
127
|
+
if not os.path.exists(artifact_file):
|
|
128
|
+
with open(artifact_file, "w") as f:
|
|
129
|
+
f.write(artifact["content"])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_file_extension(artifact_type):
|
|
133
|
+
"""
|
|
134
|
+
Get the appropriate file extension for a given artifact type.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
artifact_type (str): The MIME type of the artifact.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
str: The corresponding file extension.
|
|
141
|
+
"""
|
|
142
|
+
type_to_extension = {
|
|
143
|
+
"text/html": "html",
|
|
144
|
+
"application/vnd.ant.code": "txt",
|
|
145
|
+
"image/svg+xml": "svg",
|
|
146
|
+
"application/vnd.ant.mermaid": "mmd",
|
|
147
|
+
"application/vnd.ant.react": "jsx",
|
|
148
|
+
}
|
|
149
|
+
return type_to_extension.get(artifact_type, "txt")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def extract_artifacts(text):
|
|
153
|
+
"""
|
|
154
|
+
Extract artifacts from the given text.
|
|
155
|
+
|
|
156
|
+
This function searches for antArtifact tags in the text and extracts
|
|
157
|
+
the artifact information, including identifier, type, and content.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
text (str): The text to search for artifacts.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
list: A list of dictionaries containing artifact information.
|
|
164
|
+
"""
|
|
165
|
+
artifacts = []
|
|
166
|
+
|
|
167
|
+
# Regular expression to match the <antArtifact> tags and extract their attributes and content
|
|
168
|
+
pattern = re.compile(
|
|
169
|
+
r'<antArtifact\s+identifier="([^"]+)"\s+type="([^"]+)"\s+title="([^"]+)">([\s\S]*?)</antArtifact>',
|
|
170
|
+
re.MULTILINE,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Find all matches in the text
|
|
174
|
+
matches = pattern.findall(text)
|
|
175
|
+
|
|
176
|
+
for match in matches:
|
|
177
|
+
identifier, artifact_type, title, content = match
|
|
178
|
+
artifacts.append(
|
|
179
|
+
{
|
|
180
|
+
"identifier": identifier,
|
|
181
|
+
"type": artifact_type,
|
|
182
|
+
"content": content.strip(),
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return artifacts
|
ctxsync/cli/__init__.py
ADDED
ctxsync/cli/auth.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from ctxsync.provider_factory import get_provider
|
|
4
|
+
from ..exceptions import ProviderError
|
|
5
|
+
from ..utils import handle_errors
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
def auth():
|
|
10
|
+
"""Manage authentication."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@auth.command()
|
|
15
|
+
@click.option(
|
|
16
|
+
"--provider",
|
|
17
|
+
prompt="Choose provider",
|
|
18
|
+
type=click.Choice(["claude.ai"], case_sensitive=False),
|
|
19
|
+
default="claude.ai",
|
|
20
|
+
help="The provider to use for this project",
|
|
21
|
+
)
|
|
22
|
+
@click.option(
|
|
23
|
+
"--session-key",
|
|
24
|
+
help="Directly provide the Claude.ai session key",
|
|
25
|
+
envvar="CLAUDE_SESSION_KEY",
|
|
26
|
+
)
|
|
27
|
+
@click.option(
|
|
28
|
+
"--auto-approve",
|
|
29
|
+
is_flag=True,
|
|
30
|
+
help="Automatically approve the suggested expiry time",
|
|
31
|
+
)
|
|
32
|
+
@click.pass_context
|
|
33
|
+
@handle_errors
|
|
34
|
+
def login(ctx, provider, session_key, auto_approve):
|
|
35
|
+
"""Authenticate with an AI provider."""
|
|
36
|
+
config = ctx.obj
|
|
37
|
+
provider_instance = get_provider(config, provider)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
if session_key:
|
|
41
|
+
# If session key is provided, bypass the interactive prompt
|
|
42
|
+
if not session_key.startswith("sk-ant"):
|
|
43
|
+
raise ProviderError(
|
|
44
|
+
"Invalid sessionKey format. Must start with 'sk-ant'"
|
|
45
|
+
)
|
|
46
|
+
# Set auto_approve to True when session key is provided
|
|
47
|
+
provider_instance._auto_approve_expiry = auto_approve
|
|
48
|
+
provider_instance._provided_session_key = session_key
|
|
49
|
+
|
|
50
|
+
session_key, expiry = provider_instance.login()
|
|
51
|
+
config.set_session_key(provider, session_key, expiry)
|
|
52
|
+
click.echo(
|
|
53
|
+
f"Successfully authenticated with {provider}. Session key stored globally."
|
|
54
|
+
)
|
|
55
|
+
except ProviderError as e:
|
|
56
|
+
click.echo(f"Authentication failed: {str(e)}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@auth.command()
|
|
60
|
+
@click.pass_obj
|
|
61
|
+
def logout(config):
|
|
62
|
+
"""Log out from all AI providers."""
|
|
63
|
+
config.clear_all_session_keys()
|
|
64
|
+
click.echo("Logged out from all providers successfully.")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@auth.command()
|
|
68
|
+
@click.pass_obj
|
|
69
|
+
def ls(config):
|
|
70
|
+
"""List all authenticated providers."""
|
|
71
|
+
authenticated_providers = config.get_providers_with_session_keys()
|
|
72
|
+
if authenticated_providers:
|
|
73
|
+
click.echo("Authenticated providers:")
|
|
74
|
+
for provider in authenticated_providers:
|
|
75
|
+
click.echo(f" - {provider}")
|
|
76
|
+
else:
|
|
77
|
+
click.echo("No authenticated providers found.")
|
ctxsync/cli/category.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from ..utils import handle_errors
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@click.group()
|
|
6
|
+
def category():
|
|
7
|
+
"""Manage file categories."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@category.command()
|
|
12
|
+
@click.argument("name")
|
|
13
|
+
@click.option("--description", required=True, help="Description of the category")
|
|
14
|
+
@click.option(
|
|
15
|
+
"--patterns", required=True, multiple=True, help="File patterns for the category"
|
|
16
|
+
)
|
|
17
|
+
@click.pass_obj
|
|
18
|
+
@handle_errors
|
|
19
|
+
def add(config, name, description, patterns):
|
|
20
|
+
"""Add a new file category."""
|
|
21
|
+
config.add_file_category(name, description, list(patterns))
|
|
22
|
+
click.echo(f"File category '{name}' added successfully.")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@category.command()
|
|
26
|
+
@click.argument("name")
|
|
27
|
+
@click.pass_obj
|
|
28
|
+
@handle_errors
|
|
29
|
+
def rm(config, name):
|
|
30
|
+
"""Remove a file category."""
|
|
31
|
+
config.remove_file_category(name)
|
|
32
|
+
click.echo(f"File category '{name}' removed successfully.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@category.command()
|
|
36
|
+
@click.argument("name")
|
|
37
|
+
@click.option("--description", help="New description for the category")
|
|
38
|
+
@click.option("--patterns", multiple=True, help="New file patterns for the category")
|
|
39
|
+
@click.pass_obj
|
|
40
|
+
@handle_errors
|
|
41
|
+
def update(config, name, description, patterns):
|
|
42
|
+
"""Update an existing file category."""
|
|
43
|
+
config.update_file_category(name, description, list(patterns) if patterns else None)
|
|
44
|
+
click.echo(f"File category '{name}' updated successfully.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@category.command()
|
|
48
|
+
@click.pass_obj
|
|
49
|
+
@handle_errors
|
|
50
|
+
def ls(config):
|
|
51
|
+
"""List all file categories."""
|
|
52
|
+
categories = config.get("file_categories", {})
|
|
53
|
+
if not categories:
|
|
54
|
+
click.echo("No file categories defined.")
|
|
55
|
+
else:
|
|
56
|
+
for name, data in categories.items():
|
|
57
|
+
click.echo(f"\nCategory: {name}")
|
|
58
|
+
click.echo(f"Description: {data['description']}")
|
|
59
|
+
click.echo("Patterns:")
|
|
60
|
+
for pattern in data["patterns"]:
|
|
61
|
+
click.echo(f" - {pattern}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@category.command()
|
|
65
|
+
@click.argument("category", required=True)
|
|
66
|
+
@click.pass_obj
|
|
67
|
+
@handle_errors
|
|
68
|
+
def set_default(config, category):
|
|
69
|
+
"""Set the default category for synchronization."""
|
|
70
|
+
config.set_default_category(category)
|
|
71
|
+
click.echo(f"Default sync category set to: {category}")
|
ctxsync/cli/chat.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import logging
|
|
5
|
+
from ..exceptions import ProviderError
|
|
6
|
+
from ..utils import handle_errors, validate_and_get_provider
|
|
7
|
+
from ..chat_sync import sync_chats
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
def chat():
|
|
14
|
+
"""Manage and synchronize chats."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@chat.command()
|
|
19
|
+
@click.pass_obj
|
|
20
|
+
@handle_errors
|
|
21
|
+
def pull(config):
|
|
22
|
+
"""Synchronize chats and their artifacts from the remote source."""
|
|
23
|
+
provider = validate_and_get_provider(config, require_project=True)
|
|
24
|
+
sync_chats(provider, config)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@chat.command()
|
|
28
|
+
@click.pass_obj
|
|
29
|
+
@handle_errors
|
|
30
|
+
def ls(config):
|
|
31
|
+
"""List all chats."""
|
|
32
|
+
provider = validate_and_get_provider(config)
|
|
33
|
+
organization_id = config.get("active_organization_id")
|
|
34
|
+
chats = provider.get_chat_conversations(organization_id)
|
|
35
|
+
|
|
36
|
+
for chat in chats:
|
|
37
|
+
project = chat.get("project")
|
|
38
|
+
project_name = project.get("name") if project else ""
|
|
39
|
+
click.echo(
|
|
40
|
+
f"UUID: {chat.get('uuid', 'Unknown')}, "
|
|
41
|
+
f"Name: {chat.get('name', 'Unnamed')}, "
|
|
42
|
+
f"Project: {project_name}, "
|
|
43
|
+
f"Updated: {chat.get('updated_at', 'Unknown')}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@chat.command()
|
|
48
|
+
@click.option("-a", "--all", "delete_all", is_flag=True, help="Delete all chats")
|
|
49
|
+
@click.pass_obj
|
|
50
|
+
@handle_errors
|
|
51
|
+
def rm(config, delete_all):
|
|
52
|
+
"""Delete chat conversations. Use -a to delete all chats, or run without -a to select specific chats to delete."""
|
|
53
|
+
provider = validate_and_get_provider(config)
|
|
54
|
+
organization_id = config.get("active_organization_id")
|
|
55
|
+
|
|
56
|
+
if delete_all:
|
|
57
|
+
delete_all_chats(provider, organization_id)
|
|
58
|
+
else:
|
|
59
|
+
delete_single_chat(provider, organization_id)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def delete_chats(provider, organization_id, uuids):
|
|
63
|
+
"""Delete a list of chats by their UUIDs."""
|
|
64
|
+
try:
|
|
65
|
+
result = provider.delete_chat(organization_id, uuids)
|
|
66
|
+
return len(result), 0
|
|
67
|
+
except ProviderError as e:
|
|
68
|
+
logger.error(f"Error deleting chats: {str(e)}")
|
|
69
|
+
click.echo(f"Error occurred while deleting chats: {str(e)}")
|
|
70
|
+
return 0, len(uuids)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def delete_all_chats(provider, organization_id):
|
|
74
|
+
"""Delete all chats for the given organization."""
|
|
75
|
+
if click.confirm("Are you sure you want to delete all chats?"):
|
|
76
|
+
total_deleted = 0
|
|
77
|
+
with click.progressbar(length=100, label="Deleting chats") as bar:
|
|
78
|
+
while True:
|
|
79
|
+
chats = provider.get_chat_conversations(organization_id)
|
|
80
|
+
if not chats:
|
|
81
|
+
break
|
|
82
|
+
uuids_to_delete = [chat["uuid"] for chat in chats[:50]]
|
|
83
|
+
deleted, _ = delete_chats(provider, organization_id, uuids_to_delete)
|
|
84
|
+
total_deleted += deleted
|
|
85
|
+
bar.update(len(uuids_to_delete))
|
|
86
|
+
click.echo(f"Chat deletion complete. Total chats deleted: {total_deleted}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def delete_single_chat(provider, organization_id):
|
|
90
|
+
"""Delete a single chat selected by the user."""
|
|
91
|
+
chats = provider.get_chat_conversations(organization_id)
|
|
92
|
+
if not chats:
|
|
93
|
+
click.echo("No chats found.")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
display_chat_list(chats)
|
|
97
|
+
selected_chat = get_chat_selection(chats)
|
|
98
|
+
if selected_chat:
|
|
99
|
+
confirm_and_delete_chat(provider, organization_id, selected_chat)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def display_chat_list(chats):
|
|
103
|
+
"""Display a list of chats to the user."""
|
|
104
|
+
click.echo("Available chats:")
|
|
105
|
+
for idx, chat in enumerate(chats, 1):
|
|
106
|
+
project = chat.get("project")
|
|
107
|
+
project_name = project.get("name") if project else ""
|
|
108
|
+
click.echo(
|
|
109
|
+
f"{idx}. Name: {chat.get('name', 'Unnamed')}, "
|
|
110
|
+
f"Project: {project_name}, Updated: {chat.get('updated_at', 'Unknown')}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_chat_selection(chats):
|
|
115
|
+
"""Get a valid chat selection from the user."""
|
|
116
|
+
while True:
|
|
117
|
+
selection = click.prompt(
|
|
118
|
+
"Enter the number of the chat to delete (or 'q' to quit)", type=str
|
|
119
|
+
)
|
|
120
|
+
if selection.lower() == "q":
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
selection = int(selection)
|
|
124
|
+
if 1 <= selection <= len(chats):
|
|
125
|
+
return chats[selection - 1]
|
|
126
|
+
click.echo("Invalid selection. Please try again.")
|
|
127
|
+
except ValueError:
|
|
128
|
+
click.echo("Invalid input. Please enter a number or 'q' to quit.")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def confirm_and_delete_chat(provider, organization_id, chat):
|
|
132
|
+
"""Confirm deletion with the user and delete the selected chat."""
|
|
133
|
+
if click.confirm(
|
|
134
|
+
f"Are you sure you want to delete the chat '{chat.get('name', 'Unnamed')}'?"
|
|
135
|
+
):
|
|
136
|
+
deleted, _ = delete_chats(provider, organization_id, [chat["uuid"]])
|
|
137
|
+
if deleted:
|
|
138
|
+
click.echo(f"Successfully deleted chat: {chat.get('name', 'Unnamed')}")
|
|
139
|
+
else:
|
|
140
|
+
click.echo(f"Failed to delete chat: {chat.get('name', 'Unnamed')}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@chat.command()
|
|
144
|
+
@click.option("--name", default="", help="Name of the chat conversation")
|
|
145
|
+
@click.option("--project", help="UUID of the project to associate the chat with")
|
|
146
|
+
@click.pass_obj
|
|
147
|
+
@handle_errors
|
|
148
|
+
def init(config, name, project):
|
|
149
|
+
"""Initializes a new chat conversation on the active provider."""
|
|
150
|
+
provider = validate_and_get_provider(config)
|
|
151
|
+
organization_id = config.get("active_organization_id")
|
|
152
|
+
active_project_id = config.get("active_project_id")
|
|
153
|
+
active_project_name = config.get("active_project_name")
|
|
154
|
+
local_path = config.get("local_path")
|
|
155
|
+
|
|
156
|
+
if not organization_id:
|
|
157
|
+
click.echo("No active organization set.")
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
if not project:
|
|
161
|
+
project = select_project(
|
|
162
|
+
active_project_id,
|
|
163
|
+
active_project_name,
|
|
164
|
+
local_path,
|
|
165
|
+
organization_id,
|
|
166
|
+
provider,
|
|
167
|
+
)
|
|
168
|
+
if project is None:
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
new_chat = provider.create_chat(
|
|
173
|
+
organization_id, chat_name=name, project_uuid=project
|
|
174
|
+
)
|
|
175
|
+
click.echo(f"Created new chat conversation: {new_chat['uuid']}")
|
|
176
|
+
if name:
|
|
177
|
+
click.echo(f"Chat name: {name}")
|
|
178
|
+
click.echo(f"Associated project: {project}")
|
|
179
|
+
except Exception as e:
|
|
180
|
+
click.echo(f"Failed to create chat conversation: {str(e)}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def process_message_event(event):
|
|
184
|
+
"""Process a single streaming event from the message response."""
|
|
185
|
+
# Handle new API format (content_block_delta with text_delta)
|
|
186
|
+
if event.get("type") == "content_block_delta":
|
|
187
|
+
delta = event.get("delta", {})
|
|
188
|
+
if delta.get("type") == "text_delta":
|
|
189
|
+
click.echo(delta.get("text", ""), nl=False)
|
|
190
|
+
# Handle legacy format (for backward compatibility)
|
|
191
|
+
elif "completion" in event:
|
|
192
|
+
click.echo(event["completion"], nl=False)
|
|
193
|
+
elif "content" in event:
|
|
194
|
+
click.echo(event["content"], nl=False)
|
|
195
|
+
elif "error" in event:
|
|
196
|
+
click.echo(f"\nError: {event['error']}")
|
|
197
|
+
elif "message_limit" in event:
|
|
198
|
+
click.echo(f"\nRemaining messages: {event['message_limit']['remaining']}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@chat.command()
|
|
202
|
+
@click.argument("message", nargs=-1, required=True)
|
|
203
|
+
@click.option("--chat", help="UUID of the chat to send the message to")
|
|
204
|
+
@click.option("--timezone", default="UTC", help="Timezone for the message")
|
|
205
|
+
@click.option(
|
|
206
|
+
"--model",
|
|
207
|
+
help="Model to use for the conversation. Available options:\n"
|
|
208
|
+
+ "- claude-3-5-haiku-20241022\n"
|
|
209
|
+
+ "- claude-3-opus-20240229\n"
|
|
210
|
+
+ "Or any custom model string. If not specified, uses the default model.",
|
|
211
|
+
)
|
|
212
|
+
@click.pass_obj
|
|
213
|
+
@handle_errors
|
|
214
|
+
def message(config, message, chat, timezone, model):
|
|
215
|
+
"""Send a message to a specified chat or create a new chat and send the message."""
|
|
216
|
+
provider = validate_and_get_provider(config, require_project=True)
|
|
217
|
+
active_organization_id = config.get("active_organization_id")
|
|
218
|
+
active_project_id = config.get("active_project_id")
|
|
219
|
+
active_project_name = config.get("active_project_name")
|
|
220
|
+
|
|
221
|
+
message = " ".join(message) # Join all message parts into a single string
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
chat = create_chat(
|
|
225
|
+
config,
|
|
226
|
+
active_project_id,
|
|
227
|
+
active_project_name,
|
|
228
|
+
chat,
|
|
229
|
+
active_organization_id,
|
|
230
|
+
provider,
|
|
231
|
+
model,
|
|
232
|
+
)
|
|
233
|
+
if chat is None:
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
# Send message and process the streaming response
|
|
237
|
+
for event in provider.send_message(
|
|
238
|
+
active_organization_id, chat, message, timezone, model
|
|
239
|
+
):
|
|
240
|
+
process_message_event(event)
|
|
241
|
+
|
|
242
|
+
click.echo() # Print a newline at the end of the response
|
|
243
|
+
|
|
244
|
+
except Exception as e:
|
|
245
|
+
click.echo(f"Failed to send message: {str(e)}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def create_chat(
|
|
249
|
+
config,
|
|
250
|
+
active_project_id,
|
|
251
|
+
active_project_name,
|
|
252
|
+
chat,
|
|
253
|
+
active_organization_id,
|
|
254
|
+
provider,
|
|
255
|
+
model,
|
|
256
|
+
):
|
|
257
|
+
if not chat:
|
|
258
|
+
if not active_project_name:
|
|
259
|
+
active_project_id = select_project(
|
|
260
|
+
config,
|
|
261
|
+
active_project_id,
|
|
262
|
+
active_project_name,
|
|
263
|
+
active_organization_id,
|
|
264
|
+
provider,
|
|
265
|
+
)
|
|
266
|
+
if active_project_id is None:
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
# Create a new chat with the selected project
|
|
270
|
+
new_chat = provider.create_chat(
|
|
271
|
+
active_organization_id, project_uuid=active_project_id, model=model
|
|
272
|
+
)
|
|
273
|
+
chat = new_chat["uuid"]
|
|
274
|
+
click.echo(f"New chat created with ID: {chat}")
|
|
275
|
+
return chat
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def select_project(
|
|
279
|
+
config, active_project_id, active_project_name, active_organization_id, provider
|
|
280
|
+
):
|
|
281
|
+
all_projects = provider.get_projects(active_organization_id)
|
|
282
|
+
if not all_projects:
|
|
283
|
+
click.echo("No projects found in the active organization.")
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
# Filter projects to include only the active project and its submodules
|
|
287
|
+
filtered_projects = [
|
|
288
|
+
p
|
|
289
|
+
for p in all_projects
|
|
290
|
+
if p["id"] == active_project_id
|
|
291
|
+
or (
|
|
292
|
+
p["name"].startswith(f"{active_project_name}-SubModule-")
|
|
293
|
+
and not p.get("archived_at")
|
|
294
|
+
)
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
if not filtered_projects:
|
|
298
|
+
click.echo("No active project or related submodules found.")
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
# Determine the current working directory
|
|
302
|
+
current_dir = os.path.abspath(os.getcwd())
|
|
303
|
+
|
|
304
|
+
default_project = get_default_project(
|
|
305
|
+
config, active_project_id, active_project_name, current_dir, filtered_projects
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
click.echo("Available projects:")
|
|
309
|
+
for idx, proj in enumerate(filtered_projects, 1):
|
|
310
|
+
project_type = (
|
|
311
|
+
"Active Project" if proj["id"] == active_project_id else "Submodule"
|
|
312
|
+
)
|
|
313
|
+
default_marker = " (default)" if idx - 1 == default_project else ""
|
|
314
|
+
click.echo(
|
|
315
|
+
f"{idx}. {proj['name']} (ID: {proj['id']}) - {project_type}{default_marker}"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
while True:
|
|
319
|
+
prompt = "Enter the number of the project to associate with the chat"
|
|
320
|
+
if default_project is not None:
|
|
321
|
+
default_project_name = filtered_projects[default_project]["name"]
|
|
322
|
+
prompt += f" (default: {default_project + 1} - {default_project_name})"
|
|
323
|
+
selection = click.prompt(
|
|
324
|
+
prompt,
|
|
325
|
+
type=int,
|
|
326
|
+
default=default_project + 1 if default_project is not None else None,
|
|
327
|
+
)
|
|
328
|
+
if 1 <= selection <= len(filtered_projects):
|
|
329
|
+
project = filtered_projects[selection - 1]["id"]
|
|
330
|
+
break
|
|
331
|
+
click.echo("Invalid selection. Please try again.")
|
|
332
|
+
return project
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def get_default_project(
|
|
336
|
+
config, active_project_id, active_project_name, current_dir, filtered_projects
|
|
337
|
+
):
|
|
338
|
+
local_path = config.get("local_path")
|
|
339
|
+
if not local_path:
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
# Find the project that matches the current directory
|
|
343
|
+
default_project = None
|
|
344
|
+
for idx, proj in enumerate(filtered_projects):
|
|
345
|
+
if proj["id"] == active_project_id:
|
|
346
|
+
project_path = os.path.abspath(local_path)
|
|
347
|
+
else:
|
|
348
|
+
submodule_name = proj["name"].replace(
|
|
349
|
+
f"{active_project_name}-SubModule-", ""
|
|
350
|
+
)
|
|
351
|
+
project_path = os.path.abspath(
|
|
352
|
+
os.path.join(local_path, "services", submodule_name)
|
|
353
|
+
)
|
|
354
|
+
if current_dir.startswith(project_path):
|
|
355
|
+
default_project = idx
|
|
356
|
+
break
|
|
357
|
+
return default_project
|