sibyl-memory-cli 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.
- sibyl_memory_cli/__init__.py +18 -0
- sibyl_memory_cli/cli.py +572 -0
- sibyl_memory_cli-0.1.0.dist-info/METADATA +128 -0
- sibyl_memory_cli-0.1.0.dist-info/RECORD +7 -0
- sibyl_memory_cli-0.1.0.dist-info/WHEEL +5 -0
- sibyl_memory_cli-0.1.0.dist-info/entry_points.txt +2 -0
- sibyl_memory_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""sibyl-memory-cli — Command-line interface for the Sibyl Memory Plugin.
|
|
2
|
+
|
|
3
|
+
Entry point: `sibyl` (installed via [project.scripts] in pyproject).
|
|
4
|
+
|
|
5
|
+
Commands:
|
|
6
|
+
sibyl init activate the plugin — opens browser SIWE flow, writes ~/.sibyl-memory/credentials.json
|
|
7
|
+
sibyl upgrade open the upgrade flow — stake $SIBYL or subscribe in USDC
|
|
8
|
+
sibyl status show current tier, DB size, expiry, account
|
|
9
|
+
sibyl health provider self-check (mirrors SibylMemoryProvider.health())
|
|
10
|
+
|
|
11
|
+
Browser pages live at sibyllabs.org/plugin/{activate,upgrade}.
|
|
12
|
+
All HTTP calls target https://api.sibyllabs.org/api/plugin/*.
|
|
13
|
+
"""
|
|
14
|
+
from .cli import main
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = ["main", "__version__"]
|
sibyl_memory_cli/cli.py
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
"""`sibyl` command-line interface.
|
|
2
|
+
|
|
3
|
+
Stdlib only. The CLI is a thin wrapper around HTTP calls to
|
|
4
|
+
https://api.sibyllabs.org/api/plugin/* and the local SibylMemoryProvider.
|
|
5
|
+
|
|
6
|
+
Design pillars:
|
|
7
|
+
- Zero non-stdlib deps in this file. urllib is enough.
|
|
8
|
+
- Credentials are written atomically with 0600 perms.
|
|
9
|
+
- session_token is never printed in full — display short slice only.
|
|
10
|
+
- Polling has explicit timeouts; no infinite loops.
|
|
11
|
+
- Every command exits with a clear status code (0 ok, 1 user error, 2 server error).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import secrets
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import urllib.request
|
|
25
|
+
import uuid
|
|
26
|
+
import webbrowser
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
# ---- Defaults ----------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
API_BASE = os.environ.get("SIBYL_API_BASE", "https://api.sibyllabs.org")
|
|
33
|
+
ACTIVATE_BASE = os.environ.get("SIBYL_ACTIVATE_BASE", "https://sibyllabs.org/plugin/activate")
|
|
34
|
+
UPGRADE_BASE = os.environ.get("SIBYL_UPGRADE_BASE", "https://sibyllabs.org/plugin/upgrade")
|
|
35
|
+
|
|
36
|
+
DEFAULT_CRED_PATH = Path("~/.sibyl-memory/credentials.json").expanduser()
|
|
37
|
+
DEFAULT_DB_PATH = Path("~/.sibyl-memory/memory.db").expanduser()
|
|
38
|
+
DEFAULT_TIER_CACHE_PATH = Path("~/.sibyl-memory/tier_cache.json").expanduser()
|
|
39
|
+
|
|
40
|
+
POLL_INTERVAL_SEC = 3
|
|
41
|
+
INIT_TIMEOUT_SEC = 10 * 60 # 10 minutes for /init activation
|
|
42
|
+
UPGRADE_TIMEOUT_SEC = 15 * 60 # 15 minutes for upgrade — wallet ux can be slow
|
|
43
|
+
|
|
44
|
+
# ---- Color / output ----------------------------------------------------
|
|
45
|
+
|
|
46
|
+
_NO_COLOR = bool(os.environ.get("NO_COLOR")) or not sys.stdout.isatty()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def c(code: str, s: str) -> str:
|
|
50
|
+
if _NO_COLOR:
|
|
51
|
+
return s
|
|
52
|
+
return f"\033[{code}m{s}\033[0m"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def dim(s: str) -> str: return c("2", s)
|
|
56
|
+
def bold(s: str) -> str: return c("1", s)
|
|
57
|
+
def green(s: str) -> str: return c("32", s)
|
|
58
|
+
def yellow(s: str) -> str: return c("33", s)
|
|
59
|
+
def red(s: str) -> str: return c("31", s)
|
|
60
|
+
def cyan(s: str) -> str: return c("36", s)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _detect_os_family() -> str | None:
|
|
64
|
+
p = sys.platform
|
|
65
|
+
if p == "darwin": return "macos"
|
|
66
|
+
if p.startswith("linux"): return "linux"
|
|
67
|
+
if p.startswith("win"): return "windows"
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def short(token: str | None) -> str:
|
|
72
|
+
if not token:
|
|
73
|
+
return "—"
|
|
74
|
+
if len(token) <= 12:
|
|
75
|
+
return token
|
|
76
|
+
return f"{token[:8]}…{token[-4:]}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def print_status(label: str, value: str) -> None:
|
|
80
|
+
print(f" {dim(label.ljust(18))} {value}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---- HTTP --------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
class HttpError(Exception):
|
|
86
|
+
def __init__(self, status: int, body: Any, url: str) -> None:
|
|
87
|
+
super().__init__(f"HTTP {status} for {url}: {body}")
|
|
88
|
+
self.status = status
|
|
89
|
+
self.body = body
|
|
90
|
+
self.url = url
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def http_request(
|
|
94
|
+
method: str,
|
|
95
|
+
path: str,
|
|
96
|
+
*,
|
|
97
|
+
body: dict | None = None,
|
|
98
|
+
timeout: float = 15.0,
|
|
99
|
+
headers: dict | None = None,
|
|
100
|
+
) -> dict:
|
|
101
|
+
"""Single source of truth for HTTP calls. Returns parsed JSON or raises HttpError."""
|
|
102
|
+
url = f"{API_BASE}{path}"
|
|
103
|
+
data = None
|
|
104
|
+
full_headers = {"Accept": "application/json", "User-Agent": "sibyl-memory-cli/0.1.0"}
|
|
105
|
+
if body is not None:
|
|
106
|
+
data = json.dumps(body).encode("utf-8")
|
|
107
|
+
full_headers["Content-Type"] = "application/json"
|
|
108
|
+
if headers:
|
|
109
|
+
full_headers.update(headers)
|
|
110
|
+
req = urllib.request.Request(url, data=data, method=method, headers=full_headers)
|
|
111
|
+
try:
|
|
112
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
113
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
114
|
+
except urllib.error.HTTPError as e:
|
|
115
|
+
try:
|
|
116
|
+
err_body = json.loads(e.read().decode("utf-8"))
|
|
117
|
+
except Exception:
|
|
118
|
+
err_body = {"error": "unparseable response body"}
|
|
119
|
+
raise HttpError(e.code, err_body, url) from None
|
|
120
|
+
except urllib.error.URLError as e:
|
|
121
|
+
raise HttpError(0, {"error": str(e.reason)}, url) from None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---- Credentials I/O ---------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def write_credentials_atomic(creds: dict, path: Path = DEFAULT_CRED_PATH) -> Path:
|
|
127
|
+
"""Write credentials.json atomically at mode 0600."""
|
|
128
|
+
path = path.expanduser()
|
|
129
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
130
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
131
|
+
tmp.write_text(json.dumps(creds, indent=2), encoding="utf-8")
|
|
132
|
+
os.chmod(tmp, 0o600)
|
|
133
|
+
tmp.replace(path)
|
|
134
|
+
return path
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def read_credentials(path: Path = DEFAULT_CRED_PATH) -> dict | None:
|
|
138
|
+
path = path.expanduser()
|
|
139
|
+
if not path.exists():
|
|
140
|
+
return None
|
|
141
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def invalidate_tier_cache(path: Path = DEFAULT_TIER_CACHE_PATH) -> None:
|
|
145
|
+
"""Drop the local tier cache so the next write refreshes against the server."""
|
|
146
|
+
path = path.expanduser()
|
|
147
|
+
if path.exists():
|
|
148
|
+
path.unlink()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---- `sibyl init` ------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _gen_pairing_code() -> str:
|
|
154
|
+
"""6-digit cryptographic pairing code. Uniform across 000000-999999."""
|
|
155
|
+
return f"{secrets.randbelow(1_000_000):06d}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _hash_pairing_code(code: str, session: str) -> str:
|
|
159
|
+
return hashlib.sha256(f"{code}:{session}".encode("utf-8")).hexdigest()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
163
|
+
"""Activation flow. Generate session UUID + pairing code, register with
|
|
164
|
+
server, open activation page in browser, poll /check until bound.
|
|
165
|
+
|
|
166
|
+
The pairing code is printed in the terminal. If the user picks the
|
|
167
|
+
email path in the browser, they type both their email and this code.
|
|
168
|
+
No external email service is required."""
|
|
169
|
+
cred_path = Path(args.credentials).expanduser()
|
|
170
|
+
if cred_path.exists() and not args.force:
|
|
171
|
+
existing = read_credentials(cred_path) or {}
|
|
172
|
+
print(yellow("Already activated.") + " Use --force to re-activate.")
|
|
173
|
+
print_status("Account", short(existing.get("account_id")))
|
|
174
|
+
print_status("Tier", (existing.get("tier") or "free").upper())
|
|
175
|
+
print_status("Credentials", str(cred_path))
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
session_token = str(uuid.uuid4())
|
|
179
|
+
pairing_code = _gen_pairing_code()
|
|
180
|
+
code_hash = _hash_pairing_code(pairing_code, session_token)
|
|
181
|
+
activate_url = f"{ACTIVATE_BASE}?session={session_token}"
|
|
182
|
+
|
|
183
|
+
# Pre-register the session + pairing code hash with the server.
|
|
184
|
+
# The code itself never leaves the user's machine until they type it
|
|
185
|
+
# into the browser.
|
|
186
|
+
try:
|
|
187
|
+
http_request(
|
|
188
|
+
"POST",
|
|
189
|
+
"/api/plugin/session-init",
|
|
190
|
+
body={
|
|
191
|
+
"session": session_token,
|
|
192
|
+
"pairing_code_hash": code_hash,
|
|
193
|
+
"env": {
|
|
194
|
+
"os_family": _detect_os_family(),
|
|
195
|
+
"install_method": "cli",
|
|
196
|
+
"client_version": __import__("sibyl_memory_cli").__version__,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
timeout=10.0,
|
|
200
|
+
)
|
|
201
|
+
except HttpError as e:
|
|
202
|
+
# Non-fatal: SIWE path doesn't need the pairing code. If session-init
|
|
203
|
+
# fails the user can still complete SIWE. Surface the warning.
|
|
204
|
+
print(yellow(f"Warning: session-init failed ({e.status}). Wallet path still works; email path may not."))
|
|
205
|
+
|
|
206
|
+
print()
|
|
207
|
+
print(bold("Sibyl Memory Plugin · activation"))
|
|
208
|
+
print()
|
|
209
|
+
print(f" {dim('Session:')} {short(session_token)}")
|
|
210
|
+
# Format the code with a visual break to make it easier to read off the screen
|
|
211
|
+
print(f" {dim('Code:')} {bold(pairing_code[:3] + ' ' + pairing_code[3:])} {dim('(use this in the email panel)')}")
|
|
212
|
+
print(f" {dim('Opening:')} {activate_url}")
|
|
213
|
+
print()
|
|
214
|
+
print(dim("Pick wallet (SIWE) or email + the code above. This terminal will pick up automatically."))
|
|
215
|
+
print()
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
webbrowser.open(activate_url, new=2)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
# Poll /api/plugin/check
|
|
223
|
+
deadline = time.time() + INIT_TIMEOUT_SEC
|
|
224
|
+
last_status = ""
|
|
225
|
+
spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
226
|
+
spin_i = 0
|
|
227
|
+
|
|
228
|
+
while time.time() < deadline:
|
|
229
|
+
try:
|
|
230
|
+
resp = http_request("GET", f"/api/plugin/check?session={urllib.parse.quote(session_token)}", timeout=10.0)
|
|
231
|
+
except HttpError as e:
|
|
232
|
+
if e.status in (404, 503, 0):
|
|
233
|
+
# Session not yet created server-side, or transient — keep polling
|
|
234
|
+
pass
|
|
235
|
+
else:
|
|
236
|
+
print(red(f"\nUnexpected error: {e.body}"))
|
|
237
|
+
return 2
|
|
238
|
+
resp = {"bound": False}
|
|
239
|
+
|
|
240
|
+
if resp.get("bound") and resp.get("credentials"):
|
|
241
|
+
creds = resp["credentials"]
|
|
242
|
+
# Sanity check: the server's session_token field MUST match what we sent
|
|
243
|
+
if creds.get("session_token") and creds["session_token"] != session_token:
|
|
244
|
+
print(red("\nSession token mismatch — refusing to write credentials."))
|
|
245
|
+
return 2
|
|
246
|
+
# If server didn't echo session_token, inject our locally-generated one
|
|
247
|
+
creds.setdefault("session_token", session_token)
|
|
248
|
+
path = write_credentials_atomic(creds, cred_path)
|
|
249
|
+
print(f"\r{' ' * 80}\r", end="") # clear spinner line
|
|
250
|
+
print(green("✓ Activated."))
|
|
251
|
+
print_status("Account", short(creds.get("account_id")))
|
|
252
|
+
print_status("Tier", (creds.get("tier") or "free").upper())
|
|
253
|
+
print_status("Wallet", creds.get("wallet") or "—")
|
|
254
|
+
print_status("Email", creds.get("email") or "—")
|
|
255
|
+
print_status("Credentials", str(path))
|
|
256
|
+
print()
|
|
257
|
+
print(dim("Wire the provider into Hermes:"))
|
|
258
|
+
print(dim(" from sibyl_memory_hermes import SibylMemoryProvider"))
|
|
259
|
+
print(dim(" agent = Agent(memory=SibylMemoryProvider())"))
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
# Spinner tick
|
|
263
|
+
spin_i = (spin_i + 1) % len(spinner)
|
|
264
|
+
remaining = int(deadline - time.time())
|
|
265
|
+
status = f"\r {cyan(spinner[spin_i])} waiting for browser activation … {dim(f'{remaining // 60}:{remaining % 60:02d} left')}"
|
|
266
|
+
if status != last_status:
|
|
267
|
+
sys.stdout.write(status)
|
|
268
|
+
sys.stdout.flush()
|
|
269
|
+
last_status = status
|
|
270
|
+
time.sleep(POLL_INTERVAL_SEC)
|
|
271
|
+
|
|
272
|
+
print(red("\nActivation timed out. Re-run `sibyl init` to try again."))
|
|
273
|
+
return 1
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---- `sibyl upgrade` ---------------------------------------------------
|
|
277
|
+
|
|
278
|
+
def cmd_upgrade(args: argparse.Namespace) -> int:
|
|
279
|
+
"""Upgrade flow. Read existing creds → open upgrade page → poll /access until tier flips."""
|
|
280
|
+
creds = read_credentials(Path(args.credentials).expanduser())
|
|
281
|
+
if not creds:
|
|
282
|
+
print(red("Not activated.") + " Run `sibyl init` first.")
|
|
283
|
+
return 1
|
|
284
|
+
|
|
285
|
+
account_id = creds.get("account_id")
|
|
286
|
+
session_token = creds.get("session_token")
|
|
287
|
+
current_tier = (creds.get("tier") or "free").lower()
|
|
288
|
+
|
|
289
|
+
if not account_id or not session_token:
|
|
290
|
+
print(red("credentials.json is missing account_id or session_token. Re-run `sibyl init`."))
|
|
291
|
+
return 1
|
|
292
|
+
|
|
293
|
+
upgrade_url = f"{UPGRADE_BASE}?session={session_token}"
|
|
294
|
+
|
|
295
|
+
print()
|
|
296
|
+
print(bold("Sibyl Memory Plugin · upgrade"))
|
|
297
|
+
print()
|
|
298
|
+
print_status("Account", short(account_id))
|
|
299
|
+
print_status("Current tier", current_tier.upper())
|
|
300
|
+
print_status("Opening", upgrade_url)
|
|
301
|
+
print()
|
|
302
|
+
print(dim("Two paths in your browser:"))
|
|
303
|
+
print(dim(" 1. Stake $SIBYL on Base (free unlimited if you qualify)"))
|
|
304
|
+
print(dim(" 2. Subscribe in USDC (monthly / quarterly / annual)"))
|
|
305
|
+
print()
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
webbrowser.open(upgrade_url, new=2)
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
# Poll /api/plugin/access until tier changes
|
|
313
|
+
deadline = time.time() + UPGRADE_TIMEOUT_SEC
|
|
314
|
+
last_status = ""
|
|
315
|
+
spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
316
|
+
spin_i = 0
|
|
317
|
+
|
|
318
|
+
while time.time() < deadline:
|
|
319
|
+
try:
|
|
320
|
+
resp = http_request(
|
|
321
|
+
"POST",
|
|
322
|
+
"/api/plugin/access",
|
|
323
|
+
body={"account_id": account_id, "session_token": session_token},
|
|
324
|
+
timeout=10.0,
|
|
325
|
+
)
|
|
326
|
+
except HttpError as e:
|
|
327
|
+
if e.status == 401:
|
|
328
|
+
print(red("\nSession expired. Re-run `sibyl init`."))
|
|
329
|
+
return 1
|
|
330
|
+
# Transient — keep polling
|
|
331
|
+
resp = {}
|
|
332
|
+
|
|
333
|
+
new_tier = (resp.get("tier") or current_tier).lower()
|
|
334
|
+
source = resp.get("source")
|
|
335
|
+
|
|
336
|
+
if new_tier != current_tier and source in ("subscription", "staker"):
|
|
337
|
+
# Tier changed. Refresh credentials.
|
|
338
|
+
creds["tier"] = new_tier
|
|
339
|
+
if resp.get("staker") and resp["staker"].get("wallet"):
|
|
340
|
+
creds["wallet"] = resp["staker"]["wallet"]
|
|
341
|
+
write_credentials_atomic(creds, Path(args.credentials).expanduser())
|
|
342
|
+
invalidate_tier_cache()
|
|
343
|
+
|
|
344
|
+
print(f"\r{' ' * 80}\r", end="")
|
|
345
|
+
print(green(f"✓ Upgraded to {new_tier.upper()} via {source}."))
|
|
346
|
+
print_status("Source", source)
|
|
347
|
+
if resp.get("expires_at"):
|
|
348
|
+
print_status("Expires", resp["expires_at"])
|
|
349
|
+
if resp.get("cap_bytes") is None:
|
|
350
|
+
print_status("Storage cap", "unlimited")
|
|
351
|
+
else:
|
|
352
|
+
print_status("Storage cap", f"{resp['cap_bytes']:,} bytes")
|
|
353
|
+
if resp.get("staker"):
|
|
354
|
+
s = resp["staker"]
|
|
355
|
+
print_status("Wallet", s.get("wallet", "—"))
|
|
356
|
+
print_status("$SIBYL held", s.get("total_sibyl", "—"))
|
|
357
|
+
print()
|
|
358
|
+
print(dim("Local tier cache cleared. Your next write will sync the new tier."))
|
|
359
|
+
return 0
|
|
360
|
+
|
|
361
|
+
spin_i = (spin_i + 1) % len(spinner)
|
|
362
|
+
remaining = int(deadline - time.time())
|
|
363
|
+
status = f"\r {cyan(spinner[spin_i])} waiting for browser upgrade … current: {current_tier.upper()} {dim(f'{remaining // 60}:{remaining % 60:02d} left')}"
|
|
364
|
+
if status != last_status:
|
|
365
|
+
sys.stdout.write(status)
|
|
366
|
+
sys.stdout.flush()
|
|
367
|
+
last_status = status
|
|
368
|
+
time.sleep(POLL_INTERVAL_SEC)
|
|
369
|
+
|
|
370
|
+
print(red("\nUpgrade timed out. Tier unchanged. Re-run `sibyl upgrade` to retry."))
|
|
371
|
+
return 1
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ---- `sibyl status` ----------------------------------------------------
|
|
375
|
+
|
|
376
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
377
|
+
"""Show local + server-side state without modifying anything."""
|
|
378
|
+
cred_path = Path(args.credentials).expanduser()
|
|
379
|
+
creds = read_credentials(cred_path)
|
|
380
|
+
|
|
381
|
+
print()
|
|
382
|
+
print(bold("Sibyl Memory Plugin · status"))
|
|
383
|
+
print()
|
|
384
|
+
|
|
385
|
+
if not creds:
|
|
386
|
+
print(yellow("Not activated.") + " Run `sibyl init`.")
|
|
387
|
+
return 0
|
|
388
|
+
|
|
389
|
+
# Local view
|
|
390
|
+
print(dim("LOCAL"))
|
|
391
|
+
print_status("Credentials", str(cred_path))
|
|
392
|
+
print_status("Account", short(creds.get("account_id")))
|
|
393
|
+
print_status("Tier", (creds.get("tier") or "free").upper())
|
|
394
|
+
print_status("Wallet", creds.get("wallet") or "—")
|
|
395
|
+
print_status("Email", creds.get("email") or "—")
|
|
396
|
+
print_status("Issued", creds.get("issued_at") or "—")
|
|
397
|
+
|
|
398
|
+
db_path = Path(args.db).expanduser()
|
|
399
|
+
if db_path.exists():
|
|
400
|
+
size = db_path.stat().st_size
|
|
401
|
+
print_status("DB path", str(db_path))
|
|
402
|
+
print_status("DB size", f"{size:,} bytes ({size / (1024 * 1024):.2f} MB)")
|
|
403
|
+
else:
|
|
404
|
+
print_status("DB path", f"{db_path} (not created)")
|
|
405
|
+
|
|
406
|
+
tier_cache = Path(args.tier_cache).expanduser()
|
|
407
|
+
if tier_cache.exists():
|
|
408
|
+
cache = json.loads(tier_cache.read_text(encoding="utf-8"))
|
|
409
|
+
print_status("Tier cache", f"{cache.get('tier','?')} (checked {cache.get('checked_at','?')[:19]})")
|
|
410
|
+
else:
|
|
411
|
+
print_status("Tier cache", "—")
|
|
412
|
+
|
|
413
|
+
# Server view (only if account_id + session_token are present)
|
|
414
|
+
if creds.get("account_id") and creds.get("session_token"):
|
|
415
|
+
print()
|
|
416
|
+
print(dim("SERVER"))
|
|
417
|
+
try:
|
|
418
|
+
resp = http_request(
|
|
419
|
+
"POST",
|
|
420
|
+
"/api/plugin/access",
|
|
421
|
+
body={"account_id": creds["account_id"], "session_token": creds["session_token"]},
|
|
422
|
+
timeout=10.0,
|
|
423
|
+
)
|
|
424
|
+
print_status("Tier", (resp.get("tier") or "free").upper())
|
|
425
|
+
print_status("Source", resp.get("source") or "—")
|
|
426
|
+
print_status("Cap bytes", "unlimited" if resp.get("cap_bytes") is None else f"{resp['cap_bytes']:,}")
|
|
427
|
+
if resp.get("expires_at"):
|
|
428
|
+
print_status("Expires", resp["expires_at"])
|
|
429
|
+
if resp.get("staker"):
|
|
430
|
+
s = resp["staker"]
|
|
431
|
+
print_status("$SIBYL held", s.get("total_sibyl", "—"))
|
|
432
|
+
print_status("Threshold", str(s.get("threshold_sibyl", "—")))
|
|
433
|
+
print_status("Qualified", "yes" if s.get("qualified") else "no")
|
|
434
|
+
# Detect server/local drift
|
|
435
|
+
srv_tier = (resp.get("tier") or "free").lower()
|
|
436
|
+
loc_tier = (creds.get("tier") or "free").lower()
|
|
437
|
+
if srv_tier != loc_tier:
|
|
438
|
+
print()
|
|
439
|
+
print(yellow(f"⚠ Local tier ({loc_tier}) differs from server tier ({srv_tier})."))
|
|
440
|
+
print(dim(" Run `sibyl upgrade` to refresh, or remove credentials.json + `sibyl init` to re-activate."))
|
|
441
|
+
except HttpError as e:
|
|
442
|
+
print_status("Tier", red(f"server error: {e.status}"))
|
|
443
|
+
|
|
444
|
+
print()
|
|
445
|
+
return 0
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# ---- `sibyl dashboard` (placeholder, today routes to status) -----------
|
|
449
|
+
|
|
450
|
+
def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
451
|
+
"""Open the web account dashboard. In v0.1.0, the dashboard at
|
|
452
|
+
account.sibyllabs.org is not yet live (queued post-V1-ship per the
|
|
453
|
+
operator design memo). Until then, `sibyl dashboard` delegates to
|
|
454
|
+
`sibyl status` so the command surface exists from day one and users
|
|
455
|
+
who muscle-memory it get a real result.
|
|
456
|
+
|
|
457
|
+
When account.sibyllabs.org ships, this will flip to
|
|
458
|
+
`webbrowser.open(...)` with no UX disruption — same command, real
|
|
459
|
+
web dashboard."""
|
|
460
|
+
DASHBOARD_BASE = os.environ.get("SIBYL_DASHBOARD_BASE")
|
|
461
|
+
if DASHBOARD_BASE:
|
|
462
|
+
# If env var is set, open the web dashboard with the session token.
|
|
463
|
+
creds = read_credentials(Path(args.credentials).expanduser())
|
|
464
|
+
if creds and creds.get("session_token"):
|
|
465
|
+
url = f"{DASHBOARD_BASE}?session={creds['session_token']}"
|
|
466
|
+
print()
|
|
467
|
+
print(bold("Sibyl Memory Plugin · dashboard"))
|
|
468
|
+
print(f" {dim('Opening:')} {url}")
|
|
469
|
+
print()
|
|
470
|
+
try:
|
|
471
|
+
webbrowser.open(url, new=2)
|
|
472
|
+
except Exception:
|
|
473
|
+
pass
|
|
474
|
+
return 0
|
|
475
|
+
# Fall through: account.sibyllabs.org isn't live yet, run status instead.
|
|
476
|
+
return cmd_status(args)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# ---- `sibyl logout` ----------------------------------------------------
|
|
480
|
+
|
|
481
|
+
def cmd_logout(args: argparse.Namespace) -> int:
|
|
482
|
+
"""Delete credentials.json + tier_cache.json. memory.db stays — that's your data."""
|
|
483
|
+
cred_path = Path(args.credentials).expanduser()
|
|
484
|
+
tier_cache = Path(args.tier_cache).expanduser()
|
|
485
|
+
|
|
486
|
+
deleted = []
|
|
487
|
+
if cred_path.exists():
|
|
488
|
+
cred_path.unlink()
|
|
489
|
+
deleted.append(str(cred_path))
|
|
490
|
+
if tier_cache.exists():
|
|
491
|
+
tier_cache.unlink()
|
|
492
|
+
deleted.append(str(tier_cache))
|
|
493
|
+
|
|
494
|
+
print()
|
|
495
|
+
if not deleted:
|
|
496
|
+
print(yellow("Nothing to remove.") + " Already logged out.")
|
|
497
|
+
else:
|
|
498
|
+
print(green("✓ Logged out."))
|
|
499
|
+
for path in deleted:
|
|
500
|
+
print(f" {dim('removed')} {path}")
|
|
501
|
+
print()
|
|
502
|
+
print(dim("Your memory.db file is untouched — that's your data."))
|
|
503
|
+
print(dim("Run `sibyl init` to activate a fresh account."))
|
|
504
|
+
print()
|
|
505
|
+
return 0
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ---- `sibyl health` ----------------------------------------------------
|
|
509
|
+
|
|
510
|
+
def cmd_health(args: argparse.Namespace) -> int:
|
|
511
|
+
"""SibylMemoryProvider.health() — minimal self-check."""
|
|
512
|
+
try:
|
|
513
|
+
from sibyl_memory_hermes import SibylMemoryProvider
|
|
514
|
+
except ImportError:
|
|
515
|
+
print(red("sibyl-memory-hermes not installed."))
|
|
516
|
+
return 1
|
|
517
|
+
provider = SibylMemoryProvider(db_path=args.db)
|
|
518
|
+
h = provider.health()
|
|
519
|
+
print(json.dumps(h, indent=2))
|
|
520
|
+
return 0 if h.get("ok") else 1
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# ---- Dispatch ----------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
526
|
+
p = argparse.ArgumentParser(
|
|
527
|
+
prog="sibyl",
|
|
528
|
+
description="Command-line interface for the Sibyl Memory Plugin.",
|
|
529
|
+
)
|
|
530
|
+
p.add_argument("--credentials", default=str(DEFAULT_CRED_PATH),
|
|
531
|
+
help="Path to credentials.json (default: ~/.sibyl-memory/credentials.json)")
|
|
532
|
+
p.add_argument("--db", default=str(DEFAULT_DB_PATH),
|
|
533
|
+
help="Path to memory.db (default: ~/.sibyl-memory/memory.db)")
|
|
534
|
+
p.add_argument("--tier-cache", default=str(DEFAULT_TIER_CACHE_PATH),
|
|
535
|
+
help="Path to tier_cache.json (default: ~/.sibyl-memory/tier_cache.json)")
|
|
536
|
+
|
|
537
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
538
|
+
|
|
539
|
+
p_init = sub.add_parser("init", help="Activate the plugin in your browser")
|
|
540
|
+
p_init.add_argument("--force", action="store_true", help="Re-activate even if credentials.json exists")
|
|
541
|
+
p_init.set_defaults(func=cmd_init)
|
|
542
|
+
|
|
543
|
+
p_up = sub.add_parser("upgrade", help="Open the upgrade flow (stake or subscribe)")
|
|
544
|
+
p_up.set_defaults(func=cmd_upgrade)
|
|
545
|
+
|
|
546
|
+
p_st = sub.add_parser("status", help="Show local + server tier / DB stats")
|
|
547
|
+
p_st.set_defaults(func=cmd_status)
|
|
548
|
+
|
|
549
|
+
p_dash = sub.add_parser("dashboard", help="Open the account dashboard (delegates to status until account.sibyllabs.org ships)")
|
|
550
|
+
p_dash.set_defaults(func=cmd_dashboard)
|
|
551
|
+
|
|
552
|
+
p_lo = sub.add_parser("logout", help="Remove local credentials (memory.db stays)")
|
|
553
|
+
p_lo.set_defaults(func=cmd_logout)
|
|
554
|
+
|
|
555
|
+
p_h = sub.add_parser("health", help="Run the provider self-check")
|
|
556
|
+
p_h.set_defaults(func=cmd_health)
|
|
557
|
+
|
|
558
|
+
return p
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def main(argv: list[str] | None = None) -> int:
|
|
562
|
+
parser = build_parser()
|
|
563
|
+
args = parser.parse_args(argv)
|
|
564
|
+
try:
|
|
565
|
+
return args.func(args)
|
|
566
|
+
except KeyboardInterrupt:
|
|
567
|
+
print(red("\nInterrupted."))
|
|
568
|
+
return 130
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
if __name__ == "__main__":
|
|
572
|
+
sys.exit(main())
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sibyl-memory-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line interface for the Sibyl Memory Plugin. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats.
|
|
5
|
+
Author-email: Sibyl Labs <sibyl@sibyllabs.org>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://sibyllabs.org/plugin
|
|
8
|
+
Project-URL: Documentation, https://docs.sibyllabs.org/memory/
|
|
9
|
+
Keywords: sibyl,memory,cli,agent,hermes
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Environment :: Console
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: sibyl-memory-client>=0.3.0
|
|
22
|
+
Requires-Dist: sibyl-memory-hermes>=0.2.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# sibyl-memory-cli
|
|
27
|
+
|
|
28
|
+
Command-line interface for the **Sibyl Memory Plugin**.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install sibyl-memory-cli
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
This pulls in `sibyl-memory-client` (the local SDK) and `sibyl-memory-hermes` (the Hermes provider) automatically.
|
|
35
|
+
|
|
36
|
+
## Commands
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
sibyl init Open the browser activation page. Writes ~/.sibyl-memory/credentials.json.
|
|
40
|
+
sibyl upgrade Open the upgrade page. Stake $SIBYL or subscribe in USDC.
|
|
41
|
+
sibyl status Show local credentials, DB size, and the server's view of your tier.
|
|
42
|
+
sibyl health Run the SibylMemoryProvider self-check (schema version, DB path, tenant).
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Activation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
$ sibyl init
|
|
49
|
+
|
|
50
|
+
Sibyl Memory Plugin · activation
|
|
51
|
+
|
|
52
|
+
Session: a1b2c3d4…e5f6
|
|
53
|
+
Opening: https://sibyllabs.org/plugin/activate?session=a1b2c3d4-…
|
|
54
|
+
|
|
55
|
+
Sign in with your wallet in the browser. This terminal will pick up automatically.
|
|
56
|
+
|
|
57
|
+
⠹ waiting for browser activation … 9:42 left
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The browser opens. Sign a SIWE message with your wallet. The terminal picks up the moment the binding lands. Credentials are written to `~/.sibyl-memory/credentials.json` at mode 0600.
|
|
61
|
+
|
|
62
|
+
## Upgrade
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
$ sibyl upgrade
|
|
66
|
+
|
|
67
|
+
Sibyl Memory Plugin · upgrade
|
|
68
|
+
|
|
69
|
+
Account a1b2c3d4…e5f6
|
|
70
|
+
Current tier FREE
|
|
71
|
+
Opening https://sibyllabs.org/plugin/upgrade?session=…
|
|
72
|
+
|
|
73
|
+
Two paths in your browser:
|
|
74
|
+
1. Stake $SIBYL on Base (free unlimited if you qualify)
|
|
75
|
+
2. Subscribe in USDC (monthly / quarterly / annual)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
In the browser:
|
|
79
|
+
- **Stake** — connect your wallet (browser or Coinbase Smart Wallet), sign to bind, and the page checks your `$SIBYL` balance on Base. If you hold the threshold (default 100,000 $SIBYL liquid+staked, configurable), the local cap lifts.
|
|
80
|
+
- **Subscribe** — pick monthly ($29) / quarterly ($79) / annual ($290) USDC, sign the transfer, the server records the subscription. Tier flips immediately.
|
|
81
|
+
|
|
82
|
+
On either path, the CLI sees the tier change, rewrites `credentials.json`, and clears `tier_cache.json` so your next write picks up the new entitlement without delay.
|
|
83
|
+
|
|
84
|
+
## Status
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
$ sibyl status
|
|
88
|
+
|
|
89
|
+
Sibyl Memory Plugin · status
|
|
90
|
+
|
|
91
|
+
LOCAL
|
|
92
|
+
Credentials ~/.sibyl-memory/credentials.json
|
|
93
|
+
Account a1b2c3d4…e5f6
|
|
94
|
+
Tier FREE
|
|
95
|
+
DB size 1,247,300 bytes (1.19 MB)
|
|
96
|
+
Tier cache free (checked 2026-05-16T18:12:03)
|
|
97
|
+
|
|
98
|
+
SERVER
|
|
99
|
+
Tier FREE
|
|
100
|
+
Source free
|
|
101
|
+
Cap bytes 2,097,152
|
|
102
|
+
$SIBYL held 0
|
|
103
|
+
Threshold 100,000
|
|
104
|
+
Qualified no
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
If `LOCAL` and `SERVER` tiers diverge, run `sibyl upgrade`.
|
|
108
|
+
|
|
109
|
+
## Environment overrides
|
|
110
|
+
|
|
111
|
+
For internal testing only:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
SIBYL_API_BASE=https://staging.api.sibyllabs.org sibyl init
|
|
115
|
+
SIBYL_ACTIVATE_BASE=https://staging.sibyllabs.org/plugin/activate sibyl init
|
|
116
|
+
SIBYL_UPGRADE_BASE=https://staging.sibyllabs.org/plugin/upgrade sibyl upgrade
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Security
|
|
120
|
+
|
|
121
|
+
- `credentials.json` is written atomically at mode 0600.
|
|
122
|
+
- `session_token` is never printed in full — only a short slice.
|
|
123
|
+
- No memory content ever transits these endpoints. The CLI never reads `memory.db` content; it only checks file size.
|
|
124
|
+
- Wallet operations happen in the browser. The CLI sees only the resulting tier change.
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
sibyl_memory_cli/__init__.py,sha256=oHw0S-aW8QNMtrEBp3sGR4WJz992RvLpxys5qP9Chz4,715
|
|
2
|
+
sibyl_memory_cli/cli.py,sha256=QWAND-QRKGuyYPC--BqAnFGm2KeSdPQejWhcbFkFEts,21959
|
|
3
|
+
sibyl_memory_cli-0.1.0.dist-info/METADATA,sha256=fqn_o95ikWj0F3iRIvtc9nJBw4TV4HZnH5GJFBquFrA,4395
|
|
4
|
+
sibyl_memory_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
sibyl_memory_cli-0.1.0.dist-info/entry_points.txt,sha256=1QQ9SGFZYipdcWk2QTS_vaayvbQeFAnu88btGt7PRaY,52
|
|
6
|
+
sibyl_memory_cli-0.1.0.dist-info/top_level.txt,sha256=WzJRDcCaFHLUdX7HxrrX27776Y5Y_9P_nR1ZVX0qRxg,17
|
|
7
|
+
sibyl_memory_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sibyl_memory_cli
|