basic-memory 0.2.12__py3-none-any.whl → 0.16.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (149) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +27 -3
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  6. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  7. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  8. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  9. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  10. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  11. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
  12. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  13. basic_memory/api/app.py +63 -31
  14. basic_memory/api/routers/__init__.py +4 -1
  15. basic_memory/api/routers/directory_router.py +84 -0
  16. basic_memory/api/routers/importer_router.py +152 -0
  17. basic_memory/api/routers/knowledge_router.py +165 -28
  18. basic_memory/api/routers/management_router.py +80 -0
  19. basic_memory/api/routers/memory_router.py +28 -67
  20. basic_memory/api/routers/project_router.py +406 -0
  21. basic_memory/api/routers/prompt_router.py +260 -0
  22. basic_memory/api/routers/resource_router.py +219 -14
  23. basic_memory/api/routers/search_router.py +21 -13
  24. basic_memory/api/routers/utils.py +130 -0
  25. basic_memory/api/template_loader.py +292 -0
  26. basic_memory/cli/app.py +52 -1
  27. basic_memory/cli/auth.py +277 -0
  28. basic_memory/cli/commands/__init__.py +13 -2
  29. basic_memory/cli/commands/cloud/__init__.py +6 -0
  30. basic_memory/cli/commands/cloud/api_client.py +112 -0
  31. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  32. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  33. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  34. basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
  35. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  36. basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
  37. basic_memory/cli/commands/cloud/upload.py +233 -0
  38. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  39. basic_memory/cli/commands/command_utils.py +51 -0
  40. basic_memory/cli/commands/db.py +26 -7
  41. basic_memory/cli/commands/import_chatgpt.py +83 -0
  42. basic_memory/cli/commands/import_claude_conversations.py +86 -0
  43. basic_memory/cli/commands/import_claude_projects.py +85 -0
  44. basic_memory/cli/commands/import_memory_json.py +35 -92
  45. basic_memory/cli/commands/mcp.py +84 -10
  46. basic_memory/cli/commands/project.py +876 -0
  47. basic_memory/cli/commands/status.py +47 -30
  48. basic_memory/cli/commands/tool.py +341 -0
  49. basic_memory/cli/main.py +13 -6
  50. basic_memory/config.py +481 -22
  51. basic_memory/db.py +192 -32
  52. basic_memory/deps.py +252 -22
  53. basic_memory/file_utils.py +113 -58
  54. basic_memory/ignore_utils.py +297 -0
  55. basic_memory/importers/__init__.py +27 -0
  56. basic_memory/importers/base.py +79 -0
  57. basic_memory/importers/chatgpt_importer.py +232 -0
  58. basic_memory/importers/claude_conversations_importer.py +177 -0
  59. basic_memory/importers/claude_projects_importer.py +148 -0
  60. basic_memory/importers/memory_json_importer.py +108 -0
  61. basic_memory/importers/utils.py +58 -0
  62. basic_memory/markdown/entity_parser.py +143 -23
  63. basic_memory/markdown/markdown_processor.py +3 -3
  64. basic_memory/markdown/plugins.py +39 -21
  65. basic_memory/markdown/schemas.py +1 -1
  66. basic_memory/markdown/utils.py +28 -13
  67. basic_memory/mcp/async_client.py +134 -4
  68. basic_memory/mcp/project_context.py +141 -0
  69. basic_memory/mcp/prompts/__init__.py +19 -0
  70. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  71. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  72. basic_memory/mcp/prompts/recent_activity.py +188 -0
  73. basic_memory/mcp/prompts/search.py +57 -0
  74. basic_memory/mcp/prompts/utils.py +162 -0
  75. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  76. basic_memory/mcp/resources/project_info.py +71 -0
  77. basic_memory/mcp/server.py +7 -13
  78. basic_memory/mcp/tools/__init__.py +33 -21
  79. basic_memory/mcp/tools/build_context.py +120 -0
  80. basic_memory/mcp/tools/canvas.py +130 -0
  81. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  82. basic_memory/mcp/tools/delete_note.py +225 -0
  83. basic_memory/mcp/tools/edit_note.py +320 -0
  84. basic_memory/mcp/tools/list_directory.py +167 -0
  85. basic_memory/mcp/tools/move_note.py +545 -0
  86. basic_memory/mcp/tools/project_management.py +200 -0
  87. basic_memory/mcp/tools/read_content.py +271 -0
  88. basic_memory/mcp/tools/read_note.py +255 -0
  89. basic_memory/mcp/tools/recent_activity.py +534 -0
  90. basic_memory/mcp/tools/search.py +369 -14
  91. basic_memory/mcp/tools/utils.py +374 -16
  92. basic_memory/mcp/tools/view_note.py +77 -0
  93. basic_memory/mcp/tools/write_note.py +207 -0
  94. basic_memory/models/__init__.py +3 -2
  95. basic_memory/models/knowledge.py +67 -15
  96. basic_memory/models/project.py +87 -0
  97. basic_memory/models/search.py +10 -6
  98. basic_memory/repository/__init__.py +2 -0
  99. basic_memory/repository/entity_repository.py +229 -7
  100. basic_memory/repository/observation_repository.py +35 -3
  101. basic_memory/repository/project_info_repository.py +10 -0
  102. basic_memory/repository/project_repository.py +103 -0
  103. basic_memory/repository/relation_repository.py +21 -2
  104. basic_memory/repository/repository.py +147 -29
  105. basic_memory/repository/search_repository.py +437 -59
  106. basic_memory/schemas/__init__.py +22 -9
  107. basic_memory/schemas/base.py +97 -8
  108. basic_memory/schemas/cloud.py +50 -0
  109. basic_memory/schemas/directory.py +30 -0
  110. basic_memory/schemas/importer.py +35 -0
  111. basic_memory/schemas/memory.py +188 -23
  112. basic_memory/schemas/project_info.py +211 -0
  113. basic_memory/schemas/prompt.py +90 -0
  114. basic_memory/schemas/request.py +57 -3
  115. basic_memory/schemas/response.py +9 -1
  116. basic_memory/schemas/search.py +33 -35
  117. basic_memory/schemas/sync_report.py +72 -0
  118. basic_memory/services/__init__.py +2 -1
  119. basic_memory/services/context_service.py +251 -106
  120. basic_memory/services/directory_service.py +295 -0
  121. basic_memory/services/entity_service.py +595 -60
  122. basic_memory/services/exceptions.py +21 -0
  123. basic_memory/services/file_service.py +284 -30
  124. basic_memory/services/initialization.py +191 -0
  125. basic_memory/services/link_resolver.py +50 -56
  126. basic_memory/services/project_service.py +863 -0
  127. basic_memory/services/search_service.py +172 -34
  128. basic_memory/sync/__init__.py +3 -2
  129. basic_memory/sync/background_sync.py +26 -0
  130. basic_memory/sync/sync_service.py +1176 -96
  131. basic_memory/sync/watch_service.py +412 -135
  132. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  133. basic_memory/templates/prompts/search.hbs +101 -0
  134. basic_memory/utils.py +388 -28
  135. basic_memory-0.16.1.dist-info/METADATA +493 -0
  136. basic_memory-0.16.1.dist-info/RECORD +148 -0
  137. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
  138. basic_memory/alembic/README +0 -1
  139. basic_memory/cli/commands/sync.py +0 -203
  140. basic_memory/mcp/tools/knowledge.py +0 -56
  141. basic_memory/mcp/tools/memory.py +0 -151
  142. basic_memory/mcp/tools/notes.py +0 -122
  143. basic_memory/schemas/discovery.py +0 -28
  144. basic_memory/sync/file_change_scanner.py +0 -158
  145. basic_memory/sync/utils.py +0 -34
  146. basic_memory-0.2.12.dist-info/METADATA +0 -291
  147. basic_memory-0.2.12.dist-info/RECORD +0 -78
  148. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
  149. {basic_memory-0.2.12.dist-info → basic_memory-0.16.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,112 @@
1
+ """Cloud API client utilities."""
2
+
3
+ from typing import Optional
4
+
5
+ import httpx
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from basic_memory.cli.auth import CLIAuth
10
+ from basic_memory.config import ConfigManager
11
+
12
+ console = Console()
13
+
14
+
15
+ class CloudAPIError(Exception):
16
+ """Exception raised for cloud API errors."""
17
+
18
+ def __init__(
19
+ self, message: str, status_code: Optional[int] = None, detail: Optional[dict] = None
20
+ ):
21
+ super().__init__(message)
22
+ self.status_code = status_code
23
+ self.detail = detail or {}
24
+
25
+
26
+ class SubscriptionRequiredError(CloudAPIError):
27
+ """Exception raised when user needs an active subscription."""
28
+
29
+ def __init__(self, message: str, subscribe_url: str):
30
+ super().__init__(message, status_code=403, detail={"error": "subscription_required"})
31
+ self.subscribe_url = subscribe_url
32
+
33
+
34
+ def get_cloud_config() -> tuple[str, str, str]:
35
+ """Get cloud OAuth configuration from config."""
36
+ config_manager = ConfigManager()
37
+ config = config_manager.config
38
+ return config.cloud_client_id, config.cloud_domain, config.cloud_host
39
+
40
+
41
+ async def get_authenticated_headers() -> dict[str, str]:
42
+ """
43
+ Get authentication headers with JWT token.
44
+ handles jwt refresh if needed.
45
+ """
46
+ client_id, domain, _ = get_cloud_config()
47
+ auth = CLIAuth(client_id=client_id, authkit_domain=domain)
48
+ token = await auth.get_valid_token()
49
+ if not token:
50
+ console.print("[red]Not authenticated. Please run 'basic-memory cloud login' first.[/red]")
51
+ raise typer.Exit(1)
52
+
53
+ return {"Authorization": f"Bearer {token}"}
54
+
55
+
56
+ async def make_api_request(
57
+ method: str,
58
+ url: str,
59
+ headers: Optional[dict] = None,
60
+ json_data: Optional[dict] = None,
61
+ timeout: float = 30.0,
62
+ ) -> httpx.Response:
63
+ """Make an API request to the cloud service."""
64
+ headers = headers or {}
65
+ auth_headers = await get_authenticated_headers()
66
+ headers.update(auth_headers)
67
+ # Add debug headers to help with compression issues
68
+ headers.setdefault("Accept-Encoding", "identity") # Disable compression for debugging
69
+
70
+ async with httpx.AsyncClient(timeout=timeout) as client:
71
+ try:
72
+ response = await client.request(method=method, url=url, headers=headers, json=json_data)
73
+ response.raise_for_status()
74
+ return response
75
+ except httpx.HTTPError as e:
76
+ # Check if this is a response error with response details
77
+ if hasattr(e, "response") and e.response is not None: # pyright: ignore [reportAttributeAccessIssue]
78
+ response = e.response # type: ignore
79
+
80
+ # Try to parse error detail from response
81
+ error_detail = None
82
+ try:
83
+ error_detail = response.json()
84
+ except Exception:
85
+ # If JSON parsing fails, we'll handle it as a generic error
86
+ pass
87
+
88
+ # Check for subscription_required error (403)
89
+ if response.status_code == 403 and isinstance(error_detail, dict):
90
+ # Handle both FastAPI HTTPException format (nested under "detail")
91
+ # and direct format
92
+ detail_obj = error_detail.get("detail", error_detail)
93
+ if (
94
+ isinstance(detail_obj, dict)
95
+ and detail_obj.get("error") == "subscription_required"
96
+ ):
97
+ message = detail_obj.get("message", "Active subscription required")
98
+ subscribe_url = detail_obj.get(
99
+ "subscribe_url", "https://basicmemory.com/subscribe"
100
+ )
101
+ raise SubscriptionRequiredError(
102
+ message=message, subscribe_url=subscribe_url
103
+ ) from e
104
+
105
+ # Raise generic CloudAPIError with status code and detail
106
+ raise CloudAPIError(
107
+ f"API request failed: {e}",
108
+ status_code=response.status_code,
109
+ detail=error_detail if isinstance(error_detail, dict) else {},
110
+ ) from e
111
+
112
+ raise CloudAPIError(f"API request failed: {e}") from e
@@ -0,0 +1,110 @@
1
+ """Cloud bisync utility functions for Basic Memory CLI."""
2
+
3
+ from pathlib import Path
4
+
5
+ from basic_memory.cli.commands.cloud.api_client import make_api_request
6
+ from basic_memory.config import ConfigManager
7
+ from basic_memory.ignore_utils import create_default_bmignore, get_bmignore_path
8
+ from basic_memory.schemas.cloud import MountCredentials, TenantMountInfo
9
+
10
+
11
+ class BisyncError(Exception):
12
+ """Exception raised for bisync-related errors."""
13
+
14
+ pass
15
+
16
+
17
+ async def get_mount_info() -> TenantMountInfo:
18
+ """Get current tenant information from cloud API."""
19
+ try:
20
+ config_manager = ConfigManager()
21
+ config = config_manager.config
22
+ host_url = config.cloud_host.rstrip("/")
23
+
24
+ response = await make_api_request(method="GET", url=f"{host_url}/tenant/mount/info")
25
+
26
+ return TenantMountInfo.model_validate(response.json())
27
+ except Exception as e:
28
+ raise BisyncError(f"Failed to get tenant info: {e}") from e
29
+
30
+
31
+ async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
32
+ """Generate scoped credentials for syncing."""
33
+ try:
34
+ config_manager = ConfigManager()
35
+ config = config_manager.config
36
+ host_url = config.cloud_host.rstrip("/")
37
+
38
+ response = await make_api_request(method="POST", url=f"{host_url}/tenant/mount/credentials")
39
+
40
+ return MountCredentials.model_validate(response.json())
41
+ except Exception as e:
42
+ raise BisyncError(f"Failed to generate credentials: {e}") from e
43
+
44
+
45
+ def convert_bmignore_to_rclone_filters() -> Path:
46
+ """Convert .bmignore patterns to rclone filter format.
47
+
48
+ Reads ~/.basic-memory/.bmignore (gitignore-style) and converts to
49
+ ~/.basic-memory/.bmignore.rclone (rclone filter format).
50
+
51
+ Only regenerates if .bmignore has been modified since last conversion.
52
+
53
+ Returns:
54
+ Path to converted rclone filter file
55
+ """
56
+ # Ensure .bmignore exists
57
+ create_default_bmignore()
58
+
59
+ bmignore_path = get_bmignore_path()
60
+ # Create rclone filter path: ~/.basic-memory/.bmignore -> ~/.basic-memory/.bmignore.rclone
61
+ rclone_filter_path = bmignore_path.parent / f"{bmignore_path.name}.rclone"
62
+
63
+ # Skip regeneration if rclone file is newer than bmignore
64
+ if rclone_filter_path.exists():
65
+ bmignore_mtime = bmignore_path.stat().st_mtime
66
+ rclone_mtime = rclone_filter_path.stat().st_mtime
67
+ if rclone_mtime >= bmignore_mtime:
68
+ return rclone_filter_path
69
+
70
+ # Read .bmignore patterns
71
+ patterns = []
72
+ try:
73
+ with bmignore_path.open("r", encoding="utf-8") as f:
74
+ for line in f:
75
+ line = line.strip()
76
+ # Keep comments and empty lines
77
+ if not line or line.startswith("#"):
78
+ patterns.append(line)
79
+ continue
80
+
81
+ # Convert gitignore pattern to rclone filter syntax
82
+ # gitignore: node_modules → rclone: - node_modules/**
83
+ # gitignore: *.pyc → rclone: - *.pyc
84
+ if "*" in line:
85
+ # Pattern already has wildcard, just add exclude prefix
86
+ patterns.append(f"- {line}")
87
+ else:
88
+ # Directory pattern - add /** for recursive exclude
89
+ patterns.append(f"- {line}/**")
90
+
91
+ except Exception:
92
+ # If we can't read the file, create a minimal filter
93
+ patterns = ["# Error reading .bmignore, using minimal filters", "- .git/**"]
94
+
95
+ # Write rclone filter file
96
+ rclone_filter_path.write_text("\n".join(patterns) + "\n")
97
+
98
+ return rclone_filter_path
99
+
100
+
101
+ def get_bisync_filter_path() -> Path:
102
+ """Get path to bisync filter file.
103
+
104
+ Uses ~/.basic-memory/.bmignore (converted to rclone format).
105
+ The file is automatically created with default patterns on first use.
106
+
107
+ Returns:
108
+ Path to rclone filter file
109
+ """
110
+ return convert_bmignore_to_rclone_filters()
@@ -0,0 +1,101 @@
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, force_full: bool = False) -> None:
73
+ """Trigger sync for a specific project on cloud.
74
+
75
+ Args:
76
+ project_name: Name of project to sync
77
+ force_full: If True, force a full scan bypassing watermark optimization
78
+ """
79
+ try:
80
+ from basic_memory.cli.commands.command_utils import run_sync
81
+
82
+ await run_sync(project=project_name, force_full=force_full)
83
+ except Exception as e:
84
+ raise CloudUtilsError(f"Failed to sync project '{project_name}': {e}") from e
85
+
86
+
87
+ async def project_exists(project_name: str) -> bool:
88
+ """Check if a project exists on cloud.
89
+
90
+ Args:
91
+ project_name: Name of project to check
92
+
93
+ Returns:
94
+ True if project exists, False otherwise
95
+ """
96
+ try:
97
+ projects = await fetch_cloud_projects()
98
+ project_names = {p.name for p in projects.projects}
99
+ return project_name in project_names
100
+ except Exception:
101
+ return False
@@ -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)