basic-memory 0.2.12__py3-none-any.whl → 0.16.1__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.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (149) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +27 -3
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  6. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  7. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  8. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  9. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  10. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  11. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
  12. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  13. basic_memory/api/app.py +63 -31
  14. basic_memory/api/routers/__init__.py +4 -1
  15. basic_memory/api/routers/directory_router.py +84 -0
  16. basic_memory/api/routers/importer_router.py +152 -0
  17. basic_memory/api/routers/knowledge_router.py +165 -28
  18. basic_memory/api/routers/management_router.py +80 -0
  19. basic_memory/api/routers/memory_router.py +28 -67
  20. basic_memory/api/routers/project_router.py +406 -0
  21. basic_memory/api/routers/prompt_router.py +260 -0
  22. basic_memory/api/routers/resource_router.py +219 -14
  23. basic_memory/api/routers/search_router.py +21 -13
  24. basic_memory/api/routers/utils.py +130 -0
  25. basic_memory/api/template_loader.py +292 -0
  26. basic_memory/cli/app.py +52 -1
  27. basic_memory/cli/auth.py +277 -0
  28. basic_memory/cli/commands/__init__.py +13 -2
  29. basic_memory/cli/commands/cloud/__init__.py +6 -0
  30. basic_memory/cli/commands/cloud/api_client.py +112 -0
  31. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  32. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  33. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  34. basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
  35. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  36. basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
  37. basic_memory/cli/commands/cloud/upload.py +233 -0
  38. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  39. basic_memory/cli/commands/command_utils.py +51 -0
  40. basic_memory/cli/commands/db.py +26 -7
  41. basic_memory/cli/commands/import_chatgpt.py +83 -0
  42. basic_memory/cli/commands/import_claude_conversations.py +86 -0
  43. basic_memory/cli/commands/import_claude_projects.py +85 -0
  44. basic_memory/cli/commands/import_memory_json.py +35 -92
  45. basic_memory/cli/commands/mcp.py +84 -10
  46. basic_memory/cli/commands/project.py +876 -0
  47. basic_memory/cli/commands/status.py +47 -30
  48. basic_memory/cli/commands/tool.py +341 -0
  49. basic_memory/cli/main.py +13 -6
  50. basic_memory/config.py +481 -22
  51. basic_memory/db.py +192 -32
  52. basic_memory/deps.py +252 -22
  53. basic_memory/file_utils.py +113 -58
  54. basic_memory/ignore_utils.py +297 -0
  55. basic_memory/importers/__init__.py +27 -0
  56. basic_memory/importers/base.py +79 -0
  57. basic_memory/importers/chatgpt_importer.py +232 -0
  58. basic_memory/importers/claude_conversations_importer.py +177 -0
  59. basic_memory/importers/claude_projects_importer.py +148 -0
  60. basic_memory/importers/memory_json_importer.py +108 -0
  61. basic_memory/importers/utils.py +58 -0
  62. basic_memory/markdown/entity_parser.py +143 -23
  63. basic_memory/markdown/markdown_processor.py +3 -3
  64. basic_memory/markdown/plugins.py +39 -21
  65. basic_memory/markdown/schemas.py +1 -1
  66. basic_memory/markdown/utils.py +28 -13
  67. basic_memory/mcp/async_client.py +134 -4
  68. basic_memory/mcp/project_context.py +141 -0
  69. basic_memory/mcp/prompts/__init__.py +19 -0
  70. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  71. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  72. basic_memory/mcp/prompts/recent_activity.py +188 -0
  73. basic_memory/mcp/prompts/search.py +57 -0
  74. basic_memory/mcp/prompts/utils.py +162 -0
  75. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  76. basic_memory/mcp/resources/project_info.py +71 -0
  77. basic_memory/mcp/server.py +7 -13
  78. basic_memory/mcp/tools/__init__.py +33 -21
  79. basic_memory/mcp/tools/build_context.py +120 -0
  80. basic_memory/mcp/tools/canvas.py +130 -0
  81. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  82. basic_memory/mcp/tools/delete_note.py +225 -0
  83. basic_memory/mcp/tools/edit_note.py +320 -0
  84. basic_memory/mcp/tools/list_directory.py +167 -0
  85. basic_memory/mcp/tools/move_note.py +545 -0
  86. basic_memory/mcp/tools/project_management.py +200 -0
  87. basic_memory/mcp/tools/read_content.py +271 -0
  88. basic_memory/mcp/tools/read_note.py +255 -0
  89. basic_memory/mcp/tools/recent_activity.py +534 -0
  90. basic_memory/mcp/tools/search.py +369 -14
  91. basic_memory/mcp/tools/utils.py +374 -16
  92. basic_memory/mcp/tools/view_note.py +77 -0
  93. basic_memory/mcp/tools/write_note.py +207 -0
  94. basic_memory/models/__init__.py +3 -2
  95. basic_memory/models/knowledge.py +67 -15
  96. basic_memory/models/project.py +87 -0
  97. basic_memory/models/search.py +10 -6
  98. basic_memory/repository/__init__.py +2 -0
  99. basic_memory/repository/entity_repository.py +229 -7
  100. basic_memory/repository/observation_repository.py +35 -3
  101. basic_memory/repository/project_info_repository.py +10 -0
  102. basic_memory/repository/project_repository.py +103 -0
  103. basic_memory/repository/relation_repository.py +21 -2
  104. basic_memory/repository/repository.py +147 -29
  105. basic_memory/repository/search_repository.py +437 -59
  106. basic_memory/schemas/__init__.py +22 -9
  107. basic_memory/schemas/base.py +97 -8
  108. basic_memory/schemas/cloud.py +50 -0
  109. basic_memory/schemas/directory.py +30 -0
  110. basic_memory/schemas/importer.py +35 -0
  111. basic_memory/schemas/memory.py +188 -23
  112. basic_memory/schemas/project_info.py +211 -0
  113. basic_memory/schemas/prompt.py +90 -0
  114. basic_memory/schemas/request.py +57 -3
  115. basic_memory/schemas/response.py +9 -1
  116. basic_memory/schemas/search.py +33 -35
  117. basic_memory/schemas/sync_report.py +72 -0
  118. basic_memory/services/__init__.py +2 -1
  119. basic_memory/services/context_service.py +251 -106
  120. basic_memory/services/directory_service.py +295 -0
  121. basic_memory/services/entity_service.py +595 -60
  122. basic_memory/services/exceptions.py +21 -0
  123. basic_memory/services/file_service.py +284 -30
  124. basic_memory/services/initialization.py +191 -0
  125. basic_memory/services/link_resolver.py +50 -56
  126. basic_memory/services/project_service.py +863 -0
  127. basic_memory/services/search_service.py +172 -34
  128. basic_memory/sync/__init__.py +3 -2
  129. basic_memory/sync/background_sync.py +26 -0
  130. basic_memory/sync/sync_service.py +1176 -96
  131. basic_memory/sync/watch_service.py +412 -135
  132. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  133. basic_memory/templates/prompts/search.hbs +101 -0
  134. basic_memory/utils.py +388 -28
  135. basic_memory-0.16.1.dist-info/METADATA +493 -0
  136. basic_memory-0.16.1.dist-info/RECORD +148 -0
  137. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
  138. basic_memory/alembic/README +0 -1
  139. basic_memory/cli/commands/sync.py +0 -203
  140. basic_memory/mcp/tools/knowledge.py +0 -56
  141. basic_memory/mcp/tools/memory.py +0 -151
  142. basic_memory/mcp/tools/notes.py +0 -122
  143. basic_memory/schemas/discovery.py +0 -28
  144. basic_memory/sync/file_change_scanner.py +0 -158
  145. basic_memory/sync/utils.py +0 -34
  146. basic_memory-0.2.12.dist-info/METADATA +0 -291
  147. basic_memory-0.2.12.dist-info/RECORD +0 -78
  148. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
  149. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,301 @@
1
+ """Project-scoped rclone sync commands for Basic Memory Cloud.
2
+
3
+ This module provides simplified, project-scoped rclone operations:
4
+ - Each project syncs independently
5
+ - Uses single "basic-memory-cloud" remote (not tenant-specific)
6
+ - Balanced defaults from SPEC-8 Phase 4 testing
7
+ - Per-project bisync state tracking
8
+
9
+ Replaces tenant-wide sync with project-scoped workflows.
10
+ """
11
+
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from rich.console import Console
18
+
19
+ from basic_memory.utils import normalize_project_path
20
+
21
+ console = Console()
22
+
23
+
24
+ class RcloneError(Exception):
25
+ """Exception raised for rclone command errors."""
26
+
27
+ pass
28
+
29
+
30
+ @dataclass
31
+ class SyncProject:
32
+ """Project configured for cloud sync.
33
+
34
+ Attributes:
35
+ name: Project name
36
+ path: Cloud path (e.g., "app/data/research")
37
+ local_sync_path: Local directory for syncing (optional)
38
+ """
39
+
40
+ name: str
41
+ path: str
42
+ local_sync_path: Optional[str] = None
43
+
44
+
45
+ def get_bmignore_filter_path() -> Path:
46
+ """Get path to rclone filter file.
47
+
48
+ Uses ~/.basic-memory/.bmignore converted to rclone format.
49
+ File is automatically created with default patterns on first use.
50
+
51
+ Returns:
52
+ Path to rclone filter file
53
+ """
54
+ # Import here to avoid circular dependency
55
+ from basic_memory.cli.commands.cloud.bisync_commands import (
56
+ convert_bmignore_to_rclone_filters,
57
+ )
58
+
59
+ return convert_bmignore_to_rclone_filters()
60
+
61
+
62
+ def get_project_bisync_state(project_name: str) -> Path:
63
+ """Get path to project's bisync state directory.
64
+
65
+ Args:
66
+ project_name: Name of the project
67
+
68
+ Returns:
69
+ Path to bisync state directory for this project
70
+ """
71
+ return Path.home() / ".basic-memory" / "bisync-state" / project_name
72
+
73
+
74
+ def bisync_initialized(project_name: str) -> bool:
75
+ """Check if bisync has been initialized for this project.
76
+
77
+ Args:
78
+ project_name: Name of the project
79
+
80
+ Returns:
81
+ True if bisync state exists, False otherwise
82
+ """
83
+ state_path = get_project_bisync_state(project_name)
84
+ return state_path.exists() and any(state_path.iterdir())
85
+
86
+
87
+ def get_project_remote(project: SyncProject, bucket_name: str) -> str:
88
+ """Build rclone remote path for project.
89
+
90
+ Args:
91
+ project: Project with cloud path
92
+ bucket_name: S3 bucket name
93
+
94
+ Returns:
95
+ Remote path like "basic-memory-cloud:bucket-name/basic-memory-llc"
96
+
97
+ Note:
98
+ The API returns paths like "/app/data/basic-memory-llc" because the S3 bucket
99
+ is mounted at /app/data on the fly machine. We need to strip the /app/data/
100
+ prefix to get the actual S3 path within the bucket.
101
+ """
102
+ # Normalize path to strip /app/data/ mount point prefix
103
+ cloud_path = normalize_project_path(project.path).lstrip("/")
104
+ return f"basic-memory-cloud:{bucket_name}/{cloud_path}"
105
+
106
+
107
+ def project_sync(
108
+ project: SyncProject,
109
+ bucket_name: str,
110
+ dry_run: bool = False,
111
+ verbose: bool = False,
112
+ ) -> bool:
113
+ """One-way sync: local → cloud.
114
+
115
+ Makes cloud identical to local using rclone sync.
116
+
117
+ Args:
118
+ project: Project to sync
119
+ bucket_name: S3 bucket name
120
+ dry_run: Preview changes without applying
121
+ verbose: Show detailed output
122
+
123
+ Returns:
124
+ True if sync succeeded, False otherwise
125
+
126
+ Raises:
127
+ RcloneError: If project has no local_sync_path configured
128
+ """
129
+ if not project.local_sync_path:
130
+ raise RcloneError(f"Project {project.name} has no local_sync_path configured")
131
+
132
+ local_path = Path(project.local_sync_path).expanduser()
133
+ remote_path = get_project_remote(project, bucket_name)
134
+ filter_path = get_bmignore_filter_path()
135
+
136
+ cmd = [
137
+ "rclone",
138
+ "sync",
139
+ str(local_path),
140
+ remote_path,
141
+ "--filter-from",
142
+ str(filter_path),
143
+ ]
144
+
145
+ if verbose:
146
+ cmd.append("--verbose")
147
+ else:
148
+ cmd.append("--progress")
149
+
150
+ if dry_run:
151
+ cmd.append("--dry-run")
152
+
153
+ result = subprocess.run(cmd, text=True)
154
+ return result.returncode == 0
155
+
156
+
157
+ def project_bisync(
158
+ project: SyncProject,
159
+ bucket_name: str,
160
+ dry_run: bool = False,
161
+ resync: bool = False,
162
+ verbose: bool = False,
163
+ ) -> bool:
164
+ """Two-way sync: local ↔ cloud.
165
+
166
+ Uses rclone bisync with balanced defaults:
167
+ - conflict_resolve: newer (auto-resolve to most recent)
168
+ - max_delete: 25 (safety limit)
169
+ - compare: modtime (ignore size differences from line ending conversions)
170
+ - check_access: false (skip for performance)
171
+
172
+ Args:
173
+ project: Project to sync
174
+ bucket_name: S3 bucket name
175
+ dry_run: Preview changes without applying
176
+ resync: Force resync to establish new baseline
177
+ verbose: Show detailed output
178
+
179
+ Returns:
180
+ True if bisync succeeded, False otherwise
181
+
182
+ Raises:
183
+ RcloneError: If project has no local_sync_path or needs --resync
184
+ """
185
+ if not project.local_sync_path:
186
+ raise RcloneError(f"Project {project.name} has no local_sync_path configured")
187
+
188
+ local_path = Path(project.local_sync_path).expanduser()
189
+ remote_path = get_project_remote(project, bucket_name)
190
+ filter_path = get_bmignore_filter_path()
191
+ state_path = get_project_bisync_state(project.name)
192
+
193
+ # Ensure state directory exists
194
+ state_path.mkdir(parents=True, exist_ok=True)
195
+
196
+ cmd = [
197
+ "rclone",
198
+ "bisync",
199
+ str(local_path),
200
+ remote_path,
201
+ "--create-empty-src-dirs",
202
+ "--resilient",
203
+ "--conflict-resolve=newer",
204
+ "--max-delete=25",
205
+ "--compare=modtime", # Ignore size differences from line ending conversions
206
+ "--filter-from",
207
+ str(filter_path),
208
+ "--workdir",
209
+ str(state_path),
210
+ ]
211
+
212
+ if verbose:
213
+ cmd.append("--verbose")
214
+ else:
215
+ cmd.append("--progress")
216
+
217
+ if dry_run:
218
+ cmd.append("--dry-run")
219
+
220
+ if resync:
221
+ cmd.append("--resync")
222
+
223
+ # Check if first run requires resync
224
+ if not resync and not bisync_initialized(project.name) and not dry_run:
225
+ raise RcloneError(
226
+ f"First bisync for {project.name} requires --resync to establish baseline.\n"
227
+ f"Run: bm project bisync --name {project.name} --resync"
228
+ )
229
+
230
+ result = subprocess.run(cmd, text=True)
231
+ return result.returncode == 0
232
+
233
+
234
+ def project_check(
235
+ project: SyncProject,
236
+ bucket_name: str,
237
+ one_way: bool = False,
238
+ ) -> bool:
239
+ """Check integrity between local and cloud.
240
+
241
+ Verifies files match without transferring data.
242
+
243
+ Args:
244
+ project: Project to check
245
+ bucket_name: S3 bucket name
246
+ one_way: Only check for missing files on destination (faster)
247
+
248
+ Returns:
249
+ True if files match, False if differences found
250
+
251
+ Raises:
252
+ RcloneError: If project has no local_sync_path configured
253
+ """
254
+ if not project.local_sync_path:
255
+ raise RcloneError(f"Project {project.name} has no local_sync_path configured")
256
+
257
+ local_path = Path(project.local_sync_path).expanduser()
258
+ remote_path = get_project_remote(project, bucket_name)
259
+ filter_path = get_bmignore_filter_path()
260
+
261
+ cmd = [
262
+ "rclone",
263
+ "check",
264
+ str(local_path),
265
+ remote_path,
266
+ "--filter-from",
267
+ str(filter_path),
268
+ ]
269
+
270
+ if one_way:
271
+ cmd.append("--one-way")
272
+
273
+ result = subprocess.run(cmd, capture_output=True, text=True)
274
+ return result.returncode == 0
275
+
276
+
277
+ def project_ls(
278
+ project: SyncProject,
279
+ bucket_name: str,
280
+ path: Optional[str] = None,
281
+ ) -> list[str]:
282
+ """List files in remote project.
283
+
284
+ Args:
285
+ project: Project to list files from
286
+ bucket_name: S3 bucket name
287
+ path: Optional subdirectory within project
288
+
289
+ Returns:
290
+ List of file paths
291
+
292
+ Raises:
293
+ subprocess.CalledProcessError: If rclone command fails
294
+ """
295
+ remote_path = get_project_remote(project, bucket_name)
296
+ if path:
297
+ remote_path = f"{remote_path}/{path}"
298
+
299
+ cmd = ["rclone", "ls", remote_path]
300
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
301
+ return result.stdout.splitlines()
@@ -0,0 +1,110 @@
1
+ """rclone configuration management for Basic Memory Cloud.
2
+
3
+ This module provides simplified rclone configuration for SPEC-20.
4
+ Uses a single "basic-memory-cloud" remote for all operations.
5
+ """
6
+
7
+ import configparser
8
+ import os
9
+ import shutil
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from rich.console import Console
14
+
15
+ console = Console()
16
+
17
+
18
+ class RcloneConfigError(Exception):
19
+ """Exception raised for rclone configuration errors."""
20
+
21
+ pass
22
+
23
+
24
+ def get_rclone_config_path() -> Path:
25
+ """Get the path to rclone configuration file."""
26
+ config_dir = Path.home() / ".config" / "rclone"
27
+ config_dir.mkdir(parents=True, exist_ok=True)
28
+ return config_dir / "rclone.conf"
29
+
30
+
31
+ def backup_rclone_config() -> Optional[Path]:
32
+ """Create a backup of existing rclone config."""
33
+ config_path = get_rclone_config_path()
34
+ if not config_path.exists():
35
+ return None
36
+
37
+ backup_path = config_path.with_suffix(f".conf.backup-{os.getpid()}")
38
+ shutil.copy2(config_path, backup_path)
39
+ console.print(f"[dim]Created backup: {backup_path}[/dim]")
40
+ return backup_path
41
+
42
+
43
+ def load_rclone_config() -> configparser.ConfigParser:
44
+ """Load existing rclone configuration."""
45
+ config = configparser.ConfigParser()
46
+ config_path = get_rclone_config_path()
47
+
48
+ if config_path.exists():
49
+ config.read(config_path)
50
+
51
+ return config
52
+
53
+
54
+ def save_rclone_config(config: configparser.ConfigParser) -> None:
55
+ """Save rclone configuration to file."""
56
+ config_path = get_rclone_config_path()
57
+
58
+ with open(config_path, "w") as f:
59
+ config.write(f)
60
+
61
+ console.print(f"[dim]Updated rclone config: {config_path}[/dim]")
62
+
63
+
64
+ def configure_rclone_remote(
65
+ access_key: str,
66
+ secret_key: str,
67
+ endpoint: str = "https://fly.storage.tigris.dev",
68
+ region: str = "auto",
69
+ ) -> str:
70
+ """Configure single rclone remote named 'basic-memory-cloud'.
71
+
72
+ This is the simplified approach from SPEC-20 that uses one remote
73
+ for all Basic Memory cloud operations (not tenant-specific).
74
+
75
+ Args:
76
+ access_key: S3 access key ID
77
+ secret_key: S3 secret access key
78
+ endpoint: S3-compatible endpoint URL
79
+ region: S3 region (default: auto)
80
+
81
+ Returns:
82
+ The remote name: "basic-memory-cloud"
83
+ """
84
+ # Backup existing config
85
+ backup_rclone_config()
86
+
87
+ # Load existing config
88
+ config = load_rclone_config()
89
+
90
+ # Single remote name (not tenant-specific)
91
+ REMOTE_NAME = "basic-memory-cloud"
92
+
93
+ # Add/update the remote section
94
+ if not config.has_section(REMOTE_NAME):
95
+ config.add_section(REMOTE_NAME)
96
+
97
+ config.set(REMOTE_NAME, "type", "s3")
98
+ config.set(REMOTE_NAME, "provider", "Other")
99
+ config.set(REMOTE_NAME, "access_key_id", access_key)
100
+ config.set(REMOTE_NAME, "secret_access_key", secret_key)
101
+ config.set(REMOTE_NAME, "endpoint", endpoint)
102
+ config.set(REMOTE_NAME, "region", region)
103
+ # Prevent unnecessary encoding of filenames (only encode slashes and invalid UTF-8)
104
+ # This prevents files with spaces like "Hello World.md" from being quoted
105
+ config.set(REMOTE_NAME, "encoding", "Slash,InvalidUtf8")
106
+ # Save updated config
107
+ save_rclone_config(config)
108
+
109
+ console.print(f"[green]Configured rclone remote: {REMOTE_NAME}[/green]")
110
+ return REMOTE_NAME
@@ -0,0 +1,249 @@
1
+ """Cross-platform rclone installation utilities."""
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import subprocess
7
+ from typing import Optional
8
+
9
+ from rich.console import Console
10
+
11
+ console = Console()
12
+
13
+
14
+ class RcloneInstallError(Exception):
15
+ """Exception raised for rclone installation errors."""
16
+
17
+ pass
18
+
19
+
20
+ def is_rclone_installed() -> bool:
21
+ """Check if rclone is already installed and available in PATH."""
22
+ return shutil.which("rclone") is not None
23
+
24
+
25
+ def get_platform() -> str:
26
+ """Get the current platform identifier."""
27
+ system = platform.system().lower()
28
+ if system == "darwin":
29
+ return "macos"
30
+ elif system == "linux":
31
+ return "linux"
32
+ elif system == "windows":
33
+ return "windows"
34
+ else:
35
+ raise RcloneInstallError(f"Unsupported platform: {system}")
36
+
37
+
38
+ def run_command(command: list[str], check: bool = True) -> subprocess.CompletedProcess:
39
+ """Run a command with proper error handling."""
40
+ try:
41
+ console.print(f"[dim]Running: {' '.join(command)}[/dim]")
42
+ result = subprocess.run(command, capture_output=True, text=True, check=check)
43
+ if result.stdout:
44
+ console.print(f"[dim]Output: {result.stdout.strip()}[/dim]")
45
+ return result
46
+ except subprocess.CalledProcessError as e:
47
+ console.print(f"[red]Command failed: {e}[/red]")
48
+ if e.stderr:
49
+ console.print(f"[red]Error output: {e.stderr}[/red]")
50
+ raise RcloneInstallError(f"Command failed: {e}") from e
51
+ except FileNotFoundError as e:
52
+ raise RcloneInstallError(f"Command not found: {' '.join(command)}") from e
53
+
54
+
55
+ def install_rclone_macos() -> None:
56
+ """Install rclone on macOS using Homebrew or official script."""
57
+ # Try Homebrew first
58
+ if shutil.which("brew"):
59
+ try:
60
+ console.print("[blue]Installing rclone via Homebrew...[/blue]")
61
+ run_command(["brew", "install", "rclone"])
62
+ console.print("[green]rclone installed via Homebrew[/green]")
63
+ return
64
+ except RcloneInstallError:
65
+ console.print(
66
+ "[yellow]Homebrew installation failed, trying official script...[/yellow]"
67
+ )
68
+
69
+ # Fallback to official script
70
+ console.print("[blue]Installing rclone via official script...[/blue]")
71
+ try:
72
+ run_command(["sh", "-c", "curl https://rclone.org/install.sh | sudo bash"])
73
+ console.print("[green]rclone installed via official script[/green]")
74
+ except RcloneInstallError:
75
+ raise RcloneInstallError(
76
+ "Failed to install rclone. Please install manually: brew install rclone"
77
+ )
78
+
79
+
80
+ def install_rclone_linux() -> None:
81
+ """Install rclone on Linux using package managers or official script."""
82
+ # Try snap first (most universal)
83
+ if shutil.which("snap"):
84
+ try:
85
+ console.print("[blue]Installing rclone via snap...[/blue]")
86
+ run_command(["sudo", "snap", "install", "rclone"])
87
+ console.print("[green]rclone installed via snap[/green]")
88
+ return
89
+ except RcloneInstallError:
90
+ console.print("[yellow]Snap installation failed, trying apt...[/yellow]")
91
+
92
+ # Try apt (Debian/Ubuntu)
93
+ if shutil.which("apt"):
94
+ try:
95
+ console.print("[blue]Installing rclone via apt...[/blue]")
96
+ run_command(["sudo", "apt", "update"])
97
+ run_command(["sudo", "apt", "install", "-y", "rclone"])
98
+ console.print("[green]rclone installed via apt[/green]")
99
+ return
100
+ except RcloneInstallError:
101
+ console.print("[yellow]apt installation failed, trying official script...[/yellow]")
102
+
103
+ # Fallback to official script
104
+ console.print("[blue]Installing rclone via official script...[/blue]")
105
+ try:
106
+ run_command(["sh", "-c", "curl https://rclone.org/install.sh | sudo bash"])
107
+ console.print("[green]rclone installed via official script[/green]")
108
+ except RcloneInstallError:
109
+ raise RcloneInstallError(
110
+ "Failed to install rclone. Please install manually: sudo snap install rclone"
111
+ )
112
+
113
+
114
+ def install_rclone_windows() -> None:
115
+ """Install rclone on Windows using package managers."""
116
+ # Try winget first (built into Windows 10+)
117
+ if shutil.which("winget"):
118
+ try:
119
+ console.print("[blue]Installing rclone via winget...[/blue]")
120
+ run_command(
121
+ [
122
+ "winget",
123
+ "install",
124
+ "Rclone.Rclone",
125
+ "--accept-source-agreements",
126
+ "--accept-package-agreements",
127
+ ]
128
+ )
129
+ console.print("[green]rclone installed via winget[/green]")
130
+ return
131
+ except RcloneInstallError:
132
+ console.print("[yellow]winget installation failed, trying chocolatey...[/yellow]")
133
+
134
+ # Try chocolatey
135
+ if shutil.which("choco"):
136
+ try:
137
+ console.print("[blue]Installing rclone via chocolatey...[/blue]")
138
+ run_command(["choco", "install", "rclone", "-y"])
139
+ console.print("[green]rclone installed via chocolatey[/green]")
140
+ return
141
+ except RcloneInstallError:
142
+ console.print("[yellow]chocolatey installation failed, trying scoop...[/yellow]")
143
+
144
+ # Try scoop
145
+ if shutil.which("scoop"):
146
+ try:
147
+ console.print("[blue]Installing rclone via scoop...[/blue]")
148
+ run_command(["scoop", "install", "rclone"])
149
+ console.print("[green]rclone installed via scoop[/green]")
150
+ return
151
+ except RcloneInstallError:
152
+ console.print("[yellow]scoop installation failed[/yellow]")
153
+
154
+ # No package manager available
155
+ raise RcloneInstallError(
156
+ "Could not install rclone automatically. Please install a package manager "
157
+ "(winget, chocolatey, or scoop) or install rclone manually from https://rclone.org/downloads/"
158
+ )
159
+
160
+
161
+ def install_rclone(platform_override: Optional[str] = None) -> None:
162
+ """Install rclone for the current platform."""
163
+ if is_rclone_installed():
164
+ console.print("[green]rclone is already installed[/green]")
165
+ return
166
+
167
+ platform_name = platform_override or get_platform()
168
+ console.print(f"[blue]Installing rclone for {platform_name}...[/blue]")
169
+
170
+ try:
171
+ if platform_name == "macos":
172
+ install_rclone_macos()
173
+ elif platform_name == "linux":
174
+ install_rclone_linux()
175
+ elif platform_name == "windows":
176
+ install_rclone_windows()
177
+ refresh_windows_path()
178
+ else:
179
+ raise RcloneInstallError(f"Unsupported platform: {platform_name}")
180
+
181
+ # Verify installation
182
+ if not is_rclone_installed():
183
+ raise RcloneInstallError("rclone installation completed but command not found in PATH")
184
+
185
+ console.print("[green]rclone installation completed successfully[/green]")
186
+
187
+ except RcloneInstallError:
188
+ raise
189
+ except Exception as e:
190
+ raise RcloneInstallError(f"Unexpected error during installation: {e}") from e
191
+
192
+
193
+ def refresh_windows_path() -> None:
194
+ """Refresh the Windows PATH environment variable for the current session."""
195
+ if platform.system().lower() != "windows":
196
+ return
197
+
198
+ # Importing here after performing platform detection. Also note that we have to ignore pylance/pyright
199
+ # warnings about winreg attributes so that "errors" don't appear on non-Windows platforms.
200
+ import winreg
201
+
202
+ user_key_path = r"Environment"
203
+ system_key_path = r"System\CurrentControlSet\Control\Session Manager\Environment"
204
+ new_path = ""
205
+
206
+ # Read user PATH
207
+ try:
208
+ reg_key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, user_key_path, 0, winreg.KEY_READ) # type: ignore[reportAttributeAccessIssue]
209
+ user_path, _ = winreg.QueryValueEx(reg_key, "PATH") # type: ignore[reportAttributeAccessIssue]
210
+ winreg.CloseKey(reg_key) # type: ignore[reportAttributeAccessIssue]
211
+ except Exception:
212
+ user_path = ""
213
+
214
+ # Read system PATH
215
+ try:
216
+ reg_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, system_key_path, 0, winreg.KEY_READ) # type: ignore[reportAttributeAccessIssue]
217
+ system_path, _ = winreg.QueryValueEx(reg_key, "PATH") # type: ignore[reportAttributeAccessIssue]
218
+ winreg.CloseKey(reg_key) # type: ignore[reportAttributeAccessIssue]
219
+ except Exception:
220
+ system_path = ""
221
+
222
+ # Merge user and system PATHs (system first, then user)
223
+ if system_path and user_path:
224
+ new_path = system_path + ";" + user_path
225
+ elif system_path:
226
+ new_path = system_path
227
+ elif user_path:
228
+ new_path = user_path
229
+
230
+ if new_path:
231
+ os.environ["PATH"] = new_path
232
+
233
+
234
+ def get_rclone_version() -> Optional[str]:
235
+ """Get the installed rclone version."""
236
+ if not is_rclone_installed():
237
+ return None
238
+
239
+ try:
240
+ result = run_command(["rclone", "version"], check=False)
241
+ if result.returncode == 0:
242
+ # Parse version from output (format: "rclone v1.64.0")
243
+ lines = result.stdout.strip().split("\n")
244
+ for line in lines:
245
+ if line.startswith("rclone v"):
246
+ return line.split()[1]
247
+ return "unknown"
248
+ except Exception:
249
+ return "unknown"