sibyl-memory-cli 0.1.0__tar.gz → 0.1.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,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibyl-memory-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
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.
5
- Author-email: Sibyl Labs <sibyl@sibyllabs.org>
5
+ Author-email: "SIBYL, Sibyl Labs LLC" <sibyl@sibyllabs.org>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://sibyllabs.org/plugin
8
8
  Project-URL: Documentation, https://docs.sibyllabs.org/memory/
@@ -18,8 +18,8 @@ Classifier: Environment :: Console
18
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
19
  Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
- Requires-Dist: sibyl-memory-client>=0.3.0
22
- Requires-Dist: sibyl-memory-hermes>=0.2.0
21
+ Requires-Dist: sibyl-memory-client>=0.3.3
22
+ Requires-Dist: sibyl-memory-hermes>=0.3.1
23
23
  Provides-Extra: dev
24
24
  Requires-Dist: pytest>=7.0; extra == "dev"
25
25
 
@@ -4,9 +4,9 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sibyl-memory-cli"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
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."
9
- authors = [{ name = "Sibyl Labs", email = "sibyl@sibyllabs.org" }]
9
+ authors = [{ name = "SIBYL, Sibyl Labs LLC", email = "sibyl@sibyllabs.org" }]
10
10
  license = { text = "MIT" }
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -23,8 +23,8 @@ classifiers = [
23
23
  "Topic :: Software Development :: Libraries :: Python Modules",
24
24
  ]
25
25
  dependencies = [
26
- "sibyl-memory-client>=0.3.0",
27
- "sibyl-memory-hermes>=0.2.0",
26
+ "sibyl-memory-client>=0.3.3",
27
+ "sibyl-memory-hermes>=0.3.1",
28
28
  ]
29
29
 
30
30
  [project.optional-dependencies]
@@ -13,6 +13,12 @@ All HTTP calls target https://api.sibyllabs.org/api/plugin/*.
13
13
  """
14
14
  from .cli import main
15
15
 
16
- __version__ = "0.1.0"
16
+ # Single-sourced from installed metadata so wheel + code can't drift
17
+ # (C3 audit fix v0.1.2). Same pattern as sibyl-memory-hermes v0.3.0+.
18
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
19
+ try:
20
+ __version__ = _pkg_version("sibyl-memory-cli")
21
+ except PackageNotFoundError: # pragma: no cover - source-tree dev only
22
+ __version__ = "0.0.0+source"
17
23
 
18
24
  __all__ = ["main", "__version__"]
@@ -0,0 +1,117 @@
1
+ """ASCII banner for sibyl-memory-cli.
2
+
3
+ Prints the SIBYL wordmark in ANSI Shadow boxchars with a 24-bit truecolor
4
+ vertical gradient flowing from cream/white at the top through warm gold
5
+ to deep ochre at the bottom — aligned with the lab visual identity per
6
+ the operator's brand-discipline rule (creme palette, deep-ochre accent).
7
+
8
+ Gracefully degrades:
9
+ - NO_COLOR env var set → plain text fallback
10
+ - stdout is not a TTY → plain text fallback (or skip entirely)
11
+ - TERM=dumb → plain text fallback
12
+
13
+ Truecolor support is detected via $COLORTERM (truecolor / 24bit) — most
14
+ modern terminals (iTerm2, Alacritty, Kitty, wezterm, Windows Terminal,
15
+ modern xterm builds, Ghostty) advertise it. Falls back to 256-color
16
+ gradient when not available.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import sys
22
+
23
+ # ANSI Shadow rendering of "SIBYL" — 6 rows, 41 cols. Each row gets its
24
+ # own gradient color (top = pale cream/white, bottom = deep ochre).
25
+ _LINES = (
26
+ "███████╗██╗██████╗ ██╗ ██╗██╗ ",
27
+ "██╔════╝██║██╔══██╗╚██╗ ██╔╝██║ ",
28
+ "███████╗██║██████╔╝ ╚████╔╝ ██║ ",
29
+ "╚════██║██║██╔══██╗ ╚██╔╝ ██║ ",
30
+ "███████║██║██████╔╝ ██║ ███████╗",
31
+ "╚══════╝╚═╝╚═════╝ ╚═╝ ╚══════╝",
32
+ )
33
+
34
+ # Vertical gradient · cream → gold → deep ochre. One RGB tuple per row.
35
+ # Tuned against the SIBYL palette: --paper #f5f1e6 (top blend),
36
+ # --accent #8a6a2a (mid-bottom), with extra highlight + shadow stops
37
+ # to give the wordmark visible dimension.
38
+ _GRADIENT = (
39
+ (253, 251, 245), # almost white, slight cream (top highlight)
40
+ (244, 229, 184), # pale gold (upper)
41
+ (224, 194, 119), # mid gold (upper-mid)
42
+ (184, 146, 73), # rich ochre gold (mid)
43
+ (138, 106, 42), # deep ochre · brand --accent (lower)
44
+ (106, 79, 31), # deepest (bottom shadow)
45
+ )
46
+
47
+ _TAGLINE = "memory you can hold in your hand"
48
+
49
+
50
+ def _supports_truecolor() -> bool:
51
+ """Detect 24-bit color support. Conservative — fall back gracefully."""
52
+ if os.environ.get("NO_COLOR"):
53
+ return False
54
+ if os.environ.get("TERM", "").lower() == "dumb":
55
+ return False
56
+ if not sys.stdout.isatty():
57
+ return False
58
+ colorterm = os.environ.get("COLORTERM", "").lower()
59
+ if "truecolor" in colorterm or "24bit" in colorterm:
60
+ return True
61
+ # Many modern terminals don't set COLORTERM but do support truecolor.
62
+ # Recognize the well-behaved emitters.
63
+ term_program = os.environ.get("TERM_PROGRAM", "").lower()
64
+ if term_program in {"iterm.app", "wezterm", "ghostty", "vscode", "tabby"}:
65
+ return True
66
+ term = os.environ.get("TERM", "").lower()
67
+ if any(k in term for k in ("256color", "kitty", "alacritty", "xterm-direct")):
68
+ return True
69
+ return False
70
+
71
+
72
+ def _color_supported() -> bool:
73
+ """Plain ANSI color (3/4-bit). Stricter than truecolor."""
74
+ if os.environ.get("NO_COLOR"):
75
+ return False
76
+ if os.environ.get("TERM", "").lower() == "dumb":
77
+ return False
78
+ return sys.stdout.isatty()
79
+
80
+
81
+ def _rgb(r: int, g: int, b: int) -> str:
82
+ return f"\033[38;2;{r};{g};{b}m"
83
+
84
+
85
+ _RESET = "\033[0m"
86
+
87
+
88
+ def render_banner(*, force_color: bool | None = None) -> str:
89
+ """Return the banner as a string ready to print.
90
+
91
+ Args:
92
+ force_color: Override auto-detection. None = auto, True = force
93
+ truecolor, False = force plain text. Useful for testing.
94
+ """
95
+ use_truecolor = force_color if force_color is not None else _supports_truecolor()
96
+
97
+ if not use_truecolor:
98
+ # Plain text — still visually clean, just no color.
99
+ body = "\n".join(" " + line for line in _LINES)
100
+ tagline = f"\n {_TAGLINE}\n"
101
+ return body + tagline
102
+
103
+ # Colored — apply per-row gradient.
104
+ colored_lines = []
105
+ for line, (r, g, b) in zip(_LINES, _GRADIENT):
106
+ colored_lines.append(f" {_rgb(r, g, b)}{line}{_RESET}")
107
+
108
+ body = "\n".join(colored_lines)
109
+ # Tagline in dim italic-feeling color (the deepest gold)
110
+ r, g, b = _GRADIENT[-1]
111
+ tagline = f"\n {_rgb(r, g, b)}{_TAGLINE}{_RESET}\n"
112
+ return body + tagline
113
+
114
+
115
+ def print_banner(*, force_color: bool | None = None) -> None:
116
+ """Print the banner. Safe to call unconditionally; honors NO_COLOR + TTY checks."""
117
+ print(render_banner(force_color=force_color))
@@ -5,7 +5,11 @@ https://api.sibyllabs.org/api/plugin/* and the local SibylMemoryProvider.
5
5
 
6
6
  Design pillars:
7
7
  - Zero non-stdlib deps in this file. urllib is enough.
8
- - Credentials are written atomically with 0600 perms.
8
+ - Credentials are written atomically at mode 0600, set at file-creation
9
+ time via O_CREAT|O_EXCL|O_NOFOLLOW (no chmod-after-write race).
10
+ - The URL parameter handed to the browser is an opaque session identifier,
11
+ not the long-lived bearer (audit SEC-1 — server-side pairing handoff
12
+ issues a separate bearer at activation completion if available).
9
13
  - session_token is never printed in full — display short slice only.
10
14
  - Polling has explicit timeouts; no infinite loops.
11
15
  - Every command exits with a clear status code (0 ok, 1 user error, 2 server error).
@@ -27,6 +31,18 @@ import webbrowser
27
31
  from pathlib import Path
28
32
  from typing import Any
29
33
 
34
+
35
+ def _client_version() -> str:
36
+ """Return the installed package version from metadata, never hardcoded."""
37
+ try:
38
+ from importlib.metadata import PackageNotFoundError, version as _v
39
+ try:
40
+ return _v("sibyl-memory-cli")
41
+ except PackageNotFoundError:
42
+ return "0.0.0+source"
43
+ except Exception:
44
+ return "0.0.0+source"
45
+
30
46
  # ---- Defaults ----------------------------------------------------------
31
47
 
32
48
  API_BASE = os.environ.get("SIBYL_API_BASE", "https://api.sibyllabs.org")
@@ -101,7 +117,7 @@ def http_request(
101
117
  """Single source of truth for HTTP calls. Returns parsed JSON or raises HttpError."""
102
118
  url = f"{API_BASE}{path}"
103
119
  data = None
104
- full_headers = {"Accept": "application/json", "User-Agent": "sibyl-memory-cli/0.1.0"}
120
+ full_headers = {"Accept": "application/json", "User-Agent": f"sibyl-memory-cli/{_client_version()}"}
105
121
  if body is not None:
106
122
  data = json.dumps(body).encode("utf-8")
107
123
  full_headers["Content-Type"] = "application/json"
@@ -124,20 +140,45 @@ def http_request(
124
140
  # ---- Credentials I/O ---------------------------------------------------
125
141
 
126
142
  def write_credentials_atomic(creds: dict, path: Path = DEFAULT_CRED_PATH) -> Path:
127
- """Write credentials.json atomically at mode 0600."""
143
+ """Write credentials.json atomically at mode 0600.
144
+
145
+ v0.1.2 hardening (audit SEC-2): mode 0600 is set by the kernel at
146
+ file-creation time via O_CREAT|O_EXCL|O_NOFOLLOW. Previously used
147
+ write_text() followed by os.chmod(), leaving a world-readable window
148
+ between syscalls every credential save.
149
+ """
128
150
  path = path.expanduser()
129
151
  path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
152
+ data = json.dumps(creds, indent=2).encode("utf-8")
130
153
  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)
154
+ # Clean any leftover .tmp from a crashed prior write so O_EXCL can succeed.
155
+ try:
156
+ os.unlink(tmp)
157
+ except FileNotFoundError:
158
+ pass
159
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
160
+ if hasattr(os, "O_NOFOLLOW"):
161
+ flags |= os.O_NOFOLLOW
162
+ fd = os.open(str(tmp), flags, 0o600)
163
+ try:
164
+ os.write(fd, data)
165
+ os.fsync(fd)
166
+ finally:
167
+ os.close(fd)
168
+ os.replace(str(tmp), str(path))
134
169
  return path
135
170
 
136
171
 
137
172
  def read_credentials(path: Path = DEFAULT_CRED_PATH) -> dict | None:
173
+ """Read credentials.json.
174
+
175
+ v0.1.2 hardening (audit SEC-11): refuses to follow symlinks.
176
+ Returns None if the file is a symlink or doesn't exist."""
138
177
  path = path.expanduser()
139
178
  if not path.exists():
140
179
  return None
180
+ if path.is_symlink():
181
+ return None
141
182
  return json.loads(path.read_text(encoding="utf-8"))
142
183
 
143
184
 
@@ -166,6 +207,11 @@ def cmd_init(args: argparse.Namespace) -> int:
166
207
  The pairing code is printed in the terminal. If the user picks the
167
208
  email path in the browser, they type both their email and this code.
168
209
  No external email service is required."""
210
+ # Brand moment — gold/white gradient SIBYL wordmark.
211
+ # Honors NO_COLOR + TTY detection automatically; safe to always call.
212
+ from ._banner import print_banner
213
+ print_banner()
214
+
169
215
  cred_path = Path(args.credentials).expanduser()
170
216
  if cred_path.exists() and not args.force:
171
217
  existing = read_credentials(cred_path) or {}
@@ -175,10 +221,18 @@ def cmd_init(args: argparse.Namespace) -> int:
175
221
  print_status("Credentials", str(cred_path))
176
222
  return 0
177
223
 
178
- session_token = str(uuid.uuid4())
224
+ # SEC-1 mitigation (v0.1.2): the URL parameter is an opaque pairing
225
+ # session identifier, NOT the long-lived bearer used by /access and
226
+ # /check-write. The CLI generates it locally and the server treats
227
+ # it as the activation rendezvous key only. The persistent bearer
228
+ # is issued by the server in the /check response (`bearer_token`
229
+ # field) after activation completes. Servers running pre-SEC-1
230
+ # firmware that echo the URL identifier as the bearer still work —
231
+ # we use whichever the server returns in the bound credentials.
232
+ session_id = str(uuid.uuid4())
179
233
  pairing_code = _gen_pairing_code()
180
- code_hash = _hash_pairing_code(pairing_code, session_token)
181
- activate_url = f"{ACTIVATE_BASE}?session={session_token}"
234
+ code_hash = _hash_pairing_code(pairing_code, session_id)
235
+ activate_url = f"{ACTIVATE_BASE}?session={session_id}"
182
236
 
183
237
  # Pre-register the session + pairing code hash with the server.
184
238
  # The code itself never leaves the user's machine until they type it
@@ -188,12 +242,12 @@ def cmd_init(args: argparse.Namespace) -> int:
188
242
  "POST",
189
243
  "/api/plugin/session-init",
190
244
  body={
191
- "session": session_token,
245
+ "session": session_id,
192
246
  "pairing_code_hash": code_hash,
193
247
  "env": {
194
248
  "os_family": _detect_os_family(),
195
249
  "install_method": "cli",
196
- "client_version": __import__("sibyl_memory_cli").__version__,
250
+ "client_version": _client_version(),
197
251
  },
198
252
  },
199
253
  timeout=10.0,
@@ -206,7 +260,7 @@ def cmd_init(args: argparse.Namespace) -> int:
206
260
  print()
207
261
  print(bold("Sibyl Memory Plugin · activation"))
208
262
  print()
209
- print(f" {dim('Session:')} {short(session_token)}")
263
+ print(f" {dim('Session:')} {short(session_id)}")
210
264
  # Format the code with a visual break to make it easier to read off the screen
211
265
  print(f" {dim('Code:')} {bold(pairing_code[:3] + ' ' + pairing_code[3:])} {dim('(use this in the email panel)')}")
212
266
  print(f" {dim('Opening:')} {activate_url}")
@@ -227,7 +281,7 @@ def cmd_init(args: argparse.Namespace) -> int:
227
281
 
228
282
  while time.time() < deadline:
229
283
  try:
230
- resp = http_request("GET", f"/api/plugin/check?session={urllib.parse.quote(session_token)}", timeout=10.0)
284
+ resp = http_request("GET", f"/api/plugin/check?session={urllib.parse.quote(session_id)}", timeout=10.0)
231
285
  except HttpError as e:
232
286
  if e.status in (404, 503, 0):
233
287
  # Session not yet created server-side, or transient — keep polling
@@ -239,12 +293,23 @@ def cmd_init(args: argparse.Namespace) -> int:
239
293
 
240
294
  if resp.get("bound") and resp.get("credentials"):
241
295
  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:
296
+ # SEC-1: prefer the server-issued bearer_token (post-fix) over
297
+ # echoing the URL pairing-session id. Servers running pre-SEC-1
298
+ # firmware echo `session_token` back as the bearer — we use
299
+ # whichever the server returns. The CLI's session_id (URL
300
+ # identifier) is the rendezvous key, not the persistent bearer.
301
+ bearer = creds.get("bearer_token") or creds.get("session_token")
302
+ if not bearer:
303
+ # Fallback: pre-SEC-1 server flow where neither field is
304
+ # echoed back — inject the pairing session id so subsequent
305
+ # /access and /check-write calls have something to send.
306
+ bearer = session_id
307
+ # Sanity check on echoed session_token (pre-SEC-1 flow only)
308
+ if creds.get("session_token") and creds["session_token"] != session_id \
309
+ and not creds.get("bearer_token"):
244
310
  print(red("\nSession token mismatch — refusing to write credentials."))
245
311
  return 2
246
- # If server didn't echo session_token, inject our locally-generated one
247
- creds.setdefault("session_token", session_token)
312
+ creds["session_token"] = bearer
248
313
  path = write_credentials_atomic(creds, cred_path)
249
314
  print(f"\r{' ' * 80}\r", end="") # clear spinner line
250
315
  print(green("✓ Activated."))
@@ -254,9 +319,18 @@ def cmd_init(args: argparse.Namespace) -> int:
254
319
  print_status("Email", creds.get("email") or "—")
255
320
  print_status("Credentials", str(path))
256
321
  print()
257
- print(dim("Wire the provider into Hermes:"))
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"))
327
+ print()
328
+ print(dim("Or use the MCP server (Claude Code / Codex / Cursor / Continue):"))
329
+ print(dim(" pip install sibyl-memory-mcp"))
330
+ print()
331
+ print(dim("Or call the SDK directly from any Python orchestration:"))
258
332
  print(dim(" from sibyl_memory_hermes import SibylMemoryProvider"))
259
- print(dim(" agent = Agent(memory=SibylMemoryProvider())"))
333
+ print(dim(" provider = SibylMemoryProvider()"))
260
334
  return 0
261
335
 
262
336
  # Spinner tick
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibyl-memory-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
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.
5
- Author-email: Sibyl Labs <sibyl@sibyllabs.org>
5
+ Author-email: "SIBYL, Sibyl Labs LLC" <sibyl@sibyllabs.org>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://sibyllabs.org/plugin
8
8
  Project-URL: Documentation, https://docs.sibyllabs.org/memory/
@@ -18,8 +18,8 @@ Classifier: Environment :: Console
18
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
19
  Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
- Requires-Dist: sibyl-memory-client>=0.3.0
22
- Requires-Dist: sibyl-memory-hermes>=0.2.0
21
+ Requires-Dist: sibyl-memory-client>=0.3.3
22
+ Requires-Dist: sibyl-memory-hermes>=0.3.1
23
23
  Provides-Extra: dev
24
24
  Requires-Dist: pytest>=7.0; extra == "dev"
25
25
 
@@ -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/_banner.py
4
5
  src/sibyl_memory_cli/cli.py
5
6
  src/sibyl_memory_cli.egg-info/PKG-INFO
6
7
  src/sibyl_memory_cli.egg-info/SOURCES.txt
@@ -0,0 +1,5 @@
1
+ sibyl-memory-client>=0.3.3
2
+ sibyl-memory-hermes>=0.3.1
3
+
4
+ [dev]
5
+ pytest>=7.0
@@ -1,5 +0,0 @@
1
- sibyl-memory-client>=0.3.0
2
- sibyl-memory-hermes>=0.2.0
3
-
4
- [dev]
5
- pytest>=7.0