basic-memory 0.15.0__py3-none-any.whl → 0.15.2__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 (53) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/api/routers/directory_router.py +23 -2
  3. basic_memory/api/routers/project_router.py +1 -0
  4. basic_memory/cli/auth.py +2 -2
  5. basic_memory/cli/commands/cloud/__init__.py +2 -1
  6. basic_memory/cli/commands/cloud/bisync_commands.py +4 -57
  7. basic_memory/cli/commands/cloud/cloud_utils.py +100 -0
  8. basic_memory/cli/commands/cloud/upload.py +128 -0
  9. basic_memory/cli/commands/cloud/upload_command.py +93 -0
  10. basic_memory/cli/commands/command_utils.py +11 -28
  11. basic_memory/cli/commands/mcp.py +72 -67
  12. basic_memory/cli/commands/project.py +140 -120
  13. basic_memory/cli/commands/status.py +6 -15
  14. basic_memory/config.py +55 -9
  15. basic_memory/deps.py +7 -5
  16. basic_memory/ignore_utils.py +7 -7
  17. basic_memory/mcp/async_client.py +102 -4
  18. basic_memory/mcp/prompts/continue_conversation.py +16 -15
  19. basic_memory/mcp/prompts/search.py +12 -11
  20. basic_memory/mcp/resources/ai_assistant_guide.md +185 -453
  21. basic_memory/mcp/resources/project_info.py +9 -7
  22. basic_memory/mcp/tools/build_context.py +40 -39
  23. basic_memory/mcp/tools/canvas.py +21 -20
  24. basic_memory/mcp/tools/chatgpt_tools.py +11 -2
  25. basic_memory/mcp/tools/delete_note.py +22 -21
  26. basic_memory/mcp/tools/edit_note.py +105 -104
  27. basic_memory/mcp/tools/list_directory.py +98 -95
  28. basic_memory/mcp/tools/move_note.py +127 -125
  29. basic_memory/mcp/tools/project_management.py +101 -98
  30. basic_memory/mcp/tools/read_content.py +64 -63
  31. basic_memory/mcp/tools/read_note.py +88 -88
  32. basic_memory/mcp/tools/recent_activity.py +139 -135
  33. basic_memory/mcp/tools/search.py +27 -26
  34. basic_memory/mcp/tools/sync_status.py +133 -128
  35. basic_memory/mcp/tools/utils.py +0 -15
  36. basic_memory/mcp/tools/view_note.py +14 -28
  37. basic_memory/mcp/tools/write_note.py +97 -87
  38. basic_memory/repository/entity_repository.py +60 -0
  39. basic_memory/repository/repository.py +16 -3
  40. basic_memory/repository/search_repository.py +42 -0
  41. basic_memory/schemas/cloud.py +7 -3
  42. basic_memory/schemas/project_info.py +1 -1
  43. basic_memory/services/directory_service.py +124 -3
  44. basic_memory/services/entity_service.py +31 -9
  45. basic_memory/services/project_service.py +97 -10
  46. basic_memory/services/search_service.py +16 -8
  47. basic_memory/sync/sync_service.py +28 -13
  48. {basic_memory-0.15.0.dist-info → basic_memory-0.15.2.dist-info}/METADATA +51 -4
  49. {basic_memory-0.15.0.dist-info → basic_memory-0.15.2.dist-info}/RECORD +52 -50
  50. basic_memory/mcp/tools/headers.py +0 -44
  51. {basic_memory-0.15.0.dist-info → basic_memory-0.15.2.dist-info}/WHEEL +0 -0
  52. {basic_memory-0.15.0.dist-info → basic_memory-0.15.2.dist-info}/entry_points.txt +0 -0
  53. {basic_memory-0.15.0.dist-info → basic_memory-0.15.2.dist-info}/licenses/LICENSE +0 -0
basic_memory/__init__.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
2
2
 
3
3
  # Package version - updated by release automation
4
- __version__ = "0.15.0"
4
+ __version__ = "0.15.2"
5
5
 
6
6
  # API version for FastAPI - independent of package version
7
7
  __api_version__ = "v0"
@@ -10,7 +10,7 @@ from basic_memory.schemas.directory import DirectoryNode
10
10
  router = APIRouter(prefix="/directory", tags=["directory"])
11
11
 
12
12
 
13
- @router.get("/tree", response_model=DirectoryNode)
13
+ @router.get("/tree", response_model=DirectoryNode, response_model_exclude_none=True)
14
14
  async def get_directory_tree(
15
15
  directory_service: DirectoryServiceDep,
16
16
  project_id: ProjectIdDep,
@@ -31,7 +31,28 @@ async def get_directory_tree(
31
31
  return tree
32
32
 
33
33
 
34
- @router.get("/list", response_model=List[DirectoryNode])
34
+ @router.get("/structure", response_model=DirectoryNode, response_model_exclude_none=True)
35
+ async def get_directory_structure(
36
+ directory_service: DirectoryServiceDep,
37
+ project_id: ProjectIdDep,
38
+ ):
39
+ """Get folder structure for navigation (no files).
40
+
41
+ Optimized endpoint for folder tree navigation. Returns only directory nodes
42
+ without file metadata. For full tree with files, use /directory/tree.
43
+
44
+ Args:
45
+ directory_service: Service for directory operations
46
+ project_id: ID of the current project
47
+
48
+ Returns:
49
+ DirectoryNode tree containing only folders (type="directory")
50
+ """
51
+ structure = await directory_service.get_directory_structure()
52
+ return structure
53
+
54
+
55
+ @router.get("/list", response_model=List[DirectoryNode], response_model_exclude_none=True)
35
56
  async def list_directory(
36
57
  directory_service: DirectoryServiceDep,
37
58
  project_id: ProjectIdDep,
@@ -194,6 +194,7 @@ async def add_project(
194
194
  Response confirming the project was added
195
195
  """
196
196
  try: # pragma: no cover
197
+ # The service layer now handles cloud mode validation and path sanitization
197
198
  await project_service.add_project(
198
199
  project_data.name, project_data.path, set_default=project_data.set_default
199
200
  )
basic_memory/cli/auth.py CHANGED
@@ -244,7 +244,7 @@ class CLIAuth:
244
244
 
245
245
  async def login(self) -> bool:
246
246
  """Perform OAuth Device Authorization login flow."""
247
- console.print("[blue]Initiating WorkOS authentication...[/blue]")
247
+ console.print("[blue]Initiating authentication...[/blue]")
248
248
 
249
249
  # Step 1: Request device authorization
250
250
  device_response = await self.request_device_authorization()
@@ -265,7 +265,7 @@ class CLIAuth:
265
265
  # Step 4: Save tokens
266
266
  self.save_tokens(tokens)
267
267
 
268
- console.print("\n[green]✅ Successfully authenticated with WorkOS![/green]")
268
+ console.print("\n[green]✅ Successfully authenticated with Basic Memory Cloud![/green]")
269
269
  return True
270
270
 
271
271
  def logout(self) -> None:
@@ -2,4 +2,5 @@
2
2
 
3
3
  # Import all commands to register them with typer
4
4
  from basic_memory.cli.commands.cloud.core_commands import * # noqa: F401,F403
5
- from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers # noqa: F401
5
+ from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers, get_cloud_config # noqa: F401
6
+ from basic_memory.cli.commands.cloud.upload_command import * # noqa: F401,F403
@@ -12,6 +12,10 @@ from rich.console import Console
12
12
  from rich.table import Table
13
13
 
14
14
  from basic_memory.cli.commands.cloud.api_client import CloudAPIError, make_api_request
15
+ from basic_memory.cli.commands.cloud.cloud_utils import (
16
+ create_cloud_project,
17
+ fetch_cloud_projects,
18
+ )
15
19
  from basic_memory.cli.commands.cloud.rclone_config import (
16
20
  add_tenant_to_rclone_config,
17
21
  )
@@ -21,11 +25,7 @@ from basic_memory.ignore_utils import get_bmignore_path, create_default_bmignore
21
25
  from basic_memory.schemas.cloud import (
22
26
  TenantMountInfo,
23
27
  MountCredentials,
24
- CloudProjectList,
25
- CloudProjectCreateRequest,
26
- CloudProjectCreateResponse,
27
28
  )
28
- from basic_memory.utils import generate_permalink
29
29
 
30
30
  console = Console()
31
31
 
@@ -110,24 +110,6 @@ async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
110
110
  raise BisyncError(f"Failed to generate credentials: {e}") from e
111
111
 
112
112
 
113
- async def fetch_cloud_projects() -> CloudProjectList:
114
- """Fetch list of projects from cloud API.
115
-
116
- Returns:
117
- CloudProjectList with projects from cloud
118
- """
119
- try:
120
- config_manager = ConfigManager()
121
- config = config_manager.config
122
- host_url = config.cloud_host.rstrip("/")
123
-
124
- response = await make_api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
125
-
126
- return CloudProjectList.model_validate(response.json())
127
- except Exception as e:
128
- raise BisyncError(f"Failed to fetch cloud projects: {e}") from e
129
-
130
-
131
113
  def scan_local_directories(sync_dir: Path) -> list[str]:
132
114
  """Scan local sync directory for project folders.
133
115
 
@@ -148,41 +130,6 @@ def scan_local_directories(sync_dir: Path) -> list[str]:
148
130
  return directories
149
131
 
150
132
 
151
- async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
152
- """Create a new project on cloud.
153
-
154
- Args:
155
- project_name: Name of project to create
156
-
157
- Returns:
158
- CloudProjectCreateResponse with project details from API
159
- """
160
- try:
161
- config_manager = ConfigManager()
162
- config = config_manager.config
163
- host_url = config.cloud_host.rstrip("/")
164
-
165
- # Use generate_permalink to ensure consistent naming
166
- project_path = generate_permalink(project_name)
167
-
168
- project_data = CloudProjectCreateRequest(
169
- name=project_name,
170
- path=project_path,
171
- set_default=False,
172
- )
173
-
174
- response = await make_api_request(
175
- method="POST",
176
- url=f"{host_url}/proxy/projects/projects",
177
- headers={"Content-Type": "application/json"},
178
- json_data=project_data.model_dump(),
179
- )
180
-
181
- return CloudProjectCreateResponse.model_validate(response.json())
182
- except Exception as e:
183
- raise BisyncError(f"Failed to create cloud project '{project_name}': {e}") from e
184
-
185
-
186
133
  def get_bisync_state_path(tenant_id: str) -> Path:
187
134
  """Get path to bisync state directory."""
188
135
  return Path.home() / ".basic-memory" / "bisync-state" / tenant_id
@@ -0,0 +1,100 @@
1
+ """Shared utilities for cloud operations."""
2
+
3
+ from basic_memory.cli.commands.cloud.api_client import make_api_request
4
+ from basic_memory.config import ConfigManager
5
+ from basic_memory.schemas.cloud import (
6
+ CloudProjectList,
7
+ CloudProjectCreateRequest,
8
+ CloudProjectCreateResponse,
9
+ )
10
+ from basic_memory.utils import generate_permalink
11
+
12
+
13
+ class CloudUtilsError(Exception):
14
+ """Exception raised for cloud utility errors."""
15
+
16
+ pass
17
+
18
+
19
+ async def fetch_cloud_projects() -> CloudProjectList:
20
+ """Fetch list of projects from cloud API.
21
+
22
+ Returns:
23
+ CloudProjectList with projects from cloud
24
+ """
25
+ try:
26
+ config_manager = ConfigManager()
27
+ config = config_manager.config
28
+ host_url = config.cloud_host.rstrip("/")
29
+
30
+ response = await make_api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
31
+
32
+ return CloudProjectList.model_validate(response.json())
33
+ except Exception as e:
34
+ raise CloudUtilsError(f"Failed to fetch cloud projects: {e}") from e
35
+
36
+
37
+ async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
38
+ """Create a new project on cloud.
39
+
40
+ Args:
41
+ project_name: Name of project to create
42
+
43
+ Returns:
44
+ CloudProjectCreateResponse with project details from API
45
+ """
46
+ try:
47
+ config_manager = ConfigManager()
48
+ config = config_manager.config
49
+ host_url = config.cloud_host.rstrip("/")
50
+
51
+ # Use generate_permalink to ensure consistent naming
52
+ project_path = generate_permalink(project_name)
53
+
54
+ project_data = CloudProjectCreateRequest(
55
+ name=project_name,
56
+ path=project_path,
57
+ set_default=False,
58
+ )
59
+
60
+ response = await make_api_request(
61
+ method="POST",
62
+ url=f"{host_url}/proxy/projects/projects",
63
+ headers={"Content-Type": "application/json"},
64
+ json_data=project_data.model_dump(),
65
+ )
66
+
67
+ return CloudProjectCreateResponse.model_validate(response.json())
68
+ except Exception as e:
69
+ raise CloudUtilsError(f"Failed to create cloud project '{project_name}': {e}") from e
70
+
71
+
72
+ async def sync_project(project_name: str) -> None:
73
+ """Trigger sync for a specific project on cloud.
74
+
75
+ Args:
76
+ project_name: Name of project to sync
77
+ """
78
+ try:
79
+ from basic_memory.cli.commands.command_utils import run_sync
80
+
81
+ await run_sync(project=project_name)
82
+ except Exception as e:
83
+ raise CloudUtilsError(f"Failed to sync project '{project_name}': {e}") from e
84
+
85
+
86
+ async def project_exists(project_name: str) -> bool:
87
+ """Check if a project exists on cloud.
88
+
89
+ Args:
90
+ project_name: Name of project to check
91
+
92
+ Returns:
93
+ True if project exists, False otherwise
94
+ """
95
+ try:
96
+ projects = await fetch_cloud_projects()
97
+ project_names = {p.name for p in projects.projects}
98
+ return project_name in project_names
99
+ except Exception:
100
+ return False
@@ -0,0 +1,128 @@
1
+ """WebDAV upload functionality for basic-memory projects."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import aiofiles
7
+ import httpx
8
+
9
+ from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
10
+ from basic_memory.mcp.async_client import get_client
11
+ from basic_memory.mcp.tools.utils import call_put
12
+
13
+
14
+ async def upload_path(local_path: Path, project_name: str) -> bool:
15
+ """
16
+ Upload a file or directory to cloud project via WebDAV.
17
+
18
+ Args:
19
+ local_path: Path to local file or directory
20
+ project_name: Name of cloud project (destination)
21
+
22
+ Returns:
23
+ True if upload succeeded, False otherwise
24
+ """
25
+ try:
26
+ # Resolve path
27
+ local_path = local_path.resolve()
28
+
29
+ # Check if path exists
30
+ if not local_path.exists():
31
+ print(f"Error: Path does not exist: {local_path}")
32
+ return False
33
+
34
+ # Get files to upload
35
+ if local_path.is_file():
36
+ files_to_upload = [(local_path, local_path.name)]
37
+ else:
38
+ files_to_upload = _get_files_to_upload(local_path)
39
+
40
+ if not files_to_upload:
41
+ print("No files found to upload")
42
+ return True
43
+
44
+ print(f"Found {len(files_to_upload)} file(s) to upload")
45
+
46
+ # Upload files using httpx
47
+ total_bytes = 0
48
+
49
+ async with get_client() as client:
50
+ for i, (file_path, relative_path) in enumerate(files_to_upload, 1):
51
+ # Build remote path: /webdav/{project_name}/{relative_path}
52
+ remote_path = f"/webdav/{project_name}/{relative_path}"
53
+ print(f"Uploading {relative_path} ({i}/{len(files_to_upload)})")
54
+
55
+ # Read file content asynchronously
56
+ async with aiofiles.open(file_path, "rb") as f:
57
+ content = await f.read()
58
+
59
+ # Upload via HTTP PUT to WebDAV endpoint
60
+ response = await call_put(client, remote_path, content=content)
61
+ response.raise_for_status()
62
+
63
+ total_bytes += file_path.stat().st_size
64
+
65
+ # Format size based on magnitude
66
+ if total_bytes < 1024:
67
+ size_str = f"{total_bytes} bytes"
68
+ elif total_bytes < 1024 * 1024:
69
+ size_str = f"{total_bytes / 1024:.1f} KB"
70
+ else:
71
+ size_str = f"{total_bytes / (1024 * 1024):.1f} MB"
72
+
73
+ print(f"✓ Upload complete: {len(files_to_upload)} file(s) ({size_str})")
74
+ return True
75
+
76
+ except httpx.HTTPStatusError as e:
77
+ print(f"Upload failed: HTTP {e.response.status_code} - {e.response.text}")
78
+ return False
79
+ except Exception as e:
80
+ print(f"Upload failed: {e}")
81
+ return False
82
+
83
+
84
+ def _get_files_to_upload(directory: Path) -> list[tuple[Path, str]]:
85
+ """
86
+ Get list of files to upload from directory.
87
+
88
+ Uses .bmignore and .gitignore patterns for filtering.
89
+
90
+ Args:
91
+ directory: Directory to scan
92
+
93
+ Returns:
94
+ List of (absolute_path, relative_path) tuples
95
+ """
96
+ files = []
97
+
98
+ # Load ignore patterns from .bmignore and .gitignore
99
+ ignore_patterns = load_gitignore_patterns(directory)
100
+
101
+ # Walk through directory
102
+ for root, dirs, filenames in os.walk(directory):
103
+ root_path = Path(root)
104
+
105
+ # Filter directories based on ignore patterns
106
+ filtered_dirs = []
107
+ for d in dirs:
108
+ dir_path = root_path / d
109
+ if not should_ignore_path(dir_path, directory, ignore_patterns):
110
+ filtered_dirs.append(d)
111
+ dirs[:] = filtered_dirs
112
+
113
+ # Process files
114
+ for filename in filenames:
115
+ file_path = root_path / filename
116
+
117
+ # Check if file should be ignored
118
+ if should_ignore_path(file_path, directory, ignore_patterns):
119
+ continue
120
+
121
+ # Calculate relative path for remote
122
+ rel_path = file_path.relative_to(directory)
123
+ # Use forward slashes for WebDAV paths
124
+ remote_path = str(rel_path).replace("\\", "/")
125
+
126
+ files.append((file_path, remote_path))
127
+
128
+ return files
@@ -0,0 +1,93 @@
1
+ """Upload CLI commands for basic-memory projects."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from basic_memory.cli.app import cloud_app
10
+ from basic_memory.cli.commands.cloud.cloud_utils import (
11
+ create_cloud_project,
12
+ project_exists,
13
+ sync_project,
14
+ )
15
+ from basic_memory.cli.commands.cloud.upload import upload_path
16
+
17
+ console = Console()
18
+
19
+
20
+ @cloud_app.command("upload")
21
+ def upload(
22
+ path: Path = typer.Argument(
23
+ ...,
24
+ help="Path to local file or directory to upload",
25
+ exists=True,
26
+ readable=True,
27
+ resolve_path=True,
28
+ ),
29
+ project: str = typer.Option(
30
+ ...,
31
+ "--project",
32
+ "-p",
33
+ help="Cloud project name (destination)",
34
+ ),
35
+ create_project: bool = typer.Option(
36
+ False,
37
+ "--create-project",
38
+ "-c",
39
+ help="Create project if it doesn't exist",
40
+ ),
41
+ sync: bool = typer.Option(
42
+ True,
43
+ "--sync/--no-sync",
44
+ help="Sync project after upload (default: true)",
45
+ ),
46
+ ) -> None:
47
+ """Upload local files or directories to cloud project via WebDAV.
48
+
49
+ Examples:
50
+ bm cloud upload ~/my-notes --project research
51
+ bm cloud upload notes.md --project research --create-project
52
+ bm cloud upload ~/docs --project work --no-sync
53
+ """
54
+
55
+ async def _upload():
56
+ # Check if project exists
57
+ if not await project_exists(project):
58
+ if create_project:
59
+ console.print(f"[blue]Creating cloud project '{project}'...[/blue]")
60
+ try:
61
+ await create_cloud_project(project)
62
+ console.print(f"[green]✓ Created project '{project}'[/green]")
63
+ except Exception as e:
64
+ console.print(f"[red]Failed to create project: {e}[/red]")
65
+ raise typer.Exit(1)
66
+ else:
67
+ console.print(
68
+ f"[red]Project '{project}' does not exist.[/red]\n"
69
+ f"[yellow]Options:[/yellow]\n"
70
+ f" 1. Create it first: bm project add {project}\n"
71
+ f" 2. Use --create-project flag to create automatically"
72
+ )
73
+ raise typer.Exit(1)
74
+
75
+ # Perform upload
76
+ console.print(f"[blue]Uploading {path} to project '{project}'...[/blue]")
77
+ success = await upload_path(path, project)
78
+ if not success:
79
+ console.print("[red]Upload failed[/red]")
80
+ raise typer.Exit(1)
81
+
82
+ console.print(f"[green]✅ Successfully uploaded to '{project}'[/green]")
83
+
84
+ # Sync project if requested
85
+ if sync:
86
+ console.print(f"[blue]Syncing project '{project}'...[/blue]")
87
+ try:
88
+ await sync_project(project)
89
+ except Exception as e:
90
+ console.print(f"[yellow]Warning: Sync failed: {e}[/yellow]")
91
+ console.print("[dim]Files uploaded but may not be indexed yet[/dim]")
92
+
93
+ asyncio.run(_upload())
@@ -7,8 +7,7 @@ import typer
7
7
 
8
8
  from rich.console import Console
9
9
 
10
- from basic_memory.cli.commands.cloud import get_authenticated_headers
11
- from basic_memory.mcp.async_client import client
10
+ from basic_memory.mcp.async_client import get_client
12
11
 
13
12
  from basic_memory.mcp.tools.utils import call_post, call_get
14
13
  from basic_memory.mcp.project_context import get_active_project
@@ -21,40 +20,24 @@ async def run_sync(project: Optional[str] = None):
21
20
  """Run sync operation via API endpoint."""
22
21
 
23
22
  try:
24
- from basic_memory.config import ConfigManager
25
-
26
- config = ConfigManager().config
27
- auth_headers = {}
28
- if config.cloud_mode_enabled:
29
- auth_headers = await get_authenticated_headers()
30
-
31
- project_item = await get_active_project(client, project, None, headers=auth_headers)
32
- response = await call_post(
33
- client, f"{project_item.project_url}/project/sync", headers=auth_headers
34
- )
35
- data = response.json()
36
- console.print(f"[green]✓ {data['message']}[/green]")
23
+ async with get_client() as client:
24
+ project_item = await get_active_project(client, project, None)
25
+ response = await call_post(client, f"{project_item.project_url}/project/sync")
26
+ data = response.json()
27
+ console.print(f"[green]✓ {data['message']}[/green]")
37
28
  except (ToolError, ValueError) as e:
38
29
  console.print(f"[red]✗ Sync failed: {e}[/red]")
39
30
  raise typer.Exit(1)
40
31
 
41
32
 
42
33
  async def get_project_info(project: str):
43
- """Run sync operation via API endpoint."""
34
+ """Get project information via API endpoint."""
44
35
 
45
36
  try:
46
- from basic_memory.config import ConfigManager
47
-
48
- config = ConfigManager().config
49
- auth_headers = {}
50
- if config.cloud_mode_enabled:
51
- auth_headers = await get_authenticated_headers()
52
-
53
- project_item = await get_active_project(client, project, None, headers=auth_headers)
54
- response = await call_get(
55
- client, f"{project_item.project_url}/project/info", headers=auth_headers
56
- )
57
- return ProjectInfoResponse.model_validate(response.json())
37
+ async with get_client() as client:
38
+ project_item = await get_active_project(client, project, None)
39
+ response = await call_get(client, f"{project_item.project_url}/project/info")
40
+ return ProjectInfoResponse.model_validate(response.json())
58
41
  except (ToolError, ValueError) as e:
59
42
  console.print(f"[red]✗ Sync failed: {e}[/red]")
60
43
  raise typer.Exit(1)