claudesync 0.2.8__tar.gz → 0.2.9__tar.gz

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.
Files changed (36) hide show
  1. {claudesync-0.2.8/src/claudesync.egg-info → claudesync-0.2.9}/PKG-INFO +8 -3
  2. {claudesync-0.2.8 → claudesync-0.2.9}/README.md +1 -1
  3. {claudesync-0.2.8 → claudesync-0.2.9}/pyproject.toml +15 -4
  4. {claudesync-0.2.8 → claudesync-0.2.9}/setup.py +1 -1
  5. claudesync-0.2.9/src/claudesync/cli/__init__.py +3 -0
  6. claudesync-0.2.9/src/claudesync/cli/auth.py +34 -0
  7. claudesync-0.2.9/src/claudesync/cli/main.py +60 -0
  8. claudesync-0.2.9/src/claudesync/cli/organization.py +47 -0
  9. claudesync-0.2.9/src/claudesync/cli/project.py +122 -0
  10. claudesync-0.2.9/src/claudesync/cli/sync.py +120 -0
  11. {claudesync-0.2.8 → claudesync-0.2.9}/src/claudesync/config_manager.py +9 -9
  12. {claudesync-0.2.8 → claudesync-0.2.9}/src/claudesync/exceptions.py +3 -2
  13. {claudesync-0.2.8 → claudesync-0.2.9}/src/claudesync/provider_factory.py +4 -2
  14. {claudesync-0.2.8 → claudesync-0.2.9}/src/claudesync/providers/claude_ai.py +55 -43
  15. claudesync-0.2.9/src/claudesync/utils.py +122 -0
  16. {claudesync-0.2.8 → claudesync-0.2.9/src/claudesync.egg-info}/PKG-INFO +8 -3
  17. {claudesync-0.2.8 → claudesync-0.2.9}/src/claudesync.egg-info/SOURCES.txt +11 -2
  18. claudesync-0.2.9/src/claudesync.egg-info/entry_points.txt +2 -0
  19. claudesync-0.2.9/src/claudesync.egg-info/requires.txt +8 -0
  20. claudesync-0.2.9/tests/test_auth.py +29 -0
  21. claudesync-0.2.9/tests/test_main.py +29 -0
  22. claudesync-0.2.9/tests/test_organization.py +81 -0
  23. claudesync-0.2.9/tests/test_project.py +128 -0
  24. claudesync-0.2.9/tests/test_sync.py +147 -0
  25. {claudesync-0.2.8 → claudesync-0.2.9}/tests/test_utils.py +22 -17
  26. claudesync-0.2.8/src/claudesync/cli.py +0 -357
  27. claudesync-0.2.8/src/claudesync/utils.py +0 -57
  28. claudesync-0.2.8/src/claudesync.egg-info/entry_points.txt +0 -2
  29. claudesync-0.2.8/src/claudesync.egg-info/requires.txt +0 -3
  30. claudesync-0.2.8/tests/test_cli.py +0 -56
  31. {claudesync-0.2.8 → claudesync-0.2.9}/LICENSE +0 -0
  32. {claudesync-0.2.8 → claudesync-0.2.9}/setup.cfg +0 -0
  33. {claudesync-0.2.8 → claudesync-0.2.9}/src/claudesync/__init__.py +0 -0
  34. {claudesync-0.2.8 → claudesync-0.2.9}/src/claudesync/providers/__init__.py +0 -0
  35. {claudesync-0.2.8 → claudesync-0.2.9}/src/claudesync.egg-info/dependency_links.txt +0 -0
  36. {claudesync-0.2.8 → claudesync-0.2.9}/src/claudesync.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: claudesync
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: A tool to synchronize local files with Claude.ai projects
5
5
  Author-email: Jahziah Wagner <jahziah.wagner+pypi@gmail.com>
6
6
  License: MIT License
@@ -35,7 +35,12 @@ Description-Content-Type: text/markdown
35
35
  License-File: LICENSE
36
36
  Requires-Dist: Click
37
37
  Requires-Dist: requests
38
- Requires-Dist: watchdog
38
+ Requires-Dist: pathspec
39
+ Requires-Dist: crontab
40
+ Requires-Dist: setuptools
41
+ Requires-Dist: pytest
42
+ Requires-Dist: pytest-cov
43
+ Requires-Dist: click_completion
39
44
 
40
45
  ```
41
46
  .oooooo. oooo .o8 .oooooo..o
@@ -105,7 +110,7 @@ ClaudeSync bridges the gap between your local development environment and Claude
105
110
  ## Advanced Usage
106
111
 
107
112
  ### Organization Management
108
- - List organizations: `claudesync organization list`
113
+ - List organizations: `claudesync organization ls`
109
114
  - Select active organization: `claudesync organization select`
110
115
 
111
116
  ### Project Management
@@ -66,7 +66,7 @@ ClaudeSync bridges the gap between your local development environment and Claude
66
66
  ## Advanced Usage
67
67
 
68
68
  ### Organization Management
69
- - List organizations: `claudesync organization list`
69
+ - List organizations: `claudesync organization ls`
70
70
  - Select active organization: `claudesync organization select`
71
71
 
72
72
  ### Project Management
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "claudesync"
7
- version = "0.2.8"
7
+ version = "0.2.9"
8
8
  authors = [
9
9
  {name = "Jahziah Wagner", email = "jahziah.wagner+pypi@gmail.com"},
10
10
  ]
@@ -20,7 +20,12 @@ classifiers = [
20
20
  dependencies = [
21
21
  "Click",
22
22
  "requests",
23
- "watchdog",
23
+ "pathspec",
24
+ "crontab",
25
+ "setuptools",
26
+ "pytest",
27
+ "pytest-cov",
28
+ "click_completion",
24
29
  ]
25
30
 
26
31
  [project.urls]
@@ -28,8 +33,14 @@ dependencies = [
28
33
  "Bug Tracker" = "https://github.com/jahwag/claudesync/issues"
29
34
 
30
35
  [project.scripts]
31
- claudesync = "claudesync.cli:cli"
36
+ claudesync = "claudesync.cli.main:cli"
32
37
 
33
38
  [tool.setuptools.packages.find]
34
39
  where = ["src"]
35
- include = ["claudesync*"]
40
+ include = ["claudesync*"]
41
+
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+ python_files = "test_*.py"
46
+ addopts = "-v --cov=claudesync --cov-report=term-missing"
@@ -3,4 +3,4 @@ from setuptools import setup, find_packages
3
3
  setup(
4
4
  packages=find_packages(where="src"),
5
5
  package_dir={"": "src"},
6
- )
6
+ )
@@ -0,0 +1,3 @@
1
+ from .main import cli
2
+
3
+ __all__ = ["cli"]
@@ -0,0 +1,34 @@
1
+ import click
2
+ from claudesync.provider_factory import get_provider
3
+ from ..utils import handle_errors
4
+
5
+
6
+ @click.command()
7
+ @click.argument("provider", required=False)
8
+ @click.pass_obj
9
+ @handle_errors
10
+ def login(config, provider):
11
+ """Authenticate with an AI provider."""
12
+ providers = get_provider()
13
+ if not provider:
14
+ click.echo("Available providers:\n" + "\n".join(f" - {p}" for p in providers))
15
+ return
16
+ if provider not in providers:
17
+ click.echo(
18
+ f"Error: Unknown provider '{provider}'. Available: {', '.join(providers)}"
19
+ )
20
+ return
21
+ provider_instance = get_provider(provider)
22
+ session_key = provider_instance.login()
23
+ config.set("session_key", session_key)
24
+ config.set("active_provider", provider)
25
+ click.echo("Logged in successfully.")
26
+
27
+
28
+ @click.command()
29
+ @click.pass_obj
30
+ def logout(config):
31
+ """Log out from the current AI provider."""
32
+ for key in ["session_key", "active_provider", "active_organization_id"]:
33
+ config.set(key, None)
34
+ click.echo("Logged out successfully.")
@@ -0,0 +1,60 @@
1
+ import click
2
+ from claudesync.config_manager import ConfigManager
3
+ import click_completion
4
+ import click_completion.core
5
+
6
+ # Import commands from other CLI files
7
+ from .auth import login, logout
8
+ from .organization import organization
9
+ from .project import project
10
+ from .sync import ls, sync, schedule
11
+
12
+ click_completion.init()
13
+
14
+
15
+ @click.group()
16
+ @click.pass_context
17
+ def cli(ctx):
18
+ """ClaudeSync: Synchronize local files with ai projects."""
19
+ ctx.obj = ConfigManager()
20
+
21
+
22
+ @cli.command()
23
+ @click.argument(
24
+ "shell", required=False, type=click.Choice(["bash", "zsh", "fish", "powershell"])
25
+ )
26
+ def install_completion(shell):
27
+ """Install completion for the specified shell."""
28
+ if shell is None:
29
+ shell = click_completion.get_auto_shell()
30
+ click.echo("Shell is set to '%s'" % shell)
31
+ click_completion.install(shell=shell)
32
+ click.echo("Completion installed.")
33
+
34
+
35
+ @cli.command()
36
+ @click.pass_obj
37
+ def status(config):
38
+ """Display current configuration status."""
39
+ for key in [
40
+ "active_provider",
41
+ "active_organization_id",
42
+ "active_project_id",
43
+ "active_project_name",
44
+ "local_path",
45
+ "log_level",
46
+ ]:
47
+ value = config.get(key)
48
+ click.echo(f"{key.replace('_', ' ').capitalize()}: {value or 'Not set'}")
49
+
50
+
51
+ cli.add_command(login)
52
+ cli.add_command(logout)
53
+ cli.add_command(organization)
54
+ cli.add_command(project)
55
+ cli.add_command(ls)
56
+ cli.add_command(sync)
57
+ cli.add_command(schedule)
58
+
59
+ if __name__ == "__main__":
60
+ cli()
@@ -0,0 +1,47 @@
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."""
16
+ provider = validate_and_get_provider(config, require_org=False)
17
+ organizations = provider.get_organizations()
18
+ if not organizations:
19
+ click.echo("No organizations found.")
20
+ else:
21
+ click.echo("Available organizations:")
22
+ for idx, org in enumerate(organizations, 1):
23
+ click.echo(f" {idx}. {org['name']} (ID: {org['id']})")
24
+
25
+
26
+ @organization.command()
27
+ @click.pass_obj
28
+ @handle_errors
29
+ def select(config):
30
+ """Set the active organization."""
31
+ provider = validate_and_get_provider(config, require_org=False)
32
+ organizations = provider.get_organizations()
33
+ if not organizations:
34
+ click.echo("No organizations found.")
35
+ return
36
+ click.echo("Available organizations:")
37
+ for idx, org in enumerate(organizations, 1):
38
+ click.echo(f" {idx}. {org['name']} (ID: {org['id']})")
39
+ selection = click.prompt("Enter the number of the organization to select", type=int)
40
+ if 1 <= selection <= len(organizations):
41
+ selected_org = organizations[selection - 1]
42
+ config.set("active_organization_id", selected_org["id"])
43
+ click.echo(
44
+ f"Selected organization: {selected_org['name']} (ID: {selected_org['id']})"
45
+ )
46
+ else:
47
+ click.echo("Invalid selection. Please try again.")
@@ -0,0 +1,122 @@
1
+ import click
2
+ from claudesync.exceptions import ProviderError
3
+ from ..utils import (
4
+ handle_errors,
5
+ validate_and_get_provider,
6
+ validate_and_store_local_path,
7
+ )
8
+
9
+
10
+ @click.group()
11
+ def project():
12
+ """Manage ai projects within the active organization."""
13
+ pass
14
+
15
+
16
+ @project.command()
17
+ @click.pass_obj
18
+ @handle_errors
19
+ def create(config):
20
+ """Create a new project in the active organization."""
21
+ provider = validate_and_get_provider(config)
22
+ active_organization_id = config.get("active_organization_id")
23
+
24
+ title = click.prompt("Enter the project title")
25
+ description = click.prompt("Enter the project description (optional)", default="")
26
+
27
+ try:
28
+ new_project = provider.create_project(
29
+ active_organization_id, title, description
30
+ )
31
+ click.echo(
32
+ f"Project '{new_project['name']}' (uuid: {new_project['uuid']}) has been created successfully."
33
+ )
34
+
35
+ config.set("active_project_id", new_project["uuid"])
36
+ config.set("active_project_name", new_project["name"])
37
+ click.echo(
38
+ f"Active project set to: {new_project['name']} (uuid: {new_project['uuid']})"
39
+ )
40
+
41
+ validate_and_store_local_path(config)
42
+
43
+ except ProviderError as e:
44
+ click.echo(f"Failed to create project: {str(e)}")
45
+
46
+
47
+ @project.command()
48
+ @click.pass_obj
49
+ @handle_errors
50
+ def archive(config):
51
+ """Archive an existing project."""
52
+ provider = validate_and_get_provider(config)
53
+ active_organization_id = config.get("active_organization_id")
54
+ projects = provider.get_projects(active_organization_id, include_archived=False)
55
+ if not projects:
56
+ click.echo("No active projects found.")
57
+ return
58
+ click.echo("Available projects to archive:")
59
+ for idx, project in enumerate(projects, 1):
60
+ click.echo(f" {idx}. {project['name']} (ID: {project['id']})")
61
+ selection = click.prompt("Enter the number of the project to archive", type=int)
62
+ if 1 <= selection <= len(projects):
63
+ selected_project = projects[selection - 1]
64
+ if click.confirm(
65
+ f"Are you sure you want to archive '{selected_project['name']}'?"
66
+ ):
67
+ provider.archive_project(active_organization_id, selected_project["id"])
68
+ click.echo(f"Project '{selected_project['name']}' has been archived.")
69
+ else:
70
+ click.echo("Invalid selection. Please try again.")
71
+
72
+
73
+ @project.command()
74
+ @click.pass_obj
75
+ @handle_errors
76
+ def select(config):
77
+ """Set the active project for syncing."""
78
+ provider = validate_and_get_provider(config)
79
+ active_organization_id = config.get("active_organization_id")
80
+ projects = provider.get_projects(active_organization_id, include_archived=False)
81
+ if not projects:
82
+ click.echo("No active projects found.")
83
+ return
84
+ click.echo("Available projects:")
85
+ for idx, project in enumerate(projects, 1):
86
+ click.echo(f" {idx}. {project['name']} (ID: {project['id']})")
87
+ selection = click.prompt("Enter the number of the project to select", type=int)
88
+ if 1 <= selection <= len(projects):
89
+ selected_project = projects[selection - 1]
90
+ config.set("active_project_id", selected_project["id"])
91
+ config.set("active_project_name", selected_project["name"])
92
+ click.echo(
93
+ f"Selected project: {selected_project['name']} (ID: {selected_project['id']})"
94
+ )
95
+
96
+ validate_and_store_local_path(config)
97
+ else:
98
+ click.echo("Invalid selection. Please try again.")
99
+
100
+
101
+ @project.command()
102
+ @click.option(
103
+ "-a",
104
+ "--all",
105
+ "show_all",
106
+ is_flag=True,
107
+ help="Include archived projects in the list",
108
+ )
109
+ @click.pass_obj
110
+ @handle_errors
111
+ def ls(config, show_all):
112
+ """List all projects in the active organization."""
113
+ provider = validate_and_get_provider(config)
114
+ active_organization_id = config.get("active_organization_id")
115
+ projects = provider.get_projects(active_organization_id, include_archived=show_all)
116
+ if not projects:
117
+ click.echo("No projects found.")
118
+ else:
119
+ click.echo("Remote projects:")
120
+ for project in projects:
121
+ status = " (Archived)" if project.get("archived_at") else ""
122
+ click.echo(f" - {project['name']} (ID: {project['id']}){status}")
@@ -0,0 +1,120 @@
1
+ import click
2
+ import sys
3
+ import os
4
+ import shutil
5
+ from crontab import CronTab
6
+ from claudesync.utils import calculate_checksum, get_local_files
7
+ from ..utils import handle_errors, validate_and_get_provider
8
+
9
+
10
+ @click.command()
11
+ @click.pass_obj
12
+ @handle_errors
13
+ def ls(config):
14
+ """List files in the active remote project."""
15
+ provider = validate_and_get_provider(config)
16
+ active_organization_id = config.get("active_organization_id")
17
+ active_project_id = config.get("active_project_id")
18
+ files = provider.list_files(active_organization_id, active_project_id)
19
+ if not files:
20
+ click.echo("No files found in the active project.")
21
+ else:
22
+ click.echo(
23
+ f"Files in project '{config.get('active_project_name')}' (ID: {active_project_id}):"
24
+ )
25
+ for file in files:
26
+ click.echo(
27
+ f" - {file['file_name']} (ID: {file['uuid']}, Created: {file['created_at']})"
28
+ )
29
+
30
+
31
+ @click.command()
32
+ @click.pass_obj
33
+ @handle_errors
34
+ def sync(config):
35
+ """Synchronize local files with the active remote project."""
36
+ provider = validate_and_get_provider(config)
37
+ active_organization_id = config.get("active_organization_id")
38
+ active_project_id = config.get("active_project_id")
39
+ local_path = config.get("local_path")
40
+
41
+ if not local_path:
42
+ click.echo(
43
+ "No local path set. Please select or create a project to set the local path."
44
+ )
45
+ sys.exit(1)
46
+
47
+ if not os.path.exists(local_path):
48
+ click.echo(f"The configured local path does not exist: {local_path}")
49
+ click.echo("Please update the local path by selecting or creating a project.")
50
+ sys.exit(1)
51
+
52
+ remote_files = provider.list_files(active_organization_id, active_project_id)
53
+ local_files = get_local_files(local_path)
54
+
55
+ for local_file, local_checksum in local_files.items():
56
+ remote_file = next(
57
+ (rf for rf in remote_files if rf["file_name"] == local_file), None
58
+ )
59
+ if remote_file:
60
+ remote_checksum = calculate_checksum(remote_file["content"])
61
+ if local_checksum != remote_checksum:
62
+ click.echo(f"Updating {local_file} on remote...")
63
+ for rf in remote_files:
64
+ if rf["file_name"] == local_file:
65
+ provider.delete_file(
66
+ active_organization_id, active_project_id, rf["uuid"]
67
+ )
68
+ with open(
69
+ os.path.join(local_path, local_file), "r", encoding="utf-8"
70
+ ) as file:
71
+ content = file.read()
72
+ provider.upload_file(
73
+ active_organization_id, active_project_id, local_file, content
74
+ )
75
+ else:
76
+ click.echo(f"Uploading new file {local_file} to remote...")
77
+ with open(
78
+ os.path.join(local_path, local_file), "r", encoding="utf-8"
79
+ ) as file:
80
+ content = file.read()
81
+ provider.upload_file(
82
+ active_organization_id, active_project_id, local_file, content
83
+ )
84
+
85
+ click.echo("Sync completed successfully.")
86
+
87
+
88
+ @click.command()
89
+ @click.pass_obj
90
+ @click.option(
91
+ "--interval", type=int, default=5, prompt="Enter sync interval in minutes"
92
+ )
93
+ @handle_errors
94
+ def schedule(config, interval):
95
+ """Set up automated synchronization at regular intervals."""
96
+ claudesync_path = shutil.which("claudesync")
97
+ if not claudesync_path:
98
+ click.echo(
99
+ "Error: claudesync not found in PATH. Please ensure it's installed correctly."
100
+ )
101
+ sys.exit(1)
102
+
103
+ if sys.platform.startswith("win"):
104
+ click.echo("Windows Task Scheduler setup:")
105
+ command = f'schtasks /create /tn "ClaudeSync" /tr "{claudesync_path} sync" /sc minute /mo {interval}'
106
+ click.echo(f"Run this command to create the task:\n{command}")
107
+ click.echo('\nTo remove the task, run: schtasks /delete /tn "ClaudeSync" /f')
108
+ else:
109
+ # Unix-like systems (Linux, macOS)
110
+ cron = CronTab(user=True)
111
+ job = cron.new(command=f"{claudesync_path} sync")
112
+ job.minute.every(interval)
113
+
114
+ cron.write()
115
+ click.echo(
116
+ f"Cron job created successfully! It will run every {interval} minutes."
117
+ )
118
+ click.echo(
119
+ "\nTo remove the cron job, run: crontab -e and remove the line for ClaudeSync"
120
+ )
@@ -1,25 +1,25 @@
1
1
  import json
2
- import os
3
2
  from pathlib import Path
4
3
 
4
+
5
5
  class ConfigManager:
6
6
  def __init__(self):
7
- self.config_dir = Path.home() / '.claudesync'
8
- self.config_file = self.config_dir / 'config.json'
7
+ self.config_dir = Path.home() / ".claudesync"
8
+ self.config_file = self.config_dir / "config.json"
9
9
  self.config = self._load_config()
10
10
 
11
11
  def _load_config(self):
12
12
  if not self.config_file.exists():
13
13
  self.config_dir.mkdir(parents=True, exist_ok=True)
14
- return {'log_level': 'INFO'} # Default log level
15
- with open(self.config_file, 'r') as f:
14
+ return {"log_level": "INFO"} # Default log level
15
+ with open(self.config_file, "r") as f:
16
16
  config = json.load(f)
17
- if 'log_level' not in config:
18
- config['log_level'] = 'INFO' # Set default if not present
17
+ if "log_level" not in config:
18
+ config["log_level"] = "INFO" # Set default if not present
19
19
  return config
20
20
 
21
21
  def _save_config(self):
22
- with open(self.config_file, 'w') as f:
22
+ with open(self.config_file, "w") as f:
23
23
  json.dump(self.config, f, indent=2)
24
24
 
25
25
  def get(self, key, default=None):
@@ -27,4 +27,4 @@ class ConfigManager:
27
27
 
28
28
  def set(self, key, value):
29
29
  self.config[key] = value
30
- self._save_config()
30
+ self._save_config()
@@ -1,9 +1,10 @@
1
-
2
1
  class ConfigurationError(Exception):
3
2
  """Raised when there's an issue with the configuration."""
3
+
4
4
  pass
5
5
 
6
+
6
7
  class ProviderError(Exception):
7
8
  """Raised when there's an issue with a provider operation."""
8
- pass
9
9
 
10
+ pass
@@ -1,9 +1,11 @@
1
1
  from .providers.claude_ai import ClaudeAIProvider
2
+
2
3
  # Import other providers here as they are added
3
4
 
5
+
4
6
  def get_provider(provider_name=None, session_key=None):
5
7
  providers = {
6
- 'claude.ai': ClaudeAIProvider,
8
+ "claude.ai": ClaudeAIProvider,
7
9
  # Add other providers here as they are implemented
8
10
  }
9
11
 
@@ -14,4 +16,4 @@ def get_provider(provider_name=None, session_key=None):
14
16
  if provider_class is None:
15
17
  raise ValueError(f"Unsupported provider: {provider_name}")
16
18
 
17
- return provider_class(session_key) if session_key else provider_class()
19
+ return provider_class(session_key) if session_key else provider_class()