sibyl-memory-cli 0.3.23__tar.gz → 0.4.1__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.
Files changed (36) hide show
  1. {sibyl_memory_cli-0.3.23/src/sibyl_memory_cli.egg-info → sibyl_memory_cli-0.4.1}/PKG-INFO +8 -4
  2. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/README.md +2 -0
  3. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/pyproject.toml +6 -4
  4. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/src/sibyl_memory_cli/cli.py +242 -14
  5. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/src/sibyl_memory_cli/setup.py +7 -0
  6. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1/src/sibyl_memory_cli.egg-info}/PKG-INFO +8 -4
  7. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/src/sibyl_memory_cli.egg-info/SOURCES.txt +4 -0
  8. sibyl_memory_cli-0.4.1/src/sibyl_memory_cli.egg-info/requires.txt +9 -0
  9. sibyl_memory_cli-0.4.1/tests/test_claim_and_fullid_2026_09_04.py +99 -0
  10. sibyl_memory_cli-0.4.1/tests/test_platform_gate_2026_09_04.py +188 -0
  11. sibyl_memory_cli-0.4.1/tests/test_trust_context_2026_08_25.py +78 -0
  12. sibyl_memory_cli-0.4.1/tests/test_verdict_contract_2026_08_31.py +126 -0
  13. sibyl_memory_cli-0.3.23/src/sibyl_memory_cli.egg-info/requires.txt +0 -9
  14. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/LICENSE +0 -0
  15. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/setup.cfg +0 -0
  16. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/src/sibyl_memory_cli/__init__.py +0 -0
  17. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/src/sibyl_memory_cli/_aesthetic.py +0 -0
  18. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/src/sibyl_memory_cli/_banner.py +0 -0
  19. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/src/sibyl_memory_cli/migrate.py +0 -0
  20. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/src/sibyl_memory_cli.egg-info/dependency_links.txt +0 -0
  21. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/src/sibyl_memory_cli.egg-info/entry_points.txt +0 -0
  22. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/src/sibyl_memory_cli.egg-info/top_level.txt +0 -0
  23. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/tests/test_hermes_install_args.py +0 -0
  24. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/tests/test_init_perms_guard.py +0 -0
  25. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/tests/test_memory_cmd_2026_06_16.py +0 -0
  26. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/tests/test_memory_tenant_2026_08_12.py +0 -0
  27. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/tests/test_migrate.py +0 -0
  28. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/tests/test_migrate_adversarial.py +0 -0
  29. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/tests/test_migrate_force_2026_06_05.py +0 -0
  30. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/tests/test_orchestrator.py +0 -0
  31. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/tests/test_prelaunch_fixes_2026_06_25.py +0 -0
  32. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/tests/test_quality_fixes_2026_06_30.py +0 -0
  33. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/tests/test_setup.py +0 -0
  34. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/tests/test_status_stores_2026_06_11.py +0 -0
  35. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/tests/test_superpatch_2026_07_05.py +0 -0
  36. {sibyl_memory_cli-0.3.23 → sibyl_memory_cli-0.4.1}/tests/test_wiring_fix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibyl-memory-cli
3
- Version: 0.3.23
3
+ Version: 0.4.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, `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
@@ -17,14 +17,16 @@ Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Environment :: Console
19
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Operating System :: POSIX :: Linux
21
+ Classifier: Operating System :: MacOS
20
22
  Requires-Python: >=3.10
21
23
  Description-Content-Type: text/markdown
22
24
  License-File: LICENSE
23
- Requires-Dist: sibyl-memory-client>=0.7.0
24
- Requires-Dist: sibyl-memory-hermes>=0.3.2
25
+ Requires-Dist: sibyl-memory-client>=0.8.1
26
+ Requires-Dist: sibyl-memory-hermes>=0.4.0
25
27
  Requires-Dist: pyyaml<7,>=6.0
26
28
  Provides-Extra: mcp
27
- Requires-Dist: sibyl-memory-mcp>=0.1.2; extra == "mcp"
29
+ Requires-Dist: sibyl-memory-mcp>=0.2.0; extra == "mcp"
28
30
  Provides-Extra: dev
29
31
  Requires-Dist: pytest>=7.0; extra == "dev"
30
32
  Dynamic: license-file
@@ -37,6 +39,8 @@ Command-line interface for the **Sibyl Memory Plugin**.
37
39
  pip install sibyl-memory-cli
38
40
  ```
39
41
 
42
+ Sibyl Memory runs on Linux, macOS (Apple Silicon and Intel), and Windows through WSL2. Native Windows is not supported.
43
+
40
44
  This pulls in `sibyl-memory-client` (the local SDK) and `sibyl-memory-hermes` (the Hermes provider) automatically.
41
45
 
42
46
  ## Commands
@@ -6,6 +6,8 @@ Command-line interface for the **Sibyl Memory Plugin**.
6
6
  pip install sibyl-memory-cli
7
7
  ```
8
8
 
9
+ Sibyl Memory runs on Linux, macOS (Apple Silicon and Intel), and Windows through WSL2. Native Windows is not supported.
10
+
9
11
  This pulls in `sibyl-memory-client` (the local SDK) and `sibyl-memory-hermes` (the Hermes provider) automatically.
10
12
 
11
13
  ## Commands
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sibyl-memory-cli"
7
- version = "0.3.23"
7
+ version = "0.4.1"
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, `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" }
@@ -21,16 +21,18 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.12",
22
22
  "Environment :: Console",
23
23
  "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Operating System :: POSIX :: Linux",
25
+ "Operating System :: MacOS",
24
26
  ]
25
27
  dependencies = [
26
- "sibyl-memory-client>=0.7.0",
27
- "sibyl-memory-hermes>=0.3.2",
28
+ "sibyl-memory-client>=0.8.1",
29
+ "sibyl-memory-hermes>=0.4.0",
28
30
  "pyyaml>=6.0,<7",
29
31
  ]
30
32
 
31
33
  [project.optional-dependencies]
32
34
  mcp = [
33
- "sibyl-memory-mcp>=0.1.2",
35
+ "sibyl-memory-mcp>=0.2.0",
34
36
  ]
35
37
  dev = [
36
38
  "pytest>=7.0",
@@ -4,7 +4,9 @@ Stdlib only. The CLI is a thin wrapper around HTTP calls to
4
4
  https://api.sibyllabs.org/api/plugin/* and the local SibylMemoryProvider.
5
5
 
6
6
  Design pillars:
7
- - Zero non-stdlib deps in this file. urllib is enough.
7
+ - Zero HARD non-stdlib deps in this file. urllib is enough; sibyl_memory_client
8
+ is imported lazily where it helps (tenant resolution, TLS trust) and every
9
+ such import degrades gracefully when the package is absent.
8
10
  - Credentials are written atomically at mode 0600, set at file-creation
9
11
  time via O_CREAT|O_EXCL|O_NOFOLLOW (no chmod-after-write race).
10
12
  - The URL parameter handed to the browser is an opaque session identifier,
@@ -20,7 +22,9 @@ import argparse
20
22
  import hashlib
21
23
  import json
22
24
  import os
25
+ import platform as _platform
23
26
  import secrets
27
+ import ssl
24
28
  import sys
25
29
  import time
26
30
  import urllib.error
@@ -98,6 +102,94 @@ def _detect_os_family() -> str | None:
98
102
  return None
99
103
 
100
104
 
105
+ # ---- platform support policy (2026-09-04) ------------------------------
106
+ #
107
+ # Sibyl Memory runs on Linux, macOS (Apple Silicon and Intel), and Windows
108
+ # through WSL2. Native Windows is not supported. WSL2 is Linux to Python and
109
+ # to us, so it needs no special case beyond the label.
110
+ #
111
+ # "block" refuses `sibyl init` and `sibyl setup` on native Windows. Every
112
+ # other command keeps working on machines that already activated there, so an
113
+ # existing install never loses `status`, `upgrade`, `claim`, `devices`,
114
+ # `logout`, `health`, `memory` or `update`.
115
+ NATIVE_WINDOWS_POLICY = "block"
116
+ NATIVE_WINDOWS_ESCAPE_ENV = "SIBYL_ALLOW_NATIVE_WINDOWS"
117
+ WSL_DOCS_URL = "https://docs.sibyllabs.org/memory/install#windows"
118
+
119
+
120
+ def _platform_info() -> tuple[str, str, bool]:
121
+ """(os_family, human label, supported). WSL2 is Linux to Python and to us.
122
+
123
+ The os_family element is deliberately the same vocabulary
124
+ `_detect_os_family()` returns (`linux|macos|windows`), because telemetry
125
+ consumers key on it. `unknown` is the one value that function reports as
126
+ None instead.
127
+ """
128
+ p = sys.platform
129
+ arch = _platform.machine() or "unknown"
130
+ if p == "darwin":
131
+ ver = _platform.mac_ver()[0] or ""
132
+ return "macos", f"macOS {ver} ({arch})".replace(" ", " "), True
133
+ if p.startswith("linux"):
134
+ rel = _platform.release().lower()
135
+ wsl = "microsoft" in rel or bool(os.environ.get("WSL_DISTRO_NAME"))
136
+ return "linux", ("Linux, WSL2" if wsl else "Linux") + f" ({arch})", True
137
+ if p.startswith("win"):
138
+ return "windows", f"Windows {_platform.release()} native ({arch})", False
139
+ return "unknown", p, False
140
+
141
+
142
+ def _is_wsl() -> bool:
143
+ if not sys.platform.startswith("linux"):
144
+ return False
145
+ return "microsoft" in _platform.release().lower() or bool(os.environ.get("WSL_DISTRO_NAME"))
146
+
147
+
148
+ def _native_windows_override() -> bool:
149
+ """True when the user has explicitly opted into the unsupported path."""
150
+ v = (os.environ.get(NATIVE_WINDOWS_ESCAPE_ENV) or "").strip().lower()
151
+ return v not in ("", "0", "false", "no", "off")
152
+
153
+
154
+ def print_platform_line(*, gate: bool = False) -> int:
155
+ """Print the platform line. Returns 0 to continue, 1 to refuse.
156
+
157
+ `gate=True` is passed by `sibyl init` and `sibyl setup` only. Those are the
158
+ two commands that stand up a NEW install, and they are the only two the
159
+ native-Windows block applies to.
160
+ """
161
+ family, label, supported = _platform_info()
162
+ if supported:
163
+ print(dim(f" platform {label} \u00b7 supported"))
164
+ return 0
165
+
166
+ blocked = (
167
+ gate
168
+ and family == "windows"
169
+ and NATIVE_WINDOWS_POLICY == "block"
170
+ and not _native_windows_override()
171
+ )
172
+ paint = red if blocked else yellow
173
+ print(paint(f" platform {label} \u00b7 not supported"))
174
+
175
+ if family != "windows":
176
+ print(yellow(" Sibyl Memory is built and tested on Linux and macOS. This platform is"))
177
+ print(yellow(" neither, so nothing here is tested or fixed. Continuing anyway."))
178
+ return 0
179
+
180
+ if blocked:
181
+ print(red(" Sibyl Memory is not supported on native Windows. Install it inside WSL2 and"))
182
+ print(red(f" run this command again there: {WSL_DOCS_URL}"))
183
+ print(red(" Already activated here? status, upgrade, claim, devices and logout keep working."))
184
+ print(red(f" To continue unsupported anyway, set {NATIVE_WINDOWS_ESCAPE_ENV}=1."))
185
+ return 1
186
+
187
+ print(yellow(" Sibyl Memory is not supported on native Windows. Run it inside WSL2:"))
188
+ print(yellow(f" {WSL_DOCS_URL}"))
189
+ print(yellow(" Continuing anyway. Nothing on this platform is tested or fixed."))
190
+ return 0
191
+
192
+
101
193
  def short(token: str | None) -> str:
102
194
  if not token:
103
195
  return "—"
@@ -135,6 +227,24 @@ class HttpError(Exception):
135
227
  self.url = url
136
228
 
137
229
 
230
+ def _https_context() -> ssl.SSLContext:
231
+ """TLS context for every CLI network call (api.sibyllabs.org, pypi.org).
232
+
233
+ Prefers the shared certifi-backed trust context from sibyl-memory-client,
234
+ the fix for macOS framework-build Pythons that ship an empty OpenSSL trust
235
+ store (the `session-init failed (0)` / `Bind failed` / `server error: 0`
236
+ class of report). Degrades to the stdlib default context when the client
237
+ package is absent, mirroring the `_ver_lt` packaging fallback: the CLI
238
+ stays runnable stdlib-only, just without the Darwin trust fix. Verification
239
+ is fully on in both branches; nothing here ever weakens it.
240
+ """
241
+ try:
242
+ from sibyl_memory_client._trust import https_context
243
+ except ImportError:
244
+ return ssl.create_default_context()
245
+ return https_context()
246
+
247
+
138
248
  def http_request( # noqa: D401
139
249
  method: str,
140
250
  path: str,
@@ -154,7 +264,7 @@ def http_request( # noqa: D401
154
264
  full_headers.update(headers)
155
265
  req = urllib.request.Request(url, data=data, method=method, headers=full_headers)
156
266
  try:
157
- with urllib.request.urlopen(req, timeout=timeout) as resp:
267
+ with urllib.request.urlopen(req, timeout=timeout, context=_https_context()) as resp:
158
268
  return json.loads(resp.read().decode("utf-8"))
159
269
  except urllib.error.HTTPError as e:
160
270
  try:
@@ -292,12 +402,16 @@ def cmd_init(args: argparse.Namespace) -> int:
292
402
  from ._banner import print_banner
293
403
  print_banner()
294
404
 
405
+ # Platform line first, so it shows on --force re-activation too.
406
+ if print_platform_line(gate=True) != 0:
407
+ return 1
408
+
295
409
  cred_path = Path(args.credentials).expanduser()
296
410
  if cred_path.exists() and not args.force:
297
411
  existing = read_credentials(cred_path) or {}
298
412
  print(a.section_header("already activated", subtitle="use --force to re-activate"))
299
413
  print()
300
- print(a.kv("Account", short(existing.get("account_id"))))
414
+ print(a.kv("Account", existing.get("account_id") or "—"))
301
415
  print(a.kv("Tier", (existing.get("tier") or "free").upper(), value_color="accent"))
302
416
  print(a.kv("Credentials", str(cred_path)))
303
417
  print()
@@ -345,6 +459,9 @@ def cmd_init(args: argparse.Namespace) -> int:
345
459
  "pairing_code_hash": code_hash,
346
460
  "env": {
347
461
  "os_family": _detect_os_family(),
462
+ "os_version": _platform.release(),
463
+ "arch": _platform.machine(),
464
+ "wsl": _is_wsl(),
348
465
  "install_method": "cli",
349
466
  "client_version": _client_version(),
350
467
  },
@@ -439,7 +556,7 @@ def cmd_init(args: argparse.Namespace) -> int:
439
556
  print()
440
557
  print(a.success_line("Activated."))
441
558
  print()
442
- print(a.kv("Account", short(creds.get("account_id"))))
559
+ print(a.kv("Account", creds.get("account_id") or "—"))
443
560
  print(a.kv("Tier", (creds.get("tier") or "free").upper(), value_color="accent"))
444
561
  print(a.kv("Wallet", creds.get("wallet") or "—"))
445
562
  print(a.kv("Email", creds.get("email") or "—"))
@@ -508,7 +625,7 @@ def cmd_upgrade(args: argparse.Namespace) -> int:
508
625
  print()
509
626
  print(a.section_header("upgrade", subtitle="lift the 5 MB free-tier cap"))
510
627
  print()
511
- print(a.kv("Account", short(account_id)))
628
+ print(a.kv("Account", account_id or "—"))
512
629
  print(a.kv("Current tier", current_tier.upper(), value_color="accent"))
513
630
  # F3 (red-team 2026-06-17): never print the bearer to stdout (terminal
514
631
  # scrollback / tmux / CI logs / screen-shares) — restores the invariant
@@ -672,11 +789,14 @@ def cmd_status(args: argparse.Namespace) -> int:
672
789
  # Local view
673
790
  print(a.eyebrow("local"))
674
791
  print(a.kv("Credentials", str(cred_path)))
675
- print(a.kv("Account", short(creds.get("account_id"))))
792
+ print(a.kv("Account", creds.get("account_id") or "—"))
676
793
  print(a.kv("Tier", (creds.get("tier") or "free").upper(), value_color="accent"))
677
794
  print(a.kv("Wallet", creds.get("wallet") or "—"))
678
795
  print(a.kv("Email", creds.get("email") or "—"))
679
796
  print(a.kv("Issued", creds.get("issued_at") or "—"))
797
+ _family, plat_label, plat_ok = _platform_info()
798
+ print(a.kv("Platform", plat_label if plat_ok else f"{plat_label} \u00b7 unsupported",
799
+ value_color="ink" if plat_ok else "warn"))
680
800
 
681
801
  db_path = Path(args.db).expanduser()
682
802
  if db_path.exists():
@@ -695,11 +815,20 @@ def cmd_status(args: argparse.Namespace) -> int:
695
815
  # write burst (committed pages still in memory.db-wal), so the
696
816
  # displayed size/percentage would otherwise disagree with the gate.
697
817
  from sibyl_memory_client.storage import db_size_bytes
818
+ from sibyl_memory_client._capcheck import FREE_TIER_CAP_BYTES, PAID_TIERS
698
819
 
699
820
  size = db_size_bytes(db_path)
700
- pct = size / 2_097_152 * 100
701
- size_label = f"{size:,} bytes ({size / (1024 * 1024):.2f} MB · {pct:.1f}% of free cap)"
702
- size_color = "warn" if pct > 80 else "soft"
821
+ # Panel fix 2026-09-04: the divisor was a hardcoded pre-2026-08-06
822
+ # 2 MiB; the free cap is FREE_TIER_CAP_BYTES (5 MiB). And a paid
823
+ # tier has no cap, so no percentage is shown for it.
824
+ tier_now = (creds.get("tier") or "free") if creds else "free"
825
+ if tier_now in PAID_TIERS:
826
+ size_label = f"{size:,} bytes ({size / (1024 * 1024):.2f} MB · uncapped)"
827
+ size_color = "soft"
828
+ else:
829
+ pct = size / FREE_TIER_CAP_BYTES * 100
830
+ size_label = f"{size:,} bytes ({size / (1024 * 1024):.2f} MB · {pct:.1f}% of free cap)"
831
+ size_color = "warn" if pct > 80 else "soft"
703
832
  print(a.kv("DB path", str(db_path)))
704
833
  print(a.kv("DB size", size_label, value_color=size_color))
705
834
  else:
@@ -852,16 +981,95 @@ def cmd_whoami(args: argparse.Namespace) -> int:
852
981
  wallet = creds.get("wallet") if full else _mask_wallet(creds.get("wallet"))
853
982
 
854
983
  print()
855
- print(f" {a.color('account', a.INK_FAINT)} {a.bold(short(acct))} {a.dim(a.GLYPH_DOT)} {a.gradient_gold(tier)}")
984
+ print(f" {a.color('account', a.INK_FAINT)} {a.bold(acct or "—")} {a.dim(a.GLYPH_DOT)} {a.gradient_gold(tier)}")
856
985
  print(f" {a.color('wallet ', a.INK_FAINT)} {a.color(wallet or '—', a.INK)}")
857
986
  print(f" {a.color('email ', a.INK_FAINT)} {a.color(email or '—', a.INK)}")
858
- os_label = _detect_os_family() or "unknown"
859
- device_line = f"sibyl-memory-cli/{_client_version()} {os_label}"
987
+ _family, plat_label, plat_ok = _platform_info()
988
+ device_line = f"sibyl-memory-cli/{_client_version()} {plat_label}"
989
+ if not plat_ok:
990
+ device_line += " \u00b7 unsupported"
860
991
  print(f" {a.color('device ', a.INK_FAINT)} {a.dim(device_line)}")
861
992
  print()
862
993
  return 0
863
994
 
864
995
 
996
+ # ---- `sibyl claim` -----------------------------------------------------
997
+
998
+ def _looks_like_checkout_code(cs: str) -> bool:
999
+ """Mirror of the server's CS_ID_RE without importing re: cs_ prefix,
1000
+ optional test_/live_ marker, then 10..240 alphanumerics."""
1001
+ if not cs.startswith("cs_"):
1002
+ return False
1003
+ rest = cs[3:]
1004
+ for marker in ("test_", "live_"):
1005
+ if rest.startswith(marker):
1006
+ rest = rest[len(marker):]
1007
+ break
1008
+ return 10 <= len(rest) <= 240 and rest.isalnum()
1009
+
1010
+
1011
+ def cmd_claim(args: argparse.Namespace) -> int:
1012
+ """Link a card purchase (Stripe checkout) to this account.
1013
+
1014
+ Buy-before-install: the page after checkout shows the buyer their
1015
+ checkout code (cs_...). This redeems it onto the account activated on
1016
+ this machine — email or wallet activation both work. The bearer rides
1017
+ along as session_token so the server verifies the caller controls the
1018
+ destination account, not just the receipt.
1019
+ """
1020
+ cs = (getattr(args, "checkout_code", "") or "").strip()
1021
+ if not _looks_like_checkout_code(cs):
1022
+ print(a.warn_line("That does not look like a checkout code."))
1023
+ print(a.dim(" It starts with cs_ and appears on the page you landed on after paying"))
1024
+ print(a.dim(" (sibyllabs.org/pro/welcome). Copy the whole code."))
1025
+ return 1
1026
+
1027
+ creds = read_credentials(Path(args.credentials).expanduser())
1028
+ if not creds or not creds.get("account_id"):
1029
+ print(a.warn_line("Not activated."))
1030
+ print(a.dim(" Run `sibyl init` first, then `sibyl claim` again."))
1031
+ return 1
1032
+ account_id = creds["account_id"]
1033
+
1034
+ body: dict = {"cs": cs, "account_id": account_id}
1035
+ token = creds.get("bearer_token") or creds.get("session_token")
1036
+ if token:
1037
+ body["session_token"] = token
1038
+
1039
+ try:
1040
+ resp = http_request("POST", "/api/plugin/stripe-claim", body=body)
1041
+ except HttpError as e:
1042
+ msg = e.body.get("error") if isinstance(e.body, dict) else None
1043
+ print(a.warn_line(f"Claim failed ({e.status or 'network'})."))
1044
+ if msg:
1045
+ print(a.dim(f" {msg}"))
1046
+ if e.status == 403:
1047
+ print(a.dim(" This session does not match the account. Run `sibyl init`, then retry."))
1048
+ elif e.status == 0:
1049
+ print(a.dim(" Could not reach the server. Check your connection and retry."))
1050
+ return 1
1051
+
1052
+ tier = (resp.get("tier") or "pro").upper()
1053
+ print()
1054
+ print(a.success_line(f"Purchase linked. Tier: {tier}"))
1055
+ if resp.get("expires_at"):
1056
+ print(a.kv("Covers through", str(resp["expires_at"])))
1057
+ print(a.dim(" Run `sibyl status` to see it. Your agent picks it up next session."))
1058
+ print()
1059
+
1060
+ # Sync local state so the very next write sees the new tier.
1061
+ try:
1062
+ creds["tier"] = resp.get("tier") or "pro"
1063
+ write_credentials_atomic(creds, Path(args.credentials).expanduser())
1064
+ except Exception:
1065
+ pass # server is the source of truth; local hint refreshes on next access check
1066
+ try:
1067
+ invalidate_tier_cache()
1068
+ except Exception:
1069
+ pass
1070
+ return 0
1071
+
1072
+
865
1073
  # ---- `sibyl devices` ---------------------------------------------------
866
1074
 
867
1075
  def cmd_devices(args: argparse.Namespace) -> int:
@@ -1151,7 +1359,7 @@ def _pypi_latest(pkg: str, timeout: float = 4.0) -> str | None:
1151
1359
  url = f"https://pypi.org/pypi/{pkg}/json"
1152
1360
  try:
1153
1361
  req = urllib.request.Request(url, headers={"User-Agent": f"sibyl-memory-cli/{_client_version()}"})
1154
- with urllib.request.urlopen(req, timeout=timeout) as r:
1362
+ with urllib.request.urlopen(req, timeout=timeout, context=_https_context()) as r:
1155
1363
  data = json.loads(r.read().decode("utf-8"))
1156
1364
  return (data.get("info") or {}).get("version")
1157
1365
  except Exception:
@@ -1429,6 +1637,10 @@ def build_parser() -> argparse.ArgumentParser:
1429
1637
  p_up = sub.add_parser("upgrade", help="Open the upgrade flow (stake or subscribe)")
1430
1638
  p_up.set_defaults(func=cmd_upgrade)
1431
1639
 
1640
+ p_claim = sub.add_parser("claim", help="Link a card purchase to this account (code from sibyllabs.org/pro/welcome)")
1641
+ p_claim.add_argument("checkout_code", help="The cs_... checkout code shown after paying")
1642
+ p_claim.set_defaults(func=cmd_claim)
1643
+
1432
1644
  p_st = sub.add_parser("status", help="Show local + server tier / DB stats")
1433
1645
  p_st.set_defaults(func=cmd_status)
1434
1646
 
@@ -1549,6 +1761,10 @@ def cmd_memory(args: argparse.Namespace) -> int:
1549
1761
  you can inspect any split-brain store that `sibyl status` surfaces.
1550
1762
  """
1551
1763
  from sibyl_memory_client import DEFAULT_TENANT, MemoryClient
1764
+ # THE canonical cause vocabulary (stage 3, 2026-08-31). Imported from the
1765
+ # client, never re-declared here — the CLI prints causes, it does not own
1766
+ # them.
1767
+ from sibyl_memory_client.verdicts import refine_zero
1552
1768
 
1553
1769
  db_path = Path(args.db).expanduser()
1554
1770
  print()
@@ -1582,9 +1798,21 @@ def cmd_memory(args: argparse.Namespace) -> int:
1582
1798
  print(a.kv(f"{cat}/{name}", r.get("status") or "-"))
1583
1799
  return 0
1584
1800
  if op == "search":
1585
- hits = client.search(args.query, limit=args.limit)
1801
+ # THE VERDICT CONTRACT (stage 3, 2026-08-31). This used to print
1802
+ # "(no matches)" and stop — the terminal-shaped version of the defect
1803
+ # this stage closes: a zero that cannot explain itself. The SDK now
1804
+ # returns a cause on every call, and `refine_zero` pays one COUNT on the
1805
+ # zero path so a brand-new user is told their store is EMPTY rather than
1806
+ # left to conclude their query was wrong.
1807
+ hits = refine_zero(client, client.search(args.query, limit=args.limit))
1586
1808
  if not hits:
1587
1809
  print(a.dim(f"(no matches for {args.query!r})"))
1810
+ verdict = getattr(hits, "verdict", None)
1811
+ if verdict is not None:
1812
+ # Plain language, generated from the enum and never from stored
1813
+ # text, so it is always safe to print to a terminal.
1814
+ print(a.warn_line(verdict.explain()))
1815
+ print(a.dim(f"cause: {verdict.code.value}"))
1588
1816
  return 0
1589
1817
  print(a.eyebrow(f"matches ({len(hits)})"))
1590
1818
  for h in hits:
@@ -817,6 +817,13 @@ def cmd_setup(args: argparse.Namespace) -> int:
817
817
  print(bold("Sibyl Memory Plugin setup"))
818
818
  print()
819
819
 
820
+ # Platform gate. `setup` wires a new machine, so it is one of the two
821
+ # commands the native-Windows block applies to (see cli.NATIVE_WINDOWS_POLICY).
822
+ from .cli import print_platform_line
823
+ if print_platform_line(gate=True) != 0:
824
+ return 1
825
+ print()
826
+
820
827
  # Detection
821
828
  if skip_present_check:
822
829
  detected = wirers
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibyl-memory-cli
3
- Version: 0.3.23
3
+ Version: 0.4.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, `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
@@ -17,14 +17,16 @@ Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Environment :: Console
19
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Operating System :: POSIX :: Linux
21
+ Classifier: Operating System :: MacOS
20
22
  Requires-Python: >=3.10
21
23
  Description-Content-Type: text/markdown
22
24
  License-File: LICENSE
23
- Requires-Dist: sibyl-memory-client>=0.7.0
24
- Requires-Dist: sibyl-memory-hermes>=0.3.2
25
+ Requires-Dist: sibyl-memory-client>=0.8.1
26
+ Requires-Dist: sibyl-memory-hermes>=0.4.0
25
27
  Requires-Dist: pyyaml<7,>=6.0
26
28
  Provides-Extra: mcp
27
- Requires-Dist: sibyl-memory-mcp>=0.1.2; extra == "mcp"
29
+ Requires-Dist: sibyl-memory-mcp>=0.2.0; extra == "mcp"
28
30
  Provides-Extra: dev
29
31
  Requires-Dist: pytest>=7.0; extra == "dev"
30
32
  Dynamic: license-file
@@ -37,6 +39,8 @@ Command-line interface for the **Sibyl Memory Plugin**.
37
39
  pip install sibyl-memory-cli
38
40
  ```
39
41
 
42
+ Sibyl Memory runs on Linux, macOS (Apple Silicon and Intel), and Windows through WSL2. Native Windows is not supported.
43
+
40
44
  This pulls in `sibyl-memory-client` (the local SDK) and `sibyl-memory-hermes` (the Hermes provider) automatically.
41
45
 
42
46
  ## Commands
@@ -13,6 +13,7 @@ src/sibyl_memory_cli.egg-info/dependency_links.txt
13
13
  src/sibyl_memory_cli.egg-info/entry_points.txt
14
14
  src/sibyl_memory_cli.egg-info/requires.txt
15
15
  src/sibyl_memory_cli.egg-info/top_level.txt
16
+ tests/test_claim_and_fullid_2026_09_04.py
16
17
  tests/test_hermes_install_args.py
17
18
  tests/test_init_perms_guard.py
18
19
  tests/test_memory_cmd_2026_06_16.py
@@ -21,9 +22,12 @@ tests/test_migrate.py
21
22
  tests/test_migrate_adversarial.py
22
23
  tests/test_migrate_force_2026_06_05.py
23
24
  tests/test_orchestrator.py
25
+ tests/test_platform_gate_2026_09_04.py
24
26
  tests/test_prelaunch_fixes_2026_06_25.py
25
27
  tests/test_quality_fixes_2026_06_30.py
26
28
  tests/test_setup.py
27
29
  tests/test_status_stores_2026_06_11.py
28
30
  tests/test_superpatch_2026_07_05.py
31
+ tests/test_trust_context_2026_08_25.py
32
+ tests/test_verdict_contract_2026_08_31.py
29
33
  tests/test_wiring_fix.py
@@ -0,0 +1,9 @@
1
+ sibyl-memory-client>=0.8.1
2
+ sibyl-memory-hermes>=0.4.0
3
+ pyyaml<7,>=6.0
4
+
5
+ [dev]
6
+ pytest>=7.0
7
+
8
+ [mcp]
9
+ sibyl-memory-mcp>=0.2.0
@@ -0,0 +1,99 @@
1
+ """C2 fixes (2026-09-04): full account id on every display surface + `sibyl claim`.
2
+
3
+ Panel finding (three seats independently): the buy-before-install claim flow
4
+ requires the full 36-char account id, but every CLI surface printed it through
5
+ short() as `12345678…abcd`, making the documented flow unusable. account_id is
6
+ a routing identifier (rides URL querystrings and subscribe calls), not a
7
+ bearer secret; the F3 never-print-the-bearer invariant covers tokens.
8
+ """
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from sibyl_memory_cli import cli
16
+
17
+
18
+ ACCT = "11111111-2222-4333-8444-555555555555"
19
+
20
+
21
+ @pytest.fixture()
22
+ def creds_file(tmp_path):
23
+ p = tmp_path / "credentials.json"
24
+ p.write_text(json.dumps({
25
+ "account_id": ACCT,
26
+ "tenant_id": ACCT,
27
+ "tier": "free",
28
+ "email": "buyer@example.com",
29
+ "wallet": None,
30
+ "issued_at": "2026-09-04T00:00:00Z",
31
+ "schema_version": 3,
32
+ "session_token": "22222222-3333-4444-8555-666666666666",
33
+ "bearer_token": "22222222-3333-4444-8555-666666666666",
34
+ }))
35
+ return p
36
+
37
+
38
+ class _Args:
39
+ def __init__(self, **kw):
40
+ self.__dict__.update(kw)
41
+
42
+
43
+ def test_whoami_prints_full_account_id(creds_file, capsys):
44
+ rc = cli.cmd_whoami(_Args(credentials=str(creds_file), full=False))
45
+ out = capsys.readouterr().out
46
+ assert rc == 0
47
+ assert ACCT in out # full 36-char id, verbatim
48
+ assert "…" not in out.split("account")[1].split("\n")[0] # no ellipsis on the account line
49
+
50
+
51
+ def test_claim_code_validation():
52
+ assert cli._looks_like_checkout_code("cs_test_a1B2c3D4e5F6g7H8")
53
+ assert cli._looks_like_checkout_code("cs_live_" + "a" * 24)
54
+ assert cli._looks_like_checkout_code("cs_" + "z" * 12)
55
+ assert not cli._looks_like_checkout_code("cs_test_short") # < 10 after marker
56
+ assert not cli._looks_like_checkout_code("cs_test_abc123…") # pasted ellipsis
57
+ assert not cli._looks_like_checkout_code("pi_123456789012") # wrong prefix
58
+ assert not cli._looks_like_checkout_code("")
59
+
60
+
61
+ def test_claim_happy_path_posts_and_syncs(creds_file, capsys, monkeypatch):
62
+ calls = {}
63
+
64
+ def fake_http(method, path, *, body=None, **kw):
65
+ calls["method"], calls["path"], calls["body"] = method, path, body
66
+ return {"ok": True, "tier": "pro", "subscription_id": "s1", "expires_at": "2026-10-07T00:00:00Z"}
67
+
68
+ monkeypatch.setattr(cli, "http_request", fake_http)
69
+ monkeypatch.setattr(cli, "invalidate_tier_cache", lambda *a, **k: calls.setdefault("cache_cleared", True))
70
+
71
+ rc = cli.cmd_claim(_Args(credentials=str(creds_file), checkout_code="cs_test_a1B2c3D4e5F6g7H8"))
72
+ out = capsys.readouterr().out
73
+ assert rc == 0
74
+ assert calls["method"] == "POST" and calls["path"] == "/api/plugin/stripe-claim"
75
+ assert calls["body"]["cs"] == "cs_test_a1B2c3D4e5F6g7H8"
76
+ assert calls["body"]["account_id"] == ACCT
77
+ assert calls["body"]["session_token"] == "22222222-3333-4444-8555-666666666666" # bearer-bound
78
+ assert "PRO" in out
79
+ assert calls.get("cache_cleared") is True
80
+ # local tier hint synced through the atomic writer
81
+ assert json.loads(creds_file.read_text())["tier"] == "pro"
82
+
83
+
84
+ def test_claim_maps_server_errors(creds_file, capsys, monkeypatch):
85
+ def fake_http(method, path, *, body=None, **kw):
86
+ raise cli.HttpError(409, {"error": "this purchase was already claimed to a different account."}, "u")
87
+
88
+ monkeypatch.setattr(cli, "http_request", fake_http)
89
+ rc = cli.cmd_claim(_Args(credentials=str(creds_file), checkout_code="cs_test_a1B2c3D4e5F6g7H8"))
90
+ out = capsys.readouterr().out
91
+ assert rc == 1
92
+ assert "different account" in out
93
+
94
+
95
+ def test_claim_requires_activation(tmp_path, capsys):
96
+ rc = cli.cmd_claim(_Args(credentials=str(tmp_path / "missing.json"), checkout_code="cs_test_a1B2c3D4e5F6g7H8"))
97
+ out = capsys.readouterr().out
98
+ assert rc == 1
99
+ assert "sibyl init" in out
@@ -0,0 +1,188 @@
1
+ """Platform support gate (2026-09-04 operator ruling).
2
+
3
+ Sibyl Memory runs on Linux, macOS (Apple Silicon and Intel), and Windows
4
+ through WSL2. Native Windows is not supported: `sibyl init` and `sibyl setup`
5
+ refuse it, every other command keeps working so existing native-Windows
6
+ accounts never lose `status`, `upgrade`, `claim`, `devices` or `logout`.
7
+
8
+ Six cases, and for each of them a pin on `_detect_os_family()`, whose return
9
+ vocabulary telemetry consumers key on and which this change must not move.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import platform as _platform
14
+
15
+ import pytest
16
+
17
+ from sibyl_memory_cli import cli
18
+
19
+
20
+ def _fake(monkeypatch, *, sys_platform, release="", machine="", mac_ver="", env=None):
21
+ monkeypatch.setattr(cli.sys, "platform", sys_platform, raising=False)
22
+ monkeypatch.setattr(_platform, "release", lambda: release)
23
+ monkeypatch.setattr(_platform, "machine", lambda: machine)
24
+ monkeypatch.setattr(_platform, "mac_ver", lambda: (mac_ver, ("", "", ""), ""))
25
+ monkeypatch.delenv("WSL_DISTRO_NAME", raising=False)
26
+ monkeypatch.delenv(cli.NATIVE_WINDOWS_ESCAPE_ENV, raising=False)
27
+ for k, v in (env or {}).items():
28
+ monkeypatch.setenv(k, v)
29
+
30
+
31
+ # ---------------------------------------------------------------- supported
32
+
33
+ def test_linux(monkeypatch, capsys):
34
+ _fake(monkeypatch, sys_platform="linux", release="6.8.0-40-generic", machine="x86_64")
35
+ assert cli._platform_info() == ("linux", "Linux (x86_64)", True)
36
+ assert cli._detect_os_family() == "linux"
37
+ assert cli._is_wsl() is False
38
+ assert cli.print_platform_line(gate=True) == 0
39
+ assert "platform Linux (x86_64) · supported" in capsys.readouterr().out
40
+
41
+
42
+ def test_wsl_detected_from_kernel_release(monkeypatch, capsys):
43
+ _fake(monkeypatch, sys_platform="linux",
44
+ release="5.15.153.1-microsoft-standard-WSL2", machine="x86_64")
45
+ assert cli._platform_info() == ("linux", "Linux, WSL2 (x86_64)", True)
46
+ assert cli._detect_os_family() == "linux"
47
+ assert cli._is_wsl() is True
48
+ assert cli.print_platform_line(gate=True) == 0
49
+ assert "platform Linux, WSL2 (x86_64) · supported" in capsys.readouterr().out
50
+
51
+
52
+ def test_wsl_detected_from_env_alone(monkeypatch, capsys):
53
+ """A WSL kernel that does not carry 'microsoft' in its release string still
54
+ exports WSL_DISTRO_NAME. Either signal is enough."""
55
+ _fake(monkeypatch, sys_platform="linux", release="6.6.36.6", machine="aarch64",
56
+ env={"WSL_DISTRO_NAME": "Ubuntu"})
57
+ assert cli._platform_info() == ("linux", "Linux, WSL2 (aarch64)", True)
58
+ assert cli._detect_os_family() == "linux"
59
+ assert cli._is_wsl() is True
60
+ assert cli.print_platform_line(gate=True) == 0
61
+
62
+
63
+ def test_macos(monkeypatch, capsys):
64
+ _fake(monkeypatch, sys_platform="darwin", release="24.6.0", machine="arm64",
65
+ mac_ver="15.6")
66
+ assert cli._platform_info() == ("macos", "macOS 15.6 (arm64)", True)
67
+ assert cli._detect_os_family() == "macos"
68
+ assert cli.print_platform_line(gate=True) == 0
69
+ assert "platform macOS 15.6 (arm64) · supported" in capsys.readouterr().out
70
+
71
+
72
+ # ------------------------------------------------------------ native Windows
73
+
74
+ def test_native_windows_labels_itself_unsupported(monkeypatch):
75
+ _fake(monkeypatch, sys_platform="win32", release="11", machine="AMD64")
76
+ assert cli._platform_info() == ("windows", "Windows 11 native (AMD64)", False)
77
+ assert cli._detect_os_family() == "windows"
78
+ assert cli._is_wsl() is False
79
+
80
+
81
+ def test_native_windows_warns_and_continues_under_warn_policy(monkeypatch, capsys):
82
+ _fake(monkeypatch, sys_platform="win32", release="11", machine="AMD64")
83
+ monkeypatch.setattr(cli, "NATIVE_WINDOWS_POLICY", "warn")
84
+ assert cli.print_platform_line(gate=True) == 0
85
+ out = capsys.readouterr().out
86
+ assert "platform Windows 11 native (AMD64) · not supported" in out
87
+ assert "Run it inside WSL2" in out
88
+ assert cli.WSL_DOCS_URL in out
89
+ assert "Continuing anyway." in out
90
+
91
+
92
+ def test_native_windows_blocks_init_and_setup_under_block_policy(monkeypatch, capsys):
93
+ _fake(monkeypatch, sys_platform="win32", release="11", machine="AMD64")
94
+ assert cli.NATIVE_WINDOWS_POLICY == "block" # shipped policy
95
+ assert cli.print_platform_line(gate=True) == 1
96
+ out = capsys.readouterr().out
97
+ assert "platform Windows 11 native (AMD64) · not supported" in out
98
+ assert "Install it inside WSL2" in out
99
+ assert cli.WSL_DOCS_URL in out
100
+ assert "status, upgrade, claim, devices and logout keep working." in out
101
+ assert "SIBYL_ALLOW_NATIVE_WINDOWS=1" in out
102
+
103
+
104
+ def test_native_windows_escape_hatch_lets_init_through(monkeypatch, capsys):
105
+ _fake(monkeypatch, sys_platform="win32", release="11", machine="AMD64",
106
+ env={"SIBYL_ALLOW_NATIVE_WINDOWS": "1"})
107
+ assert cli.print_platform_line(gate=True) == 0
108
+ assert "Continuing anyway." in capsys.readouterr().out
109
+
110
+
111
+ def test_native_windows_never_blocks_an_ungated_command(monkeypatch, capsys):
112
+ """The 76 native-Windows activations keep every command except init/setup."""
113
+ _fake(monkeypatch, sys_platform="win32", release="11", machine="AMD64")
114
+ assert cli.print_platform_line(gate=False) == 0
115
+ assert "Continuing anyway." in capsys.readouterr().out
116
+
117
+
118
+ # ------------------------------------------------------------------ unknown
119
+
120
+ def test_unknown_platform_warns_but_never_blocks(monkeypatch, capsys):
121
+ _fake(monkeypatch, sys_platform="freebsd14", release="14.1-RELEASE", machine="amd64")
122
+ assert cli._platform_info() == ("unknown", "freebsd14", False)
123
+ assert cli._detect_os_family() is None
124
+ assert cli.print_platform_line(gate=True) == 0
125
+ out = capsys.readouterr().out
126
+ assert "platform freebsd14 · not supported" in out
127
+ assert "built and tested on Linux and macOS" in out
128
+
129
+
130
+ # ------------------------------------------- session-init telemetry payload
131
+
132
+ @pytest.mark.parametrize(
133
+ "sys_platform,release,machine,expect_family,expect_wsl",
134
+ [
135
+ ("linux", "6.8.0-40-generic", "x86_64", "linux", False),
136
+ ("linux", "5.15.153.1-microsoft-standard-WSL2", "x86_64", "linux", True),
137
+ ("darwin", "24.6.0", "arm64", "macos", False),
138
+ ("win32", "11", "AMD64", "windows", False),
139
+ ],
140
+ )
141
+ def test_session_init_env_keys(monkeypatch, sys_platform, release, machine,
142
+ expect_family, expect_wsl):
143
+ """The three new keys the server records (section 6): os_version, arch, wsl."""
144
+ _fake(monkeypatch, sys_platform=sys_platform, release=release, machine=machine)
145
+ assert cli._detect_os_family() == expect_family
146
+ assert _platform.release() == release
147
+ assert _platform.machine() == machine
148
+ assert cli._is_wsl() is expect_wsl
149
+
150
+
151
+ def test_init_sends_the_new_env_keys(tmp_path, monkeypatch):
152
+ """End to end: `sibyl init` posts os_version, arch and wsl to session-init."""
153
+ _fake(monkeypatch, sys_platform="linux",
154
+ release="5.15.153.1-microsoft-standard-WSL2", machine="x86_64")
155
+ seen: dict = {}
156
+
157
+ def fake_http(method, path, *, body=None, timeout=15.0, headers=None):
158
+ if path.startswith("/api/plugin/session-init"):
159
+ seen.update(body["env"])
160
+ return {"pairing_ttl_seconds": 300}
161
+ if path.startswith("/api/plugin/check"):
162
+ return {"bound": True, "credentials": {
163
+ "account_id": "acct-1", "tier": "free", "bearer_token": "btok",
164
+ }}
165
+ raise AssertionError(f"unexpected call {method} {path}")
166
+
167
+ monkeypatch.setattr(cli, "http_request", fake_http)
168
+ monkeypatch.setattr(cli.webbrowser, "open", lambda *a, **k: True)
169
+ rc = cli.main(["--credentials", str(tmp_path / "credentials.json"), "init"])
170
+ assert rc == 0
171
+ assert seen["os_family"] == "linux"
172
+ assert seen["os_version"] == "5.15.153.1-microsoft-standard-WSL2"
173
+ assert seen["arch"] == "x86_64"
174
+ assert seen["wsl"] is True
175
+
176
+
177
+ def test_init_refuses_native_windows_before_it_touches_the_network(tmp_path, monkeypatch):
178
+ _fake(monkeypatch, sys_platform="win32", release="11", machine="AMD64")
179
+
180
+ def fake_http(method, path, **kw):
181
+ raise AssertionError("init must refuse before calling the server")
182
+
183
+ monkeypatch.setattr(cli, "http_request", fake_http)
184
+ monkeypatch.setattr(cli.webbrowser, "open", lambda *a, **k: True)
185
+ cred = tmp_path / "credentials.json"
186
+ rc = cli.main(["--credentials", str(cred), "init"])
187
+ assert rc == 1
188
+ assert not cred.exists()
@@ -0,0 +1,78 @@
1
+ """Darwin trust-store fix (2026-08-25), CLI leg: `sibyl init` on the macOS
2
+ python.org framework build failed every server call with
3
+ `Warning: session-init failed (0)` (CERTIFICATE_VERIFY_FAILED surfacing as
4
+ HttpError status 0). Both CLI network paths, http_request (all
5
+ api.sibyllabs.org calls) and _pypi_latest (update check), now pass the shared
6
+ certifi-backed context from sibyl_memory_client._trust, with a stdlib-default
7
+ fallback when the client package is absent. These tests pin the wiring at both
8
+ call sites and the graceful fallback."""
9
+ from __future__ import annotations
10
+
11
+ import ssl
12
+ import sys
13
+ import urllib.request
14
+
15
+ from sibyl_memory_cli import cli
16
+
17
+
18
+ class _Resp:
19
+ def __init__(self, body: bytes = b"{}"):
20
+ self._body = body
21
+ def read(self):
22
+ return self._body
23
+ def __enter__(self):
24
+ return self
25
+ def __exit__(self, *a):
26
+ return False
27
+
28
+
29
+ def _shared_context():
30
+ from sibyl_memory_client import _trust
31
+ return _trust.https_context()
32
+
33
+
34
+ def test_http_request_passes_trust_context(monkeypatch):
35
+ captured = {}
36
+
37
+ def fake_urlopen(req, timeout=None, context=None):
38
+ captured["context"] = context
39
+ return _Resp(b"{\"ok\": true}")
40
+
41
+ monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
42
+ out = cli.http_request("GET", "/api/plugin/whoami")
43
+ assert out == {"ok": True}
44
+ assert captured["context"] is _shared_context()
45
+
46
+
47
+ def test_pypi_latest_passes_trust_context(monkeypatch):
48
+ captured = {}
49
+
50
+ def fake_urlopen(req, timeout=None, context=None):
51
+ captured["context"] = context
52
+ return _Resp(b"{\"info\": {\"version\": \"9.9.9\"}}")
53
+
54
+ monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
55
+ assert cli._pypi_latest("sibyl-memory-cli") == "9.9.9"
56
+ assert captured["context"] is _shared_context()
57
+
58
+
59
+ def test_fallback_without_client_package(monkeypatch):
60
+ # sys.modules[name] = None makes the from-import raise ImportError.
61
+ monkeypatch.setitem(sys.modules, "sibyl_memory_client._trust", None)
62
+ ctx = cli._https_context() # must not raise
63
+ assert isinstance(ctx, ssl.SSLContext)
64
+ assert ctx.check_hostname is True
65
+ assert ctx.verify_mode == ssl.CERT_REQUIRED
66
+
67
+
68
+ def test_fallback_still_serves_http_request(monkeypatch):
69
+ monkeypatch.setitem(sys.modules, "sibyl_memory_client._trust", None)
70
+ captured = {}
71
+
72
+ def fake_urlopen(req, timeout=None, context=None):
73
+ captured["context"] = context
74
+ return _Resp()
75
+
76
+ monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
77
+ assert cli.http_request("GET", "/api/plugin/whoami") == {}
78
+ assert isinstance(captured["context"], ssl.SSLContext)
@@ -0,0 +1,126 @@
1
+ """The verdict contract in the terminal (stage 3, 2026-08-31).
2
+
3
+ `sibyl memory search` used to print "(no matches for 'x')" and stop. That is the
4
+ terminal-shaped version of the defect this stage closes: a zero that cannot
5
+ explain itself, and in the CLI's case the person reading it is a new user
6
+ deciding whether the product works. "No matches" against an EMPTY store is the
7
+ single most misleading thing this command could say, and it was what it said.
8
+
9
+ These tests pin that every zero prints a cause in plain language, that the cause
10
+ is the canonical one from `sibyl_memory_client.verdicts` rather than a
11
+ CLI-local string, and that nothing about a successful search changed.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import inspect
17
+ from pathlib import Path
18
+
19
+ from sibyl_memory_client import MemoryClient
20
+ from sibyl_memory_client.verdicts import GateCause, VerdictCode
21
+ from sibyl_memory_cli import cli
22
+
23
+
24
+ def _store(tmp_path: Path) -> Path:
25
+ d = tmp_path / "memory.db"
26
+ c = MemoryClient.local(path=d)
27
+ c.set_entity("partner", "Blocktronics",
28
+ {"stage": "active", "note": "token forensics suite"})
29
+ c.set_entity("partner", "Reppo", {"stage": "negotiation"})
30
+ return d
31
+
32
+
33
+ def _empty_store(tmp_path: Path) -> Path:
34
+ d = tmp_path / "empty.db"
35
+ MemoryClient.local(path=d)
36
+ return d
37
+
38
+
39
+ def test_a_zero_result_prints_a_cause_not_just_no_matches(tmp_path, capsys):
40
+ d = _store(tmp_path)
41
+ rc = cli.main(["--db", str(d), "memory", "search", "qwzjvxzzyplm"])
42
+ out = capsys.readouterr().out
43
+ assert rc == 0
44
+ assert "(no matches" in out
45
+ assert f"cause: {VerdictCode.NO_MATCH.value}" in out
46
+ assert "genuine miss" in out
47
+
48
+
49
+ def test_an_empty_store_says_so_instead_of_blaming_the_query(tmp_path, capsys):
50
+ """The one that matters most to a new user: this is a fixable state, and
51
+ 'no matches' hid that behind what looks like a failed search."""
52
+ d = _empty_store(tmp_path)
53
+ rc = cli.main(["--db", str(d), "memory", "search", "anything at all"])
54
+ out = capsys.readouterr().out
55
+ assert rc == 0
56
+ assert f"cause: {VerdictCode.EMPTY_STORE.value}" in out
57
+ assert "no rows yet" in out
58
+
59
+
60
+ def test_the_cause_is_plain_language_a_human_can_act_on(tmp_path, capsys):
61
+ d = _empty_store(tmp_path)
62
+ cli.main(["--db", str(d), "memory", "search", "anything at all"])
63
+ out = capsys.readouterr().out
64
+ # a sentence, not an enum dump
65
+ assert "Nothing was returned" in out
66
+
67
+
68
+ def test_a_successful_search_is_unchanged(tmp_path, capsys):
69
+ d = _store(tmp_path)
70
+ rc = cli.main(["--db", str(d), "memory", "search", "forensics"])
71
+ out = capsys.readouterr().out
72
+ assert rc == 0 and "Blocktronics" in out
73
+ assert "cause:" not in out # no noise on the success path
74
+
75
+ # --------------------------------------------------------------------------
76
+ # one vocabulary
77
+ # --------------------------------------------------------------------------
78
+ def _code_only(text: str) -> str:
79
+ """Strip docstrings and comments, leaving code.
80
+
81
+ Parsed, not split on `\"\"\"`. The first version of this helper did
82
+ `text.replace('\"\"\"', chr(0)).split(chr(0))[0::2]`, which silently INVERTS
83
+ which half it scans when the marker count is odd — the test would then check
84
+ the prose and skip the code, and still pass.
85
+ """
86
+ try:
87
+ tree = ast.parse(text)
88
+ except SyntaxError: # pragma: no cover
89
+ return text
90
+ spans = []
91
+ for node in ast.walk(tree):
92
+ body = getattr(node, "body", None)
93
+ if not isinstance(body, list): # ast.IfExp.body is an expr
94
+ continue
95
+ for child in body:
96
+ if (isinstance(child, ast.Expr)
97
+ and isinstance(child.value, ast.Constant)
98
+ and isinstance(child.value.value, str)):
99
+ spans.append((child.lineno, child.end_lineno))
100
+ drop = {i for a, b in spans for i in range(a, b + 1)}
101
+ return "\n".join(ln.split("#", 1)[0]
102
+ for i, ln in enumerate(text.splitlines(), 1) if i not in drop)
103
+
104
+
105
+ #: Derived from the enums, so renaming a cause updates the guard automatically.
106
+ CAUSE_LITERALS = tuple([c.value for c in VerdictCode if c is not VerdictCode.OK]
107
+ + [g.value for g in GateCause])
108
+
109
+
110
+ def _assert_declares_no_vocabulary(*modules):
111
+ """BOTH quote styles. These tests originally checked only double quotes, and
112
+ an adversarial review slipped `_LOCAL_CAUSE_COPY = 'abstained_on'` into the
113
+ MCP server past a green suite — exactly the drift the guard exists to stop."""
114
+ offenders = []
115
+ for mod in modules:
116
+ code = _code_only(inspect.getsource(mod))
117
+ for lit in CAUSE_LITERALS:
118
+ if f'"{lit}"' in code or f"'{lit}'" in code:
119
+ offenders.append((mod.__name__, lit))
120
+ assert offenders == [], f"cause strings re-declared outside verdicts.py: {offenders}"
121
+
122
+
123
+ def test_the_cli_imports_the_vocabulary_and_declares_none():
124
+ """One vocabulary, one module. The CLI prints causes; it does not own them."""
125
+ assert "from sibyl_memory_client.verdicts import refine_zero" in inspect.getsource(cli)
126
+ _assert_declares_no_vocabulary(cli)
@@ -1,9 +0,0 @@
1
- sibyl-memory-client>=0.7.0
2
- sibyl-memory-hermes>=0.3.2
3
- pyyaml<7,>=6.0
4
-
5
- [dev]
6
- pytest>=7.0
7
-
8
- [mcp]
9
- sibyl-memory-mcp>=0.1.2