echome 0.1.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.
echome/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """EchoMe CLI - Personal memory sync tool for AI CLI environments."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,77 @@
1
+ """echome clean - Remove all EchoMe injected content for a clean environment."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.prompt import Confirm
8
+
9
+ from echome.core.client import HubClient
10
+ from echome.core.config import Config
11
+ from echome.targets.claude import ClaudeCodeTarget
12
+ from echome.targets.codex import CodexTarget
13
+
14
+ console = Console()
15
+
16
+ ALL_TARGETS = [ClaudeCodeTarget(), CodexTarget()]
17
+
18
+
19
+ def clean(
20
+ scope: str = typer.Option(
21
+ "all",
22
+ "--scope",
23
+ "-s",
24
+ help="What to clean: 'global', 'project', or 'all'",
25
+ ),
26
+ delete_hub_data: bool = typer.Option(
27
+ False,
28
+ "--delete-hub-data",
29
+ help="Also delete all memories from Hub (DESTRUCTIVE!)",
30
+ ),
31
+ ) -> None:
32
+ """Remove EchoMe injected content for a clean AI environment.
33
+
34
+ Scopes:
35
+ global — Remove from ~/.claude/CLAUDE.md and ~/.codex/AGENTS.md
36
+ project — Remove from current project's CLAUDE.md / AGENTS.md
37
+ all — Remove both global and project level
38
+ """
39
+ project_dir = Path.cwd()
40
+ console.print("\n[bold yellow]EchoMe Clean[/bold yellow]\n")
41
+
42
+ if scope in ("global", "all"):
43
+ console.print("[bold]Global files:[/bold]")
44
+ for t in ALL_TARGETS:
45
+ t.eject_global()
46
+ console.print(f" [green]✓[/green] Cleaned {t.name}: {t.global_file}")
47
+
48
+ if scope in ("project", "all"):
49
+ console.print(f"\n[bold]Project files[/bold] ({project_dir}):")
50
+ for t in ALL_TARGETS:
51
+ pf = t.project_file(project_dir)
52
+ if pf.exists() and "<!-- echome:begin -->" in pf.read_text():
53
+ t.eject_project(project_dir)
54
+ console.print(f" [green]✓[/green] Cleaned {t.name}: {pf}")
55
+ else:
56
+ console.print(f" [dim] {t.name}: nothing to clean[/dim]")
57
+
58
+ if delete_hub_data:
59
+ console.print("\n[bold red]⚠ Delete ALL memories from Hub?[/bold red]")
60
+ console.print("[dim]This will permanently remove all your stored memories.[/dim]")
61
+ if Confirm.ask("Are you sure?", default=False):
62
+ config = Config.load()
63
+ client = HubClient(config)
64
+ try:
65
+ # Get all memories and delete them
66
+ result = client.list_memories(limit=200)
67
+ items = result.get("items", [])
68
+ for item in items:
69
+ client.delete_memory(item["id"], hard=True)
70
+ console.print(f" [green]✓[/green] Deleted {len(items)} memories from Hub")
71
+ except Exception as e:
72
+ console.print(f" [red]Error: {e}[/red]")
73
+ else:
74
+ console.print(" [dim]Skipped.[/dim]")
75
+
76
+ console.print("\n[green]Done![/green] Your AI environment is clean.")
77
+ console.print("[dim]Run `echome sync` when you want to re-inject memories.[/dim]")
@@ -0,0 +1,123 @@
1
+ """echome init - Initialize local vault, configure Hub, and optionally set up MCP."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.prompt import Confirm, Prompt
6
+
7
+ from echome.core.config import Config, ensure_vault_dirs
8
+
9
+ console = Console()
10
+
11
+
12
+ def _mcp_available() -> bool:
13
+ """Check if MCP server package is installed."""
14
+ try:
15
+ import echome_mcp # noqa: F401
16
+ return True
17
+ except ImportError:
18
+ return False
19
+
20
+
21
+ def _setup_mcp() -> None:
22
+ """Register EchoMe MCP server in Claude Code and Codex CLI."""
23
+ import json
24
+ from pathlib import Path
25
+
26
+ mcp_config = {
27
+ "command": "echome",
28
+ "args": ["mcp", "serve"],
29
+ }
30
+
31
+ registered = []
32
+
33
+ # Claude Code
34
+ claude_mcp = Path.home() / ".claude" / "mcp.json"
35
+ claude_mcp.parent.mkdir(parents=True, exist_ok=True)
36
+ try:
37
+ data = json.loads(claude_mcp.read_text()) if claude_mcp.exists() else {}
38
+ except (json.JSONDecodeError, OSError):
39
+ data = {}
40
+ data.setdefault("mcpServers", {})["echome"] = mcp_config
41
+ claude_mcp.write_text(json.dumps(data, indent=2))
42
+ registered.append(f"Claude Code ({claude_mcp})")
43
+
44
+ # Codex CLI
45
+ codex_mcp = Path.home() / ".codex" / "mcp.json"
46
+ codex_mcp.parent.mkdir(parents=True, exist_ok=True)
47
+ try:
48
+ data = json.loads(codex_mcp.read_text()) if codex_mcp.exists() else {}
49
+ except (json.JSONDecodeError, OSError):
50
+ data = {}
51
+ data.setdefault("mcpServers", {})["echome"] = mcp_config
52
+ codex_mcp.write_text(json.dumps(data, indent=2))
53
+ registered.append(f"Codex CLI ({codex_mcp})")
54
+
55
+ for r in registered:
56
+ console.print(f" [green]✓[/green] {r}")
57
+
58
+
59
+ def init(
60
+ hub_url: str = typer.Option("", "--hub-url", "-u", help="Hub URL"),
61
+ token: str = typer.Option("", "--token", "-t", help="Auth token"),
62
+ skip_mcp: bool = typer.Option(False, "--skip-mcp", help="Skip MCP server setup"),
63
+ ) -> None:
64
+ """Initialize EchoMe: vault + Hub connection + MCP registration."""
65
+ console.print("\n[bold green]━━━ EchoMe Init ━━━[/bold green]\n")
66
+
67
+ # ─── Step 1: Hub Connection ───
68
+ console.print("[bold]1. Hub Connection[/bold]")
69
+
70
+ if not hub_url:
71
+ hub_url = Prompt.ask(" Hub URL", default="http://localhost:20000")
72
+ if not token:
73
+ token = Prompt.ask(" Auth Token", password=True, default="")
74
+
75
+ config = Config(hub_url=hub_url, token=token)
76
+
77
+ # Test connection
78
+ if token:
79
+ console.print(" Testing connection...", end=" ")
80
+ try:
81
+ from echome.core.client import HubClient
82
+ client = HubClient(config)
83
+ health = client.health()
84
+ console.print(f"[green]✓ Connected[/green] (Hub v{health.get('version', '?')})")
85
+ except Exception as e:
86
+ console.print(f"[yellow]✗ Failed: {e}[/yellow]")
87
+ console.print(" [dim]You can fix this later in ~/.echome/config.yaml[/dim]")
88
+ else:
89
+ console.print(" [dim]Skipping connection test (no token)[/dim]")
90
+
91
+ # Save config + create vault dirs
92
+ config.save()
93
+ ensure_vault_dirs()
94
+ console.print(" [green]✓[/green] Vault created at ~/.echome/\n")
95
+
96
+ # ─── Step 2: MCP Server (optional) ───
97
+ console.print("[bold]2. MCP Server[/bold] [dim](让 AI 可以查询你的记忆)[/dim]")
98
+
99
+ if skip_mcp:
100
+ console.print(" [dim]Skipped (--skip-mcp)[/dim]\n")
101
+ elif _mcp_available():
102
+ install_mcp = Confirm.ask(" Register MCP server to Claude Code / Codex CLI?", default=True)
103
+ if install_mcp:
104
+ _setup_mcp()
105
+ console.print(" [green]✓[/green] MCP registered. Restart AI CLI to activate.\n")
106
+ else:
107
+ console.print(" [dim]Skipped. Run `echome mcp install` later if needed.[/dim]\n")
108
+ else:
109
+ console.print(" [yellow]⚠ MCP 未安装[/yellow]")
110
+ console.print(" [dim]安装 MCP 支持: pip install echome-cli[mcp][/dim]")
111
+ console.print(" [dim]安装后运行 `echome mcp install` 即可注册[/dim]\n")
112
+
113
+ # ─── Done ───
114
+ console.print("[bold green]━━━ 初始化完成 ━━━[/bold green]\n")
115
+ console.print("下一步:")
116
+ console.print(" [cyan]eme add[/cyan] — 添加第一条记忆")
117
+ console.print(" [cyan]eme list[/cyan] — 查看所有记忆")
118
+ console.print(" [cyan]eme search[/cyan] — 搜索记忆")
119
+ console.print(" [cyan]eme sync[/cyan] — 同步到 AI CLI 配置文件")
120
+
121
+
122
+ if __name__ == "__main__":
123
+ typer.run(init)
@@ -0,0 +1,188 @@
1
+ """Login/logout/whoami commands for multi-user authentication."""
2
+
3
+ import http.server
4
+ import threading
5
+ import urllib.parse
6
+ import webbrowser
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from echome.core.config import Config
12
+
13
+ console = Console()
14
+
15
+ # Local callback server state
16
+ _received_token: str | None = None
17
+ _server_event = threading.Event()
18
+
19
+
20
+ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
21
+ """HTTP handler to capture the JWT from browser redirect."""
22
+
23
+ def do_GET(self) -> None:
24
+ global _received_token
25
+ parsed = urllib.parse.urlparse(self.path)
26
+ params = urllib.parse.parse_qs(parsed.query)
27
+
28
+ token = params.get("token", [None])[0]
29
+ if token:
30
+ _received_token = token
31
+ self.send_response(200)
32
+ self.send_header("Content-Type", "text/html; charset=utf-8")
33
+ self.end_headers()
34
+ self.wfile.write(
35
+ b"<html><body><h2>&#10004; Login successful!</h2>"
36
+ b"<p>You can close this tab and return to the terminal.</p>"
37
+ b"</body></html>"
38
+ )
39
+ _server_event.set()
40
+ else:
41
+ self.send_response(400)
42
+ self.send_header("Content-Type", "text/html")
43
+ self.end_headers()
44
+ self.wfile.write(b"<html><body><h2>Missing token</h2></body></html>")
45
+
46
+ def log_message(self, format, *args) -> None:
47
+ """Suppress server logs."""
48
+ pass
49
+
50
+
51
+ def login(
52
+ browser: bool = typer.Option(False, "--browser", "-b", help="Use browser flow (requires GUI desktop)"),
53
+ manual: bool = typer.Option(False, "--manual", "-m", help="(deprecated, now default) Paste token manually"),
54
+ hub: str = typer.Option("", "--hub", help="Hub URL (default: from config or https://echome.qzhqzh.com)"),
55
+ ) -> None:
56
+ """Login via GitHub OAuth. Default: open URL, copy token, paste back."""
57
+ global _received_token
58
+ _received_token = None
59
+
60
+ config = Config.load()
61
+
62
+ # Allow overriding hub URL
63
+ if hub.strip():
64
+ config.hub_url = hub.strip().rstrip("/")
65
+ config.save()
66
+
67
+ hub_url = config.hub_url.rstrip("/")
68
+
69
+ if browser:
70
+ # Browser flow with local callback server (for GUI desktops)
71
+ _login_browser(config, hub_url)
72
+ return
73
+
74
+ # Default: manual/token-paste flow (works on any Linux)
75
+ console.print("\n[bold]EchoMe Login[/bold]\n")
76
+
77
+ # Direct user to the CLI-friendly login page
78
+ cli_login_url = f"{hub_url}/login?source=cli"
79
+
80
+ console.print(f" 1. Open this URL in your browser:\n")
81
+ console.print(f" [cyan]{cli_login_url}[/cyan]\n")
82
+ console.print(f" 2. Click [bold]Login with GitHub[/bold] and authorize")
83
+ console.print(f" 3. Copy the token shown on the page\n")
84
+
85
+ # Allow retrying if token is wrong
86
+ while True:
87
+ token = typer.prompt("Paste token here")
88
+ if not token.strip():
89
+ console.print("[red]No token provided.[/red]")
90
+ raise typer.Exit(1)
91
+
92
+ # Strip common accidental prefixes (e.g. "$ " from copy-paste)
93
+ cleaned = token.strip()
94
+ if cleaned.startswith("$ "):
95
+ cleaned = cleaned[2:]
96
+
97
+ config.token = cleaned
98
+ config.save()
99
+
100
+ # Verify the token works
101
+ try:
102
+ _verify_and_show_user(config)
103
+ console.print("[green]✓ Login successful![/green]\n")
104
+ return
105
+ except SystemExit:
106
+ # _verify_and_show_user calls typer.Exit on failure
107
+ console.print("[yellow]Token invalid or expired. Try again (Ctrl+C to quit).[/yellow]\n")
108
+ config.token = ""
109
+ config.save()
110
+ continue
111
+
112
+
113
+ def _login_browser(config: Config, hub_url: str) -> None:
114
+ """Browser-based login with local callback server (for GUI desktops)."""
115
+ global _received_token
116
+ console.print("\n[bold]GitHub OAuth Login (Browser)[/bold]\n")
117
+
118
+ port = 19876
119
+ server = http.server.HTTPServer(("127.0.0.1", port), _CallbackHandler)
120
+ server_thread = threading.Thread(target=server.serve_forever, daemon=True)
121
+ server_thread.start()
122
+
123
+ auth_url = f"{hub_url}/api/v1/auth/github"
124
+ console.print(f"Opening browser to: [cyan]{auth_url}[/cyan]")
125
+ console.print(f"[dim]Waiting for callback on http://127.0.0.1:{port} ...[/dim]\n")
126
+
127
+ try:
128
+ webbrowser.open(auth_url)
129
+ except Exception:
130
+ console.print(f"[yellow]Could not open browser.[/yellow]")
131
+ console.print(f"Try: [cyan]echome login[/cyan] (default token-paste flow)\n")
132
+
133
+ if _server_event.wait(timeout=120):
134
+ server.shutdown()
135
+ if _received_token:
136
+ config.token = _received_token
137
+ config.save()
138
+ console.print("[green]✓ Login successful![/green]\n")
139
+ _verify_and_show_user(config)
140
+ else:
141
+ console.print("[red]✗ Failed to receive token.[/red]")
142
+ raise typer.Exit(1)
143
+ else:
144
+ server.shutdown()
145
+ console.print("[red]✗ Timeout.[/red] Try: [cyan]echome login[/cyan]\n")
146
+ raise typer.Exit(1)
147
+
148
+
149
+ def logout() -> None:
150
+ """Clear saved JWT token."""
151
+ config = Config.load()
152
+ if not config.token:
153
+ console.print("[dim]Not logged in.[/dim]")
154
+ return
155
+ config.token = ""
156
+ config.save()
157
+ console.print("[green]✓ Logged out.[/green]\n")
158
+
159
+
160
+ def whoami() -> None:
161
+ """Show current user info."""
162
+ config = Config.load()
163
+ if not config.token:
164
+ console.print("[yellow]Not logged in.[/yellow] Run: [cyan]echome login[/cyan]\n")
165
+ raise typer.Exit(1)
166
+ _verify_and_show_user(config)
167
+
168
+
169
+ def _verify_and_show_user(config: Config) -> None:
170
+ """Verify token and display user info. Raises typer.Exit(1) on failure."""
171
+ from echome.core.client import HubClient
172
+
173
+ try:
174
+ client = HubClient(config)
175
+ with client._client() as http_client:
176
+ resp = http_client.get("/api/v1/auth/me")
177
+ resp.raise_for_status()
178
+ user = resp.json()
179
+
180
+ console.print(f" User: [bold]{user['username']}[/bold]")
181
+ console.print(f" Role: {user['role']}")
182
+ console.print(f" Hub: {config.hub_url}")
183
+ console.print()
184
+ except SystemExit:
185
+ raise
186
+ except Exception as e:
187
+ console.print(f"[red]✗ Verification failed:[/red] {e}")
188
+ raise typer.Exit(1)
@@ -0,0 +1,204 @@
1
+ """Market commands: browse, search, fork public memories."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from echome.core.client import HubClient
8
+ from echome.core.config import Config
9
+
10
+ console = Console()
11
+
12
+ market_app = typer.Typer(help="Browse and fork public memories from the market")
13
+
14
+
15
+ def _market_client() -> HubClient:
16
+ """Create a HubClient (auth optional for read-only)."""
17
+ return HubClient(Config.load())
18
+
19
+
20
+ @market_app.callback(invoke_without_command=True)
21
+ def market_default(ctx: typer.Context) -> None:
22
+ """Show market stats when no subcommand is given."""
23
+ if ctx.invoked_subcommand is not None:
24
+ return
25
+
26
+ client = _market_client()
27
+ with client._client() as http_client:
28
+ resp = http_client.get("/api/v1/market/stats")
29
+ resp.raise_for_status()
30
+ stats = resp.json()
31
+
32
+ console.print("\n[bold cyan]📚 EchoMe Memory Market[/bold cyan]\n")
33
+ console.print(f" Total public memories: [green]{stats['total_public']}[/green]")
34
+ console.print(f" New in last 7 days: [green]{stats['recent_count_7d']}[/green]")
35
+
36
+ if stats.get("by_type"):
37
+ console.print("\n [bold]By type:[/bold]")
38
+ for t, count in sorted(stats["by_type"].items(), key=lambda x: -x[1]):
39
+ console.print(f" {t:<16} {count}")
40
+
41
+ console.print("\n[dim]Commands: echome market search <query> | echome market browse | echome market fork <id>[/dim]\n")
42
+
43
+
44
+ @market_app.command("browse")
45
+ def browse(
46
+ type: str = typer.Option(None, "--type", "-t", help="Filter by memory type"),
47
+ layer: str = typer.Option(None, "--layer", "-l", help="Filter by layer (L0/L1/L2)"),
48
+ tags: str = typer.Option(None, "--tags", help="Comma-separated tags to filter"),
49
+ limit: int = typer.Option(20, "--limit", "-n", help="Number of results"),
50
+ offset: int = typer.Option(0, "--offset", help="Pagination offset"),
51
+ ) -> None:
52
+ """Browse public memories in the market."""
53
+ client = _market_client()
54
+ params: dict = {"limit": limit, "offset": offset}
55
+ if type:
56
+ params["type"] = type
57
+ if layer:
58
+ params["layer"] = layer
59
+ if tags:
60
+ params["tags"] = tags
61
+
62
+ with client._client() as http_client:
63
+ resp = http_client.get("/api/v1/market/memories", params=params)
64
+ resp.raise_for_status()
65
+ data = resp.json()
66
+
67
+ if not data["items"]:
68
+ console.print("[dim]No public memories found.[/dim]\n")
69
+ return
70
+
71
+ table = Table(title=f"Market ({data['total']} total)")
72
+ table.add_column("ID", style="dim", max_width=8)
73
+ table.add_column("Title", style="bold")
74
+ table.add_column("Type", style="cyan")
75
+ table.add_column("Layer")
76
+ table.add_column("Tags", style="green")
77
+ table.add_column("Tokens", justify="right")
78
+
79
+ for item in data["items"]:
80
+ short_id = str(item["id"])[:8]
81
+ tags_str = ", ".join(item.get("tags", [])[:3])
82
+ table.add_row(
83
+ short_id,
84
+ item["title"][:50],
85
+ item["type"],
86
+ item["layer"],
87
+ tags_str,
88
+ str(item.get("token_count", 0)),
89
+ )
90
+
91
+ console.print(table)
92
+ console.print(f"\n[dim]Showing {data['offset'] + 1}-{data['offset'] + len(data['items'])} of {data['total']}[/dim]\n")
93
+
94
+
95
+ @market_app.command("search")
96
+ def search(
97
+ query: str = typer.Argument(..., help="Search keywords"),
98
+ type: str = typer.Option(None, "--type", "-t", help="Filter by memory type"),
99
+ limit: int = typer.Option(20, "--limit", "-n", help="Number of results"),
100
+ ) -> None:
101
+ """Search public memories by keyword."""
102
+ client = _market_client()
103
+ params: dict = {"q": query, "limit": limit}
104
+ if type:
105
+ params["type"] = type
106
+
107
+ with client._client() as http_client:
108
+ resp = http_client.get("/api/v1/market/memories", params=params)
109
+ resp.raise_for_status()
110
+ data = resp.json()
111
+
112
+ if not data["items"]:
113
+ console.print(f"[dim]No results for '{query}'[/dim]\n")
114
+ return
115
+
116
+ table = Table(title=f"Search: '{query}' ({data['total']} results)")
117
+ table.add_column("ID", style="dim", max_width=8)
118
+ table.add_column("Title", style="bold")
119
+ table.add_column("Type", style="cyan")
120
+ table.add_column("Layer")
121
+ table.add_column("Tags", style="green")
122
+
123
+ for item in data["items"]:
124
+ short_id = str(item["id"])[:8]
125
+ tags_str = ", ".join(item.get("tags", [])[:3])
126
+ table.add_row(short_id, item["title"][:50], item["type"], item["layer"], tags_str)
127
+
128
+ console.print(table)
129
+ console.print()
130
+
131
+
132
+ @market_app.command("fork")
133
+ def fork(
134
+ memory_id: str = typer.Argument(..., help="UUID of the public memory to fork"),
135
+ ) -> None:
136
+ """Fork a public memory into your personal library."""
137
+ config = Config.load()
138
+ if not config.token:
139
+ console.print("[red]Not logged in.[/red] Run [cyan]echome login[/cyan] first.\n")
140
+ raise typer.Exit(1)
141
+
142
+ client = HubClient(config)
143
+ with client._client() as http_client:
144
+ resp = http_client.post(f"/api/v1/market/memories/{memory_id}/fork")
145
+ if resp.status_code == 404:
146
+ console.print("[red]Memory not found or not public.[/red]\n")
147
+ raise typer.Exit(1)
148
+ if resp.status_code == 400:
149
+ console.print(f"[yellow]{resp.json().get('detail', 'Bad request')}[/yellow]\n")
150
+ raise typer.Exit(1)
151
+ resp.raise_for_status()
152
+ data = resp.json()
153
+
154
+ console.print(f"[green]✓ Forked![/green] New memory: {data['id']}")
155
+ console.print(f" Title: {data['title']}")
156
+ console.print(f" Source: {data['forked_from']}\n")
157
+
158
+
159
+ @market_app.command("publish")
160
+ def publish(
161
+ memory_id: str = typer.Argument(..., help="UUID of your memory to make public"),
162
+ ) -> None:
163
+ """Set one of your memories as public (visible in market)."""
164
+ config = Config.load()
165
+ if not config.token:
166
+ console.print("[red]Not logged in.[/red] Run [cyan]echome login[/cyan] first.\n")
167
+ raise typer.Exit(1)
168
+
169
+ client = HubClient(config)
170
+ with client._client() as http_client:
171
+ resp = http_client.patch(
172
+ f"/api/v1/memories/{memory_id}",
173
+ json={"visibility": "public"},
174
+ )
175
+ if resp.status_code == 404:
176
+ console.print("[red]Memory not found.[/red]\n")
177
+ raise typer.Exit(1)
178
+ resp.raise_for_status()
179
+
180
+ console.print(f"[green]✓ Memory {memory_id[:8]}... is now public.[/green]\n")
181
+
182
+
183
+ @market_app.command("unpublish")
184
+ def unpublish(
185
+ memory_id: str = typer.Argument(..., help="UUID of your memory to make private"),
186
+ ) -> None:
187
+ """Set one of your memories as private (hide from market)."""
188
+ config = Config.load()
189
+ if not config.token:
190
+ console.print("[red]Not logged in.[/red] Run [cyan]echome login[/cyan] first.\n")
191
+ raise typer.Exit(1)
192
+
193
+ client = HubClient(config)
194
+ with client._client() as http_client:
195
+ resp = http_client.patch(
196
+ f"/api/v1/memories/{memory_id}",
197
+ json={"visibility": "private"},
198
+ )
199
+ if resp.status_code == 404:
200
+ console.print("[red]Memory not found.[/red]\n")
201
+ raise typer.Exit(1)
202
+ resp.raise_for_status()
203
+
204
+ console.print(f"[green]✓ Memory {memory_id[:8]}... is now private.[/green]\n")