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/cli/project.py ADDED
@@ -0,0 +1,422 @@
1
+ import click
2
+ import os
3
+ import logging
4
+
5
+ from tqdm import tqdm
6
+ from ..provider_factory import get_provider
7
+ from ..utils import handle_errors, validate_and_get_provider
8
+ from ..exceptions import ProviderError, ConfigurationError
9
+ from .file import file
10
+ from .submodule import submodule
11
+ from ..syncmanager import retry_on_403
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @click.group()
17
+ def project():
18
+ """Manage AI projects within the active organization."""
19
+ pass
20
+
21
+
22
+ @project.command()
23
+ @click.option(
24
+ "--name",
25
+ default=lambda: os.path.basename(os.getcwd()),
26
+ prompt="Enter a title for your project",
27
+ help="The name of the project (defaults to current directory name)",
28
+ show_default="current directory name",
29
+ )
30
+ @click.option(
31
+ "--description",
32
+ default="Project created with ctxsync",
33
+ prompt="Enter the project description",
34
+ help="The project description",
35
+ show_default=True,
36
+ )
37
+ @click.option(
38
+ "--local-path",
39
+ default=lambda: os.getcwd(),
40
+ prompt="Enter the absolute path to your local project directory",
41
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, resolve_path=True),
42
+ help="The local path for the project (defaults to current working directory)",
43
+ show_default="current working directory",
44
+ )
45
+ @click.option(
46
+ "--new",
47
+ is_flag=True,
48
+ help="Create a new remote project on Claude.ai",
49
+ )
50
+ @click.option(
51
+ "--provider",
52
+ type=click.Choice(["claude.ai"], case_sensitive=False),
53
+ default="claude.ai",
54
+ help="The provider to use for this project",
55
+ )
56
+ @click.pass_context
57
+ @handle_errors
58
+ def init(ctx, name, description, local_path, new, provider):
59
+ """Initialize a new project configuration.
60
+
61
+ If --new is specified, also creates a remote project on Claude.ai.
62
+ Otherwise, only creates the local configuration. Use 'ctxsync organization set'
63
+ and 'ctxsync project set' to link to an existing remote project."""
64
+
65
+ config = ctx.obj
66
+
67
+ # Create .ctxsync directory and save initial config
68
+ ctxsync_dir = os.path.join(local_path, ".ctxsync")
69
+ os.makedirs(ctxsync_dir, exist_ok=True)
70
+
71
+ # Set basic configuration
72
+ config.set("active_provider", provider, local=True)
73
+ config.set("local_path", local_path, local=True)
74
+
75
+ if new:
76
+ # Create remote project if --new flag is specified
77
+ provider_instance = get_provider(config, provider)
78
+
79
+ # Get organization
80
+ organizations = provider_instance.get_organizations()
81
+ if not organizations:
82
+ raise ConfigurationError(
83
+ "No organizations with required capabilities found."
84
+ )
85
+ organization = organizations[0]["id"]
86
+
87
+ try:
88
+ new_project = provider_instance.create_project(
89
+ organization, name, description
90
+ )
91
+ click.echo(
92
+ f"Project '{new_project['name']}' (uuid: {new_project['uuid']}) has been created successfully."
93
+ )
94
+
95
+ # Update configuration with remote details
96
+ config.set("active_organization_id", organization, local=True)
97
+ config.set("active_project_id", new_project["uuid"], local=True)
98
+ config.set("active_project_name", new_project["name"], local=True)
99
+
100
+ click.echo("\nProject created:")
101
+ click.echo(f" - Project location: {local_path}")
102
+ click.echo(
103
+ f" - Project config location: {os.path.join(ctxsync_dir, 'config.local.json')}"
104
+ )
105
+ click.echo(
106
+ f" - Remote URL: https://claude.ai/project/{new_project['uuid']}"
107
+ )
108
+
109
+ except (ProviderError, ConfigurationError) as e:
110
+ click.echo(f"Failed to create remote project: {str(e)}")
111
+ raise click.Abort()
112
+ else:
113
+ config._save_local_config()
114
+ click.echo("\nLocal project configuration created:")
115
+ click.echo(f" - Project location: {local_path}")
116
+ click.echo(
117
+ f" - Project config location: {os.path.join(ctxsync_dir, 'config.local.json')}"
118
+ )
119
+ click.echo("\nTo link to a remote project:")
120
+ click.echo("1. Run 'ctxsync organization set' to select an organization")
121
+ click.echo("2. Run 'ctxsync project set' to select an existing project")
122
+
123
+
124
+ @project.command()
125
+ @click.pass_context
126
+ def create(ctx, **kwargs):
127
+ """Create a new project (alias for 'init --new')."""
128
+ # Forward to init command with --new flag
129
+ ctx.forward(init, new=True)
130
+
131
+
132
+ @project.command()
133
+ @click.option(
134
+ "-a",
135
+ "--all",
136
+ "archive_all",
137
+ is_flag=True,
138
+ help="Archive all active projects",
139
+ )
140
+ @click.option(
141
+ "-y",
142
+ "--yes",
143
+ is_flag=True,
144
+ help="Skip confirmation prompt",
145
+ )
146
+ @click.pass_obj
147
+ @handle_errors
148
+ def archive(config, archive_all, yes):
149
+ """Archive existing projects."""
150
+ provider = validate_and_get_provider(config)
151
+ active_organization_id = config.get("active_organization_id")
152
+ projects = provider.get_projects(active_organization_id, include_archived=False)
153
+
154
+ if not projects:
155
+ click.echo("No active projects found.")
156
+ return
157
+
158
+ if archive_all:
159
+ if not yes:
160
+ click.echo("The following projects will be archived:")
161
+ for project in projects:
162
+ click.echo(f" - {project['name']} (ID: {project['id']})")
163
+ if not click.confirm("Are you sure you want to archive all projects?"):
164
+ click.echo("Operation cancelled.")
165
+ return
166
+
167
+ with click.progressbar(
168
+ projects,
169
+ label="Archiving projects",
170
+ item_show_func=lambda p: p["name"] if p else "",
171
+ ) as bar:
172
+ for project in bar:
173
+ try:
174
+ provider.archive_project(active_organization_id, project["id"])
175
+ except Exception as e:
176
+ click.echo(
177
+ f"\nFailed to archive project '{project['name']}': {str(e)}"
178
+ )
179
+
180
+ click.echo("\nArchive operation completed.")
181
+ return
182
+
183
+ single_project_archival(projects, yes, provider, active_organization_id)
184
+
185
+
186
+ def _save_project_selection(config, selected_project):
187
+ """Save project selection to config and display confirmation."""
188
+ config.set("active_project_id", selected_project["id"], local=True)
189
+ config.set("active_project_name", selected_project["name"], local=True)
190
+ click.echo(
191
+ f"Selected project: {selected_project['name']} (ID: {selected_project['id']})"
192
+ )
193
+
194
+ # Create .ctxsync directory in the current working directory if it doesn't exist
195
+ os.makedirs(".ctxsync", exist_ok=True)
196
+ ctxsync_dir = os.path.abspath(".ctxsync")
197
+ config_file_path = os.path.join(ctxsync_dir, "config.local.json")
198
+ config._save_local_config()
199
+
200
+ click.echo("\nProject created:")
201
+ click.echo(f" - Project location: {os.getcwd()}")
202
+ click.echo(f" - Project config location: {config_file_path}")
203
+
204
+
205
+ def single_project_archival(projects, yes, provider, active_organization_id):
206
+ click.echo("Available projects to archive:")
207
+ for idx, project in enumerate(projects, 1):
208
+ click.echo(f" {idx}. {project['name']} (ID: {project['id']})")
209
+
210
+ selection = click.prompt("Enter the number of the project to archive", type=int)
211
+ if 1 <= selection <= len(projects):
212
+ selected_project = projects[selection - 1]
213
+ if yes or click.confirm(
214
+ f"Are you sure you want to archive the project '{selected_project['name']}'? "
215
+ f"Archived projects cannot be modified but can still be viewed."
216
+ ):
217
+ provider.archive_project(active_organization_id, selected_project["id"])
218
+ click.echo(f"Project '{selected_project['name']}' has been archived.")
219
+ else:
220
+ click.echo("Invalid selection. Please try again.")
221
+
222
+
223
+ @project.command()
224
+ @click.option(
225
+ "-a",
226
+ "--all",
227
+ "show_all",
228
+ is_flag=True,
229
+ help="Include submodule projects in the selection",
230
+ )
231
+ @click.option(
232
+ "--project-id",
233
+ help="UUID of the project to set as active (skips interactive selection)",
234
+ )
235
+ @click.option(
236
+ "--provider",
237
+ type=click.Choice(["claude.ai"]), # Add more providers as they become available
238
+ default="claude.ai",
239
+ help="Specify the provider for repositories without .ctxsync",
240
+ )
241
+ @click.pass_context
242
+ @handle_errors
243
+ def set(ctx, show_all, project_id, provider):
244
+ """Set the active project for syncing."""
245
+ config = ctx.obj
246
+
247
+ # If provider is not specified, try to get it from the config
248
+ if not provider:
249
+ provider = config.get("active_provider")
250
+
251
+ # If provider is still not available, prompt the user
252
+ if not provider:
253
+ provider = click.prompt(
254
+ "Please specify the provider",
255
+ type=click.Choice(
256
+ ["claude.ai"]
257
+ ), # Add more providers as they become available
258
+ )
259
+
260
+ # Update the config with the provider
261
+ config.set("active_provider", provider, local=True)
262
+
263
+ # Now we can get the provider instance
264
+ provider_instance = validate_and_get_provider(config)
265
+ active_organization_id = config.get("active_organization_id")
266
+ active_project_name = config.get("active_project_name")
267
+ projects = provider_instance.get_projects(
268
+ active_organization_id, include_archived=False
269
+ )
270
+
271
+ if show_all:
272
+ selectable_projects = projects
273
+ else:
274
+ # Filter out submodule projects
275
+ selectable_projects = [p for p in projects if "-SubModule-" not in p["name"]]
276
+
277
+ if not selectable_projects:
278
+ click.echo("No active projects found.")
279
+ return
280
+
281
+ # Non-interactive mode: if project_id is provided, use it directly
282
+ if project_id:
283
+ selected_project = next(
284
+ (p for p in selectable_projects if p["id"] == project_id), None
285
+ )
286
+ if selected_project:
287
+ _save_project_selection(config, selected_project)
288
+ else:
289
+ click.echo(f"Project with ID {project_id} not found in available projects.")
290
+ if not show_all:
291
+ click.echo("Tip: Use --all flag to include submodule projects.")
292
+ return
293
+
294
+ # Interactive mode: display list and prompt for selection
295
+ click.echo("Available projects:")
296
+ for idx, project in enumerate(selectable_projects, 1):
297
+ project_type = (
298
+ "Main Project"
299
+ if not project["name"].startswith(f"{active_project_name}-SubModule-")
300
+ else "Submodule"
301
+ )
302
+ click.echo(f" {idx}. {project['name']} (ID: {project['id']}) - {project_type}")
303
+
304
+ selection = click.prompt(
305
+ "Enter the number of the project to select", type=int, default=1
306
+ )
307
+ if 1 <= selection <= len(selectable_projects):
308
+ selected_project = selectable_projects[selection - 1]
309
+ _save_project_selection(config, selected_project)
310
+ else:
311
+ click.echo("Invalid selection. Please try again.")
312
+
313
+
314
+ @project.command()
315
+ @click.option(
316
+ "-a",
317
+ "--all",
318
+ "show_all",
319
+ is_flag=True,
320
+ help="Include archived projects in the list",
321
+ )
322
+ @click.pass_obj
323
+ @handle_errors
324
+ def ls(config, show_all):
325
+ """List all projects in the active organization."""
326
+ provider = validate_and_get_provider(config)
327
+ active_organization_id = config.get("active_organization_id")
328
+ projects = provider.get_projects(active_organization_id, include_archived=show_all)
329
+ if not projects:
330
+ click.echo("No projects found.")
331
+ else:
332
+ click.echo("Remote projects:")
333
+ for project in projects:
334
+ status = " (Archived)" if project.get("archived_at") else ""
335
+ click.echo(f" - {project['name']} (ID: {project['id']}){status}")
336
+
337
+
338
+ @project.command()
339
+ @click.option(
340
+ "-a", "--include-archived", is_flag=True, help="Include archived projects"
341
+ )
342
+ @click.option("--all", "truncate_all", is_flag=True, help="Truncate all projects")
343
+ @click.option("-y", "--yes", is_flag=True, help="Skip confirmation prompt")
344
+ @click.pass_obj
345
+ @handle_errors
346
+ def truncate(config, include_archived, truncate_all, yes):
347
+ """Truncate one or all projects."""
348
+ provider = validate_and_get_provider(config)
349
+ active_organization_id = config.get("active_organization_id")
350
+
351
+ projects = provider.get_projects(
352
+ active_organization_id, include_archived=include_archived
353
+ )
354
+
355
+ if not projects:
356
+ click.echo("No projects found.")
357
+ return
358
+
359
+ if truncate_all:
360
+ if not yes:
361
+ click.echo("This will delete ALL files from the following projects:")
362
+ for project in projects:
363
+ status = " (Archived)" if project.get("archived_at") else ""
364
+ click.echo(f" - {project['name']} (ID: {project['id']}){status}")
365
+ if not click.confirm(
366
+ "Are you sure you want to continue? This may take some time."
367
+ ):
368
+ click.echo("Operation cancelled.")
369
+ return
370
+
371
+ with tqdm(total=len(projects), desc="Deleting files from projects") as pbar:
372
+ for project in projects:
373
+ delete_files_from_project(
374
+ provider, active_organization_id, project["id"], project["name"]
375
+ )
376
+ pbar.update(1)
377
+
378
+ click.echo("All files have been deleted from all projects.")
379
+ return
380
+
381
+ click.echo("Available projects:")
382
+ for idx, project in enumerate(projects, 1):
383
+ status = " (Archived)" if project.get("archived_at") else ""
384
+ click.echo(f" {idx}. {project['name']} (ID: {project['id']}){status}")
385
+
386
+ selection = click.prompt("Enter the number of the project to truncate", type=int)
387
+ if 1 <= selection <= len(projects):
388
+ selected_project = projects[selection - 1]
389
+ if yes or click.confirm(
390
+ f"Are you sure you want to delete ALL files from project '{selected_project['name']}'?"
391
+ ):
392
+ delete_files_from_project(
393
+ provider,
394
+ active_organization_id,
395
+ selected_project["id"],
396
+ selected_project["name"],
397
+ )
398
+ click.echo(
399
+ f"All files have been deleted from project '{selected_project['name']}'."
400
+ )
401
+ else:
402
+ click.echo("Invalid selection. Please try again.")
403
+
404
+
405
+ @retry_on_403()
406
+ def delete_files_from_project(provider, organization_id, project_id, project_name):
407
+ try:
408
+ files = provider.list_files(organization_id, project_id)
409
+ with tqdm(
410
+ total=len(files), desc=f"Deleting files from {project_name}", leave=False
411
+ ) as file_pbar:
412
+ for current_file in files:
413
+ provider.delete_file(organization_id, project_id, current_file["uuid"])
414
+ file_pbar.update(1)
415
+ except ProviderError as e:
416
+ click.echo(f"Error deleting files from project {project_name}: {str(e)}")
417
+
418
+
419
+ project.add_command(submodule)
420
+ project.add_command(file)
421
+
422
+ __all__ = ["project"]