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.
@@ -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__"]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sibyl = sibyl_memory_cli.cli:main
@@ -0,0 +1 @@
1
+ sibyl_memory_cli