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 +3 -0
- echome/commands/__init__.py +0 -0
- echome/commands/clean.py +77 -0
- echome/commands/init.py +123 -0
- echome/commands/login.py +188 -0
- echome/commands/market.py +204 -0
- echome/commands/memories.py +253 -0
- echome/commands/review.py +67 -0
- echome/commands/sync.py +217 -0
- echome/commands/update.py +137 -0
- echome/core/__init__.py +0 -0
- echome/core/client.py +104 -0
- echome/core/config.py +60 -0
- echome/main.py +265 -0
- echome/targets/__init__.py +0 -0
- echome/targets/base.py +93 -0
- echome/targets/claude.py +29 -0
- echome/targets/codex.py +29 -0
- echome-0.1.0.dist-info/METADATA +157 -0
- echome-0.1.0.dist-info/RECORD +32 -0
- echome-0.1.0.dist-info/WHEEL +4 -0
- echome-0.1.0.dist-info/entry_points.txt +3 -0
- echome-0.1.0.dist-info/licenses/LICENSE +21 -0
- echome_mcp/__init__.py +3 -0
- echome_mcp/hub_client.py +89 -0
- echome_mcp/server.py +226 -0
- echome_mcp/tools/__init__.py +0 -0
- echome_mcp/tools/get.py +34 -0
- echome_mcp/tools/list_by_type.py +37 -0
- echome_mcp/tools/project_context.py +56 -0
- echome_mcp/tools/remember.py +122 -0
- echome_mcp/tools/search.py +47 -0
echome/__init__.py
ADDED
|
File without changes
|
echome/commands/clean.py
ADDED
|
@@ -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]")
|
echome/commands/init.py
ADDED
|
@@ -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)
|
echome/commands/login.py
ADDED
|
@@ -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>✔ 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")
|