basic-memory 0.16.1__py3-none-any.whl → 0.17.4__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 (143) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +112 -26
  3. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  4. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
  5. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
  6. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  7. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  8. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
  9. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  10. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  11. basic_memory/api/app.py +45 -24
  12. basic_memory/api/container.py +133 -0
  13. basic_memory/api/routers/knowledge_router.py +17 -5
  14. basic_memory/api/routers/project_router.py +68 -14
  15. basic_memory/api/routers/resource_router.py +37 -27
  16. basic_memory/api/routers/utils.py +53 -14
  17. basic_memory/api/v2/__init__.py +35 -0
  18. basic_memory/api/v2/routers/__init__.py +21 -0
  19. basic_memory/api/v2/routers/directory_router.py +93 -0
  20. basic_memory/api/v2/routers/importer_router.py +181 -0
  21. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  22. basic_memory/api/v2/routers/memory_router.py +130 -0
  23. basic_memory/api/v2/routers/project_router.py +359 -0
  24. basic_memory/api/v2/routers/prompt_router.py +269 -0
  25. basic_memory/api/v2/routers/resource_router.py +286 -0
  26. basic_memory/api/v2/routers/search_router.py +73 -0
  27. basic_memory/cli/app.py +43 -7
  28. basic_memory/cli/auth.py +27 -4
  29. basic_memory/cli/commands/__init__.py +3 -1
  30. basic_memory/cli/commands/cloud/api_client.py +20 -5
  31. basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
  32. basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
  33. basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
  34. basic_memory/cli/commands/cloud/upload.py +10 -3
  35. basic_memory/cli/commands/command_utils.py +52 -4
  36. basic_memory/cli/commands/db.py +78 -19
  37. basic_memory/cli/commands/format.py +198 -0
  38. basic_memory/cli/commands/import_chatgpt.py +12 -8
  39. basic_memory/cli/commands/import_claude_conversations.py +12 -8
  40. basic_memory/cli/commands/import_claude_projects.py +12 -8
  41. basic_memory/cli/commands/import_memory_json.py +12 -8
  42. basic_memory/cli/commands/mcp.py +8 -26
  43. basic_memory/cli/commands/project.py +22 -9
  44. basic_memory/cli/commands/status.py +3 -2
  45. basic_memory/cli/commands/telemetry.py +81 -0
  46. basic_memory/cli/container.py +84 -0
  47. basic_memory/cli/main.py +7 -0
  48. basic_memory/config.py +177 -77
  49. basic_memory/db.py +183 -77
  50. basic_memory/deps/__init__.py +293 -0
  51. basic_memory/deps/config.py +26 -0
  52. basic_memory/deps/db.py +56 -0
  53. basic_memory/deps/importers.py +200 -0
  54. basic_memory/deps/projects.py +238 -0
  55. basic_memory/deps/repositories.py +179 -0
  56. basic_memory/deps/services.py +480 -0
  57. basic_memory/deps.py +14 -409
  58. basic_memory/file_utils.py +212 -3
  59. basic_memory/ignore_utils.py +5 -5
  60. basic_memory/importers/base.py +40 -19
  61. basic_memory/importers/chatgpt_importer.py +17 -4
  62. basic_memory/importers/claude_conversations_importer.py +27 -12
  63. basic_memory/importers/claude_projects_importer.py +50 -14
  64. basic_memory/importers/memory_json_importer.py +36 -16
  65. basic_memory/importers/utils.py +5 -2
  66. basic_memory/markdown/entity_parser.py +62 -23
  67. basic_memory/markdown/markdown_processor.py +67 -4
  68. basic_memory/markdown/plugins.py +4 -2
  69. basic_memory/markdown/utils.py +10 -1
  70. basic_memory/mcp/async_client.py +1 -0
  71. basic_memory/mcp/clients/__init__.py +28 -0
  72. basic_memory/mcp/clients/directory.py +70 -0
  73. basic_memory/mcp/clients/knowledge.py +176 -0
  74. basic_memory/mcp/clients/memory.py +120 -0
  75. basic_memory/mcp/clients/project.py +89 -0
  76. basic_memory/mcp/clients/resource.py +71 -0
  77. basic_memory/mcp/clients/search.py +65 -0
  78. basic_memory/mcp/container.py +110 -0
  79. basic_memory/mcp/project_context.py +47 -33
  80. basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
  81. basic_memory/mcp/prompts/recent_activity.py +2 -2
  82. basic_memory/mcp/prompts/utils.py +3 -3
  83. basic_memory/mcp/server.py +58 -0
  84. basic_memory/mcp/tools/build_context.py +14 -14
  85. basic_memory/mcp/tools/canvas.py +34 -12
  86. basic_memory/mcp/tools/chatgpt_tools.py +4 -1
  87. basic_memory/mcp/tools/delete_note.py +31 -7
  88. basic_memory/mcp/tools/edit_note.py +14 -9
  89. basic_memory/mcp/tools/list_directory.py +7 -17
  90. basic_memory/mcp/tools/move_note.py +35 -31
  91. basic_memory/mcp/tools/project_management.py +29 -25
  92. basic_memory/mcp/tools/read_content.py +13 -3
  93. basic_memory/mcp/tools/read_note.py +24 -14
  94. basic_memory/mcp/tools/recent_activity.py +32 -38
  95. basic_memory/mcp/tools/search.py +17 -10
  96. basic_memory/mcp/tools/utils.py +28 -0
  97. basic_memory/mcp/tools/view_note.py +2 -1
  98. basic_memory/mcp/tools/write_note.py +37 -14
  99. basic_memory/models/knowledge.py +15 -2
  100. basic_memory/models/project.py +7 -1
  101. basic_memory/models/search.py +58 -2
  102. basic_memory/project_resolver.py +222 -0
  103. basic_memory/repository/entity_repository.py +210 -3
  104. basic_memory/repository/observation_repository.py +1 -0
  105. basic_memory/repository/postgres_search_repository.py +451 -0
  106. basic_memory/repository/project_repository.py +38 -1
  107. basic_memory/repository/relation_repository.py +58 -2
  108. basic_memory/repository/repository.py +1 -0
  109. basic_memory/repository/search_index_row.py +95 -0
  110. basic_memory/repository/search_repository.py +77 -615
  111. basic_memory/repository/search_repository_base.py +241 -0
  112. basic_memory/repository/sqlite_search_repository.py +437 -0
  113. basic_memory/runtime.py +61 -0
  114. basic_memory/schemas/base.py +36 -6
  115. basic_memory/schemas/directory.py +2 -1
  116. basic_memory/schemas/memory.py +9 -2
  117. basic_memory/schemas/project_info.py +2 -0
  118. basic_memory/schemas/response.py +84 -27
  119. basic_memory/schemas/search.py +5 -0
  120. basic_memory/schemas/sync_report.py +1 -1
  121. basic_memory/schemas/v2/__init__.py +27 -0
  122. basic_memory/schemas/v2/entity.py +133 -0
  123. basic_memory/schemas/v2/resource.py +47 -0
  124. basic_memory/services/context_service.py +219 -43
  125. basic_memory/services/directory_service.py +26 -11
  126. basic_memory/services/entity_service.py +68 -33
  127. basic_memory/services/file_service.py +131 -16
  128. basic_memory/services/initialization.py +51 -26
  129. basic_memory/services/link_resolver.py +1 -0
  130. basic_memory/services/project_service.py +68 -43
  131. basic_memory/services/search_service.py +75 -16
  132. basic_memory/sync/__init__.py +2 -1
  133. basic_memory/sync/coordinator.py +160 -0
  134. basic_memory/sync/sync_service.py +135 -115
  135. basic_memory/sync/watch_service.py +32 -12
  136. basic_memory/telemetry.py +249 -0
  137. basic_memory/utils.py +96 -75
  138. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
  139. basic_memory-0.17.4.dist-info/RECORD +193 -0
  140. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  141. basic_memory-0.16.1.dist-info/RECORD +0 -148
  142. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
  143. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
basic_memory/cli/auth.py CHANGED
@@ -7,6 +7,9 @@ import os
7
7
  import secrets
8
8
  import time
9
9
  import webbrowser
10
+ from contextlib import asynccontextmanager
11
+ from collections.abc import AsyncIterator, Callable
12
+ from typing import AsyncContextManager
10
13
 
11
14
  import httpx
12
15
  from rich.console import Console
@@ -19,7 +22,12 @@ console = Console()
19
22
  class CLIAuth:
20
23
  """Handles WorkOS OAuth Device Authorization for CLI tools."""
21
24
 
22
- def __init__(self, client_id: str, authkit_domain: str):
25
+ def __init__(
26
+ self,
27
+ client_id: str,
28
+ authkit_domain: str,
29
+ http_client_factory: Callable[[], AsyncContextManager[httpx.AsyncClient]] | None = None,
30
+ ):
23
31
  self.client_id = client_id
24
32
  self.authkit_domain = authkit_domain
25
33
  app_config = ConfigManager().config
@@ -28,6 +36,21 @@ class CLIAuth:
28
36
  # PKCE parameters
29
37
  self.code_verifier = None
30
38
  self.code_challenge = None
39
+ self._http_client_factory = http_client_factory
40
+
41
+ @asynccontextmanager
42
+ async def _get_http_client(self) -> AsyncIterator[httpx.AsyncClient]:
43
+ """Create an AsyncClient, optionally via injected factory.
44
+
45
+ Why: enables reliable tests without monkeypatching httpx internals while
46
+ still using real httpx request/response objects.
47
+ """
48
+ if self._http_client_factory:
49
+ async with self._http_client_factory() as client:
50
+ yield client
51
+ else:
52
+ async with httpx.AsyncClient() as client:
53
+ yield client
31
54
 
32
55
  def generate_pkce_pair(self) -> tuple[str, str]:
33
56
  """Generate PKCE code verifier and challenge."""
@@ -57,7 +80,7 @@ class CLIAuth:
57
80
  }
58
81
 
59
82
  try:
60
- async with httpx.AsyncClient() as client:
83
+ async with self._get_http_client() as client:
61
84
  response = await client.post(device_auth_url, data=data)
62
85
 
63
86
  if response.status_code == 200:
@@ -111,7 +134,7 @@ class CLIAuth:
111
134
 
112
135
  for _attempt in range(max_attempts):
113
136
  try:
114
- async with httpx.AsyncClient() as client:
137
+ async with self._get_http_client() as client:
115
138
  response = await client.post(token_url, data=data)
116
139
 
117
140
  if response.status_code == 200:
@@ -201,7 +224,7 @@ class CLIAuth:
201
224
  }
202
225
 
203
226
  try:
204
- async with httpx.AsyncClient() as client:
227
+ async with self._get_http_client() as client:
205
228
  response = await client.post(token_url, data=data)
206
229
 
207
230
  if response.status_code == 200:
@@ -1,7 +1,7 @@
1
1
  """CLI commands for basic-memory."""
2
2
 
3
3
  from . import status, db, import_memory_json, mcp, import_claude_conversations
4
- from . import import_claude_projects, import_chatgpt, tool, project
4
+ from . import import_claude_projects, import_chatgpt, tool, project, format, telemetry
5
5
 
6
6
  __all__ = [
7
7
  "status",
@@ -13,4 +13,6 @@ __all__ = [
13
13
  "import_chatgpt",
14
14
  "tool",
15
15
  "project",
16
+ "format",
17
+ "telemetry",
16
18
  ]
@@ -1,6 +1,9 @@
1
1
  """Cloud API client utilities."""
2
2
 
3
+ from collections.abc import AsyncIterator
3
4
  from typing import Optional
5
+ from contextlib import asynccontextmanager
6
+ from typing import AsyncContextManager, Callable
4
7
 
5
8
  import httpx
6
9
  import typer
@@ -11,6 +14,8 @@ from basic_memory.config import ConfigManager
11
14
 
12
15
  console = Console()
13
16
 
17
+ HttpClientFactory = Callable[[], AsyncContextManager[httpx.AsyncClient]]
18
+
14
19
 
15
20
  class CloudAPIError(Exception):
16
21
  """Exception raised for cloud API errors."""
@@ -38,14 +43,14 @@ def get_cloud_config() -> tuple[str, str, str]:
38
43
  return config.cloud_client_id, config.cloud_domain, config.cloud_host
39
44
 
40
45
 
41
- async def get_authenticated_headers() -> dict[str, str]:
46
+ async def get_authenticated_headers(auth: CLIAuth | None = None) -> dict[str, str]:
42
47
  """
43
48
  Get authentication headers with JWT token.
44
49
  handles jwt refresh if needed.
45
50
  """
46
51
  client_id, domain, _ = get_cloud_config()
47
- auth = CLIAuth(client_id=client_id, authkit_domain=domain)
48
- token = await auth.get_valid_token()
52
+ auth_obj = auth or CLIAuth(client_id=client_id, authkit_domain=domain)
53
+ token = await auth_obj.get_valid_token()
49
54
  if not token:
50
55
  console.print("[red]Not authenticated. Please run 'basic-memory cloud login' first.[/red]")
51
56
  raise typer.Exit(1)
@@ -53,21 +58,31 @@ async def get_authenticated_headers() -> dict[str, str]:
53
58
  return {"Authorization": f"Bearer {token}"}
54
59
 
55
60
 
61
+ @asynccontextmanager
62
+ async def _default_http_client(timeout: float) -> AsyncIterator[httpx.AsyncClient]:
63
+ async with httpx.AsyncClient(timeout=timeout) as client:
64
+ yield client
65
+
66
+
56
67
  async def make_api_request(
57
68
  method: str,
58
69
  url: str,
59
70
  headers: Optional[dict] = None,
60
71
  json_data: Optional[dict] = None,
61
72
  timeout: float = 30.0,
73
+ *,
74
+ auth: CLIAuth | None = None,
75
+ http_client_factory: HttpClientFactory | None = None,
62
76
  ) -> httpx.Response:
63
77
  """Make an API request to the cloud service."""
64
78
  headers = headers or {}
65
- auth_headers = await get_authenticated_headers()
79
+ auth_headers = await get_authenticated_headers(auth=auth)
66
80
  headers.update(auth_headers)
67
81
  # Add debug headers to help with compression issues
68
82
  headers.setdefault("Accept-Encoding", "identity") # Disable compression for debugging
69
83
 
70
- async with httpx.AsyncClient(timeout=timeout) as client:
84
+ client_factory = http_client_factory or (lambda: _default_http_client(timeout))
85
+ async with client_factory() as client:
71
86
  try:
72
87
  response = await client.request(method=method, url=url, headers=headers, json=json_data)
73
88
  response.raise_for_status()
@@ -16,7 +16,10 @@ class CloudUtilsError(Exception):
16
16
  pass
17
17
 
18
18
 
19
- async def fetch_cloud_projects() -> CloudProjectList:
19
+ async def fetch_cloud_projects(
20
+ *,
21
+ api_request=make_api_request,
22
+ ) -> CloudProjectList:
20
23
  """Fetch list of projects from cloud API.
21
24
 
22
25
  Returns:
@@ -27,14 +30,18 @@ async def fetch_cloud_projects() -> CloudProjectList:
27
30
  config = config_manager.config
28
31
  host_url = config.cloud_host.rstrip("/")
29
32
 
30
- response = await make_api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
33
+ response = await api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
31
34
 
32
35
  return CloudProjectList.model_validate(response.json())
33
36
  except Exception as e:
34
37
  raise CloudUtilsError(f"Failed to fetch cloud projects: {e}") from e
35
38
 
36
39
 
37
- async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
40
+ async def create_cloud_project(
41
+ project_name: str,
42
+ *,
43
+ api_request=make_api_request,
44
+ ) -> CloudProjectCreateResponse:
38
45
  """Create a new project on cloud.
39
46
 
40
47
  Args:
@@ -57,7 +64,7 @@ async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
57
64
  set_default=False,
58
65
  )
59
66
 
60
- response = await make_api_request(
67
+ response = await api_request(
61
68
  method="POST",
62
69
  url=f"{host_url}/proxy/projects/projects",
63
70
  headers={"Content-Type": "application/json"},
@@ -84,7 +91,7 @@ async def sync_project(project_name: str, force_full: bool = False) -> None:
84
91
  raise CloudUtilsError(f"Failed to sync project '{project_name}': {e}") from e
85
92
 
86
93
 
87
- async def project_exists(project_name: str) -> bool:
94
+ async def project_exists(project_name: str, *, api_request=make_api_request) -> bool:
88
95
  """Check if a project exists on cloud.
89
96
 
90
97
  Args:
@@ -94,7 +101,7 @@ async def project_exists(project_name: str) -> bool:
94
101
  True if project exists, False otherwise
95
102
  """
96
103
  try:
97
- projects = await fetch_cloud_projects()
104
+ projects = await fetch_cloud_projects(api_request=api_request)
98
105
  project_names = {p.name for p in projects.projects}
99
106
  return project_name in project_names
100
107
  except Exception:
@@ -9,17 +9,32 @@ This module provides simplified, project-scoped rclone operations:
9
9
  Replaces tenant-wide sync with project-scoped workflows.
10
10
  """
11
11
 
12
+ import re
12
13
  import subprocess
13
14
  from dataclasses import dataclass
15
+ from functools import lru_cache
14
16
  from pathlib import Path
15
- from typing import Optional
17
+ from typing import Callable, Optional, Protocol
16
18
 
19
+ from loguru import logger
17
20
  from rich.console import Console
18
21
 
22
+ from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed
19
23
  from basic_memory.utils import normalize_project_path
20
24
 
21
25
  console = Console()
22
26
 
27
+ # Minimum rclone version for --create-empty-src-dirs support
28
+ MIN_RCLONE_VERSION_EMPTY_DIRS = (1, 64, 0)
29
+
30
+ class RunResult(Protocol):
31
+ returncode: int
32
+ stdout: str
33
+
34
+
35
+ RunFunc = Callable[..., RunResult]
36
+ IsInstalledFunc = Callable[[], bool]
37
+
23
38
 
24
39
  class RcloneError(Exception):
25
40
  """Exception raised for rclone command errors."""
@@ -27,6 +42,56 @@ class RcloneError(Exception):
27
42
  pass
28
43
 
29
44
 
45
+ def check_rclone_installed(is_installed: IsInstalledFunc = is_rclone_installed) -> None:
46
+ """Check if rclone is installed and raise helpful error if not.
47
+
48
+ Raises:
49
+ RcloneError: If rclone is not installed with installation instructions
50
+ """
51
+ if not is_installed():
52
+ raise RcloneError(
53
+ "rclone is not installed.\n\n"
54
+ "Install rclone by running: bm cloud setup\n"
55
+ "Or install manually from: https://rclone.org/downloads/\n\n"
56
+ "Windows users: Ensure you have a package manager installed (winget, chocolatey, or scoop)"
57
+ )
58
+
59
+
60
+ @lru_cache(maxsize=1)
61
+ def get_rclone_version(run: RunFunc = subprocess.run) -> tuple[int, int, int] | None:
62
+ """Get rclone version as (major, minor, patch) tuple.
63
+
64
+ Returns:
65
+ Version tuple like (1, 64, 2), or None if version cannot be determined.
66
+
67
+ Note:
68
+ Result is cached since rclone version won't change during runtime.
69
+ """
70
+ try:
71
+ result = run(["rclone", "version"], capture_output=True, text=True, timeout=10)
72
+ # Parse "rclone v1.64.2" or "rclone v1.60.1-DEV"
73
+ match = re.search(r"v(\d+)\.(\d+)\.(\d+)", result.stdout)
74
+ if match:
75
+ version = (int(match.group(1)), int(match.group(2)), int(match.group(3)))
76
+ logger.debug(f"Detected rclone version: {version}")
77
+ return version
78
+ except Exception as e:
79
+ logger.warning(f"Could not determine rclone version: {e}")
80
+ return None
81
+
82
+
83
+ def supports_create_empty_src_dirs(version: tuple[int, int, int] | None) -> bool:
84
+ """Check if installed rclone supports --create-empty-src-dirs flag.
85
+
86
+ Returns:
87
+ True if rclone version >= 1.64.0, False otherwise.
88
+ """
89
+ if version is None:
90
+ # If we can't determine version, assume older and skip the flag
91
+ return False
92
+ return version >= MIN_RCLONE_VERSION_EMPTY_DIRS
93
+
94
+
30
95
  @dataclass
31
96
  class SyncProject:
32
97
  """Project configured for cloud sync.
@@ -109,6 +174,10 @@ def project_sync(
109
174
  bucket_name: str,
110
175
  dry_run: bool = False,
111
176
  verbose: bool = False,
177
+ *,
178
+ run: RunFunc = subprocess.run,
179
+ is_installed: IsInstalledFunc = is_rclone_installed,
180
+ filter_path: Path | None = None,
112
181
  ) -> bool:
113
182
  """One-way sync: local → cloud.
114
183
 
@@ -124,14 +193,16 @@ def project_sync(
124
193
  True if sync succeeded, False otherwise
125
194
 
126
195
  Raises:
127
- RcloneError: If project has no local_sync_path configured
196
+ RcloneError: If project has no local_sync_path configured or rclone not installed
128
197
  """
198
+ check_rclone_installed(is_installed=is_installed)
199
+
129
200
  if not project.local_sync_path:
130
201
  raise RcloneError(f"Project {project.name} has no local_sync_path configured")
131
202
 
132
203
  local_path = Path(project.local_sync_path).expanduser()
133
204
  remote_path = get_project_remote(project, bucket_name)
134
- filter_path = get_bmignore_filter_path()
205
+ filter_path = filter_path or get_bmignore_filter_path()
135
206
 
136
207
  cmd = [
137
208
  "rclone",
@@ -150,7 +221,7 @@ def project_sync(
150
221
  if dry_run:
151
222
  cmd.append("--dry-run")
152
223
 
153
- result = subprocess.run(cmd, text=True)
224
+ result = run(cmd, text=True)
154
225
  return result.returncode == 0
155
226
 
156
227
 
@@ -160,6 +231,13 @@ def project_bisync(
160
231
  dry_run: bool = False,
161
232
  resync: bool = False,
162
233
  verbose: bool = False,
234
+ *,
235
+ run: RunFunc = subprocess.run,
236
+ is_installed: IsInstalledFunc = is_rclone_installed,
237
+ version: tuple[int, int, int] | None = None,
238
+ filter_path: Path | None = None,
239
+ state_path: Path | None = None,
240
+ is_initialized: Callable[[str], bool] = bisync_initialized,
163
241
  ) -> bool:
164
242
  """Two-way sync: local ↔ cloud.
165
243
 
@@ -180,15 +258,17 @@ def project_bisync(
180
258
  True if bisync succeeded, False otherwise
181
259
 
182
260
  Raises:
183
- RcloneError: If project has no local_sync_path or needs --resync
261
+ RcloneError: If project has no local_sync_path, needs --resync, or rclone not installed
184
262
  """
263
+ check_rclone_installed(is_installed=is_installed)
264
+
185
265
  if not project.local_sync_path:
186
266
  raise RcloneError(f"Project {project.name} has no local_sync_path configured")
187
267
 
188
268
  local_path = Path(project.local_sync_path).expanduser()
189
269
  remote_path = get_project_remote(project, bucket_name)
190
- filter_path = get_bmignore_filter_path()
191
- state_path = get_project_bisync_state(project.name)
270
+ filter_path = filter_path or get_bmignore_filter_path()
271
+ state_path = state_path or get_project_bisync_state(project.name)
192
272
 
193
273
  # Ensure state directory exists
194
274
  state_path.mkdir(parents=True, exist_ok=True)
@@ -198,7 +278,6 @@ def project_bisync(
198
278
  "bisync",
199
279
  str(local_path),
200
280
  remote_path,
201
- "--create-empty-src-dirs",
202
281
  "--resilient",
203
282
  "--conflict-resolve=newer",
204
283
  "--max-delete=25",
@@ -209,6 +288,11 @@ def project_bisync(
209
288
  str(state_path),
210
289
  ]
211
290
 
291
+ # Add --create-empty-src-dirs if rclone version supports it (v1.64+)
292
+ version = version if version is not None else get_rclone_version(run=run)
293
+ if supports_create_empty_src_dirs(version):
294
+ cmd.append("--create-empty-src-dirs")
295
+
212
296
  if verbose:
213
297
  cmd.append("--verbose")
214
298
  else:
@@ -221,13 +305,13 @@ def project_bisync(
221
305
  cmd.append("--resync")
222
306
 
223
307
  # Check if first run requires resync
224
- if not resync and not bisync_initialized(project.name) and not dry_run:
308
+ if not resync and not is_initialized(project.name) and not dry_run:
225
309
  raise RcloneError(
226
310
  f"First bisync for {project.name} requires --resync to establish baseline.\n"
227
311
  f"Run: bm project bisync --name {project.name} --resync"
228
312
  )
229
313
 
230
- result = subprocess.run(cmd, text=True)
314
+ result = run(cmd, text=True)
231
315
  return result.returncode == 0
232
316
 
233
317
 
@@ -235,6 +319,10 @@ def project_check(
235
319
  project: SyncProject,
236
320
  bucket_name: str,
237
321
  one_way: bool = False,
322
+ *,
323
+ run: RunFunc = subprocess.run,
324
+ is_installed: IsInstalledFunc = is_rclone_installed,
325
+ filter_path: Path | None = None,
238
326
  ) -> bool:
239
327
  """Check integrity between local and cloud.
240
328
 
@@ -249,14 +337,16 @@ def project_check(
249
337
  True if files match, False if differences found
250
338
 
251
339
  Raises:
252
- RcloneError: If project has no local_sync_path configured
340
+ RcloneError: If project has no local_sync_path configured or rclone not installed
253
341
  """
342
+ check_rclone_installed(is_installed=is_installed)
343
+
254
344
  if not project.local_sync_path:
255
345
  raise RcloneError(f"Project {project.name} has no local_sync_path configured")
256
346
 
257
347
  local_path = Path(project.local_sync_path).expanduser()
258
348
  remote_path = get_project_remote(project, bucket_name)
259
- filter_path = get_bmignore_filter_path()
349
+ filter_path = filter_path or get_bmignore_filter_path()
260
350
 
261
351
  cmd = [
262
352
  "rclone",
@@ -270,7 +360,7 @@ def project_check(
270
360
  if one_way:
271
361
  cmd.append("--one-way")
272
362
 
273
- result = subprocess.run(cmd, capture_output=True, text=True)
363
+ result = run(cmd, capture_output=True, text=True)
274
364
  return result.returncode == 0
275
365
 
276
366
 
@@ -278,6 +368,9 @@ def project_ls(
278
368
  project: SyncProject,
279
369
  bucket_name: str,
280
370
  path: Optional[str] = None,
371
+ *,
372
+ run: RunFunc = subprocess.run,
373
+ is_installed: IsInstalledFunc = is_rclone_installed,
281
374
  ) -> list[str]:
282
375
  """List files in remote project.
283
376
 
@@ -291,11 +384,14 @@ def project_ls(
291
384
 
292
385
  Raises:
293
386
  subprocess.CalledProcessError: If rclone command fails
387
+ RcloneError: If rclone is not installed
294
388
  """
389
+ check_rclone_installed(is_installed=is_installed)
390
+
295
391
  remote_path = get_project_remote(project, bucket_name)
296
392
  if path:
297
393
  remote_path = f"{remote_path}/{path}"
298
394
 
299
395
  cmd = ["rclone", "ls", remote_path]
300
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
396
+ result = run(cmd, capture_output=True, text=True, check=True)
301
397
  return result.stdout.splitlines()
@@ -151,11 +151,25 @@ def install_rclone_windows() -> None:
151
151
  except RcloneInstallError:
152
152
  console.print("[yellow]scoop installation failed[/yellow]")
153
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/"
154
+ # No package manager available - provide detailed instructions
155
+ error_msg = (
156
+ "Could not install rclone automatically.\n\n"
157
+ "Windows requires a package manager to install rclone. Options:\n\n"
158
+ "1. Install winget (recommended, built into Windows 11):\n"
159
+ " - Windows 11: Already installed\n"
160
+ " - Windows 10: Install 'App Installer' from Microsoft Store\n"
161
+ " - Then run: bm cloud setup\n\n"
162
+ "2. Install chocolatey:\n"
163
+ " - Visit: https://chocolatey.org/install\n"
164
+ " - Then run: bm cloud setup\n\n"
165
+ "3. Install scoop:\n"
166
+ " - Visit: https://scoop.sh\n"
167
+ " - Then run: bm cloud setup\n\n"
168
+ "4. Manual installation:\n"
169
+ " - Download from: https://rclone.org/downloads/\n"
170
+ " - Extract and add to PATH\n"
158
171
  )
172
+ raise RcloneInstallError(error_msg)
159
173
 
160
174
 
161
175
  def install_rclone(platform_override: Optional[str] = None) -> None:
@@ -2,6 +2,8 @@
2
2
 
3
3
  import os
4
4
  from pathlib import Path
5
+ from contextlib import AbstractAsyncContextManager
6
+ from typing import Callable
5
7
 
6
8
  import aiofiles
7
9
  import httpx
@@ -20,6 +22,9 @@ async def upload_path(
20
22
  verbose: bool = False,
21
23
  use_gitignore: bool = True,
22
24
  dry_run: bool = False,
25
+ *,
26
+ client_cm_factory: Callable[[], AbstractAsyncContextManager[httpx.AsyncClient]] | None = None,
27
+ put_func=call_put,
23
28
  ) -> bool:
24
29
  """
25
30
  Upload a file or directory to cloud project via WebDAV.
@@ -85,8 +90,10 @@ async def upload_path(
85
90
  size_str = f"{size / (1024 * 1024):.1f} MB"
86
91
  print(f" {relative_path} ({size_str})")
87
92
  else:
88
- # Upload files using httpx
89
- async with get_client() as client:
93
+ # Upload files using httpx.
94
+ # Allow injection for tests (MockTransport) while keeping production default.
95
+ cm_factory = client_cm_factory or get_client
96
+ async with cm_factory() as client:
90
97
  for i, (file_path, relative_path) in enumerate(files_to_upload, 1):
91
98
  # Skip archive files (zip, tar, gz, etc.)
92
99
  if _is_archive_file(file_path):
@@ -110,7 +117,7 @@ async def upload_path(
110
117
 
111
118
  # Upload via HTTP PUT to WebDAV endpoint with mtime header
112
119
  # Using X-OC-Mtime (ownCloud/Nextcloud standard)
113
- response = await call_put(
120
+ response = await put_func(
114
121
  client, remote_path, content=content, headers={"X-OC-Mtime": str(mtime)}
115
122
  )
116
123
  response.raise_for_status()
@@ -1,12 +1,14 @@
1
1
  """utility functions for commands"""
2
2
 
3
- from typing import Optional
3
+ import asyncio
4
+ from typing import Optional, TypeVar, Coroutine, Any
4
5
 
5
6
  from mcp.server.fastmcp.exceptions import ToolError
6
7
  import typer
7
8
 
8
9
  from rich.console import Console
9
10
 
11
+ from basic_memory import db
10
12
  from basic_memory.mcp.async_client import get_client
11
13
 
12
14
  from basic_memory.mcp.tools.utils import call_post, call_get
@@ -15,24 +17,70 @@ from basic_memory.schemas import ProjectInfoResponse
15
17
 
16
18
  console = Console()
17
19
 
20
+ T = TypeVar("T")
18
21
 
19
- async def run_sync(project: Optional[str] = None, force_full: bool = False):
22
+
23
+ def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
24
+ """Run an async coroutine with proper database cleanup.
25
+
26
+ This helper ensures database connections are cleaned up before the event
27
+ loop closes, preventing process hangs in CLI commands.
28
+
29
+ Args:
30
+ coro: The coroutine to run
31
+
32
+ Returns:
33
+ The result of the coroutine
34
+ """
35
+
36
+ async def _with_cleanup() -> T:
37
+ try:
38
+ return await coro
39
+ finally:
40
+ await db.shutdown_db()
41
+
42
+ return asyncio.run(_with_cleanup())
43
+
44
+
45
+ async def run_sync(
46
+ project: Optional[str] = None,
47
+ force_full: bool = False,
48
+ run_in_background: bool = True,
49
+ ):
20
50
  """Run sync operation via API endpoint.
21
51
 
22
52
  Args:
23
53
  project: Optional project name
24
54
  force_full: If True, force a full scan bypassing watermark optimization
55
+ run_in_background: If True, return immediately; if False, wait for completion
25
56
  """
26
57
 
27
58
  try:
28
59
  async with get_client() as client:
29
60
  project_item = await get_active_project(client, project, None)
30
61
  url = f"{project_item.project_url}/project/sync"
62
+ params = []
31
63
  if force_full:
32
- url += "?force_full=true"
64
+ params.append("force_full=true")
65
+ if not run_in_background:
66
+ params.append("run_in_background=false")
67
+ if params:
68
+ url += "?" + "&".join(params)
33
69
  response = await call_post(client, url)
34
70
  data = response.json()
35
- console.print(f"[green]{data['message']}[/green]")
71
+ # Background mode returns {"message": "..."}, foreground returns SyncReportResponse
72
+ if "message" in data:
73
+ console.print(f"[green]{data['message']}[/green]")
74
+ else:
75
+ # Foreground mode - show summary of sync results
76
+ total = data.get("total", 0)
77
+ new_count = len(data.get("new", []))
78
+ modified_count = len(data.get("modified", []))
79
+ deleted_count = len(data.get("deleted", []))
80
+ console.print(
81
+ f"[green]Synced {total} files[/green] "
82
+ f"(new: {new_count}, modified: {modified_count}, deleted: {deleted_count})"
83
+ )
36
84
  except (ToolError, ValueError) as e:
37
85
  console.print(f"[red]Sync failed: {e}[/red]")
38
86
  raise typer.Exit(1)