sibyl-memory-cli 0.1.4__tar.gz → 0.3.2__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,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibyl-memory-cli
3
- Version: 0.1.4
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.
3
+ Version: 0.3.2
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, `sibyl whoami` gives a one-line account summary, `sibyl devices` lists active devices and supports per-device revoke.
5
5
  Author-email: "SIBYL, Sibyl Labs LLC" <sibyl@sibyllabs.org>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://sibyllabs.org/plugin
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sibyl-memory-cli"
7
- version = "0.1.4"
8
- description = "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."
7
+ version = "0.3.2"
8
+ description = "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, `sibyl whoami` gives a one-line account summary, `sibyl devices` lists active devices and supports per-device revoke."
9
9
  authors = [{ name = "SIBYL, Sibyl Labs LLC", email = "sibyl@sibyllabs.org" }]
10
10
  license = { text = "MIT" }
11
11
  readme = "README.md"
@@ -0,0 +1,279 @@
1
+ """Shared visual identity for the sibyl CLI surface.
2
+
3
+ Sister module to `_banner.py`. Where the banner is the identity-reveal
4
+ moment for `sibyl init`, this module supplies the granular building
5
+ blocks every subcommand uses to share one coherent look:
6
+
7
+ - 24-bit-truecolor → 256-color → plain-text degradation cascade
8
+ - Brand palette derived from the lab creme paper face (rule 46)
9
+ - Letter-spaced eyebrow labels, gradient titles, ASCII rule dividers
10
+ - Key/value rows, status chips, success/warn/error glyphs
11
+ - Pulsing accents for live states (activation, upgrade, watching)
12
+
13
+ Voice constraint: precise, editorial, restrained. Gradients flow over
14
+ 2–3 stops max. No rainbow. The terminal is paper.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ from typing import Iterable
21
+
22
+ # ─── Palette (RGB · derived from rule 46 creme-paper tokens) ─────────
23
+ # Names map 1:1 to CSS custom properties on lab artifacts.
24
+
25
+ PAPER = (245, 241, 230) # --paper — foreground accent on dark
26
+ PAPER_DEEP = (237, 230, 211) # --paper-deep — depth on creme
27
+ CARD = (253, 251, 245) # --card — slightly lifted creme
28
+ INK = (21, 17, 10) # --ink — main text on creme
29
+ INK_SOFT = (44, 39, 29) # --ink-soft — body text
30
+ INK_MUTE = (106, 99, 86) # --ink-mute — secondary text
31
+ INK_FAINT = (152, 145, 127) # --ink-faint — tertiary text
32
+ RULE = (216, 208, 187) # --rule — hairline
33
+ RULE_STRONG = (184, 174, 147) # --rule-strong — emphasised hairline
34
+ ACCENT = (138, 106, 42) # --accent — ochre highlight
35
+ ACCENT_WARM = (160, 132, 56) # --accent-warm — softer ochre
36
+ ACCENT_GOLD = (224, 194, 119) # mid gold — gradient bridge
37
+ ACCENT_PALE = (244, 229, 184) # pale gold — gradient top
38
+ JADE = (45, 110, 106) # --jade — cool counterpoint
39
+ PULSE = (29, 138, 130) # --pulse — brighter jade (live signal)
40
+ ERROR = (162, 58, 42) # --error — measured red
41
+
42
+ # Status glyphs (Unicode, terminal-safe in modern fonts)
43
+ GLYPH_OK = "✓"
44
+ GLYPH_WARN = "⚠"
45
+ GLYPH_ERR = "✗"
46
+ GLYPH_DOT = "·"
47
+ GLYPH_ARROW = "→"
48
+ GLYPH_BULLET = "▸"
49
+
50
+
51
+ # ─── Terminal capability detection ────────────────────────────────────
52
+
53
+ def supports_truecolor() -> bool:
54
+ """24-bit RGB ANSI. Same heuristic as _banner.py."""
55
+ if os.environ.get("NO_COLOR"):
56
+ return False
57
+ if os.environ.get("TERM", "").lower() == "dumb":
58
+ return False
59
+ # SIBYL_FORCE_COLOR=1 — explicit override for non-tty rendering
60
+ # (CI logs, doc captures, dev inspection inside the Claude harness).
61
+ if os.environ.get("SIBYL_FORCE_COLOR") == "1":
62
+ return True
63
+ if not sys.stdout.isatty():
64
+ return False
65
+ colorterm = os.environ.get("COLORTERM", "").lower()
66
+ if "truecolor" in colorterm or "24bit" in colorterm:
67
+ return True
68
+ term_program = os.environ.get("TERM_PROGRAM", "").lower()
69
+ if term_program in {"iterm.app", "wezterm", "ghostty", "vscode", "tabby"}:
70
+ return True
71
+ term = os.environ.get("TERM", "").lower()
72
+ if any(k in term for k in ("256color", "kitty", "alacritty", "xterm-direct")):
73
+ return True
74
+ return False
75
+
76
+
77
+ def supports_color() -> bool:
78
+ """Any color at all (3/4-bit fallback)."""
79
+ if os.environ.get("NO_COLOR"):
80
+ return False
81
+ if os.environ.get("TERM", "").lower() == "dumb":
82
+ return False
83
+ if os.environ.get("SIBYL_FORCE_COLOR") == "1":
84
+ return True
85
+ return sys.stdout.isatty()
86
+
87
+
88
+ _TC = supports_truecolor()
89
+ _C = supports_color()
90
+ RESET = "\033[0m" if _C else ""
91
+
92
+
93
+ def rgb(r: int, g: int, b: int) -> str:
94
+ """24-bit foreground escape (no-op if color disabled)."""
95
+ if not _TC:
96
+ return ""
97
+ return f"\033[38;2;{r};{g};{b}m"
98
+
99
+
100
+ def rgb_bg(r: int, g: int, b: int) -> str:
101
+ if not _TC:
102
+ return ""
103
+ return f"\033[48;2;{r};{g};{b}m"
104
+
105
+
106
+ def color(text: str, c: tuple[int, int, int]) -> str:
107
+ if not _TC:
108
+ return text
109
+ return f"{rgb(*c)}{text}{RESET}"
110
+
111
+
112
+ # ─── Gradient · char-by-char RGB interpolation ────────────────────────
113
+
114
+ def _interp(a: int, b: int, t: float) -> int:
115
+ return round(a + (b - a) * t)
116
+
117
+
118
+ def gradient(text: str, *stops: tuple[int, int, int]) -> str:
119
+ """Color a string with a gradient across N stops, one char at a time.
120
+
121
+ Plain-text fallback: returns the input unchanged when color is off.
122
+ Whitespace is preserved (uncolored to keep terminals consistent).
123
+ """
124
+ if not _TC or len(stops) < 2 or not text:
125
+ return text
126
+ out = []
127
+ chars = list(text)
128
+ # Distribute char index across stop segments
129
+ n = max(1, len(chars) - 1)
130
+ segs = len(stops) - 1
131
+ for i, ch in enumerate(chars):
132
+ if ch == " ":
133
+ out.append(ch)
134
+ continue
135
+ seg_f = (i / n) * segs
136
+ seg_i = min(int(seg_f), segs - 1)
137
+ t = seg_f - seg_i
138
+ a = stops[seg_i]
139
+ b = stops[seg_i + 1]
140
+ r = _interp(a[0], b[0], t)
141
+ g = _interp(a[1], b[1], t)
142
+ bb = _interp(a[2], b[2], t)
143
+ out.append(f"\033[38;2;{r};{g};{bb}m{ch}")
144
+ return "".join(out) + RESET
145
+
146
+
147
+ def gradient_gold(text: str) -> str:
148
+ """Pale-gold → deep-ochre flow. The brand's headline gradient."""
149
+ return gradient(text, ACCENT_PALE, ACCENT_GOLD, ACCENT)
150
+
151
+
152
+ def gradient_jade(text: str) -> str:
153
+ """Pulse → jade. Used for success states + live indicators."""
154
+ return gradient(text, PULSE, JADE)
155
+
156
+
157
+ # ─── Style primitives ─────────────────────────────────────────────────
158
+
159
+ def dim(s: str) -> str:
160
+ return color(s, INK_FAINT)
161
+
162
+
163
+ def muted(s: str) -> str:
164
+ return color(s, INK_MUTE)
165
+
166
+
167
+ def soft(s: str) -> str:
168
+ return color(s, INK_SOFT)
169
+
170
+
171
+ def ink(s: str) -> str:
172
+ return color(s, INK)
173
+
174
+
175
+ def ok(s: str) -> str:
176
+ return color(s, PULSE)
177
+
178
+
179
+ def warn(s: str) -> str:
180
+ return color(s, ACCENT_WARM)
181
+
182
+
183
+ def err(s: str) -> str:
184
+ return color(s, ERROR)
185
+
186
+
187
+ def accent(s: str) -> str:
188
+ return color(s, ACCENT)
189
+
190
+
191
+ def bold(s: str) -> str:
192
+ if not _C:
193
+ return s
194
+ return f"\033[1m{s}{RESET}"
195
+
196
+
197
+ # ─── Composite primitives ─────────────────────────────────────────────
198
+
199
+ def eyebrow(label: str) -> str:
200
+ """Uppercase letter-spaced ochre label. Editorial section marker."""
201
+ spaced = " ".join(label.upper())
202
+ return color(spaced, ACCENT)
203
+
204
+
205
+ def divider(width: int = 60, *, glyph: str = "─") -> str:
206
+ """Creme-paper rule line."""
207
+ return color(glyph * width, RULE)
208
+
209
+
210
+ def section_header(name: str, *, subtitle: str | None = None, width: int = 60) -> str:
211
+ """The standard subcommand opener.
212
+
213
+ ─ <name> ────────────────────────────────────────
214
+ <subtitle, dim>
215
+ """
216
+ name_part = f" {gradient_gold(name)} "
217
+ # Stripped-color length for visible width calc
218
+ visible_name_len = len(f" {name} ")
219
+ rule_left = "─"
220
+ rule_right = "─" * max(3, width - 1 - visible_name_len)
221
+ head = color(rule_left, RULE) + name_part + color(rule_right, RULE)
222
+ if subtitle:
223
+ return head + "\n" + dim(subtitle)
224
+ return head
225
+
226
+
227
+ def chip(text: str, *, palette: str = "accent") -> str:
228
+ """Compact inline label · [text]."""
229
+ palettes = {
230
+ "accent": ACCENT,
231
+ "jade": PULSE,
232
+ "warn": ACCENT_WARM,
233
+ "error": ERROR,
234
+ "mute": INK_MUTE,
235
+ }
236
+ c = palettes.get(palette, ACCENT)
237
+ return color(f"[{text}]", c)
238
+
239
+
240
+ def kv(label: str, value: str, *, label_width: int = 16, value_color: str = "ink") -> str:
241
+ """One left-aligned label / value row.
242
+
243
+ Used across status / whoami / devices for the LOCAL / SERVER blocks.
244
+ """
245
+ palettes = {
246
+ "ink": INK, "soft": INK_SOFT, "mute": INK_MUTE, "faint": INK_FAINT,
247
+ "accent": ACCENT, "ok": PULSE, "warn": ACCENT_WARM, "err": ERROR,
248
+ }
249
+ val_color = palettes.get(value_color, INK_SOFT)
250
+ return f" {color(label.ljust(label_width), INK_FAINT)} {color(value, val_color)}"
251
+
252
+
253
+ def block_title(text: str) -> str:
254
+ """Sub-section title within a command output. Like 'LOCAL' or 'SERVER'."""
255
+ return "\n" + eyebrow(text)
256
+
257
+
258
+ def success_line(text: str) -> str:
259
+ """Single-line success marker with gradient + glyph."""
260
+ return f" {ok(GLYPH_OK)} {gradient_jade(text)}"
261
+
262
+
263
+ def warn_line(text: str) -> str:
264
+ return f" {warn(GLYPH_WARN)} {warn(text)}"
265
+
266
+
267
+ def err_line(text: str) -> str:
268
+ return f" {err(GLYPH_ERR)} {err(text)}"
269
+
270
+
271
+ def hr_caption(caption: str, *, width: int = 60) -> str:
272
+ """Caption line under a divider — small, muted, centered."""
273
+ pad = max(0, (width - len(caption)) // 2)
274
+ return " " * pad + dim(caption)
275
+
276
+
277
+ def footer_credits(*, width: int = 60) -> str:
278
+ """Bottom-of-output line. Used at end of long outputs."""
279
+ return color("─" * width, RULE) + "\n" + dim(" sibyl labs · memory you can hold in your hand")
@@ -45,6 +45,7 @@ _GRADIENT = (
45
45
  )
46
46
 
47
47
  _TAGLINE = "memory you can hold in your hand"
48
+ _ATTRIBUTION = "a Sibyl Labs LLC Product. Agentic Infrastructure and Memory Products"
48
49
 
49
50
 
50
51
  def _supports_truecolor() -> bool:
@@ -97,8 +98,9 @@ def render_banner(*, force_color: bool | None = None) -> str:
97
98
  if not use_truecolor:
98
99
  # Plain text — still visually clean, just no color.
99
100
  body = "\n".join(" " + line for line in _LINES)
100
- tagline = f"\n {_TAGLINE}\n"
101
- return body + tagline
101
+ tagline = f"\n {_TAGLINE}"
102
+ attribution = f"\n {_ATTRIBUTION}\n"
103
+ return body + tagline + attribution
102
104
 
103
105
  # Colored — apply per-row gradient.
104
106
  colored_lines = []
@@ -106,10 +108,14 @@ def render_banner(*, force_color: bool | None = None) -> str:
106
108
  colored_lines.append(f" {_rgb(r, g, b)}{line}{_RESET}")
107
109
 
108
110
  body = "\n".join(colored_lines)
109
- # Tagline in dim italic-feeling color (the deepest gold)
111
+ # Tagline in the deepest gold — present, but not competing with the wordmark.
110
112
  r, g, b = _GRADIENT[-1]
111
- tagline = f"\n {_rgb(r, g, b)}{_TAGLINE}{_RESET}\n"
112
- return body + tagline
113
+ tagline = f"\n {_rgb(r, g, b)}{_TAGLINE}{_RESET}"
114
+ # Attribution dimmer still — a half-step below the tagline so the hierarchy
115
+ # reads SIBYL > tagline > attribution at a glance. ANSI dim (\033[2m) gives
116
+ # ~55% perceived opacity across the supported terminals.
117
+ attribution = f"\n \033[2m{_rgb(r, g, b)}{_ATTRIBUTION}{_RESET}\n"
118
+ return body + tagline + attribution
113
119
 
114
120
 
115
121
  def print_banner(*, force_color: bool | None = None) -> None:
@@ -59,6 +59,8 @@ UPGRADE_TIMEOUT_SEC = 15 * 60 # 15 minutes for upgrade — wallet ux can be sl
59
59
 
60
60
  # ---- Color / output ----------------------------------------------------
61
61
 
62
+ from . import _aesthetic as a
63
+
62
64
  _NO_COLOR = bool(os.environ.get("NO_COLOR")) or not sys.stdout.isatty()
63
65
 
64
66
 
@@ -106,7 +108,7 @@ class HttpError(Exception):
106
108
  self.url = url
107
109
 
108
110
 
109
- def http_request(
111
+ def http_request( # noqa: D401
110
112
  method: str,
111
113
  path: str,
112
114
  *,
@@ -215,10 +217,12 @@ def cmd_init(args: argparse.Namespace) -> int:
215
217
  cred_path = Path(args.credentials).expanduser()
216
218
  if cred_path.exists() and not args.force:
217
219
  existing = read_credentials(cred_path) or {}
218
- print(yellow("Already activated.") + " Use --force to re-activate.")
219
- print_status("Account", short(existing.get("account_id")))
220
- print_status("Tier", (existing.get("tier") or "free").upper())
221
- print_status("Credentials", str(cred_path))
220
+ print(a.section_header("already activated", subtitle="use --force to re-activate"))
221
+ print()
222
+ print(a.kv("Account", short(existing.get("account_id"))))
223
+ print(a.kv("Tier", (existing.get("tier") or "free").upper(), value_color="accent"))
224
+ print(a.kv("Credentials", str(cred_path)))
225
+ print()
222
226
  return 0
223
227
 
224
228
  # SEC-1 mitigation (v0.1.2): the URL parameter is an opaque pairing
@@ -258,14 +262,16 @@ def cmd_init(args: argparse.Namespace) -> int:
258
262
  print(yellow(f"Warning: session-init failed ({e.status}). Wallet path still works; email path may not."))
259
263
 
260
264
  print()
261
- print(bold("Sibyl Memory Plugin · activation"))
265
+ print(a.section_header("activation", subtitle="three paths · pick whichever fits your device"))
262
266
  print()
263
- print(f" {dim('Session:')} {short(session_id)}")
264
- # Format the code with a visual break to make it easier to read off the screen
265
- print(f" {dim('Code:')} {bold(pairing_code[:3] + ' ' + pairing_code[3:])} {dim('(use this in the email panel)')}")
266
- print(f" {dim('Opening:')} {activate_url}")
267
+ print(a.kv("Session", short(session_id)))
268
+ formatted_code = pairing_code[:3] + " " + pairing_code[3:]
269
+ print(a.kv("Code", a.gradient_gold(formatted_code), value_color="accent")
270
+ + " " + a.dim("(use this in the email panel)"))
271
+ print(a.kv("Opening", activate_url))
267
272
  print()
268
- print(dim("Pick wallet (SIWE) or email + the code above. This terminal will pick up automatically."))
273
+ print(a.dim(" desktop wallet · email + code · or send USDC from any mobile wallet"))
274
+ print(a.dim(" this terminal will pick up automatically when you bind."))
269
275
  print()
270
276
 
271
277
  try:
@@ -312,38 +318,46 @@ def cmd_init(args: argparse.Namespace) -> int:
312
318
  creds["session_token"] = bearer
313
319
  path = write_credentials_atomic(creds, cred_path)
314
320
  print(f"\r{' ' * 80}\r", end="") # clear spinner line
315
- print(green("✓ Activated."))
316
- print_status("Account", short(creds.get("account_id")))
317
- print_status("Tier", (creds.get("tier") or "free").upper())
318
- print_status("Wallet", creds.get("wallet") or "—")
319
- print_status("Email", creds.get("email") or "—")
320
- print_status("Credentials", str(path))
321
321
  print()
322
- print(dim("Activate Sibyl in Hermes:"))
323
- print(dim(" sibyl-memory-hermes install-plugin"))
324
- print(dim(" # then edit ~/.hermes/config.yaml:"))
325
- print(dim(" # memory:"))
326
- print(dim(" # provider: sibyl"))
322
+ print(a.success_line("Activated."))
323
+ print()
324
+ print(a.kv("Account", short(creds.get("account_id"))))
325
+ print(a.kv("Tier", (creds.get("tier") or "free").upper(), value_color="accent"))
326
+ print(a.kv("Wallet", creds.get("wallet") or "—"))
327
+ print(a.kv("Email", creds.get("email") or "—"))
328
+ print(a.kv("Credentials", str(path)))
329
+ print()
330
+ print(a.section_header("wire it into your agent"))
327
331
  print()
328
- print(dim("Or use the MCP server (Claude Code / Codex / Cursor / Continue):"))
329
- print(dim(" pip install sibyl-memory-mcp"))
332
+ print(a.dim(" hermes:"))
333
+ print(a.dim(" sibyl-memory-hermes install-plugin"))
334
+ print(a.dim(" # then edit ~/.hermes/config.yaml:"))
335
+ print(a.dim(" # memory:"))
336
+ print(a.dim(" # provider: sibyl"))
337
+ print()
338
+ print(a.dim(" claude code / codex / cursor / continue (MCP):"))
339
+ print(a.dim(" pip install sibyl-memory-mcp"))
340
+ print()
341
+ print(a.dim(" python orchestration (langchain / llamaindex / custom):"))
342
+ print(a.dim(" from sibyl_memory_hermes import SibylMemoryProvider"))
343
+ print(a.dim(" provider = SibylMemoryProvider()"))
330
344
  print()
331
- print(dim("Or call the SDK directly from any Python orchestration:"))
332
- print(dim(" from sibyl_memory_hermes import SibylMemoryProvider"))
333
- print(dim(" provider = SibylMemoryProvider()"))
334
345
  return 0
335
346
 
336
347
  # Spinner tick
337
348
  spin_i = (spin_i + 1) % len(spinner)
338
349
  remaining = int(deadline - time.time())
339
- status = f"\r {cyan(spinner[spin_i])} waiting for browser activation … {dim(f'{remaining // 60}:{remaining % 60:02d} left')}"
350
+ spin_glyph = a.color(spinner[spin_i], a.PULSE)
351
+ status = f"\r {spin_glyph} {a.dim('watching the network for your bind')} … {a.dim(f'{remaining // 60}:{remaining % 60:02d} left')}"
340
352
  if status != last_status:
341
353
  sys.stdout.write(status)
342
354
  sys.stdout.flush()
343
355
  last_status = status
344
356
  time.sleep(POLL_INTERVAL_SEC)
345
357
 
346
- print(red("\nActivation timed out. Re-run `sibyl init` to try again."))
358
+ print()
359
+ print(a.err_line("Activation timed out."))
360
+ print(a.dim(" Re-run `sibyl init` to try again."))
347
361
  return 1
348
362
 
349
363
 
@@ -353,7 +367,8 @@ def cmd_upgrade(args: argparse.Namespace) -> int:
353
367
  """Upgrade flow. Read existing creds → open upgrade page → poll /access until tier flips."""
354
368
  creds = read_credentials(Path(args.credentials).expanduser())
355
369
  if not creds:
356
- print(red("Not activated.") + " Run `sibyl init` first.")
370
+ print(a.err_line("Not activated."))
371
+ print(a.dim(" Run `sibyl init` first."))
357
372
  return 1
358
373
 
359
374
  account_id = creds.get("account_id")
@@ -361,21 +376,22 @@ def cmd_upgrade(args: argparse.Namespace) -> int:
361
376
  current_tier = (creds.get("tier") or "free").lower()
362
377
 
363
378
  if not account_id or not session_token:
364
- print(red("credentials.json is missing account_id or session_token. Re-run `sibyl init`."))
379
+ print(a.err_line("credentials.json is missing account_id or session_token."))
380
+ print(a.dim(" Re-run `sibyl init`."))
365
381
  return 1
366
382
 
367
383
  upgrade_url = f"{UPGRADE_BASE}?session={session_token}"
368
384
 
369
385
  print()
370
- print(bold("Sibyl Memory Plugin · upgrade"))
386
+ print(a.section_header("upgrade", subtitle="lift the 2 MB free-tier cap"))
371
387
  print()
372
- print_status("Account", short(account_id))
373
- print_status("Current tier", current_tier.upper())
374
- print_status("Opening", upgrade_url)
388
+ print(a.kv("Account", short(account_id)))
389
+ print(a.kv("Current tier", current_tier.upper(), value_color="accent"))
390
+ print(a.kv("Opening", upgrade_url))
375
391
  print()
376
- print(dim("Two paths in your browser:"))
377
- print(dim(" 1. Stake $SIBYL on Base (free unlimited if you qualify)"))
378
- print(dim(" 2. Subscribe in USDC (monthly / quarterly / annual)"))
392
+ print(a.dim(" two paths in the browser:"))
393
+ print(a.dim(" 1. stake $SIBYL on Base (free unlimited if you qualify)"))
394
+ print(a.dim(" 2. subscribe in USDC (monthly / quarterly / annual)"))
379
395
  print()
380
396
 
381
397
  try:
@@ -416,78 +432,90 @@ def cmd_upgrade(args: argparse.Namespace) -> int:
416
432
  invalidate_tier_cache()
417
433
 
418
434
  print(f"\r{' ' * 80}\r", end="")
419
- print(green(f"✓ Upgraded to {new_tier.upper()} via {source}."))
420
- print_status("Source", source)
435
+ print()
436
+ print(a.success_line(f"Upgraded to {new_tier.upper()} via {source}."))
437
+ print()
438
+ print(a.kv("Source", source))
421
439
  if resp.get("expires_at"):
422
- print_status("Expires", resp["expires_at"])
440
+ print(a.kv("Expires", resp["expires_at"]))
423
441
  if resp.get("cap_bytes") is None:
424
- print_status("Storage cap", "unlimited")
442
+ print(a.kv("Storage cap", "unlimited", value_color="ok"))
425
443
  else:
426
- print_status("Storage cap", f"{resp['cap_bytes']:,} bytes")
444
+ print(a.kv("Storage cap", f"{resp['cap_bytes']:,} bytes"))
427
445
  if resp.get("staker"):
428
446
  s = resp["staker"]
429
- print_status("Wallet", s.get("wallet", "—"))
430
- print_status("$SIBYL held", s.get("total_sibyl", "—"))
447
+ print(a.kv("Wallet", s.get("wallet", "—")))
448
+ print(a.kv("$SIBYL held", str(s.get("total_sibyl", "—"))))
431
449
  print()
432
- print(dim("Local tier cache cleared. Your next write will sync the new tier."))
450
+ print(a.dim(" local tier cache cleared. your next write will sync the new tier."))
433
451
  return 0
434
452
 
435
453
  spin_i = (spin_i + 1) % len(spinner)
436
454
  remaining = int(deadline - time.time())
437
- status = f"\r {cyan(spinner[spin_i])} waiting for browser upgrade … current: {current_tier.upper()} {dim(f'{remaining // 60}:{remaining % 60:02d} left')}"
455
+ spin_glyph = a.color(spinner[spin_i], a.PULSE)
456
+ tier_glyph = a.color(current_tier.upper(), a.ACCENT)
457
+ status = f"\r {spin_glyph} {a.dim('waiting for browser upgrade')} · current: {tier_glyph} {a.dim(f'{remaining // 60}:{remaining % 60:02d} left')}"
438
458
  if status != last_status:
439
459
  sys.stdout.write(status)
440
460
  sys.stdout.flush()
441
461
  last_status = status
442
462
  time.sleep(POLL_INTERVAL_SEC)
443
463
 
444
- print(red("\nUpgrade timed out. Tier unchanged. Re-run `sibyl upgrade` to retry."))
464
+ print()
465
+ print(a.err_line("Upgrade timed out. Tier unchanged."))
466
+ print(a.dim(" Re-run `sibyl upgrade` to retry."))
445
467
  return 1
446
468
 
447
469
 
448
470
  # ---- `sibyl status` ----------------------------------------------------
449
471
 
450
472
  def cmd_status(args: argparse.Namespace) -> int:
451
- """Show local + server-side state without modifying anything."""
473
+ """Show local + server-side state without modifying anything.
474
+
475
+ LIGHT treatment: utilitarian dashboard. No banner, no section header,
476
+ no chrome. Eyebrow labels + kv rows + ↓ status drift surfaces. Same
477
+ convention as `git status`, `ls -la`, `btop` panel bodies."""
452
478
  cred_path = Path(args.credentials).expanduser()
453
479
  creds = read_credentials(cred_path)
454
480
 
455
- print()
456
- print(bold("Sibyl Memory Plugin · status"))
457
481
  print()
458
482
 
459
483
  if not creds:
460
- print(yellow("Not activated.") + " Run `sibyl init`.")
484
+ print(a.warn_line("Not activated."))
485
+ print(a.dim(" Run `sibyl init`."))
461
486
  return 0
462
487
 
463
488
  # Local view
464
- print(dim("LOCAL"))
465
- print_status("Credentials", str(cred_path))
466
- print_status("Account", short(creds.get("account_id")))
467
- print_status("Tier", (creds.get("tier") or "free").upper())
468
- print_status("Wallet", creds.get("wallet") or "—")
469
- print_status("Email", creds.get("email") or "—")
470
- print_status("Issued", creds.get("issued_at") or "—")
489
+ print(a.eyebrow("local"))
490
+ print(a.kv("Credentials", str(cred_path)))
491
+ print(a.kv("Account", short(creds.get("account_id"))))
492
+ print(a.kv("Tier", (creds.get("tier") or "free").upper(), value_color="accent"))
493
+ print(a.kv("Wallet", creds.get("wallet") or "—"))
494
+ print(a.kv("Email", creds.get("email") or "—"))
495
+ print(a.kv("Issued", creds.get("issued_at") or "—"))
471
496
 
472
497
  db_path = Path(args.db).expanduser()
473
498
  if db_path.exists():
474
499
  size = db_path.stat().st_size
475
- print_status("DB path", str(db_path))
476
- print_status("DB size", f"{size:,} bytes ({size / (1024 * 1024):.2f} MB)")
500
+ pct = size / 2_097_152 * 100
501
+ size_label = f"{size:,} bytes ({size / (1024 * 1024):.2f} MB · {pct:.1f}% of free cap)"
502
+ size_color = "warn" if pct > 80 else "soft"
503
+ print(a.kv("DB path", str(db_path)))
504
+ print(a.kv("DB size", size_label, value_color=size_color))
477
505
  else:
478
- print_status("DB path", f"{db_path} (not created)")
506
+ print(a.kv("DB path", f"{db_path} (not created)"))
479
507
 
480
508
  tier_cache = Path(args.tier_cache).expanduser()
481
509
  if tier_cache.exists():
482
510
  cache = json.loads(tier_cache.read_text(encoding="utf-8"))
483
- print_status("Tier cache", f"{cache.get('tier','?')} (checked {cache.get('checked_at','?')[:19]})")
511
+ print(a.kv("Tier cache", f"{cache.get('tier','?')} (checked {cache.get('checked_at','?')[:19]})"))
484
512
  else:
485
- print_status("Tier cache", "—")
513
+ print(a.kv("Tier cache", "—"))
486
514
 
487
515
  # Server view (only if account_id + session_token are present)
488
516
  if creds.get("account_id") and creds.get("session_token"):
489
517
  print()
490
- print(dim("SERVER"))
518
+ print(a.eyebrow("server"))
491
519
  try:
492
520
  resp = http_request(
493
521
  "POST",
@@ -495,25 +523,26 @@ def cmd_status(args: argparse.Namespace) -> int:
495
523
  body={"account_id": creds["account_id"], "session_token": creds["session_token"]},
496
524
  timeout=10.0,
497
525
  )
498
- print_status("Tier", (resp.get("tier") or "free").upper())
499
- print_status("Source", resp.get("source") or "—")
500
- print_status("Cap bytes", "unlimited" if resp.get("cap_bytes") is None else f"{resp['cap_bytes']:,}")
526
+ print(a.kv("Tier", (resp.get("tier") or "free").upper(), value_color="accent"))
527
+ print(a.kv("Source", resp.get("source") or "—"))
528
+ print(a.kv("Cap bytes", "unlimited" if resp.get("cap_bytes") is None else f"{resp['cap_bytes']:,}"))
501
529
  if resp.get("expires_at"):
502
- print_status("Expires", resp["expires_at"])
530
+ print(a.kv("Expires", resp["expires_at"]))
503
531
  if resp.get("staker"):
504
532
  s = resp["staker"]
505
- print_status("$SIBYL held", s.get("total_sibyl", "—"))
506
- print_status("Threshold", str(s.get("threshold_sibyl", "—")))
507
- print_status("Qualified", "yes" if s.get("qualified") else "no")
533
+ print(a.kv("$SIBYL held", str(s.get("total_sibyl", "—"))))
534
+ print(a.kv("Threshold", str(s.get("threshold_sibyl", "—"))))
535
+ print(a.kv("Qualified", "yes" if s.get("qualified") else "no",
536
+ value_color="ok" if s.get("qualified") else "soft"))
508
537
  # Detect server/local drift
509
538
  srv_tier = (resp.get("tier") or "free").lower()
510
539
  loc_tier = (creds.get("tier") or "free").lower()
511
540
  if srv_tier != loc_tier:
512
541
  print()
513
- print(yellow(f"⚠ Local tier ({loc_tier}) differs from server tier ({srv_tier})."))
514
- print(dim(" Run `sibyl upgrade` to refresh, or remove credentials.json + `sibyl init` to re-activate."))
542
+ print(a.warn_line(f"Local tier ({loc_tier}) differs from server tier ({srv_tier})."))
543
+ print(a.dim(" Run `sibyl upgrade` to refresh, or `sibyl init --force` to re-activate."))
515
544
  except HttpError as e:
516
- print_status("Tier", red(f"server error: {e.status}"))
545
+ print(a.kv("Tier", f"server error: {e.status}", value_color="err"))
517
546
 
518
547
  print()
519
548
  return 0
@@ -550,6 +579,151 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
550
579
  return cmd_status(args)
551
580
 
552
581
 
582
+ # ---- `sibyl whoami` ----------------------------------------------------
583
+
584
+ def _mask_email(e: str | None) -> str:
585
+ if not e or "@" not in e:
586
+ return "—"
587
+ user, _, domain = e.partition("@")
588
+ if "." not in domain:
589
+ return f"{user[0]}***@{domain[0]}***"
590
+ name, _, tld = domain.rpartition(".")
591
+ return f"{user[0]}***@{name[0]}***.{tld}"
592
+
593
+
594
+ def _mask_wallet(w: str | None) -> str:
595
+ if not w or not w.startswith("0x") or len(w) < 12:
596
+ return w or "—"
597
+ return f"{w[:6]}…{w[-4:]}"
598
+
599
+
600
+ def cmd_whoami(args: argparse.Namespace) -> int:
601
+ """One-line account summary. Shows account_id + tier + linked email/wallet + this device.
602
+
603
+ LIGHT treatment: 4-line glance. No banner, no section header. Same shape
604
+ as `whoami` on unix, `gh auth status`, `aws sts get-caller-identity`."""
605
+ creds = read_credentials(Path(args.credentials).expanduser())
606
+ if not creds:
607
+ print(a.warn_line("Not activated."))
608
+ print(a.dim(" Run `sibyl init`."))
609
+ return 1
610
+
611
+ full = bool(getattr(args, "full", False))
612
+ acct = creds.get("account_id") or ""
613
+ tier = (creds.get("tier") or "free").upper()
614
+ email = creds.get("email") if full else _mask_email(creds.get("email"))
615
+ wallet = creds.get("wallet") if full else _mask_wallet(creds.get("wallet"))
616
+
617
+ print()
618
+ print(f" {a.color('account', a.INK_FAINT)} {a.bold(short(acct))} {a.dim(a.GLYPH_DOT)} {a.gradient_gold(tier)}")
619
+ print(f" {a.color('wallet ', a.INK_FAINT)} {a.color(wallet or '—', a.INK)}")
620
+ print(f" {a.color('email ', a.INK_FAINT)} {a.color(email or '—', a.INK)}")
621
+ os_label = _detect_os_family() or "unknown"
622
+ device_line = f"sibyl-memory-cli/{_client_version()} {os_label}"
623
+ print(f" {a.color('device ', a.INK_FAINT)} {a.dim(device_line)}")
624
+ print()
625
+ return 0
626
+
627
+
628
+ # ---- `sibyl devices` ---------------------------------------------------
629
+
630
+ def cmd_devices(args: argparse.Namespace) -> int:
631
+ """List active bearer tokens (devices) for the account. Optional: revoke by index."""
632
+ creds = read_credentials(Path(args.credentials).expanduser())
633
+ if not creds:
634
+ print(a.err_line("Not activated."))
635
+ print(a.dim(" Run `sibyl init`."))
636
+ return 1
637
+ account_id = creds.get("account_id")
638
+ session_token = creds.get("session_token")
639
+ if not account_id or not session_token:
640
+ print(a.err_line("credentials.json missing account_id or session_token."))
641
+ print(a.dim(" Run `sibyl init`."))
642
+ return 1
643
+
644
+ sub = getattr(args, "sub", None)
645
+
646
+ # `sibyl devices revoke <index>` path
647
+ if sub == "revoke":
648
+ idx = getattr(args, "index", None)
649
+ if idx is None:
650
+ print(red("usage: sibyl devices revoke <index>"))
651
+ return 1
652
+ # List first to map index → bearer_id
653
+ try:
654
+ resp = http_request(
655
+ "GET",
656
+ f"/api/plugin/devices?account_id={urllib.parse.quote(account_id)}",
657
+ headers={"Authorization": f"Bearer {session_token}"},
658
+ timeout=10.0,
659
+ )
660
+ except HttpError as e:
661
+ print(red(f"server error: {e.status} {e.body}"))
662
+ return 2
663
+ devices = resp.get("devices", [])
664
+ try:
665
+ target = devices[idx]
666
+ except (IndexError, TypeError):
667
+ print(red(f"no device at index {idx}. Run `sibyl devices` to see indexes."))
668
+ return 1
669
+ if target.get("is_this_device"):
670
+ print(red("refusing to revoke your own device — that would lock you out. Run `sibyl logout` instead, then `sibyl init` on a fresh activation."))
671
+ return 1
672
+ try:
673
+ revoke_resp = http_request(
674
+ "POST",
675
+ "/api/plugin/devices",
676
+ body={"bearer_id": target["bearer_id"]},
677
+ headers={"Authorization": f"Bearer {session_token}"},
678
+ timeout=10.0,
679
+ )
680
+ except HttpError as e:
681
+ print(red(f"revoke failed: {e.status} {e.body}"))
682
+ return 2
683
+ print(green(f"✓ Revoked device {target.get('device_label') or target['bearer_id']}"))
684
+ return 0 if revoke_resp.get("revoked") else 1
685
+
686
+ # Default: list devices
687
+ try:
688
+ resp = http_request(
689
+ "GET",
690
+ f"/api/plugin/devices?account_id={urllib.parse.quote(account_id)}",
691
+ headers={"Authorization": f"Bearer {session_token}"},
692
+ timeout=10.0,
693
+ )
694
+ except HttpError as e:
695
+ if e.status == 401:
696
+ print(a.err_line("Session expired."))
697
+ print(a.dim(" Re-run `sibyl init`."))
698
+ else:
699
+ print(a.err_line(f"server error: {e.status} {e.body}"))
700
+ return 2
701
+
702
+ devices = resp.get("devices", [])
703
+ # LIGHT treatment: table-like dashboard. Eyebrow line with count + the rows. No banner.
704
+ print()
705
+ print(f" {a.eyebrow('devices')} {a.dim(f'· {len(devices)} active')}")
706
+ print()
707
+ if not devices:
708
+ print(a.dim(" no active devices"))
709
+ print()
710
+ return 0
711
+
712
+ for i, d in enumerate(devices):
713
+ is_this = d.get("is_this_device")
714
+ marker = a.ok("▶") if is_this else " "
715
+ label = d.get("device_label") or "(unlabeled)"
716
+ installed = d.get("install_method") or "—"
717
+ last_seen = d.get("last_seen_at", "")[:19].replace("T", " ")
718
+ idx_chip = a.chip(str(i), palette="jade" if is_this else "mute")
719
+ label_color = a.gradient_gold(label) if is_this else a.color(label, a.INK)
720
+ meta = f"{a.dim(installed)} {a.dim(a.GLYPH_DOT)} {a.dim('last seen ' + last_seen)}"
721
+ note = a.color("(this device)", a.PULSE) if is_this else a.dim(f"revoke: sibyl devices revoke {i}")
722
+ print(f" {marker} {idx_chip} {label_color} {meta} {note}")
723
+ print()
724
+ return 0
725
+
726
+
553
727
  # ---- `sibyl logout` ----------------------------------------------------
554
728
 
555
729
  def cmd_logout(args: argparse.Namespace) -> int:
@@ -565,16 +739,17 @@ def cmd_logout(args: argparse.Namespace) -> int:
565
739
  tier_cache.unlink()
566
740
  deleted.append(str(tier_cache))
567
741
 
742
+ # LIGHT treatment: quick confirmation. No banner, no section header.
568
743
  print()
569
744
  if not deleted:
570
- print(yellow("Nothing to remove.") + " Already logged out.")
745
+ print(a.warn_line("Nothing to remove."))
746
+ print(a.dim(" Already logged out."))
571
747
  else:
572
- print(green("✓ Logged out."))
748
+ print(a.success_line("Logged out."))
573
749
  for path in deleted:
574
- print(f" {dim('removed')} {path}")
750
+ print(f" {a.dim('removed')} {a.color(path, a.INK)}")
575
751
  print()
576
- print(dim("Your memory.db file is untouched — that's your data."))
577
- print(dim("Run `sibyl init` to activate a fresh account."))
752
+ print(a.dim(" memory.db untouched. run `sibyl init` to activate a fresh account."))
578
753
  print()
579
754
  return 0
580
755
 
@@ -586,12 +761,28 @@ def cmd_health(args: argparse.Namespace) -> int:
586
761
  try:
587
762
  from sibyl_memory_hermes import SibylMemoryProvider
588
763
  except ImportError:
589
- print(red("sibyl-memory-hermes not installed."))
764
+ print(a.err_line("sibyl-memory-hermes not installed."))
765
+ print(a.dim(" pip install sibyl-memory-hermes"))
590
766
  return 1
767
+
768
+ # LIGHT treatment: verdict + details. No banner, no section header.
769
+ # Pattern: `pg_isready` / `redis-cli ping` / `gh auth status`.
770
+ print()
591
771
  provider = SibylMemoryProvider(db_path=args.db)
592
772
  h = provider.health()
593
- print(json.dumps(h, indent=2))
594
- return 0 if h.get("ok") else 1
773
+ ok_state = bool(h.get("ok"))
774
+ if ok_state:
775
+ print(a.success_line("All green."))
776
+ else:
777
+ print(a.err_line("Health check reports issues."))
778
+ print()
779
+ for k, v in h.items():
780
+ if k == "ok":
781
+ continue
782
+ val = str(v)
783
+ print(a.kv(k, val, value_color="ok" if v is True else ("soft" if v else "warn")))
784
+ print()
785
+ return 0 if ok_state else 1
595
786
 
596
787
 
597
788
  # ---- Dispatch ----------------------------------------------------------
@@ -620,6 +811,17 @@ def build_parser() -> argparse.ArgumentParser:
620
811
  p_st = sub.add_parser("status", help="Show local + server tier / DB stats")
621
812
  p_st.set_defaults(func=cmd_status)
622
813
 
814
+ p_who = sub.add_parser("whoami", help="One-line account summary (masked by default)")
815
+ p_who.add_argument("--full", action="store_true", help="Show full email + wallet (no masking)")
816
+ p_who.set_defaults(func=cmd_whoami)
817
+
818
+ p_dev = sub.add_parser("devices", help="List devices (active bearer tokens) for the account")
819
+ dev_sub = p_dev.add_subparsers(dest="sub")
820
+ p_rev = dev_sub.add_parser("revoke", help="Revoke a device by index (run `sibyl devices` for indexes)")
821
+ p_rev.add_argument("index", type=int, help="Index from `sibyl devices` output")
822
+ p_dev.set_defaults(func=cmd_devices)
823
+ p_rev.set_defaults(func=cmd_devices)
824
+
623
825
  p_dash = sub.add_parser("dashboard", help="Open the account dashboard (delegates to status until account.sibyllabs.org ships)")
624
826
  p_dash.set_defaults(func=cmd_dashboard)
625
827
 
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibyl-memory-cli
3
- Version: 0.1.4
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.
3
+ Version: 0.3.2
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, `sibyl whoami` gives a one-line account summary, `sibyl devices` lists active devices and supports per-device revoke.
5
5
  Author-email: "SIBYL, Sibyl Labs LLC" <sibyl@sibyllabs.org>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://sibyllabs.org/plugin
@@ -1,6 +1,7 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  src/sibyl_memory_cli/__init__.py
4
+ src/sibyl_memory_cli/_aesthetic.py
4
5
  src/sibyl_memory_cli/_banner.py
5
6
  src/sibyl_memory_cli/cli.py
6
7
  src/sibyl_memory_cli/setup.py