basic-memory 0.7.0__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 (150) 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 +64 -18
  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 +166 -21
  18. basic_memory/api/routers/management_router.py +80 -0
  19. basic_memory/api/routers/memory_router.py +9 -64
  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 +119 -4
  23. basic_memory/api/routers/search_router.py +5 -5
  24. basic_memory/api/routers/utils.py +130 -0
  25. basic_memory/api/template_loader.py +292 -0
  26. basic_memory/cli/app.py +43 -9
  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 +28 -12
  41. basic_memory/cli/commands/import_chatgpt.py +40 -220
  42. basic_memory/cli/commands/import_claude_conversations.py +41 -168
  43. basic_memory/cli/commands/import_claude_projects.py +46 -157
  44. basic_memory/cli/commands/import_memory_json.py +48 -108
  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 +50 -33
  48. basic_memory/cli/commands/tool.py +341 -0
  49. basic_memory/cli/main.py +8 -7
  50. basic_memory/config.py +477 -23
  51. basic_memory/db.py +168 -17
  52. basic_memory/deps.py +251 -25
  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 -23
  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 +411 -62
  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 +187 -25
  112. basic_memory/schemas/project_info.py +211 -0
  113. basic_memory/schemas/prompt.py +90 -0
  114. basic_memory/schemas/request.py +56 -2
  115. basic_memory/schemas/response.py +1 -1
  116. basic_memory/schemas/search.py +31 -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 +241 -104
  120. basic_memory/services/directory_service.py +295 -0
  121. basic_memory/services/entity_service.py +590 -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 +49 -56
  126. basic_memory/services/project_service.py +863 -0
  127. basic_memory/services/search_service.py +168 -32
  128. basic_memory/sync/__init__.py +3 -2
  129. basic_memory/sync/background_sync.py +26 -0
  130. basic_memory/sync/sync_service.py +1180 -109
  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 +383 -51
  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.7.0.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 -206
  140. basic_memory/cli/commands/tools.py +0 -157
  141. basic_memory/mcp/tools/knowledge.py +0 -68
  142. basic_memory/mcp/tools/memory.py +0 -170
  143. basic_memory/mcp/tools/notes.py +0 -202
  144. basic_memory/schemas/discovery.py +0 -28
  145. basic_memory/sync/file_change_scanner.py +0 -158
  146. basic_memory/sync/utils.py +0 -31
  147. basic_memory-0.7.0.dist-info/METADATA +0 -378
  148. basic_memory-0.7.0.dist-info/RECORD +0 -82
  149. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
  150. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,195 @@
1
+ """Core cloud commands for Basic Memory CLI."""
2
+
3
+ import asyncio
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from basic_memory.cli.app import cloud_app
9
+ from basic_memory.cli.auth import CLIAuth
10
+ from basic_memory.config import ConfigManager
11
+ from basic_memory.cli.commands.cloud.api_client import (
12
+ CloudAPIError,
13
+ SubscriptionRequiredError,
14
+ get_cloud_config,
15
+ make_api_request,
16
+ )
17
+ from basic_memory.cli.commands.cloud.bisync_commands import (
18
+ BisyncError,
19
+ generate_mount_credentials,
20
+ get_mount_info,
21
+ )
22
+ from basic_memory.cli.commands.cloud.rclone_config import configure_rclone_remote
23
+ from basic_memory.cli.commands.cloud.rclone_installer import (
24
+ RcloneInstallError,
25
+ install_rclone,
26
+ )
27
+
28
+ console = Console()
29
+
30
+
31
+ @cloud_app.command()
32
+ def login():
33
+ """Authenticate with WorkOS using OAuth Device Authorization flow and enable cloud mode."""
34
+
35
+ async def _login():
36
+ client_id, domain, host_url = get_cloud_config()
37
+ auth = CLIAuth(client_id=client_id, authkit_domain=domain)
38
+
39
+ try:
40
+ success = await auth.login()
41
+ if not success:
42
+ console.print("[red]Login failed[/red]")
43
+ raise typer.Exit(1)
44
+
45
+ # Test subscription access by calling a protected endpoint
46
+ console.print("[dim]Verifying subscription access...[/dim]")
47
+ await make_api_request("GET", f"{host_url.rstrip('/')}/proxy/health")
48
+
49
+ # Enable cloud mode after successful login and subscription validation
50
+ config_manager = ConfigManager()
51
+ config = config_manager.load_config()
52
+ config.cloud_mode = True
53
+ config_manager.save_config(config)
54
+
55
+ console.print("[green]Cloud mode enabled[/green]")
56
+ console.print(f"[dim]All CLI commands now work against {host_url}[/dim]")
57
+
58
+ except SubscriptionRequiredError as e:
59
+ console.print("\n[red]Subscription Required[/red]\n")
60
+ console.print(f"[yellow]{e.args[0]}[/yellow]\n")
61
+ console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
62
+ console.print(
63
+ "[dim]Once you have an active subscription, run [bold]bm cloud login[/bold] again.[/dim]"
64
+ )
65
+ raise typer.Exit(1)
66
+
67
+ asyncio.run(_login())
68
+
69
+
70
+ @cloud_app.command()
71
+ def logout():
72
+ """Disable cloud mode and return to local mode."""
73
+
74
+ # Disable cloud mode
75
+ config_manager = ConfigManager()
76
+ config = config_manager.load_config()
77
+ config.cloud_mode = False
78
+ config_manager.save_config(config)
79
+
80
+ console.print("[green]Cloud mode disabled[/green]")
81
+ console.print("[dim]All CLI commands now work locally[/dim]")
82
+
83
+
84
+ @cloud_app.command("status")
85
+ def status() -> None:
86
+ """Check cloud mode status and cloud instance health."""
87
+ # Check cloud mode
88
+ config_manager = ConfigManager()
89
+ config = config_manager.load_config()
90
+
91
+ console.print("[bold blue]Cloud Mode Status[/bold blue]")
92
+ if config.cloud_mode:
93
+ console.print(" Mode: [green]Cloud (enabled)[/green]")
94
+ console.print(f" Host: {config.cloud_host}")
95
+ console.print(" [dim]All CLI commands work against cloud[/dim]")
96
+ else:
97
+ console.print(" Mode: [yellow]Local (disabled)[/yellow]")
98
+ console.print(" [dim]All CLI commands work locally[/dim]")
99
+ console.print("\n[dim]To enable cloud mode, run: bm cloud login[/dim]")
100
+ return
101
+
102
+ # Get cloud configuration
103
+ _, _, host_url = get_cloud_config()
104
+ host_url = host_url.rstrip("/")
105
+
106
+ # Prepare headers
107
+ headers = {}
108
+
109
+ try:
110
+ console.print("\n[blue]Checking cloud instance health...[/blue]")
111
+
112
+ # Make API request to check health
113
+ response = asyncio.run(
114
+ make_api_request(method="GET", url=f"{host_url}/proxy/health", headers=headers)
115
+ )
116
+
117
+ health_data = response.json()
118
+
119
+ console.print("[green]Cloud instance is healthy[/green]")
120
+
121
+ # Display status details
122
+ if "status" in health_data:
123
+ console.print(f" Status: {health_data['status']}")
124
+ if "version" in health_data:
125
+ console.print(f" Version: {health_data['version']}")
126
+ if "timestamp" in health_data:
127
+ console.print(f" Timestamp: {health_data['timestamp']}")
128
+
129
+ console.print("\n[dim]To sync projects, use: bm project bisync --name <project>[/dim]")
130
+
131
+ except CloudAPIError as e:
132
+ console.print(f"[red]Error checking cloud health: {e}[/red]")
133
+ raise typer.Exit(1)
134
+ except Exception as e:
135
+ console.print(f"[red]Unexpected error: {e}[/red]")
136
+ raise typer.Exit(1)
137
+
138
+
139
+ @cloud_app.command("setup")
140
+ def setup() -> None:
141
+ """Set up cloud sync by installing rclone and configuring credentials.
142
+
143
+ SPEC-20: Simplified to project-scoped workflow.
144
+ After setup, use project commands for syncing:
145
+ bm project add <name> <path> --local-path ~/projects/<name>
146
+ bm project bisync --name <name> --resync # First time
147
+ bm project bisync --name <name> # Subsequent syncs
148
+ """
149
+ console.print("[bold blue]Basic Memory Cloud Setup[/bold blue]")
150
+ console.print("Setting up cloud sync with rclone...\n")
151
+
152
+ try:
153
+ # Step 1: Install rclone
154
+ console.print("[blue]Step 1: Installing rclone...[/blue]")
155
+ install_rclone()
156
+
157
+ # Step 2: Get tenant info
158
+ console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
159
+ tenant_info = asyncio.run(get_mount_info())
160
+ console.print(f"[green]Found tenant: {tenant_info.tenant_id}[/green]")
161
+
162
+ # Step 3: Generate credentials
163
+ console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
164
+ creds = asyncio.run(generate_mount_credentials(tenant_info.tenant_id))
165
+ console.print("[green]Generated secure credentials[/green]")
166
+
167
+ # Step 4: Configure rclone remote
168
+ console.print("\n[blue]Step 4: Configuring rclone remote...[/blue]")
169
+ configure_rclone_remote(
170
+ access_key=creds.access_key,
171
+ secret_key=creds.secret_key,
172
+ )
173
+
174
+ console.print("\n[bold green]Cloud setup completed successfully![/bold green]")
175
+ console.print("\n[bold]Next steps:[/bold]")
176
+ console.print("1. Add a project with local sync path:")
177
+ console.print(" bm project add research --local-path ~/Documents/research")
178
+ console.print("\n Or configure sync for an existing project:")
179
+ console.print(" bm project sync-setup research ~/Documents/research")
180
+ console.print("\n2. Preview the initial sync (recommended):")
181
+ console.print(" bm project bisync --name research --resync --dry-run")
182
+ console.print("\n3. If all looks good, run the actual sync:")
183
+ console.print(" bm project bisync --name research --resync")
184
+ console.print("\n4. Subsequent syncs (no --resync needed):")
185
+ console.print(" bm project bisync --name research")
186
+ console.print(
187
+ "\n[dim]Tip: Always use --dry-run first to preview changes before syncing[/dim]"
188
+ )
189
+
190
+ except (RcloneInstallError, BisyncError, CloudAPIError) as e:
191
+ console.print(f"\n[red]Setup failed: {e}[/red]")
192
+ raise typer.Exit(1)
193
+ except Exception as e:
194
+ console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
195
+ raise typer.Exit(1)
@@ -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