memorybot 0.0.2__tar.gz → 0.1.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.1.0}/PKG-INFO +26 -14
- memorybot-0.1.0/README.md +41 -0
- {memorybot-0.0.2 → memorybot-0.1.0}/pyproject.toml +6 -2
- {memorybot-0.0.2 → memorybot-0.1.0}/src/memorybot/__init__.py +1 -1
- memorybot-0.1.0/src/memorybot/__main__.py +9 -0
- memorybot-0.1.0/src/memorybot/auth.py +206 -0
- memorybot-0.1.0/src/memorybot/cli.py +191 -0
- memorybot-0.1.0/src/memorybot/client.py +37 -0
- memorybot-0.1.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.1.0}/.github/workflows/publish.yml +0 -0
- {memorybot-0.0.2 → memorybot-0.1.0}/.gitignore +0 -0
- {memorybot-0.0.2 → memorybot-0.1.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.1.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.1.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,191 @@
|
|
|
1
|
+
"""MemoryBot CLI — Typer app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as json_module
|
|
6
|
+
import time
|
|
7
|
+
from typing import 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
|
|
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
|
+
@app.command()
|
|
47
|
+
def login(
|
|
48
|
+
base_url: Optional[str] = typer.Option(None, "--base-url", help="Override server URL for this run."),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Authenticate via browser-based OAuth (authorization code + PKCE)."""
|
|
51
|
+
cfg = Config.load()
|
|
52
|
+
server_url = resolve_server_url(base_url, cfg)
|
|
53
|
+
cfg.server_url = server_url
|
|
54
|
+
|
|
55
|
+
err_console.print(f"Logging in to [bold]{server_url}[/bold]...")
|
|
56
|
+
err_console.print("Opening browser for authorization. Waiting for callback...")
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
token_resp = login_flow(server_url)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
err_console.print(f"[red]Login failed:[/red] {e}")
|
|
62
|
+
raise typer.Exit(code=1)
|
|
63
|
+
|
|
64
|
+
cfg.client_id = token_resp["_client_id"]
|
|
65
|
+
cfg.client_secret = token_resp["_client_secret"]
|
|
66
|
+
cfg.access_token = token_resp["access_token"]
|
|
67
|
+
cfg.refresh_token = token_resp.get("refresh_token")
|
|
68
|
+
cfg.expires_at = time.time() + int(token_resp.get("expires_in", 36000))
|
|
69
|
+
|
|
70
|
+
cfg.user_email = fetch_user_email(server_url, cfg.access_token)
|
|
71
|
+
cfg.save()
|
|
72
|
+
|
|
73
|
+
who = cfg.user_email or "(email not available)"
|
|
74
|
+
err_console.print(f"[green]Logged in as[/green] [bold]{who}[/bold]")
|
|
75
|
+
err_console.print(f"Credentials saved to {config_path()}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def logout() -> None:
|
|
80
|
+
"""Clear stored credentials."""
|
|
81
|
+
cfg = Config.load()
|
|
82
|
+
cfg.clear_tokens()
|
|
83
|
+
cfg.client_id = None
|
|
84
|
+
cfg.client_secret = None
|
|
85
|
+
cfg.save()
|
|
86
|
+
err_console.print("Logged out.")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command()
|
|
90
|
+
def whoami() -> None:
|
|
91
|
+
"""Show the currently logged-in user."""
|
|
92
|
+
cfg = Config.load()
|
|
93
|
+
if not cfg.access_token:
|
|
94
|
+
err_console.print("Not logged in. Run `mb login`.")
|
|
95
|
+
raise typer.Exit(code=1)
|
|
96
|
+
who = cfg.user_email or "(unknown)"
|
|
97
|
+
typer.echo(who)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _print_memo_table(memos: list[dict]) -> None:
|
|
101
|
+
if not memos:
|
|
102
|
+
err_console.print("[dim](no results)[/dim]")
|
|
103
|
+
return
|
|
104
|
+
table = Table(show_lines=False)
|
|
105
|
+
table.add_column("SID", style="cyan", no_wrap=True)
|
|
106
|
+
table.add_column("Title")
|
|
107
|
+
table.add_column("Tags", style="magenta")
|
|
108
|
+
for m in memos:
|
|
109
|
+
title = m.get("title") or m.get("structured_data", {}).get("memo", {}).get("title") or "(untitled)"
|
|
110
|
+
tags = ", ".join(t.get("name", "") for t in m.get("tags", []) if t.get("name"))
|
|
111
|
+
table.add_row(m.get("sid", ""), title, tags)
|
|
112
|
+
console.print(table)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@memo_app.command("search")
|
|
116
|
+
def memo_search(
|
|
117
|
+
query: str = typer.Argument(..., help="Search query."),
|
|
118
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Max results (1-100)."),
|
|
119
|
+
mode: str = typer.Option("combined", "--mode", help="combined | fts | trigram | semantic."),
|
|
120
|
+
tag_sid: Optional[str] = typer.Option(None, "--tag-sid", help="Filter under tag sid(s), comma-separated."),
|
|
121
|
+
json: bool = typer.Option(False, "--json", help="Emit raw JSON."),
|
|
122
|
+
base_url: Optional[str] = typer.Option(None, "--base-url", help="Override server URL for this run."),
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Search memos."""
|
|
125
|
+
cfg = Config.load()
|
|
126
|
+
server_url = resolve_server_url(base_url, cfg)
|
|
127
|
+
client = Client(cfg, server_url)
|
|
128
|
+
|
|
129
|
+
params: dict[str, object] = {"q": query, "limit": limit, "mode": mode}
|
|
130
|
+
if tag_sid:
|
|
131
|
+
params["tag_sids"] = tag_sid
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
data = client.get("/memory/api/memos/search", params=params)
|
|
135
|
+
except APIError as e:
|
|
136
|
+
err_console.print(f"[red]API error:[/red] {e}")
|
|
137
|
+
raise typer.Exit(code=1)
|
|
138
|
+
|
|
139
|
+
if json:
|
|
140
|
+
typer.echo(json_module.dumps(data, indent=2))
|
|
141
|
+
return
|
|
142
|
+
_print_memo_table(data.get("memos", []))
|
|
143
|
+
err_console.print(
|
|
144
|
+
f"[dim]{data.get('count', 0)} of {data.get('total_count', 0)} results "
|
|
145
|
+
f"(mode: {data.get('mode', mode)})[/dim]"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@memo_app.command("get")
|
|
150
|
+
def memo_get(
|
|
151
|
+
sid: str = typer.Argument(..., help="Memo sid (10-char base62)."),
|
|
152
|
+
json: bool = typer.Option(False, "--json", help="Emit raw JSON."),
|
|
153
|
+
base_url: Optional[str] = typer.Option(None, "--base-url", help="Override server URL for this run."),
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Fetch a single memo by sid."""
|
|
156
|
+
cfg = Config.load()
|
|
157
|
+
server_url = resolve_server_url(base_url, cfg)
|
|
158
|
+
client = Client(cfg, server_url)
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
data = client.get("/memory/api/memos/list", params={"sids": sid})
|
|
162
|
+
except APIError as e:
|
|
163
|
+
err_console.print(f"[red]API error:[/red] {e}")
|
|
164
|
+
raise typer.Exit(code=1)
|
|
165
|
+
|
|
166
|
+
memos = data.get("memos", [])
|
|
167
|
+
if not memos:
|
|
168
|
+
err_console.print(f"[red]No memo found with sid {sid}.[/red]")
|
|
169
|
+
raise typer.Exit(code=1)
|
|
170
|
+
memo = memos[0]
|
|
171
|
+
|
|
172
|
+
if json:
|
|
173
|
+
typer.echo(json_module.dumps(memo, indent=2))
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
sd = memo.get("structured_data", {}) or {}
|
|
177
|
+
title = memo.get("title") or sd.get("memo", {}).get("title") or "(untitled)"
|
|
178
|
+
body = sd.get("memo", {}).get("content", "")
|
|
179
|
+
tags = ", ".join(t.get("name", "") for t in memo.get("tags", []) if t.get("name"))
|
|
180
|
+
|
|
181
|
+
console.print(f"[bold cyan]{memo.get('sid', '')}[/bold cyan] [bold]{title}[/bold]")
|
|
182
|
+
if tags:
|
|
183
|
+
console.print(f"[magenta]tags:[/magenta] {tags}")
|
|
184
|
+
if body:
|
|
185
|
+
console.print()
|
|
186
|
+
console.print(body)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def main() -> int:
|
|
190
|
+
app()
|
|
191
|
+
return 0
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Authenticated HTTP client with auto-refresh on 401."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .auth import refresh_access_token
|
|
10
|
+
from .config import Config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class APIError(RuntimeError):
|
|
14
|
+
def __init__(self, status: int, body: str) -> None:
|
|
15
|
+
super().__init__(f"HTTP {status}: {body}")
|
|
16
|
+
self.status = status
|
|
17
|
+
self.body = body
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Client:
|
|
21
|
+
def __init__(self, cfg: Config, server_url: str) -> None:
|
|
22
|
+
self.cfg = cfg
|
|
23
|
+
self.server_url = server_url
|
|
24
|
+
|
|
25
|
+
def _headers(self) -> dict[str, str]:
|
|
26
|
+
if not self.cfg.access_token:
|
|
27
|
+
raise RuntimeError("Not logged in. Run `mb login`.")
|
|
28
|
+
return {"Authorization": f"Bearer {self.cfg.access_token}"}
|
|
29
|
+
|
|
30
|
+
def get(self, path: str, params: Optional[dict[str, Any]] = None) -> dict:
|
|
31
|
+
url = f"{self.server_url}{path}"
|
|
32
|
+
resp = httpx.get(url, headers=self._headers(), params=params, timeout=30.0)
|
|
33
|
+
if resp.status_code == 401 and refresh_access_token(self.cfg, self.server_url):
|
|
34
|
+
resp = httpx.get(url, headers=self._headers(), params=params, timeout=30.0)
|
|
35
|
+
if resp.status_code >= 400:
|
|
36
|
+
raise APIError(resp.status_code, resp.text)
|
|
37
|
+
return resp.json()
|
|
@@ -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
|