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 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
@@ -0,0 +1,3 @@
1
+ from .main import cli
2
+
3
+ __all__ = ["cli"]
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.")
@@ -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