basic-memory 0.14.3__py3-none-any.whl → 0.15.0__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 +1 -1
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/api/app.py +10 -4
- basic_memory/api/routers/knowledge_router.py +25 -8
- basic_memory/api/routers/project_router.py +99 -4
- basic_memory/api/routers/resource_router.py +3 -3
- basic_memory/cli/app.py +9 -28
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/cloud/__init__.py +5 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
- basic_memory/cli/commands/cloud/core_commands.py +288 -0
- basic_memory/cli/commands/cloud/mount_commands.py +295 -0
- basic_memory/cli/commands/cloud/rclone_config.py +288 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
- basic_memory/cli/commands/command_utils.py +60 -0
- basic_memory/cli/commands/import_memory_json.py +0 -4
- basic_memory/cli/commands/mcp.py +16 -4
- basic_memory/cli/commands/project.py +141 -145
- basic_memory/cli/commands/status.py +34 -22
- basic_memory/cli/commands/sync.py +45 -228
- basic_memory/cli/commands/tool.py +87 -16
- basic_memory/cli/main.py +1 -0
- basic_memory/config.py +96 -20
- basic_memory/db.py +104 -3
- basic_memory/deps.py +20 -3
- basic_memory/file_utils.py +89 -0
- basic_memory/ignore_utils.py +295 -0
- basic_memory/importers/chatgpt_importer.py +1 -1
- basic_memory/importers/utils.py +2 -2
- basic_memory/markdown/entity_parser.py +2 -2
- basic_memory/markdown/markdown_processor.py +2 -2
- basic_memory/markdown/plugins.py +39 -21
- basic_memory/markdown/utils.py +1 -1
- basic_memory/mcp/async_client.py +22 -10
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
- basic_memory/mcp/prompts/continue_conversation.py +1 -1
- basic_memory/mcp/prompts/recent_activity.py +116 -32
- basic_memory/mcp/prompts/search.py +1 -1
- basic_memory/mcp/prompts/utils.py +11 -4
- basic_memory/mcp/resources/ai_assistant_guide.md +179 -41
- basic_memory/mcp/resources/project_info.py +20 -6
- basic_memory/mcp/server.py +0 -37
- basic_memory/mcp/tools/__init__.py +5 -6
- basic_memory/mcp/tools/build_context.py +39 -19
- basic_memory/mcp/tools/canvas.py +19 -8
- basic_memory/mcp/tools/chatgpt_tools.py +178 -0
- basic_memory/mcp/tools/delete_note.py +67 -34
- basic_memory/mcp/tools/edit_note.py +55 -39
- basic_memory/mcp/tools/headers.py +44 -0
- basic_memory/mcp/tools/list_directory.py +18 -8
- basic_memory/mcp/tools/move_note.py +119 -41
- basic_memory/mcp/tools/project_management.py +77 -229
- basic_memory/mcp/tools/read_content.py +28 -12
- basic_memory/mcp/tools/read_note.py +97 -57
- basic_memory/mcp/tools/recent_activity.py +441 -42
- basic_memory/mcp/tools/search.py +82 -70
- basic_memory/mcp/tools/sync_status.py +5 -4
- basic_memory/mcp/tools/utils.py +19 -0
- basic_memory/mcp/tools/view_note.py +31 -6
- basic_memory/mcp/tools/write_note.py +65 -14
- basic_memory/models/knowledge.py +19 -2
- basic_memory/models/project.py +6 -2
- basic_memory/repository/entity_repository.py +31 -84
- basic_memory/repository/project_repository.py +1 -1
- basic_memory/repository/relation_repository.py +13 -0
- basic_memory/repository/repository.py +2 -2
- basic_memory/repository/search_repository.py +9 -3
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/base.py +70 -12
- basic_memory/schemas/cloud.py +46 -0
- basic_memory/schemas/memory.py +99 -18
- basic_memory/schemas/project_info.py +9 -10
- basic_memory/schemas/sync_report.py +48 -0
- basic_memory/services/context_service.py +35 -11
- basic_memory/services/directory_service.py +7 -0
- basic_memory/services/entity_service.py +82 -52
- basic_memory/services/initialization.py +30 -11
- basic_memory/services/project_service.py +23 -33
- basic_memory/sync/sync_service.py +148 -24
- basic_memory/sync/watch_service.py +128 -44
- basic_memory/utils.py +181 -109
- {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/METADATA +26 -96
- basic_memory-0.15.0.dist-info/RECORD +147 -0
- basic_memory/mcp/project_session.py +0 -120
- basic_memory-0.14.3.dist-info/RECORD +0 -132
- {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Core cloud commands for Basic Memory CLI."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Optional
|
|
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.auth import CLIAuth
|
|
11
|
+
from basic_memory.config import ConfigManager
|
|
12
|
+
from basic_memory.cli.commands.cloud.api_client import (
|
|
13
|
+
CloudAPIError,
|
|
14
|
+
SubscriptionRequiredError,
|
|
15
|
+
get_cloud_config,
|
|
16
|
+
make_api_request,
|
|
17
|
+
)
|
|
18
|
+
from basic_memory.cli.commands.cloud.mount_commands import (
|
|
19
|
+
mount_cloud_files,
|
|
20
|
+
setup_cloud_mount,
|
|
21
|
+
show_mount_status,
|
|
22
|
+
unmount_cloud_files,
|
|
23
|
+
)
|
|
24
|
+
from basic_memory.cli.commands.cloud.bisync_commands import (
|
|
25
|
+
run_bisync,
|
|
26
|
+
run_bisync_watch,
|
|
27
|
+
run_check,
|
|
28
|
+
setup_cloud_bisync,
|
|
29
|
+
show_bisync_status,
|
|
30
|
+
)
|
|
31
|
+
from basic_memory.cli.commands.cloud.rclone_config import MOUNT_PROFILES
|
|
32
|
+
from basic_memory.cli.commands.cloud.bisync_commands import BISYNC_PROFILES
|
|
33
|
+
|
|
34
|
+
console = Console()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@cloud_app.command()
|
|
38
|
+
def login():
|
|
39
|
+
"""Authenticate with WorkOS using OAuth Device Authorization flow and enable cloud mode."""
|
|
40
|
+
|
|
41
|
+
async def _login():
|
|
42
|
+
client_id, domain, host_url = get_cloud_config()
|
|
43
|
+
auth = CLIAuth(client_id=client_id, authkit_domain=domain)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
success = await auth.login()
|
|
47
|
+
if not success:
|
|
48
|
+
console.print("[red]Login failed[/red]")
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
|
|
51
|
+
# Test subscription access by calling a protected endpoint
|
|
52
|
+
console.print("[dim]Verifying subscription access...[/dim]")
|
|
53
|
+
await make_api_request("GET", f"{host_url.rstrip('/')}/proxy/health")
|
|
54
|
+
|
|
55
|
+
# Enable cloud mode after successful login and subscription validation
|
|
56
|
+
config_manager = ConfigManager()
|
|
57
|
+
config = config_manager.load_config()
|
|
58
|
+
config.cloud_mode = True
|
|
59
|
+
config_manager.save_config(config)
|
|
60
|
+
|
|
61
|
+
console.print("[green]✓ Cloud mode enabled[/green]")
|
|
62
|
+
console.print(f"[dim]All CLI commands now work against {host_url}[/dim]")
|
|
63
|
+
|
|
64
|
+
except SubscriptionRequiredError as e:
|
|
65
|
+
console.print("\n[red]✗ Subscription Required[/red]\n")
|
|
66
|
+
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
|
|
67
|
+
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
|
|
68
|
+
console.print(
|
|
69
|
+
"[dim]Once you have an active subscription, run [bold]bm cloud login[/bold] again.[/dim]"
|
|
70
|
+
)
|
|
71
|
+
raise typer.Exit(1)
|
|
72
|
+
|
|
73
|
+
asyncio.run(_login())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@cloud_app.command()
|
|
77
|
+
def logout():
|
|
78
|
+
"""Disable cloud mode and return to local mode."""
|
|
79
|
+
|
|
80
|
+
# Disable cloud mode
|
|
81
|
+
config_manager = ConfigManager()
|
|
82
|
+
config = config_manager.load_config()
|
|
83
|
+
config.cloud_mode = False
|
|
84
|
+
config_manager.save_config(config)
|
|
85
|
+
|
|
86
|
+
console.print("[green]✓ Cloud mode disabled[/green]")
|
|
87
|
+
console.print("[dim]All CLI commands now work locally[/dim]")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@cloud_app.command("status")
|
|
91
|
+
def status(
|
|
92
|
+
bisync: bool = typer.Option(
|
|
93
|
+
True,
|
|
94
|
+
"--bisync/--mount",
|
|
95
|
+
help="Show bisync status (default) or mount status",
|
|
96
|
+
),
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Check cloud mode status and cloud instance health.
|
|
99
|
+
|
|
100
|
+
Shows cloud mode status, instance health, and sync/mount status.
|
|
101
|
+
Use --bisync (default) to show bisync status or --mount for mount status.
|
|
102
|
+
"""
|
|
103
|
+
# Check cloud mode
|
|
104
|
+
config_manager = ConfigManager()
|
|
105
|
+
config = config_manager.load_config()
|
|
106
|
+
|
|
107
|
+
console.print("[bold blue]Cloud Mode Status[/bold blue]")
|
|
108
|
+
if config.cloud_mode:
|
|
109
|
+
console.print(" Mode: [green]Cloud (enabled)[/green]")
|
|
110
|
+
console.print(f" Host: {config.cloud_host}")
|
|
111
|
+
console.print(" [dim]All CLI commands work against cloud[/dim]")
|
|
112
|
+
else:
|
|
113
|
+
console.print(" Mode: [yellow]Local (disabled)[/yellow]")
|
|
114
|
+
console.print(" [dim]All CLI commands work locally[/dim]")
|
|
115
|
+
console.print("\n[dim]To enable cloud mode, run: bm cloud login[/dim]")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
# Get cloud configuration
|
|
119
|
+
_, _, host_url = get_cloud_config()
|
|
120
|
+
host_url = host_url.rstrip("/")
|
|
121
|
+
|
|
122
|
+
# Prepare headers
|
|
123
|
+
headers = {}
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
console.print("\n[blue]Checking cloud instance health...[/blue]")
|
|
127
|
+
|
|
128
|
+
# Make API request to check health
|
|
129
|
+
response = asyncio.run(
|
|
130
|
+
make_api_request(method="GET", url=f"{host_url}/proxy/health", headers=headers)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
health_data = response.json()
|
|
134
|
+
|
|
135
|
+
console.print("[green]Cloud instance is healthy[/green]")
|
|
136
|
+
|
|
137
|
+
# Display status details
|
|
138
|
+
if "status" in health_data:
|
|
139
|
+
console.print(f" Status: {health_data['status']}")
|
|
140
|
+
if "version" in health_data:
|
|
141
|
+
console.print(f" Version: {health_data['version']}")
|
|
142
|
+
if "timestamp" in health_data:
|
|
143
|
+
console.print(f" Timestamp: {health_data['timestamp']}")
|
|
144
|
+
|
|
145
|
+
# Show sync/mount status based on flag
|
|
146
|
+
console.print()
|
|
147
|
+
if bisync:
|
|
148
|
+
show_bisync_status()
|
|
149
|
+
else:
|
|
150
|
+
show_mount_status()
|
|
151
|
+
|
|
152
|
+
except CloudAPIError as e:
|
|
153
|
+
console.print(f"[red]Error checking cloud health: {e}[/red]")
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
console.print(f"[red]Unexpected error: {e}[/red]")
|
|
157
|
+
raise typer.Exit(1)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# Mount commands
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@cloud_app.command("setup")
|
|
164
|
+
def setup(
|
|
165
|
+
bisync: bool = typer.Option(
|
|
166
|
+
True,
|
|
167
|
+
"--bisync/--mount",
|
|
168
|
+
help="Use bidirectional sync (recommended) or mount as network drive",
|
|
169
|
+
),
|
|
170
|
+
sync_dir: Optional[str] = typer.Option(
|
|
171
|
+
None,
|
|
172
|
+
"--dir",
|
|
173
|
+
help="Custom sync directory for bisync (default: ~/basic-memory-cloud-sync)",
|
|
174
|
+
),
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Set up cloud file access with automatic rclone installation and configuration.
|
|
177
|
+
|
|
178
|
+
Default: Sets up bidirectional sync (recommended).\n
|
|
179
|
+
Use --mount: Sets up mount as network drive (alternative workflow).\n
|
|
180
|
+
|
|
181
|
+
Examples:\n
|
|
182
|
+
bm cloud setup # Setup bisync (default)\n
|
|
183
|
+
bm cloud setup --mount # Setup mount instead\n
|
|
184
|
+
bm cloud setup --dir ~/sync # Custom bisync directory\n
|
|
185
|
+
"""
|
|
186
|
+
if bisync:
|
|
187
|
+
setup_cloud_bisync(sync_dir=sync_dir)
|
|
188
|
+
else:
|
|
189
|
+
setup_cloud_mount()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@cloud_app.command("mount")
|
|
193
|
+
def mount(
|
|
194
|
+
profile: str = typer.Option(
|
|
195
|
+
"balanced", help=f"Mount profile: {', '.join(MOUNT_PROFILES.keys())}"
|
|
196
|
+
),
|
|
197
|
+
path: Optional[str] = typer.Option(
|
|
198
|
+
None, help="Custom mount path (default: ~/basic-memory-{tenant-id})"
|
|
199
|
+
),
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Mount cloud files locally for editing."""
|
|
202
|
+
try:
|
|
203
|
+
mount_cloud_files(profile_name=profile)
|
|
204
|
+
except Exception as e:
|
|
205
|
+
console.print(f"[red]Mount failed: {e}[/red]")
|
|
206
|
+
raise typer.Exit(1)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@cloud_app.command("unmount")
|
|
210
|
+
def unmount() -> None:
|
|
211
|
+
"""Unmount cloud files."""
|
|
212
|
+
try:
|
|
213
|
+
unmount_cloud_files()
|
|
214
|
+
except Exception as e:
|
|
215
|
+
console.print(f"[red]Unmount failed: {e}[/red]")
|
|
216
|
+
raise typer.Exit(1)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# Bisync commands
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@cloud_app.command("bisync")
|
|
223
|
+
def bisync(
|
|
224
|
+
profile: str = typer.Option(
|
|
225
|
+
"balanced", help=f"Bisync profile: {', '.join(BISYNC_PROFILES.keys())}"
|
|
226
|
+
),
|
|
227
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
|
|
228
|
+
resync: bool = typer.Option(False, "--resync", help="Force resync to establish new baseline"),
|
|
229
|
+
watch: bool = typer.Option(False, "--watch", help="Run continuous sync in watch mode"),
|
|
230
|
+
interval: int = typer.Option(60, "--interval", help="Sync interval in seconds for watch mode"),
|
|
231
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed sync output"),
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Run bidirectional sync between local files and cloud storage.
|
|
234
|
+
|
|
235
|
+
Examples:
|
|
236
|
+
basic-memory cloud bisync # Manual sync with balanced profile
|
|
237
|
+
basic-memory cloud bisync --dry-run # Preview what would be synced
|
|
238
|
+
basic-memory cloud bisync --resync # Establish new baseline
|
|
239
|
+
basic-memory cloud bisync --watch # Continuous sync every 60s
|
|
240
|
+
basic-memory cloud bisync --watch --interval 30 # Continuous sync every 30s
|
|
241
|
+
basic-memory cloud bisync --profile safe # Use safe profile (keep conflicts)
|
|
242
|
+
basic-memory cloud bisync --verbose # Show detailed file sync output
|
|
243
|
+
"""
|
|
244
|
+
try:
|
|
245
|
+
if watch:
|
|
246
|
+
run_bisync_watch(profile_name=profile, interval_seconds=interval)
|
|
247
|
+
else:
|
|
248
|
+
run_bisync(profile_name=profile, dry_run=dry_run, resync=resync, verbose=verbose)
|
|
249
|
+
except Exception as e:
|
|
250
|
+
console.print(f"[red]Bisync failed: {e}[/red]")
|
|
251
|
+
raise typer.Exit(1)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@cloud_app.command("bisync-status")
|
|
255
|
+
def bisync_status() -> None:
|
|
256
|
+
"""Show current bisync status and configuration.
|
|
257
|
+
|
|
258
|
+
DEPRECATED: Use 'bm cloud status' instead (bisync is now the default).
|
|
259
|
+
"""
|
|
260
|
+
console.print(
|
|
261
|
+
"[yellow]Note: 'bisync-status' is deprecated. Use 'bm cloud status' instead.[/yellow]"
|
|
262
|
+
)
|
|
263
|
+
console.print("[dim]Showing bisync status...[/dim]\n")
|
|
264
|
+
show_bisync_status()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@cloud_app.command("check")
|
|
268
|
+
def check(
|
|
269
|
+
one_way: bool = typer.Option(
|
|
270
|
+
False,
|
|
271
|
+
"--one-way",
|
|
272
|
+
help="Only check for missing files on destination (faster)",
|
|
273
|
+
),
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Check file integrity between local and cloud storage using rclone check.
|
|
276
|
+
|
|
277
|
+
Verifies that files match between your local bisync directory and cloud storage
|
|
278
|
+
without transferring any data. This is useful for validating sync integrity.
|
|
279
|
+
|
|
280
|
+
Examples:
|
|
281
|
+
bm cloud check # Full integrity check
|
|
282
|
+
bm cloud check --one-way # Faster check (missing files only)
|
|
283
|
+
"""
|
|
284
|
+
try:
|
|
285
|
+
run_check(one_way=one_way)
|
|
286
|
+
except Exception as e:
|
|
287
|
+
console.print(f"[red]Check failed: {e}[/red]")
|
|
288
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Cloud mount commands for Basic Memory CLI."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from basic_memory.cli.commands.cloud.api_client import CloudAPIError, make_api_request
|
|
14
|
+
from basic_memory.cli.commands.cloud.rclone_config import (
|
|
15
|
+
MOUNT_PROFILES,
|
|
16
|
+
add_tenant_to_rclone_config,
|
|
17
|
+
build_mount_command,
|
|
18
|
+
cleanup_orphaned_rclone_processes,
|
|
19
|
+
get_default_mount_path,
|
|
20
|
+
get_rclone_processes,
|
|
21
|
+
is_path_mounted,
|
|
22
|
+
unmount_path,
|
|
23
|
+
)
|
|
24
|
+
from basic_memory.cli.commands.cloud.rclone_installer import RcloneInstallError, install_rclone
|
|
25
|
+
from basic_memory.config import ConfigManager
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MountError(Exception):
|
|
31
|
+
"""Exception raised for mount-related errors."""
|
|
32
|
+
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def get_tenant_info() -> dict:
|
|
37
|
+
"""Get current tenant information from cloud API."""
|
|
38
|
+
try:
|
|
39
|
+
config_manager = ConfigManager()
|
|
40
|
+
config = config_manager.config
|
|
41
|
+
host_url = config.cloud_host.rstrip("/")
|
|
42
|
+
|
|
43
|
+
response = await make_api_request(method="GET", url=f"{host_url}/tenant/mount/info")
|
|
44
|
+
|
|
45
|
+
return response.json()
|
|
46
|
+
except Exception as e:
|
|
47
|
+
raise MountError(f"Failed to get tenant info: {e}") from e
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def generate_mount_credentials(tenant_id: str) -> dict:
|
|
51
|
+
"""Generate scoped credentials for mounting."""
|
|
52
|
+
try:
|
|
53
|
+
config_manager = ConfigManager()
|
|
54
|
+
config = config_manager.config
|
|
55
|
+
host_url = config.cloud_host.rstrip("/")
|
|
56
|
+
|
|
57
|
+
response = await make_api_request(method="POST", url=f"{host_url}/tenant/mount/credentials")
|
|
58
|
+
|
|
59
|
+
return response.json()
|
|
60
|
+
except Exception as e:
|
|
61
|
+
raise MountError(f"Failed to generate mount credentials: {e}") from e
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def setup_cloud_mount() -> None:
|
|
65
|
+
"""Set up cloud mount with rclone installation and configuration."""
|
|
66
|
+
console.print("[bold blue]Basic Memory Cloud Setup[/bold blue]")
|
|
67
|
+
console.print("Setting up local file access to your cloud tenant...\n")
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
# Step 1: Install rclone
|
|
71
|
+
console.print("[blue]Step 1: Installing rclone...[/blue]")
|
|
72
|
+
install_rclone()
|
|
73
|
+
|
|
74
|
+
# Step 2: Get tenant info
|
|
75
|
+
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
|
|
76
|
+
tenant_info = asyncio.run(get_tenant_info())
|
|
77
|
+
|
|
78
|
+
tenant_id = tenant_info.get("tenant_id")
|
|
79
|
+
bucket_name = tenant_info.get("bucket_name")
|
|
80
|
+
|
|
81
|
+
if not tenant_id or not bucket_name:
|
|
82
|
+
raise MountError("Invalid tenant information received from cloud API")
|
|
83
|
+
|
|
84
|
+
console.print(f"[green]✓ Found tenant: {tenant_id}[/green]")
|
|
85
|
+
console.print(f"[green]✓ Bucket: {bucket_name}[/green]")
|
|
86
|
+
|
|
87
|
+
# Step 3: Generate mount credentials
|
|
88
|
+
console.print("\n[blue]Step 3: Generating mount credentials...[/blue]")
|
|
89
|
+
creds = asyncio.run(generate_mount_credentials(tenant_id))
|
|
90
|
+
|
|
91
|
+
access_key = creds.get("access_key")
|
|
92
|
+
secret_key = creds.get("secret_key")
|
|
93
|
+
|
|
94
|
+
if not access_key or not secret_key:
|
|
95
|
+
raise MountError("Failed to generate mount credentials")
|
|
96
|
+
|
|
97
|
+
console.print("[green]✓ Generated secure credentials[/green]")
|
|
98
|
+
|
|
99
|
+
# Step 4: Configure rclone
|
|
100
|
+
console.print("\n[blue]Step 4: Configuring rclone...[/blue]")
|
|
101
|
+
add_tenant_to_rclone_config(
|
|
102
|
+
tenant_id=tenant_id,
|
|
103
|
+
bucket_name=bucket_name,
|
|
104
|
+
access_key=access_key,
|
|
105
|
+
secret_key=secret_key,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Step 5: Perform initial mount
|
|
109
|
+
console.print("\n[blue]Step 5: Mounting cloud files...[/blue]")
|
|
110
|
+
mount_path = get_default_mount_path()
|
|
111
|
+
MOUNT_PROFILES["balanced"]
|
|
112
|
+
|
|
113
|
+
mount_cloud_files(
|
|
114
|
+
tenant_id=tenant_id,
|
|
115
|
+
bucket_name=bucket_name,
|
|
116
|
+
mount_path=mount_path,
|
|
117
|
+
profile_name="balanced",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
console.print("\n[bold green]✓ Cloud setup completed successfully![/bold green]")
|
|
121
|
+
console.print("\nYour cloud files are now accessible at:")
|
|
122
|
+
console.print(f" {mount_path}")
|
|
123
|
+
console.print("\nYou can now edit files locally and they will sync to the cloud!")
|
|
124
|
+
console.print("\nUseful commands:")
|
|
125
|
+
console.print(" basic-memory cloud mount-status # Check mount status")
|
|
126
|
+
console.print(" basic-memory cloud unmount # Unmount files")
|
|
127
|
+
console.print(" basic-memory cloud mount --profile fast # Remount with faster sync")
|
|
128
|
+
|
|
129
|
+
except (RcloneInstallError, MountError, CloudAPIError) as e:
|
|
130
|
+
console.print(f"\n[red]Setup failed: {e}[/red]")
|
|
131
|
+
raise typer.Exit(1)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
|
|
134
|
+
raise typer.Exit(1)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def mount_cloud_files(
|
|
138
|
+
tenant_id: Optional[str] = None,
|
|
139
|
+
bucket_name: Optional[str] = None,
|
|
140
|
+
mount_path: Optional[Path] = None,
|
|
141
|
+
profile_name: str = "balanced",
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Mount cloud files with specified profile."""
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
# Get tenant info if not provided
|
|
147
|
+
if not tenant_id or not bucket_name:
|
|
148
|
+
tenant_info = asyncio.run(get_tenant_info())
|
|
149
|
+
tenant_id = tenant_info.get("tenant_id")
|
|
150
|
+
bucket_name = tenant_info.get("bucket_name")
|
|
151
|
+
|
|
152
|
+
if not tenant_id or not bucket_name:
|
|
153
|
+
raise MountError("Could not determine tenant information")
|
|
154
|
+
|
|
155
|
+
# Set default mount path if not provided
|
|
156
|
+
if not mount_path:
|
|
157
|
+
mount_path = get_default_mount_path()
|
|
158
|
+
|
|
159
|
+
# Get mount profile
|
|
160
|
+
if profile_name not in MOUNT_PROFILES:
|
|
161
|
+
raise MountError(
|
|
162
|
+
f"Unknown profile: {profile_name}. Available: {list(MOUNT_PROFILES.keys())}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
profile = MOUNT_PROFILES[profile_name]
|
|
166
|
+
|
|
167
|
+
# Check if already mounted
|
|
168
|
+
if is_path_mounted(mount_path):
|
|
169
|
+
console.print(f"[yellow]Path {mount_path} is already mounted[/yellow]")
|
|
170
|
+
console.print("Use 'basic-memory cloud unmount' first, or mount to a different path")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
# Create mount directory
|
|
174
|
+
mount_path.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
|
|
176
|
+
# Build and execute mount command
|
|
177
|
+
mount_cmd = build_mount_command(tenant_id, bucket_name, mount_path, profile)
|
|
178
|
+
|
|
179
|
+
console.print(
|
|
180
|
+
f"[blue]Mounting with profile '{profile_name}' ({profile.description})...[/blue]"
|
|
181
|
+
)
|
|
182
|
+
console.print(f"[dim]Command: {' '.join(mount_cmd)}[/dim]")
|
|
183
|
+
|
|
184
|
+
result = subprocess.run(mount_cmd, capture_output=True, text=True)
|
|
185
|
+
|
|
186
|
+
if result.returncode != 0:
|
|
187
|
+
error_msg = result.stderr or "Unknown error"
|
|
188
|
+
raise MountError(f"Mount command failed: {error_msg}")
|
|
189
|
+
|
|
190
|
+
# Wait a moment for mount to establish
|
|
191
|
+
time.sleep(2)
|
|
192
|
+
|
|
193
|
+
# Verify mount
|
|
194
|
+
if is_path_mounted(mount_path):
|
|
195
|
+
console.print(f"[green]✓ Successfully mounted to {mount_path}[/green]")
|
|
196
|
+
console.print(f"[green]✓ Sync profile: {profile.description}[/green]")
|
|
197
|
+
else:
|
|
198
|
+
raise MountError("Mount command succeeded but path is not mounted")
|
|
199
|
+
|
|
200
|
+
except MountError:
|
|
201
|
+
raise
|
|
202
|
+
except Exception as e:
|
|
203
|
+
raise MountError(f"Unexpected error during mount: {e}") from e
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def unmount_cloud_files(tenant_id: Optional[str] = None) -> None:
|
|
207
|
+
"""Unmount cloud files."""
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
# Get tenant info if not provided
|
|
211
|
+
if not tenant_id:
|
|
212
|
+
tenant_info = asyncio.run(get_tenant_info())
|
|
213
|
+
tenant_id = tenant_info.get("tenant_id")
|
|
214
|
+
|
|
215
|
+
if not tenant_id:
|
|
216
|
+
raise MountError("Could not determine tenant ID")
|
|
217
|
+
|
|
218
|
+
mount_path = get_default_mount_path()
|
|
219
|
+
|
|
220
|
+
if not is_path_mounted(mount_path):
|
|
221
|
+
console.print(f"[yellow]Path {mount_path} is not mounted[/yellow]")
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
console.print(f"[blue]Unmounting {mount_path}...[/blue]")
|
|
225
|
+
|
|
226
|
+
# Unmount the path
|
|
227
|
+
if unmount_path(mount_path):
|
|
228
|
+
console.print(f"[green]✓ Successfully unmounted {mount_path}[/green]")
|
|
229
|
+
|
|
230
|
+
# Clean up any orphaned rclone processes
|
|
231
|
+
killed_count = cleanup_orphaned_rclone_processes()
|
|
232
|
+
if killed_count > 0:
|
|
233
|
+
console.print(
|
|
234
|
+
f"[green]✓ Cleaned up {killed_count} orphaned rclone process(es)[/green]"
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
console.print(f"[red]✗ Failed to unmount {mount_path}[/red]")
|
|
238
|
+
console.print("You may need to manually unmount or restart your system")
|
|
239
|
+
|
|
240
|
+
except MountError:
|
|
241
|
+
raise
|
|
242
|
+
except Exception as e:
|
|
243
|
+
raise MountError(f"Unexpected error during unmount: {e}") from e
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def show_mount_status() -> None:
|
|
247
|
+
"""Show current mount status and running processes."""
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
# Get tenant info
|
|
251
|
+
tenant_info = asyncio.run(get_tenant_info())
|
|
252
|
+
tenant_id = tenant_info.get("tenant_id")
|
|
253
|
+
|
|
254
|
+
if not tenant_id:
|
|
255
|
+
console.print("[red]Could not determine tenant ID[/red]")
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
mount_path = get_default_mount_path()
|
|
259
|
+
|
|
260
|
+
# Create status table
|
|
261
|
+
table = Table(title="Cloud Mount Status", show_header=True, header_style="bold blue")
|
|
262
|
+
table.add_column("Property", style="green", min_width=15)
|
|
263
|
+
table.add_column("Value", style="dim", min_width=30)
|
|
264
|
+
|
|
265
|
+
# Check mount status
|
|
266
|
+
is_mounted = is_path_mounted(mount_path)
|
|
267
|
+
mount_status = "[green]✓ Mounted[/green]" if is_mounted else "[red]✗ Not mounted[/red]"
|
|
268
|
+
|
|
269
|
+
table.add_row("Tenant ID", tenant_id)
|
|
270
|
+
table.add_row("Mount Path", str(mount_path))
|
|
271
|
+
table.add_row("Status", mount_status)
|
|
272
|
+
|
|
273
|
+
# Get rclone processes
|
|
274
|
+
processes = get_rclone_processes()
|
|
275
|
+
if processes:
|
|
276
|
+
table.add_row("rclone Processes", f"{len(processes)} running")
|
|
277
|
+
else:
|
|
278
|
+
table.add_row("rclone Processes", "None")
|
|
279
|
+
|
|
280
|
+
console.print(table)
|
|
281
|
+
|
|
282
|
+
# Show running processes details
|
|
283
|
+
if processes:
|
|
284
|
+
console.print("\n[bold]Running rclone processes:[/bold]")
|
|
285
|
+
for proc in processes:
|
|
286
|
+
console.print(f" PID {proc['pid']}: {proc['command'][:80]}...")
|
|
287
|
+
|
|
288
|
+
# Show mount profiles
|
|
289
|
+
console.print("\n[bold]Available mount profiles:[/bold]")
|
|
290
|
+
for name, profile in MOUNT_PROFILES.items():
|
|
291
|
+
console.print(f" {name}: {profile.description}")
|
|
292
|
+
|
|
293
|
+
except Exception as e:
|
|
294
|
+
console.print(f"[red]Error getting mount status: {e}[/red]")
|
|
295
|
+
raise typer.Exit(1)
|