Skip to content

Commit 806d94a

Browse files
committed
add cloud upload command
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 2db61f3 commit 806d94a

3 files changed

Lines changed: 363 additions & 1 deletion

File tree

src/basic_memory/cli/app.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,16 @@ def app_callback(
5757
update_current_project(project)
5858

5959

60+
## import
6061
# Register sub-command groups
6162
import_app = typer.Typer(help="Import data from various sources")
6263
app.add_typer(import_app, name="import")
6364

64-
claude_app = typer.Typer()
65+
claude_app = typer.Typer(help="Import Conversations from Claude JSON export.")
6566
import_app.add_typer(claude_app, name="claude")
67+
68+
69+
## cloud
70+
71+
cloud_app = typer.Typer(help="Access Basic Memory Cloud")
72+
app.add_typer(cloud_app, name="cloud")
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
"""Command module for basic-memory cloud operations."""
2+
3+
import asyncio
4+
from pathlib import Path
5+
from typing import Optional
6+
7+
import httpx
8+
import typer
9+
from rich.console import Console
10+
from rich.table import Table
11+
from rich.progress import (
12+
BarColumn,
13+
Progress,
14+
TextColumn,
15+
TimeElapsedColumn,
16+
FileSizeColumn,
17+
TotalFileSizeColumn,
18+
TransferSpeedColumn,
19+
)
20+
21+
from basic_memory.cli.app import cloud_app
22+
from basic_memory.utils import generate_permalink
23+
24+
console = Console()
25+
26+
27+
class CloudAPIError(Exception):
28+
"""Exception raised for cloud API errors."""
29+
pass
30+
31+
32+
async def make_api_request(
33+
method: str,
34+
url: str,
35+
headers: Optional[dict] = None,
36+
json_data: Optional[dict] = None,
37+
timeout: float = 30.0
38+
) -> httpx.Response:
39+
"""Make an API request to the cloud service."""
40+
async with httpx.AsyncClient(timeout=timeout) as client:
41+
try:
42+
response = await client.request(
43+
method=method,
44+
url=url,
45+
headers=headers or {},
46+
json=json_data
47+
)
48+
console.print(response.json())
49+
response.raise_for_status()
50+
return response
51+
except httpx.HTTPError as e:
52+
raise CloudAPIError(f"API request failed: {e}") from e
53+
54+
55+
@cloud_app.command("list")
56+
def list_projects(
57+
host_url: str = typer.Option(..., "--host", "-h", help="Cloud host URL"),
58+
auth_token: Optional[str] = typer.Option(None, "--token", "-t", help="Authentication token"),
59+
) -> None:
60+
"""List projects on the cloud instance."""
61+
62+
# Clean up the host URL
63+
host_url = host_url.rstrip('/')
64+
65+
# Prepare headers
66+
headers = {}
67+
if auth_token:
68+
headers["Authorization"] = f"Bearer {auth_token}"
69+
70+
try:
71+
console.print(f"[blue]Fetching projects from {host_url}...[/blue]")
72+
73+
# Make API request to list projects
74+
response = asyncio.run(make_api_request(
75+
method="GET",
76+
url=f"{host_url}/projects/projects",
77+
headers=headers
78+
))
79+
80+
projects_data = response.json()
81+
82+
if not projects_data.get("projects"):
83+
console.print("[yellow]No projects found on the cloud instance.[/yellow]")
84+
return
85+
86+
# Create table for display
87+
table = Table(
88+
title=f"Projects on {host_url}",
89+
show_header=True,
90+
header_style="bold blue"
91+
)
92+
table.add_column("Name", style="green")
93+
table.add_column("Path", style="dim")
94+
95+
for project in projects_data["projects"]:
96+
# Format the path for display
97+
path = project.get("path", "")
98+
if path.startswith("/"):
99+
path = f"~{path}" if path.startswith(str(Path.home())) else path
100+
101+
table.add_row(
102+
project.get("name", "unnamed"),
103+
path,
104+
)
105+
106+
console.print(table)
107+
console.print(f"\n[green]Found {len(projects_data['projects'])} project(s)[/green]")
108+
109+
except CloudAPIError as e:
110+
console.print(f"[red]Error: {e}[/red]")
111+
raise typer.Exit(1)
112+
except Exception as e:
113+
console.print(f"[red]Unexpected error: {e}[/red]")
114+
raise typer.Exit(1)
115+
116+
117+
@cloud_app.command("create")
118+
def create_project(
119+
name: str = typer.Argument(..., help="Name of the project to create"),
120+
host_url: str = typer.Option(..., "--host", "-h", help="Cloud host URL"),
121+
auth_token: Optional[str] = typer.Option(None, "--token", "-t", help="Authentication token"),
122+
set_default: bool = typer.Option(False, "--default", "-d", help="Set as default project"),
123+
) -> None:
124+
"""Create a new project on the cloud instance."""
125+
126+
# Clean up the host URL
127+
host_url = host_url.rstrip('/')
128+
129+
# Prepare headers
130+
headers = {"Content-Type": "application/json"}
131+
if auth_token:
132+
headers["Authorization"] = f"Bearer {auth_token}"
133+
134+
project_path = generate_permalink(name)
135+
# Prepare project data
136+
project_data = {
137+
"name": name,
138+
"path": project_path,
139+
"set_default": set_default,
140+
}
141+
142+
console.print(project_data)
143+
144+
try:
145+
console.print(f"[blue]Creating project '{name}' on {host_url}...[/blue]")
146+
147+
# Make API request to create project
148+
response = asyncio.run(make_api_request(
149+
method="POST",
150+
url=f"{host_url}/projects/projects",
151+
headers=headers,
152+
json_data=project_data
153+
))
154+
155+
result = response.json()
156+
157+
console.print(f"[green]Project '{name}' created successfully![/green]")
158+
159+
# Display project details
160+
if "project" in result:
161+
project = result["project"]
162+
console.print(f" Name: {project.get('name', name)}")
163+
console.print(f" Path: {project.get('path', 'unknown')}")
164+
if project.get('id'):
165+
console.print(f" ID: {project['id']}")
166+
167+
except CloudAPIError as e:
168+
169+
console.print(f"[red]Error creating project: {e}[/red]")
170+
raise typer.Exit(1)
171+
except Exception as e:
172+
console.print(f"[red]Unexpected error: {e}[/red]")
173+
raise typer.Exit(1)
174+
175+
176+
@cloud_app.command("upload")
177+
def upload_files(
178+
project: str = typer.Argument(..., help="Project name to upload to"),
179+
path_to_files: str = typer.Argument(..., help="Local path to files or directory to upload"),
180+
host_url: str = typer.Option(..., "--host", "-h", help="Cloud host URL"),
181+
auth_token: Optional[str] = typer.Option(None, "--token", "-t", help="Authentication token"),
182+
preserve_timestamps: bool = typer.Option(True, "--preserve-timestamps/--no-preserve-timestamps", help="Preserve file modification times"),
183+
) -> None:
184+
"""Upload files to a cloud project using WebDAV."""
185+
186+
# Clean up the host URL
187+
host_url = host_url.rstrip('/')
188+
189+
# Validate local path
190+
local_path = Path(path_to_files).expanduser().resolve()
191+
if not local_path.exists():
192+
console.print(f"[red]Error: Path '{path_to_files}' does not exist[/red]")
193+
raise typer.Exit(1)
194+
195+
# Prepare headers
196+
headers = {}
197+
if auth_token:
198+
headers["Authorization"] = f"Bearer {auth_token}"
199+
200+
try:
201+
# Collect files to upload
202+
files_to_upload = []
203+
204+
if local_path.is_file():
205+
files_to_upload.append(local_path)
206+
else:
207+
# Recursively collect all files
208+
for file_path in local_path.rglob("*"):
209+
if file_path.is_file():
210+
files_to_upload.append(file_path)
211+
212+
if not files_to_upload:
213+
console.print("[yellow]No files found to upload[/yellow]")
214+
return
215+
216+
console.print(f"[blue]Uploading {len(files_to_upload)} file(s) to project '{project}' on {host_url}...[/blue]")
217+
218+
# Calculate total size for progress tracking
219+
total_size = sum(f.stat().st_size for f in files_to_upload)
220+
221+
# Create progress bar
222+
with Progress(
223+
TextColumn("[progress.description]{task.description}"),
224+
BarColumn(),
225+
"[progress.percentage]{task.percentage:>3.0f}%",
226+
FileSizeColumn(),
227+
"/",
228+
TotalFileSizeColumn(),
229+
TransferSpeedColumn(),
230+
TimeElapsedColumn(),
231+
console=console,
232+
transient=False,
233+
) as progress:
234+
235+
task = progress.add_task("Uploading files...", total=total_size)
236+
237+
# Upload files using WebDAV
238+
asyncio.run(_upload_files_webdav(
239+
files_to_upload=files_to_upload,
240+
local_base_path=local_path,
241+
project=project,
242+
host_url=host_url,
243+
headers=headers,
244+
preserve_timestamps=preserve_timestamps,
245+
progress=progress,
246+
task=task
247+
))
248+
249+
console.print(f"[green]Successfully uploaded {len(files_to_upload)} file(s)![/green]")
250+
251+
except CloudAPIError as e:
252+
console.print(f"[red]Error uploading files: {e}[/red]")
253+
raise typer.Exit(1)
254+
except Exception as e:
255+
console.print(f"[red]Unexpected error: {e}[/red]")
256+
raise typer.Exit(1)
257+
258+
259+
async def _upload_files_webdav(
260+
files_to_upload: list[Path],
261+
local_base_path: Path,
262+
project: str,
263+
host_url: str,
264+
headers: dict,
265+
preserve_timestamps: bool,
266+
progress: Progress,
267+
task
268+
) -> None:
269+
"""Upload files using WebDAV protocol."""
270+
271+
async with httpx.AsyncClient(timeout=300.0) as client:
272+
for file_path in files_to_upload:
273+
try:
274+
# Calculate relative path for WebDAV
275+
if local_base_path.is_file():
276+
# Single file upload - use just the filename
277+
relative_path = file_path.name
278+
else:
279+
# Directory upload - preserve structure
280+
relative_path = file_path.relative_to(local_base_path)
281+
282+
# WebDAV URL
283+
webdav_url = f"{host_url}/{project}/webdav/{relative_path}"
284+
285+
# Prepare upload headers
286+
upload_headers = dict(headers)
287+
288+
# Add timestamp preservation header if requested
289+
if preserve_timestamps:
290+
mtime = file_path.stat().st_mtime
291+
upload_headers["X-OC-Mtime"] = str(mtime)
292+
293+
# Read file content
294+
file_content = file_path.read_bytes()
295+
296+
# Upload file
297+
response = await client.put(
298+
webdav_url,
299+
content=file_content,
300+
headers=upload_headers
301+
)
302+
303+
response.raise_for_status()
304+
305+
# Update progress
306+
progress.update(task, advance=len(file_content))
307+
308+
except httpx.HTTPError as e:
309+
raise CloudAPIError(f"Failed to upload {file_path.name}: {e}") from e
310+
311+
312+
@cloud_app.command("status")
313+
def status(
314+
host_url: str = typer.Option(..., "--host", "-h", help="Cloud host URL"),
315+
auth_token: Optional[str] = typer.Option(None, "--token", "-t", help="Authentication token"),
316+
) -> None:
317+
"""Check the status of the cloud instance."""
318+
319+
# Clean up the host URL
320+
host_url = host_url.rstrip('/')
321+
322+
# Prepare headers
323+
headers = {}
324+
if auth_token:
325+
headers["Authorization"] = f"Bearer {auth_token}"
326+
327+
try:
328+
console.print(f"[blue]Checking status of {host_url}...[/blue]")
329+
330+
# Make API request to check health
331+
response = asyncio.run(make_api_request(
332+
method="GET",
333+
url=f"{host_url}/health",
334+
headers=headers
335+
))
336+
337+
health_data = response.json()
338+
339+
console.print("[green]Cloud instance is healthy[/green]")
340+
341+
# Display status details
342+
if "status" in health_data:
343+
console.print(f" Status: {health_data['status']}")
344+
if "version" in health_data:
345+
console.print(f" Version: {health_data['version']}")
346+
if "timestamp" in health_data:
347+
console.print(f" Timestamp: {health_data['timestamp']}")
348+
349+
except CloudAPIError as e:
350+
console.print(f"[red]Error checking status: {e}[/red]")
351+
raise typer.Exit(1)
352+
except Exception as e:
353+
console.print(f"[red]Unexpected error: {e}[/red]")
354+
raise typer.Exit(1)

src/basic_memory/cli/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
# Register commands
66
from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
7+
cloud,
78
db,
89
import_chatgpt,
910
import_claude_conversations,

0 commit comments

Comments
 (0)