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