Skip to content

Commit 369ad37

Browse files
phernandezclaude
andauthored
feat: add SPEC-29 Phase 3 bucket snapshot CLI commands (#476)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4e5f701 commit 369ad37

File tree

7 files changed

+1534
-0
lines changed

7 files changed

+1534
-0
lines changed

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@ See SPEC-16 for full context manager refactor details.
240240
- Logout: `basic-memory cloud logout`
241241
- Check cloud status: `basic-memory cloud status`
242242
- Setup cloud sync: `basic-memory cloud setup`
243+
- Manage snapshots: `basic-memory cloud snapshot [create|list|delete|show|browse]`
244+
- Restore from snapshot: `basic-memory cloud restore <path> --snapshot <id>`
243245

244246
### MCP Capabilities
245247

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
"""Cloud commands package."""
22

3+
from basic_memory.cli.app import cloud_app
4+
35
# Import all commands to register them with typer
46
from basic_memory.cli.commands.cloud.core_commands import * # noqa: F401,F403
57
from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers, get_cloud_config # noqa: F401
68
from basic_memory.cli.commands.cloud.upload_command import * # noqa: F401,F403
9+
10+
# Register snapshot sub-command group
11+
from basic_memory.cli.commands.cloud.snapshot import snapshot_app
12+
13+
cloud_app.add_typer(snapshot_app, name="snapshot")
14+
15+
# Register restore command (directly on cloud_app via decorator)
16+
from basic_memory.cli.commands.cloud.restore import restore # noqa: F401, E402
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Restore CLI commands for Basic Memory Cloud.
2+
3+
SPEC-29 Phase 3: CLI commands for restoring files from Tigris bucket snapshots.
4+
"""
5+
6+
import asyncio
7+
8+
import typer
9+
from rich.console import Console
10+
11+
from basic_memory.cli.app import cloud_app
12+
from basic_memory.cli.commands.cloud.api_client import (
13+
CloudAPIError,
14+
SubscriptionRequiredError,
15+
make_api_request,
16+
)
17+
from basic_memory.cli.commands.cloud.schemas import BucketSnapshotBrowseResponse
18+
from basic_memory.config import ConfigManager
19+
20+
console = Console()
21+
22+
23+
@cloud_app.command("restore")
24+
def restore(
25+
path: str = typer.Argument(
26+
...,
27+
help="Path to restore (file or folder, e.g., 'notes/project.md' or 'research/')",
28+
),
29+
snapshot_id: str = typer.Option(
30+
...,
31+
"--snapshot",
32+
"-s",
33+
help="ID of the snapshot to restore from",
34+
),
35+
force: bool = typer.Option(
36+
False,
37+
"--force",
38+
"-f",
39+
help="Skip confirmation prompt",
40+
),
41+
) -> None:
42+
"""Restore a file or folder from a snapshot.
43+
44+
This command restores files from a previous snapshot to the current bucket.
45+
The restored files will overwrite any existing files at the same path.
46+
47+
Examples:
48+
bm cloud restore notes/project.md --snapshot abc123
49+
bm cloud restore research/ --snapshot abc123
50+
bm cloud restore notes/project.md --snapshot abc123 --force
51+
"""
52+
53+
async def _restore():
54+
try:
55+
config_manager = ConfigManager()
56+
config = config_manager.config
57+
host_url = config.cloud_host.rstrip("/")
58+
59+
# Normalize path - remove leading slash if present
60+
normalized_path = path.lstrip("/")
61+
62+
if not force:
63+
# Show what will be restored
64+
console.print(f"[blue]Preparing to restore from snapshot {snapshot_id}[/blue]")
65+
console.print(f" Path: {normalized_path}")
66+
67+
# Try to browse the snapshot to show what files will be affected
68+
try:
69+
browse_url = f"{host_url}/api/bucket-snapshots/{snapshot_id}/browse"
70+
if normalized_path:
71+
browse_url += f"?prefix={normalized_path}"
72+
73+
response = await make_api_request(
74+
method="GET",
75+
url=browse_url,
76+
)
77+
browse_response = BucketSnapshotBrowseResponse.model_validate(response.json())
78+
79+
if browse_response.files:
80+
if len(browse_response.files) <= 10:
81+
console.print("\n Files to restore:")
82+
for file_info in browse_response.files:
83+
console.print(f" - {file_info.key}")
84+
else:
85+
console.print(
86+
f"\n {len(browse_response.files)} files will be restored"
87+
)
88+
console.print(" First 5 files:")
89+
for file_info in browse_response.files[:5]:
90+
console.print(f" - {file_info.key}")
91+
console.print(f" ... and {len(browse_response.files) - 5} more")
92+
else:
93+
console.print(
94+
f"\n[yellow]No files found matching '{normalized_path}' "
95+
f"in snapshot[/yellow]"
96+
)
97+
raise typer.Exit(0)
98+
99+
except CloudAPIError as browse_error:
100+
if browse_error.status_code == 404:
101+
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
102+
raise typer.Exit(1)
103+
# If browse fails for other reasons, proceed with confirmation anyway
104+
pass
105+
106+
console.print(
107+
"\n[yellow]Warning: Restored files will overwrite existing files![/yellow]"
108+
)
109+
confirmed = typer.confirm("\nProceed with restore?")
110+
if not confirmed:
111+
console.print("[yellow]Restore cancelled[/yellow]")
112+
raise typer.Exit(0)
113+
114+
console.print(f"[blue]Restoring from snapshot {snapshot_id}...[/blue]")
115+
116+
response = await make_api_request(
117+
method="POST",
118+
url=f"{host_url}/api/bucket-snapshots/{snapshot_id}/restore",
119+
json_data={"path": normalized_path},
120+
)
121+
122+
data = response.json()
123+
restored_files = data.get("restored", [])
124+
returned_snapshot_id = data.get("snapshot_id", snapshot_id)
125+
126+
if restored_files:
127+
console.print(f"[green]Successfully restored {len(restored_files)} file(s)[/green]")
128+
if len(restored_files) <= 10:
129+
for file_path in restored_files:
130+
console.print(f" - {file_path}")
131+
else:
132+
console.print(" First 5 restored files:")
133+
for file_path in restored_files[:5]:
134+
console.print(f" - {file_path}")
135+
console.print(f" ... and {len(restored_files) - 5} more")
136+
console.print(f"\n[dim]Snapshot ID: {returned_snapshot_id}[/dim]")
137+
else:
138+
console.print("[yellow]No files were restored[/yellow]")
139+
console.print(f"[dim]No files matching '{normalized_path}' found in snapshot[/dim]")
140+
141+
except typer.Exit:
142+
# Re-raise typer.Exit without modification - it's used for clean exits
143+
raise
144+
except SubscriptionRequiredError as e:
145+
console.print("\n[red]Subscription Required[/red]\n")
146+
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
147+
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
148+
raise typer.Exit(1)
149+
except CloudAPIError as e:
150+
if e.status_code == 404:
151+
console.print(f"[red]Snapshot not found: {snapshot_id}[/red]")
152+
else:
153+
console.print(f"[red]Failed to restore: {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+
asyncio.run(_restore())
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Pydantic schemas for Basic Memory Cloud API responses.
2+
3+
These schemas mirror the API response models from basic-memory-cloud
4+
for type-safe parsing of API responses in CLI commands.
5+
"""
6+
7+
from datetime import datetime
8+
from uuid import UUID
9+
10+
from pydantic import BaseModel
11+
12+
13+
class BucketSnapshotFileInfo(BaseModel):
14+
"""File info from snapshot browse response."""
15+
16+
key: str
17+
size: int
18+
last_modified: datetime
19+
etag: str | None = None
20+
21+
22+
class BucketSnapshotBrowseResponse(BaseModel):
23+
"""Response from browsing snapshot contents."""
24+
25+
files: list[BucketSnapshotFileInfo]
26+
prefix: str
27+
snapshot_version: str
28+
29+
30+
class BucketSnapshotResponse(BaseModel):
31+
"""Response model for bucket snapshot data."""
32+
33+
id: UUID
34+
bucket_name: str
35+
snapshot_version: str
36+
name: str
37+
description: str | None
38+
auto: bool
39+
created_at: datetime
40+
created_by: UUID | None = None
41+
42+
43+
class BucketSnapshotListResponse(BaseModel):
44+
"""Response from listing bucket snapshots."""
45+
46+
snapshots: list[BucketSnapshotResponse]
47+
total: int
48+
49+
50+
class BucketSnapshotRestoreResponse(BaseModel):
51+
"""Response from restore operation."""
52+
53+
restored: list[str]
54+
snapshot_version: str
55+
snapshot_id: UUID

0 commit comments

Comments
 (0)