memorybot 0.0.2__tar.gz → 0.2.0__tar.gz
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.
- {memorybot-0.0.2 → memorybot-0.2.0}/PKG-INFO +26 -14
- memorybot-0.2.0/README.md +41 -0
- {memorybot-0.0.2 → memorybot-0.2.0}/pyproject.toml +6 -2
- {memorybot-0.0.2 → memorybot-0.2.0}/src/memorybot/__init__.py +1 -1
- memorybot-0.2.0/src/memorybot/__main__.py +9 -0
- memorybot-0.2.0/src/memorybot/auth.py +206 -0
- memorybot-0.2.0/src/memorybot/cli.py +263 -0
- memorybot-0.2.0/src/memorybot/client.py +61 -0
- memorybot-0.2.0/src/memorybot/config.py +64 -0
- memorybot-0.0.2/README.md +0 -32
- memorybot-0.0.2/src/memorybot/__main__.py +0 -29
- {memorybot-0.0.2 → memorybot-0.2.0}/.github/workflows/publish.yml +0 -0
- {memorybot-0.0.2 → memorybot-0.2.0}/.gitignore +0 -0
- {memorybot-0.0.2 → memorybot-0.2.0}/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memorybot
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: MemoryBot CLI — your personal knowledge graph from the command line
|
|
5
5
|
Project-URL: Homepage, https://www.memorybot.com
|
|
6
6
|
Project-URL: Repository, https://github.com/nolanlove/memorybot-cli
|
|
@@ -20,36 +20,48 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
20
20
|
Classifier: Topic :: Software Development :: Libraries
|
|
21
21
|
Classifier: Topic :: Utilities
|
|
22
22
|
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Requires-Dist: rich>=13
|
|
25
|
+
Requires-Dist: typer>=0.12
|
|
23
26
|
Description-Content-Type: text/markdown
|
|
24
27
|
|
|
25
28
|
# MemoryBot CLI
|
|
26
29
|
|
|
27
30
|
> Your personal knowledge graph from the command line.
|
|
28
31
|
|
|
29
|
-
**This is a placeholder release reserving the `memorybot` package name on PyPI.**
|
|
30
|
-
|
|
31
|
-
The full MemoryBot CLI is under active development. Visit
|
|
32
|
-
[memorybot.com](https://www.memorybot.com) to learn more about the platform.
|
|
33
|
-
|
|
34
32
|
## Install
|
|
35
33
|
|
|
36
34
|
```bash
|
|
37
|
-
|
|
35
|
+
pipx install memorybot
|
|
38
36
|
```
|
|
39
37
|
|
|
40
|
-
or
|
|
38
|
+
(or `pip install memorybot` inside a venv).
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
41
|
|
|
42
42
|
```bash
|
|
43
|
-
|
|
43
|
+
mb login # opens browser, OAuth flow
|
|
44
|
+
mb memo search "..." # full-text + semantic search
|
|
45
|
+
mb memo get <SID> # fetch a memo by sid
|
|
44
46
|
```
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
`--json` on any command emits machine-readable output for piping into `jq`.
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
- **`MEMORYBOT_URL`** — server URL (default `https://www.memorybot.com`).
|
|
53
|
+
- **`--base-url`** — per-command override.
|
|
54
|
+
|
|
55
|
+
Credentials are stored at `~/.config/memorybot/config.json` (mode 0600).
|
|
56
|
+
|
|
57
|
+
## Auth
|
|
58
|
+
|
|
59
|
+
`mb login` runs the OAuth 2.0 authorization-code flow with PKCE: it registers a
|
|
60
|
+
client via Dynamic Client Registration (RFC 7591), opens your browser to the
|
|
61
|
+
authorize endpoint, and captures the callback on a one-shot loopback server.
|
|
62
|
+
Tokens auto-refresh on 401.
|
|
51
63
|
|
|
52
|
-
|
|
64
|
+
`mb logout` clears stored credentials.
|
|
53
65
|
|
|
54
66
|
## License
|
|
55
67
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# MemoryBot CLI
|
|
2
|
+
|
|
3
|
+
> Your personal knowledge graph from the command line.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pipx install memorybot
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
(or `pip install memorybot` inside a venv).
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
mb login # opens browser, OAuth flow
|
|
17
|
+
mb memo search "..." # full-text + semantic search
|
|
18
|
+
mb memo get <SID> # fetch a memo by sid
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`--json` on any command emits machine-readable output for piping into `jq`.
|
|
22
|
+
|
|
23
|
+
## Configuration
|
|
24
|
+
|
|
25
|
+
- **`MEMORYBOT_URL`** — server URL (default `https://www.memorybot.com`).
|
|
26
|
+
- **`--base-url`** — per-command override.
|
|
27
|
+
|
|
28
|
+
Credentials are stored at `~/.config/memorybot/config.json` (mode 0600).
|
|
29
|
+
|
|
30
|
+
## Auth
|
|
31
|
+
|
|
32
|
+
`mb login` runs the OAuth 2.0 authorization-code flow with PKCE: it registers a
|
|
33
|
+
client via Dynamic Client Registration (RFC 7591), opens your browser to the
|
|
34
|
+
authorize endpoint, and captures the callback on a one-shot loopback server.
|
|
35
|
+
Tokens auto-refresh on 401.
|
|
36
|
+
|
|
37
|
+
`mb logout` clears stored credentials.
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "memorybot"
|
|
7
|
-
version = "0.0
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "MemoryBot CLI — your personal knowledge graph from the command line"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -26,7 +26,11 @@ classifiers = [
|
|
|
26
26
|
"Topic :: Software Development :: Libraries",
|
|
27
27
|
"Topic :: Utilities",
|
|
28
28
|
]
|
|
29
|
-
dependencies = [
|
|
29
|
+
dependencies = [
|
|
30
|
+
"typer>=0.12",
|
|
31
|
+
"httpx>=0.27",
|
|
32
|
+
"rich>=13",
|
|
33
|
+
]
|
|
30
34
|
|
|
31
35
|
[project.urls]
|
|
32
36
|
Homepage = "https://www.memorybot.com"
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""OAuth 2.0 authorization-code flow with PKCE and a one-shot local callback server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import http.server
|
|
8
|
+
import secrets
|
|
9
|
+
import socket
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import webbrowser
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .config import Config
|
|
19
|
+
|
|
20
|
+
CLIENT_NAME = "MemoryBot CLI"
|
|
21
|
+
SCOPES = "read write"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _b64url(data: bytes) -> str:
|
|
25
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _make_pkce() -> tuple[str, str]:
|
|
29
|
+
verifier = _b64url(secrets.token_bytes(32))
|
|
30
|
+
challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
|
|
31
|
+
return verifier, challenge
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pick_free_port() -> int:
|
|
35
|
+
with socket.socket() as s:
|
|
36
|
+
s.bind(("127.0.0.1", 0))
|
|
37
|
+
return s.getsockname()[1]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
41
|
+
captured: dict = {}
|
|
42
|
+
|
|
43
|
+
def do_GET(self) -> None: # noqa: N802
|
|
44
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
45
|
+
if parsed.path != "/callback":
|
|
46
|
+
self.send_response(404)
|
|
47
|
+
self.end_headers()
|
|
48
|
+
return
|
|
49
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
50
|
+
type(self).captured = {k: v[0] for k, v in params.items()}
|
|
51
|
+
self.send_response(200)
|
|
52
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
53
|
+
self.end_headers()
|
|
54
|
+
body = (
|
|
55
|
+
"<html><body style='font-family:system-ui;padding:2rem;'>"
|
|
56
|
+
"<h2>MemoryBot CLI — login complete</h2>"
|
|
57
|
+
"<p>You can close this tab and return to your terminal.</p>"
|
|
58
|
+
"</body></html>"
|
|
59
|
+
)
|
|
60
|
+
self.wfile.write(body.encode())
|
|
61
|
+
|
|
62
|
+
def log_message(self, format: str, *args: object) -> None: # noqa: A002
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _register_client(server_url: str, redirect_uri: str) -> tuple[str, str]:
|
|
67
|
+
"""Dynamic client registration (RFC 7591). Returns (client_id, client_secret)."""
|
|
68
|
+
resp = httpx.post(
|
|
69
|
+
f"{server_url}/oauth/register/",
|
|
70
|
+
json={
|
|
71
|
+
"client_name": CLIENT_NAME,
|
|
72
|
+
"redirect_uris": [redirect_uri],
|
|
73
|
+
},
|
|
74
|
+
timeout=15.0,
|
|
75
|
+
)
|
|
76
|
+
resp.raise_for_status()
|
|
77
|
+
data = resp.json()
|
|
78
|
+
return data["client_id"], data["client_secret"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _exchange_code(
|
|
82
|
+
server_url: str,
|
|
83
|
+
client_id: str,
|
|
84
|
+
client_secret: str,
|
|
85
|
+
code: str,
|
|
86
|
+
redirect_uri: str,
|
|
87
|
+
verifier: str,
|
|
88
|
+
) -> dict:
|
|
89
|
+
resp = httpx.post(
|
|
90
|
+
f"{server_url}/oauth/token/",
|
|
91
|
+
data={
|
|
92
|
+
"grant_type": "authorization_code",
|
|
93
|
+
"code": code,
|
|
94
|
+
"redirect_uri": redirect_uri,
|
|
95
|
+
"client_id": client_id,
|
|
96
|
+
"client_secret": client_secret,
|
|
97
|
+
"code_verifier": verifier,
|
|
98
|
+
},
|
|
99
|
+
timeout=15.0,
|
|
100
|
+
)
|
|
101
|
+
resp.raise_for_status()
|
|
102
|
+
return resp.json()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def refresh_access_token(cfg: Config, server_url: str) -> bool:
|
|
106
|
+
"""Use refresh_token to get a new access_token. Returns True on success."""
|
|
107
|
+
if not (cfg.refresh_token and cfg.client_id and cfg.client_secret):
|
|
108
|
+
return False
|
|
109
|
+
try:
|
|
110
|
+
resp = httpx.post(
|
|
111
|
+
f"{server_url}/oauth/token/",
|
|
112
|
+
data={
|
|
113
|
+
"grant_type": "refresh_token",
|
|
114
|
+
"refresh_token": cfg.refresh_token,
|
|
115
|
+
"client_id": cfg.client_id,
|
|
116
|
+
"client_secret": cfg.client_secret,
|
|
117
|
+
},
|
|
118
|
+
timeout=15.0,
|
|
119
|
+
)
|
|
120
|
+
resp.raise_for_status()
|
|
121
|
+
except httpx.HTTPError:
|
|
122
|
+
return False
|
|
123
|
+
data = resp.json()
|
|
124
|
+
cfg.access_token = data["access_token"]
|
|
125
|
+
cfg.refresh_token = data.get("refresh_token", cfg.refresh_token)
|
|
126
|
+
cfg.expires_at = time.time() + int(data.get("expires_in", 36000))
|
|
127
|
+
cfg.save()
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def login_flow(server_url: str, timeout_seconds: int = 300) -> dict:
|
|
132
|
+
"""Run the full auth-code + PKCE flow. Returns the token response dict.
|
|
133
|
+
|
|
134
|
+
Side effect: opens the user's browser to the authorize URL.
|
|
135
|
+
"""
|
|
136
|
+
port = _pick_free_port()
|
|
137
|
+
redirect_uri = f"http://127.0.0.1:{port}/callback"
|
|
138
|
+
|
|
139
|
+
client_id, client_secret = _register_client(server_url, redirect_uri)
|
|
140
|
+
|
|
141
|
+
verifier, challenge = _make_pkce()
|
|
142
|
+
state = secrets.token_urlsafe(16)
|
|
143
|
+
|
|
144
|
+
authorize_url = (
|
|
145
|
+
f"{server_url}/oauth/authorize/?"
|
|
146
|
+
+ urllib.parse.urlencode(
|
|
147
|
+
{
|
|
148
|
+
"response_type": "code",
|
|
149
|
+
"client_id": client_id,
|
|
150
|
+
"redirect_uri": redirect_uri,
|
|
151
|
+
"scope": SCOPES,
|
|
152
|
+
"state": state,
|
|
153
|
+
"code_challenge": challenge,
|
|
154
|
+
"code_challenge_method": "S256",
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
server = http.server.HTTPServer(("127.0.0.1", port), _CallbackHandler)
|
|
160
|
+
_CallbackHandler.captured = {}
|
|
161
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
162
|
+
thread.start()
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
webbrowser.open(authorize_url)
|
|
166
|
+
deadline = time.time() + timeout_seconds
|
|
167
|
+
while time.time() < deadline and not _CallbackHandler.captured:
|
|
168
|
+
time.sleep(0.1)
|
|
169
|
+
finally:
|
|
170
|
+
server.shutdown()
|
|
171
|
+
|
|
172
|
+
captured = _CallbackHandler.captured
|
|
173
|
+
if not captured:
|
|
174
|
+
raise TimeoutError("Timed out waiting for OAuth callback.")
|
|
175
|
+
if captured.get("state") != state:
|
|
176
|
+
raise RuntimeError("OAuth state mismatch — possible CSRF.")
|
|
177
|
+
if "error" in captured:
|
|
178
|
+
raise RuntimeError(f"Authorization denied: {captured['error']}")
|
|
179
|
+
if "code" not in captured:
|
|
180
|
+
raise RuntimeError("No authorization code received.")
|
|
181
|
+
|
|
182
|
+
token_resp = _exchange_code(
|
|
183
|
+
server_url=server_url,
|
|
184
|
+
client_id=client_id,
|
|
185
|
+
client_secret=client_secret,
|
|
186
|
+
code=captured["code"],
|
|
187
|
+
redirect_uri=redirect_uri,
|
|
188
|
+
verifier=verifier,
|
|
189
|
+
)
|
|
190
|
+
token_resp["_client_id"] = client_id
|
|
191
|
+
token_resp["_client_secret"] = client_secret
|
|
192
|
+
return token_resp
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def fetch_user_email(server_url: str, access_token: str) -> Optional[str]:
|
|
196
|
+
try:
|
|
197
|
+
resp = httpx.get(
|
|
198
|
+
f"{server_url}/api/auth/user/",
|
|
199
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
200
|
+
timeout=10.0,
|
|
201
|
+
)
|
|
202
|
+
if resp.status_code != 200:
|
|
203
|
+
return None
|
|
204
|
+
return resp.json().get("email")
|
|
205
|
+
except httpx.HTTPError:
|
|
206
|
+
return None
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""MemoryBot CLI — Typer app. All commands route through tool-exec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as json_module
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .auth import fetch_user_email, login_flow
|
|
15
|
+
from .client import APIError, Client, ToolError
|
|
16
|
+
from .config import Config, config_path, resolve_server_url
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="mb",
|
|
20
|
+
help="MemoryBot CLI — your personal knowledge graph from the command line.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
add_completion=False,
|
|
23
|
+
)
|
|
24
|
+
memo_app = typer.Typer(name="memo", help="Search, get, and manage memos.", no_args_is_help=True)
|
|
25
|
+
app.add_typer(memo_app)
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
err_console = Console(stderr=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _version_callback(value: bool) -> None:
|
|
32
|
+
if value:
|
|
33
|
+
typer.echo(f"mb {__version__}")
|
|
34
|
+
raise typer.Exit()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.callback()
|
|
38
|
+
def _root(
|
|
39
|
+
version: Optional[bool] = typer.Option(
|
|
40
|
+
None, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
|
|
41
|
+
),
|
|
42
|
+
) -> None:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _client(base_url: Optional[str]) -> Client:
|
|
47
|
+
cfg = Config.load()
|
|
48
|
+
server_url = resolve_server_url(base_url, cfg)
|
|
49
|
+
return Client(cfg, server_url)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _unwrap_single_op(result: dict) -> dict:
|
|
53
|
+
"""manage_* responses come wrapped as {results: [op_result]}.
|
|
54
|
+
|
|
55
|
+
Single-op CLI commands want the inner result directly.
|
|
56
|
+
"""
|
|
57
|
+
if isinstance(result, dict) and "results" in result and isinstance(result["results"], list):
|
|
58
|
+
items = result["results"]
|
|
59
|
+
if len(items) == 1:
|
|
60
|
+
return items[0]
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command()
|
|
65
|
+
def login(
|
|
66
|
+
base_url: Optional[str] = typer.Option(None, "--base-url", help="Override server URL for this run."),
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Authenticate via browser-based OAuth (authorization code + PKCE)."""
|
|
69
|
+
cfg = Config.load()
|
|
70
|
+
server_url = resolve_server_url(base_url, cfg)
|
|
71
|
+
cfg.server_url = server_url
|
|
72
|
+
|
|
73
|
+
err_console.print(f"Logging in to [bold]{server_url}[/bold]...")
|
|
74
|
+
err_console.print("Opening browser for authorization. Waiting for callback...")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
token_resp = login_flow(server_url)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
err_console.print(f"[red]Login failed:[/red] {e}")
|
|
80
|
+
raise typer.Exit(code=1)
|
|
81
|
+
|
|
82
|
+
cfg.client_id = token_resp["_client_id"]
|
|
83
|
+
cfg.client_secret = token_resp["_client_secret"]
|
|
84
|
+
cfg.access_token = token_resp["access_token"]
|
|
85
|
+
cfg.refresh_token = token_resp.get("refresh_token")
|
|
86
|
+
cfg.expires_at = time.time() + int(token_resp.get("expires_in", 36000))
|
|
87
|
+
|
|
88
|
+
cfg.user_email = fetch_user_email(server_url, cfg.access_token)
|
|
89
|
+
cfg.save()
|
|
90
|
+
|
|
91
|
+
who = cfg.user_email or "(email not available)"
|
|
92
|
+
err_console.print(f"[green]Logged in as[/green] [bold]{who}[/bold]")
|
|
93
|
+
err_console.print(f"Credentials saved to {config_path()}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.command()
|
|
97
|
+
def logout() -> None:
|
|
98
|
+
"""Clear stored credentials."""
|
|
99
|
+
cfg = Config.load()
|
|
100
|
+
cfg.clear_tokens()
|
|
101
|
+
cfg.client_id = None
|
|
102
|
+
cfg.client_secret = None
|
|
103
|
+
cfg.save()
|
|
104
|
+
err_console.print("Logged out.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command()
|
|
108
|
+
def whoami() -> None:
|
|
109
|
+
"""Show the currently logged-in user."""
|
|
110
|
+
cfg = Config.load()
|
|
111
|
+
if not cfg.access_token:
|
|
112
|
+
err_console.print("Not logged in. Run `mb login`.")
|
|
113
|
+
raise typer.Exit(code=1)
|
|
114
|
+
who = cfg.user_email or "(unknown)"
|
|
115
|
+
typer.echo(who)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _print_memos_table(memos: list[dict]) -> None:
|
|
119
|
+
if not memos:
|
|
120
|
+
err_console.print("[dim](no results)[/dim]")
|
|
121
|
+
return
|
|
122
|
+
table = Table(show_lines=False)
|
|
123
|
+
table.add_column("SID", style="cyan", no_wrap=True)
|
|
124
|
+
table.add_column("Title")
|
|
125
|
+
table.add_column("Tags", style="magenta")
|
|
126
|
+
for m in memos:
|
|
127
|
+
title = m.get("title") or m.get("structured_data", {}).get("memo", {}).get("title") or "(untitled)"
|
|
128
|
+
tag_field = m.get("tags") or []
|
|
129
|
+
if tag_field and isinstance(tag_field[0], dict):
|
|
130
|
+
tags = ", ".join(t.get("name", "") for t in tag_field if t.get("name"))
|
|
131
|
+
else:
|
|
132
|
+
tags = ", ".join(str(t) for t in tag_field)
|
|
133
|
+
table.add_row(m.get("sid", ""), title, tags)
|
|
134
|
+
console.print(table)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@memo_app.command("search")
|
|
138
|
+
def memo_search(
|
|
139
|
+
query: str = typer.Argument(..., help="Search query."),
|
|
140
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Max results."),
|
|
141
|
+
tag_sid: Optional[str] = typer.Option(None, "--tag-sid", help="Filter under tag sid(s), comma-separated."),
|
|
142
|
+
json: bool = typer.Option(False, "--json", help="Emit raw JSON."),
|
|
143
|
+
base_url: Optional[str] = typer.Option(None, "--base-url", help="Override server URL for this run."),
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Search memos via manage_memos action=search."""
|
|
146
|
+
op: dict[str, Any] = {"action": "search", "query": query, "limit": limit}
|
|
147
|
+
if tag_sid:
|
|
148
|
+
op["tag_sids"] = [s.strip() for s in tag_sid.split(",") if s.strip()]
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
result = _client(base_url).tool_exec("manage_memos", {"operations": [op]})
|
|
152
|
+
except APIError as e:
|
|
153
|
+
err_console.print(f"[red]API error:[/red] {e}")
|
|
154
|
+
raise typer.Exit(code=1)
|
|
155
|
+
except ToolError as e:
|
|
156
|
+
err_console.print(f"[red]Tool error:[/red] {e.message}")
|
|
157
|
+
raise typer.Exit(code=1)
|
|
158
|
+
|
|
159
|
+
inner = _unwrap_single_op(result)
|
|
160
|
+
|
|
161
|
+
if json:
|
|
162
|
+
typer.echo(json_module.dumps(inner, indent=2))
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
memos = inner.get("memos") if isinstance(inner, dict) else None
|
|
166
|
+
if memos is None and isinstance(inner, list):
|
|
167
|
+
memos = inner
|
|
168
|
+
_print_memos_table(memos or [])
|
|
169
|
+
if isinstance(inner, dict):
|
|
170
|
+
count = inner.get("count", len(memos or []))
|
|
171
|
+
total = inner.get("total_count", count)
|
|
172
|
+
err_console.print(f"[dim]{count} of {total} results[/dim]")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@memo_app.command("get")
|
|
176
|
+
def memo_get(
|
|
177
|
+
sid: str = typer.Argument(..., help="Memo sid (10-char base62)."),
|
|
178
|
+
json: bool = typer.Option(False, "--json", help="Emit raw JSON."),
|
|
179
|
+
base_url: Optional[str] = typer.Option(None, "--base-url", help="Override server URL for this run."),
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Fetch a single memo via manage_memos action=get."""
|
|
182
|
+
op = {"action": "get", "memo_sids": [sid], "full": True}
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
result = _client(base_url).tool_exec("manage_memos", {"operations": [op]})
|
|
186
|
+
except APIError as e:
|
|
187
|
+
err_console.print(f"[red]API error:[/red] {e}")
|
|
188
|
+
raise typer.Exit(code=1)
|
|
189
|
+
except ToolError as e:
|
|
190
|
+
err_console.print(f"[red]Tool error:[/red] {e.message}")
|
|
191
|
+
raise typer.Exit(code=1)
|
|
192
|
+
|
|
193
|
+
inner = _unwrap_single_op(result)
|
|
194
|
+
memos = inner.get("memos") if isinstance(inner, dict) else (inner if isinstance(inner, list) else [])
|
|
195
|
+
if not memos:
|
|
196
|
+
err_console.print(f"[red]No memo found with sid {sid}.[/red]")
|
|
197
|
+
raise typer.Exit(code=1)
|
|
198
|
+
memo = memos[0]
|
|
199
|
+
|
|
200
|
+
if json:
|
|
201
|
+
typer.echo(json_module.dumps(memo, indent=2))
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
sd = memo.get("structured_data", {}) or {}
|
|
205
|
+
title = memo.get("title") or sd.get("memo", {}).get("title") or "(untitled)"
|
|
206
|
+
body = sd.get("memo", {}).get("content", "")
|
|
207
|
+
tag_field = memo.get("tags") or []
|
|
208
|
+
if tag_field and isinstance(tag_field[0], dict):
|
|
209
|
+
tags = ", ".join(t.get("name", "") for t in tag_field if t.get("name"))
|
|
210
|
+
else:
|
|
211
|
+
tags = ", ".join(str(t) for t in tag_field)
|
|
212
|
+
|
|
213
|
+
console.print(f"[bold cyan]{memo.get('sid', '')}[/bold cyan] [bold]{title}[/bold]")
|
|
214
|
+
if tags:
|
|
215
|
+
console.print(f"[magenta]tags:[/magenta] {tags}")
|
|
216
|
+
if body:
|
|
217
|
+
console.print()
|
|
218
|
+
console.print(body)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@app.command("query")
|
|
222
|
+
def query_cmd(
|
|
223
|
+
sql: str = typer.Argument(..., help="A read-only SELECT against the v_* views."),
|
|
224
|
+
json: bool = typer.Option(False, "--json", help="Emit raw JSON."),
|
|
225
|
+
base_url: Optional[str] = typer.Option(None, "--base-url", help="Override server URL for this run."),
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Run a read-only SQL query against the user's data (run_query tool)."""
|
|
228
|
+
try:
|
|
229
|
+
result = _client(base_url).tool_exec("run_query", {"sql": sql})
|
|
230
|
+
except APIError as e:
|
|
231
|
+
err_console.print(f"[red]API error:[/red] {e}")
|
|
232
|
+
raise typer.Exit(code=1)
|
|
233
|
+
except ToolError as e:
|
|
234
|
+
err_console.print(f"[red]Query error:[/red] {e.message}")
|
|
235
|
+
raise typer.Exit(code=1)
|
|
236
|
+
|
|
237
|
+
if json:
|
|
238
|
+
typer.echo(json_module.dumps(result, indent=2))
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
columns = result.get("columns", [])
|
|
242
|
+
rows = result.get("rows", [])
|
|
243
|
+
if not rows:
|
|
244
|
+
err_console.print("[dim](no rows)[/dim]")
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
table = Table(show_lines=False)
|
|
248
|
+
for col in columns:
|
|
249
|
+
table.add_column(col, overflow="fold")
|
|
250
|
+
for row in rows:
|
|
251
|
+
if isinstance(row, dict):
|
|
252
|
+
table.add_row(*[str(row.get(c, "")) for c in columns])
|
|
253
|
+
else:
|
|
254
|
+
table.add_row(*[str(v) for v in row])
|
|
255
|
+
console.print(table)
|
|
256
|
+
|
|
257
|
+
suffix = " (truncated at 200)" if result.get("truncated") else ""
|
|
258
|
+
err_console.print(f"[dim]{result.get('row_count', len(rows))} rows{suffix}[/dim]")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def main() -> int:
|
|
262
|
+
app()
|
|
263
|
+
return 0
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Authenticated HTTP client for the MemoryBot tool-exec endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .auth import refresh_access_token
|
|
10
|
+
from .config import Config
|
|
11
|
+
|
|
12
|
+
TOOL_EXEC_PATH = "/memory/api/tool-exec/"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class APIError(RuntimeError):
|
|
16
|
+
def __init__(self, status: int, body: str) -> None:
|
|
17
|
+
super().__init__(f"HTTP {status}: {body}")
|
|
18
|
+
self.status = status
|
|
19
|
+
self.body = body
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ToolError(RuntimeError):
|
|
23
|
+
"""Raised when the server returns 200 with a {'error': ...} body."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.message = message
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Client:
|
|
31
|
+
def __init__(self, cfg: Config, server_url: str) -> None:
|
|
32
|
+
self.cfg = cfg
|
|
33
|
+
self.server_url = server_url
|
|
34
|
+
|
|
35
|
+
def _headers(self) -> dict[str, str]:
|
|
36
|
+
if not self.cfg.access_token:
|
|
37
|
+
raise RuntimeError("Not logged in. Run `mb login`.")
|
|
38
|
+
return {
|
|
39
|
+
"Authorization": f"Bearer {self.cfg.access_token}",
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def tool_exec(self, tool: str, arguments: dict[str, Any]) -> dict:
|
|
44
|
+
"""Call POST /api/tool-exec/ with {tool, arguments}. Returns parsed JSON.
|
|
45
|
+
|
|
46
|
+
Raises APIError on HTTP error, ToolError if the response body has
|
|
47
|
+
{'error': ...} (the executor's own validation/errors).
|
|
48
|
+
"""
|
|
49
|
+
url = f"{self.server_url}{TOOL_EXEC_PATH}"
|
|
50
|
+
body = {"tool": tool, "arguments": arguments}
|
|
51
|
+
|
|
52
|
+
resp = httpx.post(url, headers=self._headers(), json=body, timeout=60.0)
|
|
53
|
+
if resp.status_code == 401 and refresh_access_token(self.cfg, self.server_url):
|
|
54
|
+
resp = httpx.post(url, headers=self._headers(), json=body, timeout=60.0)
|
|
55
|
+
if resp.status_code >= 400:
|
|
56
|
+
raise APIError(resp.status_code, resp.text)
|
|
57
|
+
|
|
58
|
+
data = resp.json()
|
|
59
|
+
if isinstance(data, dict) and "error" in data and len(data) == 1:
|
|
60
|
+
raise ToolError(data["error"])
|
|
61
|
+
return data
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Persistent CLI config: server URL, OAuth client credentials, tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
DEFAULT_SERVER_URL = "https://www.memorybot.com"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def config_dir() -> Path:
|
|
15
|
+
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "memorybot"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def config_path() -> Path:
|
|
19
|
+
return config_dir() / "config.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Config:
|
|
24
|
+
server_url: str = DEFAULT_SERVER_URL
|
|
25
|
+
client_id: Optional[str] = None
|
|
26
|
+
client_secret: Optional[str] = None
|
|
27
|
+
access_token: Optional[str] = None
|
|
28
|
+
refresh_token: Optional[str] = None
|
|
29
|
+
expires_at: Optional[float] = None # epoch seconds
|
|
30
|
+
user_email: Optional[str] = None
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def load(cls) -> "Config":
|
|
34
|
+
path = config_path()
|
|
35
|
+
if not path.exists():
|
|
36
|
+
return cls()
|
|
37
|
+
with path.open() as f:
|
|
38
|
+
data = json.load(f)
|
|
39
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
40
|
+
|
|
41
|
+
def save(self) -> None:
|
|
42
|
+
path = config_path()
|
|
43
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
tmp = path.with_suffix(".json.tmp")
|
|
45
|
+
with tmp.open("w") as f:
|
|
46
|
+
json.dump(asdict(self), f, indent=2)
|
|
47
|
+
tmp.chmod(0o600)
|
|
48
|
+
tmp.replace(path)
|
|
49
|
+
|
|
50
|
+
def clear_tokens(self) -> None:
|
|
51
|
+
self.access_token = None
|
|
52
|
+
self.refresh_token = None
|
|
53
|
+
self.expires_at = None
|
|
54
|
+
self.user_email = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_server_url(cli_override: Optional[str], cfg: Config) -> str:
|
|
58
|
+
"""Precedence: --base-url flag > MEMORYBOT_URL env > config > default."""
|
|
59
|
+
if cli_override:
|
|
60
|
+
return cli_override.rstrip("/")
|
|
61
|
+
env = os.environ.get("MEMORYBOT_URL")
|
|
62
|
+
if env:
|
|
63
|
+
return env.rstrip("/")
|
|
64
|
+
return cfg.server_url.rstrip("/")
|
memorybot-0.0.2/README.md
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
# MemoryBot CLI
|
|
2
|
-
|
|
3
|
-
> Your personal knowledge graph from the command line.
|
|
4
|
-
|
|
5
|
-
**This is a placeholder release reserving the `memorybot` package name on PyPI.**
|
|
6
|
-
|
|
7
|
-
The full MemoryBot CLI is under active development. Visit
|
|
8
|
-
[memorybot.com](https://www.memorybot.com) to learn more about the platform.
|
|
9
|
-
|
|
10
|
-
## Install
|
|
11
|
-
|
|
12
|
-
```bash
|
|
13
|
-
pip install memorybot
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
or, recommended for CLI tools:
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
pipx install memorybot
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
## Usage
|
|
23
|
-
|
|
24
|
-
```bash
|
|
25
|
-
mb
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
Currently prints a placeholder banner. Real commands coming soon.
|
|
29
|
-
|
|
30
|
-
## License
|
|
31
|
-
|
|
32
|
-
MIT
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
"""MemoryBot CLI entry point."""
|
|
2
|
-
|
|
3
|
-
import sys
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
BANNER = r"""
|
|
7
|
-
__ __ ____ _
|
|
8
|
-
| \/ | ___ _ __ ___ ___ _ __ _ _| __ ) ___ | |_
|
|
9
|
-
| |\/| |/ _ \ '_ ` _ \ / _ \| '__| | | | _ \ / _ \| __|
|
|
10
|
-
| | | | __/ | | | | | (_) | | | |_| | |_) | (_) | |_
|
|
11
|
-
|_| |_|\___|_| |_| |_|\___/|_| \__, |____/ \___/ \__|
|
|
12
|
-
|___/
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def main() -> int:
|
|
17
|
-
"""Entry point for the `mb` command."""
|
|
18
|
-
print(BANNER)
|
|
19
|
-
print("MemoryBot CLI — coming soon.")
|
|
20
|
-
print()
|
|
21
|
-
print("This is a placeholder release. The full CLI is under construction.")
|
|
22
|
-
print("Learn more: https://www.memorybot.com")
|
|
23
|
-
print()
|
|
24
|
-
print("Installed version: 0.0.2")
|
|
25
|
-
return 0
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if __name__ == "__main__":
|
|
29
|
-
sys.exit(main())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|