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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memorybot
3
- Version: 0.0.2
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
- pip install memorybot
35
+ pipx install memorybot
38
36
  ```
39
37
 
40
- or, recommended for CLI tools:
38
+ (or `pip install memorybot` inside a venv).
39
+
40
+ ## Quick start
41
41
 
42
42
  ```bash
43
- pipx install memorybot
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
- ## Usage
48
+ `--json` on any command emits machine-readable output for piping into `jq`.
47
49
 
48
- ```bash
49
- mb
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
- Currently prints a placeholder banner. Real commands coming soon.
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.2"
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"
@@ -1,3 +1,3 @@
1
1
  """MemoryBot CLI."""
2
2
 
3
- __version__ = "0.0.2"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,9 @@
1
+ """MemoryBot CLI entry point."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
@@ -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