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.
- basic_memory/__init__.py +5 -1
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +27 -3
- basic_memory/alembic/migrations.py +4 -9
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
- basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/api/app.py +63 -31
- basic_memory/api/routers/__init__.py +4 -1
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +165 -28
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +28 -67
- basic_memory/api/routers/project_router.py +406 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +219 -14
- basic_memory/api/routers/search_router.py +21 -13
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +52 -1
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/__init__.py +13 -2
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
- basic_memory/cli/commands/cloud/upload.py +233 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +51 -0
- basic_memory/cli/commands/db.py +26 -7
- basic_memory/cli/commands/import_chatgpt.py +83 -0
- basic_memory/cli/commands/import_claude_conversations.py +86 -0
- basic_memory/cli/commands/import_claude_projects.py +85 -0
- basic_memory/cli/commands/import_memory_json.py +35 -92
- basic_memory/cli/commands/mcp.py +84 -10
- basic_memory/cli/commands/project.py +876 -0
- basic_memory/cli/commands/status.py +47 -30
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/main.py +13 -6
- basic_memory/config.py +481 -22
- basic_memory/db.py +192 -32
- basic_memory/deps.py +252 -22
- basic_memory/file_utils.py +113 -58
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +232 -0
- basic_memory/importers/claude_conversations_importer.py +177 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +108 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +143 -23
- basic_memory/markdown/markdown_processor.py +3 -3
- basic_memory/markdown/plugins.py +39 -21
- basic_memory/markdown/schemas.py +1 -1
- basic_memory/markdown/utils.py +28 -13
- basic_memory/mcp/async_client.py +134 -4
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +7 -13
- basic_memory/mcp/tools/__init__.py +33 -21
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +130 -0
- basic_memory/mcp/tools/chatgpt_tools.py +187 -0
- basic_memory/mcp/tools/delete_note.py +225 -0
- basic_memory/mcp/tools/edit_note.py +320 -0
- basic_memory/mcp/tools/list_directory.py +167 -0
- basic_memory/mcp/tools/move_note.py +545 -0
- basic_memory/mcp/tools/project_management.py +200 -0
- basic_memory/mcp/tools/read_content.py +271 -0
- basic_memory/mcp/tools/read_note.py +255 -0
- basic_memory/mcp/tools/recent_activity.py +534 -0
- basic_memory/mcp/tools/search.py +369 -14
- basic_memory/mcp/tools/utils.py +374 -16
- basic_memory/mcp/tools/view_note.py +77 -0
- basic_memory/mcp/tools/write_note.py +207 -0
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +67 -15
- basic_memory/models/project.py +87 -0
- basic_memory/models/search.py +10 -6
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +229 -7
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +103 -0
- basic_memory/repository/relation_repository.py +21 -2
- basic_memory/repository/repository.py +147 -29
- basic_memory/repository/search_repository.py +437 -59
- basic_memory/schemas/__init__.py +22 -9
- basic_memory/schemas/base.py +97 -8
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +188 -23
- basic_memory/schemas/project_info.py +211 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +57 -3
- basic_memory/schemas/response.py +9 -1
- basic_memory/schemas/search.py +33 -35
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +251 -106
- basic_memory/services/directory_service.py +295 -0
- basic_memory/services/entity_service.py +595 -60
- basic_memory/services/exceptions.py +21 -0
- basic_memory/services/file_service.py +284 -30
- basic_memory/services/initialization.py +191 -0
- basic_memory/services/link_resolver.py +50 -56
- basic_memory/services/project_service.py +863 -0
- basic_memory/services/search_service.py +172 -34
- basic_memory/sync/__init__.py +3 -2
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/sync_service.py +1176 -96
- basic_memory/sync/watch_service.py +412 -135
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +388 -28
- basic_memory-0.16.1.dist-info/METADATA +493 -0
- basic_memory-0.16.1.dist-info/RECORD +148 -0
- {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
- basic_memory/alembic/README +0 -1
- basic_memory/cli/commands/sync.py +0 -203
- basic_memory/mcp/tools/knowledge.py +0 -56
- basic_memory/mcp/tools/memory.py +0 -151
- basic_memory/mcp/tools/notes.py +0 -122
- basic_memory/schemas/discovery.py +0 -28
- basic_memory/sync/file_change_scanner.py +0 -158
- basic_memory/sync/utils.py +0 -34
- basic_memory-0.2.12.dist-info/METADATA +0 -291
- basic_memory-0.2.12.dist-info/RECORD +0 -78
- {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
- {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"
|