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/config.py ADDED
@@ -0,0 +1,72 @@
1
+ import json
2
+
3
+ import click
4
+
5
+ from .category import category
6
+ from ..exceptions import ConfigurationError
7
+ from ..utils import handle_errors
8
+
9
+
10
+ @click.group()
11
+ def config():
12
+ """Manage ctxsync configuration."""
13
+ pass
14
+
15
+
16
+ @config.command()
17
+ @click.argument("key")
18
+ @click.argument("value")
19
+ @click.pass_obj
20
+ @handle_errors
21
+ def set(config, key, value):
22
+ """Set a configuration value."""
23
+ # Check if the key exists in the configuration
24
+ if key not in config.global_config and key not in config.local_config:
25
+ raise ConfigurationError(f"Configuration property '{key}' does not exist.")
26
+
27
+ # Convert string 'true' and 'false' to boolean
28
+ if value.lower() == "true":
29
+ value = True
30
+ elif value.lower() == "false":
31
+ value = False
32
+ # Try to convert to int or float if possible
33
+ else:
34
+ try:
35
+ value = int(value)
36
+ except ValueError:
37
+ try:
38
+ value = float(value)
39
+ except ValueError:
40
+ pass # Keep as string if not a number
41
+
42
+ config.set(key, value)
43
+ click.echo(f"Configuration {key} set to {value}")
44
+
45
+
46
+ @config.command()
47
+ @click.argument("key")
48
+ @click.pass_obj
49
+ @handle_errors
50
+ def get(config, key):
51
+ """Get a configuration value."""
52
+ value = config.get(key)
53
+ if value is None:
54
+ click.echo(f"Configuration {key} is not set")
55
+ else:
56
+ click.echo(f"{key}: {value}")
57
+
58
+
59
+ @config.command()
60
+ @click.pass_obj
61
+ @handle_errors
62
+ def ls(config):
63
+ """List all configuration values."""
64
+ # Combine global and local configurations
65
+ combined_config = config.global_config.copy()
66
+ combined_config.update(config.local_config)
67
+
68
+ # Print the combined configuration as JSON
69
+ click.echo(json.dumps(combined_config, indent=2, sort_keys=True))
70
+
71
+
72
+ config.add_command(category)
ctxsync/cli/file.py ADDED
@@ -0,0 +1,29 @@
1
+ import click
2
+ from ..utils import handle_errors, validate_and_get_provider
3
+
4
+
5
+ @click.group()
6
+ def file():
7
+ """Manage remote project files."""
8
+ pass
9
+
10
+
11
+ @file.command()
12
+ @click.pass_obj
13
+ @handle_errors
14
+ def ls(config):
15
+ """List files in the active remote project."""
16
+ provider = validate_and_get_provider(config, require_project=True)
17
+ active_organization_id = config.get("active_organization_id")
18
+ active_project_id = config.get("active_project_id")
19
+ files = provider.list_files(active_organization_id, active_project_id)
20
+ if not files:
21
+ click.echo("No files found in the active project.")
22
+ else:
23
+ click.echo(
24
+ f"Files in project '{config.get('active_project_name')}' (ID: {active_project_id}):"
25
+ )
26
+ for file in files:
27
+ click.echo(
28
+ f" - {file['file_name']} (ID: {file['uuid']}, Created: {file['created_at']})"
29
+ )
ctxsync/cli/main.py ADDED
@@ -0,0 +1,257 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+ import click_completion
5
+ import click_completion.core
6
+ import json
7
+ import subprocess
8
+ import urllib.request
9
+ import importlib.metadata
10
+
11
+ from ctxsync.cli.chat import chat
12
+ from ctxsync.configmanager import FileConfigManager, InMemoryConfigManager
13
+ from ctxsync.syncmanager import SyncManager
14
+ from ctxsync.utils import (
15
+ handle_errors,
16
+ validate_and_get_provider,
17
+ get_local_files,
18
+ )
19
+ from .auth import auth
20
+ from .organization import organization
21
+ from .project import project
22
+ from .sync import schedule
23
+ from .config import config
24
+ from .session import session
25
+ import logging
26
+
27
+ logging.basicConfig(
28
+ level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
29
+ )
30
+
31
+ click_completion.init()
32
+
33
+
34
+ @click.group()
35
+ @click.pass_context
36
+ def cli(ctx):
37
+ """ctxsync: Synchronize local files with AI projects."""
38
+ if ctx.obj is None:
39
+ ctx.obj = FileConfigManager() # InMemoryConfigManager() for testing with mock
40
+
41
+
42
+ @cli.command()
43
+ @click.argument(
44
+ "shell", required=False, type=click.Choice(["bash", "zsh", "fish", "powershell"])
45
+ )
46
+ def install_completion(shell):
47
+ """Install completion for the specified shell."""
48
+ if shell is None:
49
+ shell = click_completion.get_auto_shell()
50
+ click.echo("Shell is set to '%s'" % shell)
51
+ click_completion.install(shell=shell)
52
+ click.echo("Completion installed.")
53
+
54
+
55
+ @cli.command()
56
+ @click.pass_context
57
+ def upgrade(ctx):
58
+ """Upgrade ctxsync to the latest version and reset configuration, preserving sessionKey."""
59
+ current_version = importlib.metadata.version("ctxsync")
60
+
61
+ # Check for the latest version
62
+ try:
63
+ with urllib.request.urlopen("https://pypi.org/pypi/ctxsync/json") as response:
64
+ data = json.loads(response.read())
65
+ latest_version = data["info"]["version"]
66
+
67
+ if current_version == latest_version:
68
+ click.echo(
69
+ f"You are already on the latest version of ctxsync (v{current_version})."
70
+ )
71
+ return
72
+ except Exception as e:
73
+ click.echo(f"Unable to check for the latest version: {str(e)}")
74
+ click.echo("Proceeding with the upgrade process.")
75
+
76
+ # Upgrade ctxsync
77
+ click.echo(f"Upgrading ctxsync from v{current_version} to v{latest_version}...")
78
+ try:
79
+ subprocess.run(["pip", "install", "--upgrade", "ctxsync"], check=True)
80
+ click.echo("ctxsync has been successfully upgraded.")
81
+ except subprocess.CalledProcessError:
82
+ click.echo(
83
+ "Failed to upgrade ctxsync. Please try manually: pip install --upgrade ctxsync"
84
+ )
85
+
86
+ # Inform user about the upgrade process
87
+ click.echo("\nUpgrade process completed:")
88
+ click.echo(
89
+ f"1. ctxsync has been upgraded from v{current_version} to v{latest_version}."
90
+ )
91
+ click.echo("2. Your session key has been preserved (if it existed and was valid).")
92
+ click.echo(
93
+ "\nPlease run 'ctxsync auth login' to complete your configuration setup if needed."
94
+ )
95
+
96
+
97
+ @cli.command()
98
+ @click.option("--category", help="Specify the file category to sync")
99
+ @click.option(
100
+ "--uberproject", is_flag=True, help="Include submodules in the parent project sync"
101
+ )
102
+ @click.option(
103
+ "--dryrun", is_flag=True, default=False, help="Just show what files would be sent"
104
+ )
105
+ @click.pass_obj
106
+ @handle_errors
107
+ def push(config, category, uberproject, dryrun):
108
+ """Synchronize the project files, optionally including submodules in the parent project."""
109
+ provider = validate_and_get_provider(config, require_project=True)
110
+
111
+ if not category:
112
+ category = config.get_default_category()
113
+ if category:
114
+ click.echo(f"Using default category: {category}")
115
+
116
+ active_organization_id = config.get("active_organization_id")
117
+ active_project_id = config.get("active_project_id")
118
+ active_project_name = config.get("active_project_name")
119
+ local_path = config.get_local_path()
120
+
121
+ if not local_path:
122
+ click.echo(
123
+ "No .ctxsync directory found in this directory or any parent directories. "
124
+ "Please run 'ctxsync project create' or 'ctxsync project set' first."
125
+ )
126
+ return
127
+
128
+ # Detect if we're in a submodule
129
+ current_dir = Path.cwd()
130
+ submodules = config.get("submodules", [])
131
+ current_submodule = next(
132
+ (
133
+ sm
134
+ for sm in submodules
135
+ if Path(local_path) / sm["relative_path"] == current_dir
136
+ ),
137
+ None,
138
+ )
139
+
140
+ if current_submodule:
141
+ # We're in a submodule, so only sync this submodule
142
+ click.echo(
143
+ f"Syncing submodule {current_submodule['active_project_name']} [{current_dir}]"
144
+ )
145
+ sync_submodule(provider, config, current_submodule, category)
146
+ else:
147
+ # Sync main project
148
+ sync_manager = SyncManager(provider, config, config.get_local_path())
149
+ remote_files = provider.list_files(active_organization_id, active_project_id)
150
+
151
+ if uberproject:
152
+ # Include submodule files in the parent project
153
+ local_files = get_local_files(
154
+ config, local_path, category, include_submodules=True
155
+ )
156
+ else:
157
+ # Exclude submodule files from the parent project
158
+ local_files = get_local_files(
159
+ config, local_path, category, include_submodules=False
160
+ )
161
+
162
+ if dryrun:
163
+ for file in local_files.keys():
164
+ click.echo(f"Would send file: {file}")
165
+ click.echo("Not sending files due to dry run mode.")
166
+ return
167
+
168
+ sync_manager.sync(local_files, remote_files)
169
+ click.echo(
170
+ f"Main project '{active_project_name}' synced successfully: https://claude.ai/project/{active_project_id}"
171
+ )
172
+
173
+ # Always sync submodules to their respective projects
174
+ for submodule in submodules:
175
+ sync_submodule(provider, config, submodule, category)
176
+
177
+
178
+ def sync_submodule(provider, config, submodule, category):
179
+ submodule_path = Path(config.get_local_path()) / submodule["relative_path"]
180
+ submodule_files = get_local_files(config, str(submodule_path), category)
181
+ remote_submodule_files = provider.list_files(
182
+ submodule["active_organization_id"], submodule["active_project_id"]
183
+ )
184
+
185
+ # Create a new ConfigManager instance for the submodule
186
+ submodule_config = InMemoryConfigManager()
187
+ submodule_config.load_from_file_config(config)
188
+ submodule_config.set(
189
+ "active_project_id", submodule["active_project_id"], local=True
190
+ )
191
+ submodule_config.set(
192
+ "active_project_name", submodule["active_project_name"], local=True
193
+ )
194
+
195
+ # Create a new SyncManager for the submodule
196
+ submodule_sync_manager = SyncManager(
197
+ provider, submodule_config, str(submodule_path)
198
+ )
199
+
200
+ submodule_sync_manager.sync(submodule_files, remote_submodule_files)
201
+ click.echo(
202
+ f"Submodule '{submodule['active_project_name']}' synced successfully: "
203
+ f"https://claude.ai/project/{submodule['active_project_id']}"
204
+ )
205
+
206
+
207
+ @cli.command()
208
+ @click.option("--category", help="Specify the file category to sync")
209
+ @click.option(
210
+ "--uberproject", is_flag=True, help="Include submodules in the parent project sync"
211
+ )
212
+ @click.pass_obj
213
+ @handle_errors
214
+ def embedding(config, category, uberproject):
215
+ """Generate a text embedding from the project. Does not require"""
216
+ if not category:
217
+ category = config.get_default_category()
218
+ if category:
219
+ click.echo(f"Using default category: {category}")
220
+
221
+ local_path = config.get_local_path()
222
+
223
+ if not local_path:
224
+ click.echo(
225
+ "No .ctxsync directory found in this directory or any parent directories. "
226
+ "Please run 'ctxsync project create' or 'ctxsync project set' first."
227
+ )
228
+ return
229
+
230
+ # Sync main project
231
+ sync_manager = SyncManager(None, config, config.get_local_path())
232
+
233
+ if uberproject:
234
+ # Include submodule files in the parent project
235
+ local_files = get_local_files(
236
+ config, local_path, category, include_submodules=True
237
+ )
238
+ else:
239
+ # Exclude submodule files from the parent project
240
+ local_files = get_local_files(
241
+ config, local_path, category, include_submodules=False
242
+ )
243
+
244
+ output = sync_manager.embedding(local_files)
245
+ click.echo(f"{output}")
246
+
247
+
248
+ cli.add_command(auth)
249
+ cli.add_command(organization)
250
+ cli.add_command(project)
251
+ cli.add_command(schedule)
252
+ cli.add_command(config)
253
+ cli.add_command(chat)
254
+ cli.add_command(session)
255
+
256
+ if __name__ == "__main__":
257
+ cli()
@@ -0,0 +1,98 @@
1
+ import click
2
+ from ..utils import handle_errors, validate_and_get_provider
3
+
4
+
5
+ @click.group()
6
+ def organization():
7
+ """Manage AI organizations."""
8
+ pass
9
+
10
+
11
+ @organization.command()
12
+ @click.pass_obj
13
+ @handle_errors
14
+ def ls(config):
15
+ """List all available organizations with required capabilities."""
16
+ provider = validate_and_get_provider(config, require_org=False)
17
+ organizations = provider.get_organizations()
18
+ if not organizations:
19
+ click.echo(
20
+ "No organizations with required capabilities (chat and claude_pro) found."
21
+ )
22
+ else:
23
+ click.echo("Available organizations with required capabilities:")
24
+ for idx, org in enumerate(organizations, 1):
25
+ click.echo(f" {idx}. {org['name']} (ID: {org['id']})")
26
+
27
+
28
+ @organization.command()
29
+ @click.option("--org-id", help="ID of the organization to set as active")
30
+ @click.option(
31
+ "--provider",
32
+ type=click.Choice(["claude.ai"]), # Add more providers as they become available
33
+ default="claude.ai",
34
+ help="Specify the provider for repositories without .ctxsync",
35
+ )
36
+ @click.pass_context
37
+ @handle_errors
38
+ def set(ctx, org_id, provider):
39
+ """Set the active organization."""
40
+ config = ctx.obj
41
+
42
+ # If provider is not specified, try to get it from the config
43
+ if not provider:
44
+ provider = config.get("active_provider")
45
+
46
+ # If provider is still not available, prompt the user
47
+ if not provider:
48
+ provider = click.prompt(
49
+ "Please specify the provider",
50
+ type=click.Choice(
51
+ ["claude.ai"]
52
+ ), # Add more providers as they become available
53
+ )
54
+
55
+ # Update the config with the provider
56
+ config.set("active_provider", provider, local=True)
57
+
58
+ # Now we can get the provider instance
59
+ provider_instance = validate_and_get_provider(config, require_org=False)
60
+ organizations = provider_instance.get_organizations()
61
+
62
+ if not organizations:
63
+ click.echo("No organizations with required capabilities found.")
64
+ return
65
+
66
+ if org_id:
67
+ selected_org = next((org for org in organizations if org["id"] == org_id), None)
68
+ if selected_org:
69
+ config.set("active_organization_id", selected_org["id"], local=True)
70
+ click.echo(
71
+ f"Selected organization: {selected_org['name']} (ID: {selected_org['id']})"
72
+ )
73
+ else:
74
+ click.echo(f"Organization with ID {org_id} not found.")
75
+ else:
76
+ click.echo("Available organizations:")
77
+ for idx, org in enumerate(organizations, 1):
78
+ click.echo(f" {idx}. {org['name']} (ID: {org['id']})")
79
+ selection = click.prompt(
80
+ "Enter the number of the organization you want to work with",
81
+ type=int,
82
+ default=1,
83
+ )
84
+ if 1 <= selection <= len(organizations):
85
+ selected_org = organizations[selection - 1]
86
+ config.set("active_organization_id", selected_org["id"], local=True)
87
+ click.echo(
88
+ f"Selected organization: {selected_org['name']} (ID: {selected_org['id']})"
89
+ )
90
+ else:
91
+ click.echo("Invalid selection. Please try again.")
92
+
93
+ # Clear project-related settings when changing organization
94
+ config.set("active_project_id", None, local=True)
95
+ config.set("active_project_name", None, local=True)
96
+ click.echo(
97
+ "Project settings cleared. Please select or create a new project for this organization."
98
+ )