sibyl-memory-cli 0.3.10__tar.gz → 0.3.13__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 (24) hide show
  1. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/PKG-INFO +1 -1
  2. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/pyproject.toml +1 -1
  3. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/src/sibyl_memory_cli/cli.py +22 -2
  4. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/src/sibyl_memory_cli/migrate.py +2 -2
  5. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/src/sibyl_memory_cli/setup.py +42 -7
  6. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/src/sibyl_memory_cli.egg-info/PKG-INFO +1 -1
  7. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/src/sibyl_memory_cli.egg-info/SOURCES.txt +2 -0
  8. sibyl_memory_cli-0.3.13/tests/test_init_perms_guard.py +22 -0
  9. sibyl_memory_cli-0.3.13/tests/test_migrate_force_2026_06_05.py +67 -0
  10. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/tests/test_wiring_fix.py +8 -1
  11. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/README.md +0 -0
  12. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/setup.cfg +0 -0
  13. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/src/sibyl_memory_cli/__init__.py +0 -0
  14. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/src/sibyl_memory_cli/_aesthetic.py +0 -0
  15. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/src/sibyl_memory_cli/_banner.py +0 -0
  16. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/src/sibyl_memory_cli.egg-info/dependency_links.txt +0 -0
  17. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/src/sibyl_memory_cli.egg-info/entry_points.txt +0 -0
  18. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/src/sibyl_memory_cli.egg-info/requires.txt +0 -0
  19. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/src/sibyl_memory_cli.egg-info/top_level.txt +0 -0
  20. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/tests/test_hermes_install_args.py +0 -0
  21. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/tests/test_migrate.py +0 -0
  22. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/tests/test_migrate_adversarial.py +0 -0
  23. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/tests/test_orchestrator.py +0 -0
  24. {sibyl_memory_cli-0.3.10 → sibyl_memory_cli-0.3.13}/tests/test_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibyl-memory-cli
3
- Version: 0.3.10
3
+ Version: 0.3.13
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sibyl-memory-cli"
7
- version = "0.3.10"
7
+ version = "0.3.13"
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" }
@@ -163,6 +163,13 @@ def write_credentials_atomic(creds: dict, path: Path = DEFAULT_CRED_PATH) -> Pat
163
163
  """
164
164
  path = path.expanduser()
165
165
  path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
166
+ # mkdir's mode is ignored when the dir already exists (bug, dor_alpha 2026-06-01):
167
+ # a pre-existing 0755 ~/.sibyl-memory left credentials world-readable. Tighten
168
+ # explicitly to cover the pre-existing-directory case.
169
+ try:
170
+ os.chmod(path.parent, 0o700)
171
+ except OSError:
172
+ pass
166
173
  data = json.dumps(creds, indent=2).encode("utf-8")
167
174
  tmp = path.with_suffix(path.suffix + ".tmp")
168
175
  # Clean any leftover .tmp from a crashed prior write so O_EXCL can succeed.
@@ -548,7 +555,15 @@ def cmd_status(args: argparse.Namespace) -> int:
548
555
  tier_cache = Path(args.tier_cache).expanduser()
549
556
  if tier_cache.exists():
550
557
  cache = json.loads(tier_cache.read_text(encoding="utf-8"))
551
- print(a.kv("Tier cache", f"{cache.get('tier','?')} (checked {cache.get('checked_at','?')[:19]})"))
558
+ # checked_at is written by _capcheck.py as epoch seconds (float), but
559
+ # older caches / future formats may carry an ISO string. Render both;
560
+ # never index a float (TypeError on every `sibyl status` run with a
561
+ # populated tier cache; Discord report 2026-06-10).
562
+ checked = cache.get("checked_at")
563
+ if isinstance(checked, (int, float)) and not isinstance(checked, bool):
564
+ checked = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(checked))
565
+ checked = str(checked)[:19] if checked else "?"
566
+ print(a.kv("Tier cache", f"{cache.get('tier','?')} (checked {checked})"))
552
567
  else:
553
568
  print(a.kv("Tier cache", "—"))
554
569
 
@@ -1049,7 +1064,7 @@ def cmd_migrate(args: argparse.Namespace) -> int:
1049
1064
  io = _migrate_io()
1050
1065
  report = M.run_guided_setup(
1051
1066
  home=home, cwd=cwd, db_path=db_path, backup_parent=backup_parent,
1052
- io=io, debloat=not args.no_debloat,
1067
+ io=io, debloat=not args.no_debloat, force=getattr(args, "force", False),
1053
1068
  )
1054
1069
 
1055
1070
  ph = report.get("phases", {})
@@ -1189,6 +1204,11 @@ def build_parser() -> argparse.ArgumentParser:
1189
1204
  "--yes", "-y", action="store_true",
1190
1205
  help="Skip the initial confirm (the trim step still always asks separately)",
1191
1206
  )
1207
+ p_migrate.add_argument(
1208
+ "--force", action="store_true",
1209
+ help="Overwrite an existing non-sibyl memory provider when wiring a harness "
1210
+ "(without this, migrate stops at that harness and tells you to re-run with --force)",
1211
+ )
1192
1212
  p_migrate.set_defaults(func=cmd_migrate)
1193
1213
 
1194
1214
  return p
@@ -330,7 +330,7 @@ class GuidedIO:
330
330
  def run_guided_setup(*, home=None, cwd=None, db_path=None, backup_parent=None,
331
331
  io: Optional[GuidedIO] = None, wirers: Optional[dict] = None,
332
332
  extract_fn: Optional[Callable[[Path, Path], None]] = None,
333
- debloat: bool = True, now=None) -> dict:
333
+ debloat: bool = True, force: bool = False, now=None) -> dict:
334
334
  """The assembled guided flow: backup -> auto-wire each harness (instructions on
335
335
  failure) -> extraction handoff -> verify -> confirmed debloat. Returns a structured
336
336
  report. `extract_fn(backup_dir, db_path)` performs/simulates extraction; default
@@ -367,7 +367,7 @@ def run_guided_setup(*, home=None, cwd=None, db_path=None, backup_parent=None,
367
367
  if w.current_state().get("wired_with_sibyl"):
368
368
  wire_report[name] = "already"
369
369
  continue
370
- outcome = w.wire()
370
+ outcome = w.wire(force=force)
371
371
  wire_report[name] = outcome.status
372
372
  if outcome.status not in ("wired", "already"):
373
373
  io.say(f"{name}: auto-wire incomplete ({outcome.message}). Do this manually:")
@@ -222,6 +222,15 @@ class HermesWirer:
222
222
  loaded = yaml.safe_load(raw)
223
223
  if isinstance(loaded, dict):
224
224
  cfg = loaded
225
+ elif loaded is not None:
226
+ # Fail fast: a non-mapping top level means the config is not
227
+ # something we can merge into. Silently reinitializing would
228
+ # destroy the user's existing Hermes settings.
229
+ raise ValueError(
230
+ f"{self.config_path} top level is not a YAML mapping "
231
+ f"(got {type(loaded).__name__}). Fix the file or move it "
232
+ "aside and re-run; it was not modified."
233
+ )
225
234
  if not isinstance(cfg.get("memory"), dict):
226
235
  cfg["memory"] = {}
227
236
  cfg["memory"]["provider"] = "sibyl"
@@ -338,6 +347,7 @@ class ClaudeCodeWirer:
338
347
  settings_exists = self.settings_path.exists()
339
348
  mcp_servers: dict = {}
340
349
  sibyl_block: Optional[dict] = None
350
+ settings_parse_error: Optional[str] = None
341
351
  if settings_exists:
342
352
  try:
343
353
  cfg = json.loads(self.settings_path.read_text(encoding="utf-8"))
@@ -346,8 +356,8 @@ class ClaudeCodeWirer:
346
356
  if isinstance(raw_servers, dict):
347
357
  mcp_servers = raw_servers
348
358
  sibyl_block = mcp_servers.get("sibyl-memory")
349
- except Exception:
350
- pass
359
+ except Exception as e:
360
+ settings_parse_error = f"{type(e).__name__}: {e}"
351
361
  mcp_binary = self._mcp_binary_found()
352
362
  cli_registered = self._registered_via_cli() # None when no `claude` CLI
353
363
  # Source of truth: when the claude CLI exists, trust `claude mcp get` (where
@@ -359,6 +369,7 @@ class ClaudeCodeWirer:
359
369
  return {
360
370
  "settings_path": str(self.settings_path),
361
371
  "settings_exists": settings_exists,
372
+ "settings_parse_error": settings_parse_error,
362
373
  "mcp_servers_count": len(mcp_servers),
363
374
  "sibyl_mcp": sibyl_block,
364
375
  "mcp_binary_found": mcp_binary,
@@ -392,8 +403,17 @@ class ClaudeCodeWirer:
392
403
  if rc != 0:
393
404
  return WireOutcome(self.name, "error",
394
405
  f"`claude mcp add` failed (exit {rc}): {(err or out).strip()[:200]}")
406
+ # Post-wire verification (bug, cryptoxdylan 2026-06-01): a 0 exit from
407
+ # `claude mcp add` has been observed to not guarantee discovery. Confirm the
408
+ # server actually shows in `claude mcp get`, and surface concrete remediation
409
+ # instead of reporting a false success that leaves the MCP absent from /mcp.
410
+ if self._registered_via_cli() is False:
411
+ return WireOutcome(self.name, "error",
412
+ "ran `claude mcp add` (exit 0) but the server is not in `claude mcp list`. "
413
+ "restart Claude Code, then run `claude mcp list`; if still absent, run "
414
+ f"`claude mcp add --scope user {self.MCP_NAME} -- {binpath}` manually.")
395
415
  return WireOutcome(self.name, "wired",
396
- "Registered sibyl-memory with Claude Code via `claude mcp add --scope user`.")
416
+ "Registered sibyl-memory with Claude Code via `claude mcp add --scope user` (verified in `claude mcp list`).")
397
417
 
398
418
  def wire(self, *, force: bool = False, dry_run: bool = False,
399
419
  prompt_fn: Optional[Callable[..., str]] = None) -> WireOutcome:
@@ -484,12 +504,27 @@ class ClaudeCodeWirer:
484
504
  def _write_settings_with_sibyl(self) -> None:
485
505
  cfg: dict = {}
486
506
  if self.settings_path.exists():
487
- try:
488
- loaded = json.loads(self.settings_path.read_text(encoding="utf-8"))
507
+ raw = self.settings_path.read_text(encoding="utf-8")
508
+ if raw.strip():
509
+ try:
510
+ loaded = json.loads(raw)
511
+ except ValueError as e:
512
+ # Fail fast: never atomically replace a corrupt settings.json
513
+ # with a sibyl-only file (that destroys the user's other
514
+ # mcpServers, permissions, hooks, env).
515
+ raise ValueError(
516
+ f"{self.settings_path} is not valid JSON ({e}). "
517
+ "Fix the file or move it aside and re-run setup; "
518
+ "your settings were not modified."
519
+ ) from e
489
520
  if isinstance(loaded, dict):
490
521
  cfg = loaded
491
- except Exception:
492
- cfg = {}
522
+ else:
523
+ raise ValueError(
524
+ f"{self.settings_path} top level is not a JSON object "
525
+ f"(got {type(loaded).__name__}). Fix the file or move it "
526
+ "aside and re-run setup; your settings were not modified."
527
+ )
493
528
  if not isinstance(cfg.get("mcpServers"), dict):
494
529
  cfg["mcpServers"] = {}
495
530
  cfg["mcpServers"]["sibyl-memory"] = self.SIBYL_MCP_BLOCK
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibyl-memory-cli
3
- Version: 0.3.10
3
+ Version: 0.3.13
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
@@ -13,8 +13,10 @@ src/sibyl_memory_cli.egg-info/entry_points.txt
13
13
  src/sibyl_memory_cli.egg-info/requires.txt
14
14
  src/sibyl_memory_cli.egg-info/top_level.txt
15
15
  tests/test_hermes_install_args.py
16
+ tests/test_init_perms_guard.py
16
17
  tests/test_migrate.py
17
18
  tests/test_migrate_adversarial.py
19
+ tests/test_migrate_force_2026_06_05.py
18
20
  tests/test_orchestrator.py
19
21
  tests/test_setup.py
20
22
  tests/test_wiring_fix.py
@@ -0,0 +1,22 @@
1
+ """Regression: a pre-existing loose `~/.sibyl-memory` must be tightened to 0700.
2
+
3
+ `mkdir(mode=0o700)` is a no-op when the directory already exists, so a dir that
4
+ was created earlier at 0755 kept loose permissions on the credentials directory.
5
+ `write_credentials_atomic` now chmods the parent to 0700 explicitly.
6
+ Source: beta security report (dor_alpha, 2026-06-01).
7
+ """
8
+ import os
9
+ import stat
10
+ from sibyl_memory_cli.cli import write_credentials_atomic
11
+
12
+
13
+ def test_preexisting_loose_dir_is_tightened(tmp_path):
14
+ d = tmp_path / ".sibyl-memory"
15
+ d.mkdir()
16
+ os.chmod(d, 0o755) # simulate a pre-existing loose directory
17
+ assert stat.S_IMODE(d.stat().st_mode) == 0o755
18
+
19
+ write_credentials_atomic({"tenant_id": "t"}, path=d / "credentials.json")
20
+
21
+ assert stat.S_IMODE(d.stat().st_mode) == 0o700, "parent dir not tightened to 0700"
22
+ assert stat.S_IMODE((d / "credentials.json").stat().st_mode) == 0o600
@@ -0,0 +1,67 @@
1
+ """Regression (bugflow 2026-06-05): `sibyl migrate --force` must reach the wirers.
2
+
3
+ Onboarding dead-end: when a detected harness already had a non-sibyl memory
4
+ provider, the wirer refused with "Use --force to overwrite." but
5
+ `run_guided_setup` called `wire()` with no `force`, and `sibyl migrate` had no
6
+ `--force` flag to pass. The flag now threads cli -> run_guided_setup(force=) ->
7
+ wire(force=force).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from sibyl_memory_cli import migrate as M
12
+ from sibyl_memory_cli.setup import WireOutcome
13
+ from sibyl_memory_client import MemoryClient
14
+
15
+
16
+ class _RecordingWirer:
17
+ """A fake harness wirer that records the `force` kwarg it was called with."""
18
+
19
+ name = "rec"
20
+
21
+ def __init__(self):
22
+ self.seen_force = None
23
+
24
+ def is_present(self):
25
+ return True
26
+
27
+ def current_state(self):
28
+ return {"wired_with_sibyl": False}
29
+
30
+ def wire(self, *, force: bool = False, dry_run: bool = False, prompt_fn=None):
31
+ self.seen_force = force
32
+ return WireOutcome(self.name, "wired", "ok")
33
+
34
+
35
+ def _home_with_memory(tmp_path):
36
+ h = tmp_path / "home"
37
+ (h / "proj").mkdir(parents=True)
38
+ (h / "proj" / "CLAUDE.md").write_text("# memory\n- a fact worth keeping\n")
39
+ db = h / ".sibyl-memory" / "memory.db"
40
+ db.parent.mkdir(parents=True)
41
+ return h, db
42
+
43
+
44
+ def _fake_extract(_backup_dir, db_path):
45
+ MemoryClient.local(str(db_path), tenant_id="qa").set_entity("f", "a", {"v": 1})
46
+
47
+
48
+ def test_force_true_threads_to_wirer(tmp_path):
49
+ h, db = _home_with_memory(tmp_path)
50
+ rec = _RecordingWirer()
51
+ M.run_guided_setup(
52
+ home=h, cwd=h / "proj", db_path=db, backup_parent=tmp_path / "bk",
53
+ io=M.GuidedIO(scripted=["n"]), wirers={"rec": rec},
54
+ extract_fn=_fake_extract, force=True,
55
+ )
56
+ assert rec.seen_force is True
57
+
58
+
59
+ def test_force_defaults_false(tmp_path):
60
+ h, db = _home_with_memory(tmp_path)
61
+ rec = _RecordingWirer()
62
+ M.run_guided_setup(
63
+ home=h, cwd=h / "proj", db_path=db, backup_parent=tmp_path / "bk",
64
+ io=M.GuidedIO(scripted=["n"]), wirers={"rec": rec},
65
+ extract_fn=_fake_extract,
66
+ )
67
+ assert rec.seen_force is False
@@ -16,13 +16,20 @@ def _with_cli(monkeypatch):
16
16
 
17
17
  def _mock_run(monkeypatch, *, get_rc=1, add_rc=0, add_err="boom"):
18
18
  calls = []
19
+ state = {"added": False}
19
20
  def fake(cmd, *, timeout=20.0):
20
21
  calls.append(cmd)
21
22
  if cmd[:3] == ["claude", "mcp", "get"]:
22
- return (get_rc, "", "")
23
+ # Model real CLI state: once a successful `add` has run the server is
24
+ # registered, so the post-wire verification `get` succeeds. Before that
25
+ # it returns get_rc (1 = not yet registered).
26
+ return (0, "", "") if state["added"] else (get_rc, "", "")
23
27
  if cmd[:3] == ["claude", "mcp", "add"]:
28
+ if add_rc == 0:
29
+ state["added"] = True
24
30
  return (add_rc, "", "" if add_rc == 0 else add_err)
25
31
  if cmd[:3] == ["claude", "mcp", "remove"]:
32
+ state["added"] = False
26
33
  return (0, "", "")
27
34
  return (0, "", "")
28
35
  monkeypatch.setattr(S, "_run", fake)