sibyl-memory-cli 0.3.5__tar.gz → 0.3.7__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibyl-memory-cli
3
- Version: 0.3.5
3
+ Version: 0.3.7
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
@@ -48,39 +48,37 @@ sibyl health Run the SibylMemoryProvider self-check (schema versi
48
48
  ```bash
49
49
  $ sibyl init
50
50
 
51
- activation · three paths · pick whichever fits your device
51
+ Sibyl Memory Plugin · activation
52
52
 
53
- Session a1b2c3d4…e5f6
54
- Code 1 2 3 4 5 6 (use this in the email panel)
55
- Opening https://auth.sibyllabs.org/a1b2c3d4-…
53
+ Session: a1b2c3d4…e5f6
54
+ Opening: https://sibyllabs.org/plugin/activate?session=a1b2c3d4-…
56
55
 
57
- desktop wallet · email + code · or send USDC from any mobile wallet
58
- this terminal will pick up automatically when you bind.
56
+ Sign in with your wallet in the browser. This terminal will pick up automatically.
59
57
 
60
- ⠹ watching the network for your bind … 9:42 left
58
+ ⠹ waiting for browser activation … 9:42 left
61
59
  ```
62
60
 
63
- The browser opens to a dedicated short-URL auth subdomain (`auth.sibyllabs.org/<session-uuid>`). Pick a wallet from the EIP-6963 picker (Rabby / MetaMask / Coinbase Wallet / Frame / Brave / Rainbow), enter email + 6-digit code, or send a unique sub-cent USDC amount from any mobile wallet on Base. The terminal picks up the moment any of the three paths binds. Credentials are written to `~/.sibyl-memory/credentials.json` at mode 0600.
61
+ The browser opens. Sign a SIWE message with your wallet. The terminal picks up the moment the binding lands. Credentials are written to `~/.sibyl-memory/credentials.json` at mode 0600.
64
62
 
65
63
  ## Upgrade
66
64
 
67
65
  ```bash
68
66
  $ sibyl upgrade
69
67
 
70
- upgrade · lift the 2 MB free-tier cap
68
+ Sibyl Memory Plugin · upgrade
71
69
 
72
70
  Account a1b2c3d4…e5f6
73
71
  Current tier FREE
74
72
  Opening https://sibyllabs.org/plugin/upgrade?session=…
75
73
 
76
- two paths in the browser:
77
- 1. stake $SIBYL on Base (free unlimited if you qualify)
78
- 2. subscribe in USDC (monthly / quarterly / annual)
74
+ Two paths in your browser:
75
+ 1. Stake $SIBYL on Base (free unlimited if you qualify)
76
+ 2. Subscribe in USDC (monthly / quarterly / annual)
79
77
  ```
80
78
 
81
79
  In the browser:
82
- - **Stake** — connect your wallet (browser or Coinbase Smart Wallet), sign to bind, and the page checks your `$SIBYL` balance on Base. If you hold the threshold (default 100,000 $SIBYL liquid+staked, configurable), the local cap lifts.
83
- - **Subscribe** — pick monthly ($29) / quarterly ($79) / annual ($290) USDC, sign the transfer, the server records the subscription. Tier flips immediately.
80
+ - **Stake**: connect your wallet (browser or Coinbase Smart Wallet), sign to bind, and the page checks your `$SIBYL` balance on Base. If you hold the threshold (default 100,000 $SIBYL liquid+staked, configurable), the local cap lifts.
81
+ - **Subscribe**: pick monthly ($29) / quarterly ($79) / annual ($290) USDC, sign the transfer, the server records the subscription. Tier flips immediately.
84
82
 
85
83
  On either path, the CLI sees the tier change, rewrites `credentials.json`, and clears `tier_cache.json` so your next write picks up the new entitlement without delay.
86
84
 
@@ -115,16 +113,14 @@ For internal testing only:
115
113
 
116
114
  ```bash
117
115
  SIBYL_API_BASE=https://staging.api.sibyllabs.org sibyl init
118
- SIBYL_ACTIVATE_BASE=https://staging.auth.sibyllabs.org sibyl init
116
+ SIBYL_ACTIVATE_BASE=https://staging.sibyllabs.org/plugin/activate sibyl init
119
117
  SIBYL_UPGRADE_BASE=https://staging.sibyllabs.org/plugin/upgrade sibyl upgrade
120
118
  ```
121
119
 
122
- `SIBYL_ACTIVATE_BASE` accepts both subdomain form (`https://auth.example.org` → path-based session URL: `https://auth.example.org/<uuid>`) and legacy path form (`https://example.org/plugin/activate` → query-based: `?session=<uuid>`). The CLI auto-detects from the hostname.
123
-
124
120
  ## Security
125
121
 
126
122
  - `credentials.json` is written atomically at mode 0600.
127
- - `session_token` is never printed in full — only a short slice.
123
+ - `session_token` is never printed in full: only a short slice.
128
124
  - No memory content ever transits these endpoints. The CLI never reads `memory.db` content; it only checks file size.
129
125
  - Wallet operations happen in the browser. The CLI sees only the resulting tier change.
130
126
 
@@ -22,39 +22,37 @@ sibyl health Run the SibylMemoryProvider self-check (schema versi
22
22
  ```bash
23
23
  $ sibyl init
24
24
 
25
- activation · three paths · pick whichever fits your device
25
+ Sibyl Memory Plugin · activation
26
26
 
27
- Session a1b2c3d4…e5f6
28
- Code 1 2 3 4 5 6 (use this in the email panel)
29
- Opening https://auth.sibyllabs.org/a1b2c3d4-…
27
+ Session: a1b2c3d4…e5f6
28
+ Opening: https://sibyllabs.org/plugin/activate?session=a1b2c3d4-…
30
29
 
31
- desktop wallet · email + code · or send USDC from any mobile wallet
32
- this terminal will pick up automatically when you bind.
30
+ Sign in with your wallet in the browser. This terminal will pick up automatically.
33
31
 
34
- ⠹ watching the network for your bind … 9:42 left
32
+ ⠹ waiting for browser activation … 9:42 left
35
33
  ```
36
34
 
37
- The browser opens to a dedicated short-URL auth subdomain (`auth.sibyllabs.org/<session-uuid>`). Pick a wallet from the EIP-6963 picker (Rabby / MetaMask / Coinbase Wallet / Frame / Brave / Rainbow), enter email + 6-digit code, or send a unique sub-cent USDC amount from any mobile wallet on Base. The terminal picks up the moment any of the three paths binds. Credentials are written to `~/.sibyl-memory/credentials.json` at mode 0600.
35
+ The browser opens. Sign a SIWE message with your wallet. The terminal picks up the moment the binding lands. Credentials are written to `~/.sibyl-memory/credentials.json` at mode 0600.
38
36
 
39
37
  ## Upgrade
40
38
 
41
39
  ```bash
42
40
  $ sibyl upgrade
43
41
 
44
- upgrade · lift the 2 MB free-tier cap
42
+ Sibyl Memory Plugin · upgrade
45
43
 
46
44
  Account a1b2c3d4…e5f6
47
45
  Current tier FREE
48
46
  Opening https://sibyllabs.org/plugin/upgrade?session=…
49
47
 
50
- two paths in the browser:
51
- 1. stake $SIBYL on Base (free unlimited if you qualify)
52
- 2. subscribe in USDC (monthly / quarterly / annual)
48
+ Two paths in your browser:
49
+ 1. Stake $SIBYL on Base (free unlimited if you qualify)
50
+ 2. Subscribe in USDC (monthly / quarterly / annual)
53
51
  ```
54
52
 
55
53
  In the browser:
56
- - **Stake** — connect your wallet (browser or Coinbase Smart Wallet), sign to bind, and the page checks your `$SIBYL` balance on Base. If you hold the threshold (default 100,000 $SIBYL liquid+staked, configurable), the local cap lifts.
57
- - **Subscribe** — pick monthly ($29) / quarterly ($79) / annual ($290) USDC, sign the transfer, the server records the subscription. Tier flips immediately.
54
+ - **Stake**: connect your wallet (browser or Coinbase Smart Wallet), sign to bind, and the page checks your `$SIBYL` balance on Base. If you hold the threshold (default 100,000 $SIBYL liquid+staked, configurable), the local cap lifts.
55
+ - **Subscribe**: pick monthly ($29) / quarterly ($79) / annual ($290) USDC, sign the transfer, the server records the subscription. Tier flips immediately.
58
56
 
59
57
  On either path, the CLI sees the tier change, rewrites `credentials.json`, and clears `tier_cache.json` so your next write picks up the new entitlement without delay.
60
58
 
@@ -89,16 +87,14 @@ For internal testing only:
89
87
 
90
88
  ```bash
91
89
  SIBYL_API_BASE=https://staging.api.sibyllabs.org sibyl init
92
- SIBYL_ACTIVATE_BASE=https://staging.auth.sibyllabs.org sibyl init
90
+ SIBYL_ACTIVATE_BASE=https://staging.sibyllabs.org/plugin/activate sibyl init
93
91
  SIBYL_UPGRADE_BASE=https://staging.sibyllabs.org/plugin/upgrade sibyl upgrade
94
92
  ```
95
93
 
96
- `SIBYL_ACTIVATE_BASE` accepts both subdomain form (`https://auth.example.org` → path-based session URL: `https://auth.example.org/<uuid>`) and legacy path form (`https://example.org/plugin/activate` → query-based: `?session=<uuid>`). The CLI auto-detects from the hostname.
97
-
98
94
  ## Security
99
95
 
100
96
  - `credentials.json` is written atomically at mode 0600.
101
- - `session_token` is never printed in full — only a short slice.
97
+ - `session_token` is never printed in full: only a short slice.
102
98
  - No memory content ever transits these endpoints. The CLI never reads `memory.db` content; it only checks file size.
103
99
  - Wallet operations happen in the browser. The CLI sees only the resulting tier change.
104
100
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sibyl-memory-cli"
7
- version = "0.3.5"
7
+ version = "0.3.7"
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" }
@@ -825,6 +825,162 @@ def cmd_health(args: argparse.Namespace) -> int:
825
825
  return 0 if ok_state else 1
826
826
 
827
827
 
828
+ # ---- `sibyl update` ----------------------------------------------------
829
+
830
+ # Three user-facing packages we offer to upgrade. `mcp` is opt-in and not
831
+ # bundled by default — skip it here so we don't tell users to "update"
832
+ # something they may not have installed. Add it back when an `--include-mcp`
833
+ # flag is shipped.
834
+ UPDATE_PACKAGES = ("sibyl-memory-cli", "sibyl-memory-hermes", "sibyl-memory-client")
835
+
836
+
837
+ def _installed_version(pkg: str) -> str | None:
838
+ """Return the locally-installed version of a package, or None if not installed."""
839
+ try:
840
+ from importlib.metadata import PackageNotFoundError, version as _v
841
+ try:
842
+ return _v(pkg)
843
+ except PackageNotFoundError:
844
+ return None
845
+ except Exception:
846
+ return None
847
+
848
+
849
+ def _pypi_latest(pkg: str, timeout: float = 4.0) -> str | None:
850
+ """Hit PyPI's JSON endpoint for the latest published version. Best-effort."""
851
+ url = f"https://pypi.org/pypi/{pkg}/json"
852
+ try:
853
+ req = urllib.request.Request(url, headers={"User-Agent": f"sibyl-memory-cli/{_client_version()}"})
854
+ with urllib.request.urlopen(req, timeout=timeout) as r:
855
+ data = json.loads(r.read().decode("utf-8"))
856
+ return (data.get("info") or {}).get("version")
857
+ except Exception:
858
+ return None
859
+
860
+
861
+ def _ver_tuple(v: str) -> tuple:
862
+ """Lenient version tuple for comparison. Splits on '.', tolerates non-numeric tails."""
863
+ out = []
864
+ for part in (v or "").split("."):
865
+ digits = ""
866
+ for ch in part:
867
+ if ch.isdigit():
868
+ digits += ch
869
+ else:
870
+ break
871
+ out.append(int(digits) if digits else 0)
872
+ return tuple(out)
873
+
874
+
875
+ def _detect_install_method() -> str:
876
+ """Best-guess of how the CLI was installed — pipx / venv / system-pip / pep668-blocked."""
877
+ exe = sys.executable
878
+ if "/pipx/" in exe or "/.local/pipx/" in exe:
879
+ return "pipx"
880
+ if exe and ("venv" in exe.lower() or "virtualenv" in exe.lower() or os.environ.get("VIRTUAL_ENV")):
881
+ return "venv"
882
+ # Look for PEP 668 marker file
883
+ for parent in Path(exe).resolve().parents:
884
+ marker = parent / "lib" / "EXTERNALLY-MANAGED"
885
+ if marker.exists():
886
+ return "pep668"
887
+ marker2 = parent / "EXTERNALLY-MANAGED"
888
+ if marker2.exists():
889
+ return "pep668"
890
+ if str(parent) in ("/", "/home", "/usr"):
891
+ break
892
+ return "system"
893
+
894
+
895
+ def cmd_update(args: argparse.Namespace) -> int:
896
+ """Check installed package versions against PyPI, optionally apply upgrade."""
897
+ rows = []
898
+ any_outdated = False
899
+ for pkg in UPDATE_PACKAGES:
900
+ installed = _installed_version(pkg)
901
+ latest = _pypi_latest(pkg)
902
+ outdated = False
903
+ if installed and latest:
904
+ outdated = _ver_tuple(installed) < _ver_tuple(latest)
905
+ rows.append({"pkg": pkg, "installed": installed, "latest": latest, "outdated": outdated})
906
+ if outdated:
907
+ any_outdated = True
908
+
909
+ if args.json:
910
+ print(json.dumps({"packages": rows, "any_outdated": any_outdated}, indent=2))
911
+ return 0 if not any_outdated else 2
912
+
913
+ # ASCII output — keep it small and readable, follow `sibyl status` style.
914
+ print()
915
+ if any_outdated:
916
+ print(a.err_line("Updates available."))
917
+ else:
918
+ # Distinguish "all current" from "could not reach PyPI"
919
+ any_unreachable = any(r["latest"] is None for r in rows)
920
+ if any_unreachable:
921
+ print(a.dim("Could not reach PyPI for one or more packages — showing what we know."))
922
+ else:
923
+ print(a.success_line("All packages current."))
924
+ print()
925
+
926
+ name_w = max(len(r["pkg"]) for r in rows)
927
+ for r in rows:
928
+ installed = r["installed"] or "(not installed)"
929
+ latest = r["latest"] or "(unreachable)"
930
+ if r["outdated"]:
931
+ line = f" {yellow(r['pkg'].ljust(name_w))} {installed} → {green(latest)}"
932
+ elif r["installed"] is None:
933
+ line = f" {a.dim(r['pkg'].ljust(name_w))} {a.dim(installed)}"
934
+ else:
935
+ line = f" {r['pkg'].ljust(name_w)} {a.dim(installed)}"
936
+ print(line)
937
+ print()
938
+
939
+ if not any_outdated:
940
+ return 0
941
+
942
+ pip_cmd_pkgs = " ".join(r["pkg"] for r in rows if r["outdated"])
943
+ method = _detect_install_method()
944
+
945
+ if args.apply:
946
+ # Best-effort in-process pip invocation
947
+ import subprocess
948
+ pip_args = [sys.executable, "-m", "pip", "install", "-U", *pip_cmd_pkgs.split()]
949
+ if method == "pep668":
950
+ pip_args.append("--break-system-packages")
951
+ if method == "pipx":
952
+ # pipx is a separate tool; we can't drive it via `pip install`.
953
+ print(a.err_line("Detected pipx install. Run instead:"))
954
+ print(f" pipx upgrade {' '.join(r['pkg'] for r in rows if r['outdated'])}")
955
+ return 2
956
+ print(a.dim("Running: ") + " ".join(pip_args))
957
+ try:
958
+ rc = subprocess.call(pip_args)
959
+ except FileNotFoundError:
960
+ print(a.err_line("pip not found at " + sys.executable + " -m pip"))
961
+ return 2
962
+ if rc == 0:
963
+ print()
964
+ print(a.success_line("Upgrade complete. Re-run `sibyl update` to confirm."))
965
+ return rc
966
+
967
+ # Default: print the command, do not execute
968
+ print(a.dim("To upgrade, run:"))
969
+ if method == "pipx":
970
+ print(f" pipx upgrade {pip_cmd_pkgs}")
971
+ elif method == "pep668":
972
+ print(f" pip install --break-system-packages -U {pip_cmd_pkgs}")
973
+ print()
974
+ print(a.dim(" (Your Python flags itself as externally-managed under PEP 668.)"))
975
+ print(a.dim(" (Cleanest: install inside a venv. See https://beta.sibyllabs.org for the recommended path.)"))
976
+ else:
977
+ print(f" pip install -U {pip_cmd_pkgs}")
978
+ print()
979
+ print(a.dim("Or let sibyl run it:") + " sibyl update --apply")
980
+ print()
981
+ return 2 # exit 2 signals "outdated" without being a hard error
982
+
983
+
828
984
  # ---- Dispatch ----------------------------------------------------------
829
985
 
830
986
  def build_parser() -> argparse.ArgumentParser:
@@ -871,6 +1027,14 @@ def build_parser() -> argparse.ArgumentParser:
871
1027
  p_h = sub.add_parser("health", help="Run the provider self-check")
872
1028
  p_h.set_defaults(func=cmd_health)
873
1029
 
1030
+ p_update = sub.add_parser(
1031
+ "update",
1032
+ help="Check for newer sibyl-memory-* releases on PyPI (use --apply to upgrade)",
1033
+ )
1034
+ p_update.add_argument("--apply", action="store_true", help="Run pip install -U for the outdated packages")
1035
+ p_update.add_argument("--json", action="store_true", help="Machine-readable output")
1036
+ p_update.set_defaults(func=cmd_update)
1037
+
874
1038
  # v0.1.4: one-command auto-detect-and-wire setup for any agent stack
875
1039
  from .setup import cmd_setup
876
1040
  p_setup = sub.add_parser(
@@ -878,7 +1042,7 @@ def build_parser() -> argparse.ArgumentParser:
878
1042
  help="Auto-detect Hermes / Claude Code and wire SIBYL as the memory provider",
879
1043
  )
880
1044
  p_setup.add_argument(
881
- "target", nargs="?", choices=list(["hermes", "claude-code"]),
1045
+ "target", nargs="?", choices=list(["hermes", "claude-code", "codex"]),
882
1046
  help="Wire just this framework (default: detect all)",
883
1047
  )
884
1048
  p_setup.add_argument(
@@ -899,7 +1063,11 @@ def build_parser() -> argparse.ArgumentParser:
899
1063
  )
900
1064
  p_setup.add_argument(
901
1065
  "--claude-settings", default=None,
902
- help="Override ~/.claude/settings.json autodetection",
1066
+ help="Override ~/.claude.json autodetection",
1067
+ )
1068
+ p_setup.add_argument(
1069
+ "--codex-config", default=None,
1070
+ help="Override ~/.codex/config.toml autodetection",
903
1071
  )
904
1072
  p_setup.set_defaults(func=cmd_setup)
905
1073
 
@@ -3,11 +3,12 @@
3
3
  Maximum-efficiency onboarding command. Single-command path for the user:
4
4
 
5
5
  pip install sibyl-memory-cli
6
- sibyl setup # auto-detects Hermes + Claude Code, prompts per stack, wires
6
+ sibyl setup # auto-detects Hermes + Claude Code + Codex, prompts per stack, wires
7
7
 
8
- Two wirers in v0.1.4:
8
+ Three wirers in v0.3.7:
9
9
  - HermesWirer: install-plugin + edit $HERMES_HOME/config.yaml (memory.provider)
10
- - ClaudeCodeWirer: edit ~/.claude/settings.json (mcpServers.sibyl-memory)
10
+ - ClaudeCodeWirer: edit ~/.claude.json (mcpServers.sibyl-memory)
11
+ - CodexWirer: edit ~/.codex/config.toml ([mcp_servers.sibyl_memory])
11
12
 
12
13
  Each wirer follows the same protocol:
13
14
  is_present() -> bool (filesystem + PATH detect)
@@ -26,6 +27,7 @@ from __future__ import annotations
26
27
  import argparse
27
28
  import json
28
29
  import os
30
+ import re
29
31
  import shutil
30
32
  import sys
31
33
  from dataclasses import dataclass
@@ -229,9 +231,15 @@ class ClaudeCodeWirer:
229
231
  SIBYL_MCP_BLOCK = {"command": "sibyl-memory-mcp"}
230
232
 
231
233
  def __init__(self, *, settings_path: Optional[Union[str, Path]] = None):
234
+ # v0.3.7 (2026-05-22): default flipped from ~/.claude/settings.json to
235
+ # ~/.claude.json. Recent Claude Code reads MCP server config from the
236
+ # user-level ~/.claude.json — writing to ~/.claude/settings.json was a
237
+ # silent-failure path (wirer reported success but MCP server didn't
238
+ # appear in Claude Code). Override with --claude-settings if your
239
+ # install uses a non-standard location.
232
240
  self.settings_path = (
233
241
  Path(settings_path).expanduser() if settings_path
234
- else Path.home() / ".claude" / "settings.json"
242
+ else Path.home() / ".claude.json"
235
243
  )
236
244
 
237
245
  def is_present(self) -> bool:
@@ -333,6 +341,158 @@ class ClaudeCodeWirer:
333
341
  os.replace(tmp, self.settings_path)
334
342
 
335
343
 
344
+ # ----------------------------------------------------------------------
345
+ # CodexWirer — edits ~/.codex/config.toml
346
+ # ----------------------------------------------------------------------
347
+
348
+ class CodexWirer:
349
+ """Auto-wire Codex CLI's MCP server registry at ~/.codex/config.toml.
350
+
351
+ Codex reads server defs from a TOML table named `[mcp_servers.<name>]`.
352
+ We use `[mcp_servers.sibyl_memory]` (snake_case is TOML-idiomatic; the
353
+ Claude Code side uses `sibyl-memory` because JSON keys allow hyphens
354
+ and Anthropic docs use that form).
355
+
356
+ Idempotency is text-based rather than full TOML round-trip, so we
357
+ preserve operator hand-edits and comments in the rest of the file
358
+ instead of reformatting on every run. The cost: we won't catch
359
+ pathological TOML edge cases (e.g., comments mid-key). For the
360
+ canonical Codex config layout this is fine.
361
+ """
362
+ name = "codex"
363
+ display_name = "Codex CLI"
364
+ initial = "x" # 'c' is taken by Claude Code; 'x' from codeX
365
+
366
+ SIBYL_TOML_BLOCK = '[mcp_servers.sibyl_memory]\ncommand = "sibyl-memory-mcp"\n'
367
+ BLOCK_HEADER_RE = re.compile(
368
+ r'^\s*\[\s*mcp_servers\s*\.\s*sibyl_memory\s*\]\s*$', re.MULTILINE
369
+ )
370
+ NEXT_TABLE_RE = re.compile(r'^\s*\[', re.MULTILINE)
371
+ SIBYL_COMMAND_RE = re.compile(
372
+ r'^\s*command\s*=\s*"sibyl-memory-mcp"\s*$', re.MULTILINE
373
+ )
374
+
375
+ def __init__(self, *, config_path: Optional[Union[str, Path]] = None):
376
+ self.config_path = (
377
+ Path(config_path).expanduser() if config_path
378
+ else Path.home() / ".codex" / "config.toml"
379
+ )
380
+
381
+ def is_present(self) -> bool:
382
+ if self.config_path.exists():
383
+ return True
384
+ if shutil.which("codex"):
385
+ return True
386
+ return False
387
+
388
+ def _read(self) -> str:
389
+ if not self.config_path.exists():
390
+ return ""
391
+ try:
392
+ return self.config_path.read_text(encoding="utf-8")
393
+ except Exception:
394
+ return ""
395
+
396
+ def _extract_block(self, content: str) -> Optional[str]:
397
+ m = self.BLOCK_HEADER_RE.search(content)
398
+ if not m:
399
+ return None
400
+ # Find next [...] table header after our match (start-of-line "[").
401
+ next_tbl = self.NEXT_TABLE_RE.search(content, m.end())
402
+ end = next_tbl.start() if next_tbl else len(content)
403
+ return content[m.start():end]
404
+
405
+ def current_state(self) -> dict:
406
+ content = self._read()
407
+ block = self._extract_block(content)
408
+ sibyl_block_present = block is not None
409
+ wired_with_sibyl = bool(block and self.SIBYL_COMMAND_RE.search(block))
410
+ return {
411
+ "config_path": str(self.config_path),
412
+ "config_exists": self.config_path.exists(),
413
+ "sibyl_block_present": sibyl_block_present,
414
+ "wired_with_sibyl": wired_with_sibyl,
415
+ }
416
+
417
+ def wire(self, *, force: bool = False, dry_run: bool = False,
418
+ prompt_fn: Optional[Callable[..., str]] = None) -> WireOutcome:
419
+ state = self.current_state()
420
+
421
+ if state["wired_with_sibyl"]:
422
+ return WireOutcome(
423
+ self.name, "already",
424
+ f"Codex CLI already has [mcp_servers.sibyl_memory] block in {self.config_path}",
425
+ )
426
+
427
+ if state["sibyl_block_present"] and not force:
428
+ if prompt_fn is None:
429
+ return WireOutcome(
430
+ self.name, "skipped",
431
+ "Existing [mcp_servers.sibyl_memory] differs. Use --force to overwrite.",
432
+ )
433
+ ans = prompt_fn(
434
+ "Codex has [mcp_servers.sibyl_memory] but command differs. Update?",
435
+ default="N",
436
+ )
437
+ if ans != "y":
438
+ return WireOutcome(self.name, "skipped", "TOML block update declined.")
439
+
440
+ if dry_run:
441
+ verb = "replace" if state["sibyl_block_present"] else "append"
442
+ return WireOutcome(
443
+ self.name, "dry-run",
444
+ f"Would {verb} [mcp_servers.sibyl_memory] in {self.config_path}",
445
+ )
446
+
447
+ backup = self._backup_config()
448
+ try:
449
+ self._write_config_with_sibyl(replace_existing=state["sibyl_block_present"])
450
+ except Exception as e:
451
+ return WireOutcome(
452
+ self.name, "error",
453
+ f"config write failed: {type(e).__name__}: {e}",
454
+ backup_path=backup,
455
+ )
456
+ return WireOutcome(
457
+ self.name, "wired",
458
+ f"Added [mcp_servers.sibyl_memory] to {self.config_path}",
459
+ backup_path=backup,
460
+ )
461
+
462
+ def _backup_config(self) -> Optional[Path]:
463
+ if not self.config_path.exists():
464
+ return None
465
+ backup = self.config_path.with_suffix(".toml.bak")
466
+ shutil.copy2(self.config_path, backup)
467
+ return backup
468
+
469
+ def _write_config_with_sibyl(self, *, replace_existing: bool) -> None:
470
+ content = self._read()
471
+
472
+ if replace_existing:
473
+ existing = self._extract_block(content) or ""
474
+ # Rstrip the existing block to a single newline before splice so
475
+ # we don't accumulate blank lines on repeat replaces.
476
+ stripped = existing.rstrip("\n")
477
+ new_content = content.replace(stripped, self.SIBYL_TOML_BLOCK.rstrip("\n"), 1)
478
+ # Ensure file ends with a newline
479
+ if not new_content.endswith("\n"):
480
+ new_content += "\n"
481
+ else:
482
+ # Append: one blank line of separator if file is non-empty
483
+ if not content:
484
+ new_content = self.SIBYL_TOML_BLOCK
485
+ else:
486
+ # Normalize trailing whitespace to exactly "\n\n"
487
+ trimmed = content.rstrip("\n") + "\n\n"
488
+ new_content = trimmed + self.SIBYL_TOML_BLOCK
489
+
490
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
491
+ tmp = self.config_path.with_suffix(".toml.tmp")
492
+ tmp.write_text(new_content, encoding="utf-8")
493
+ os.replace(tmp, self.config_path)
494
+
495
+
336
496
  # ----------------------------------------------------------------------
337
497
  # Registry + dispatch
338
498
  # ----------------------------------------------------------------------
@@ -340,6 +500,7 @@ class ClaudeCodeWirer:
340
500
  ALL_WIRERS: dict = {
341
501
  "hermes": HermesWirer,
342
502
  "claude-code": ClaudeCodeWirer,
503
+ "codex": CodexWirer,
343
504
  }
344
505
 
345
506
 
@@ -366,6 +527,8 @@ def _wirer_kwargs(args: argparse.Namespace, name: str) -> dict:
366
527
  kw["hermes_home"] = args.hermes_home
367
528
  if name == "claude-code" and getattr(args, "claude_settings", None):
368
529
  kw["settings_path"] = args.claude_settings
530
+ if name == "codex" and getattr(args, "codex_config", None):
531
+ kw["config_path"] = args.codex_config
369
532
  return kw
370
533
 
371
534
 
@@ -402,12 +565,13 @@ def cmd_setup(args: argparse.Namespace) -> int:
402
565
  print(dim("Looked for:"))
403
566
  for name, w in wirers.items():
404
567
  st = w.current_state()
405
- loc = st.get("hermes_home") or st.get("settings_path")
568
+ loc = st.get("hermes_home") or st.get("settings_path") or st.get("config_path")
406
569
  print(f" {w.display_name}: {loc}")
407
570
  print()
408
571
  print(dim("To override detection, point setup at a custom path:"))
409
572
  print(f" {cyan('sibyl setup --hermes-home /custom/path')}")
410
- print(f" {cyan('sibyl setup --claude-settings /custom/settings.json')}")
573
+ print(f" {cyan('sibyl setup --claude-settings /custom/.claude.json')}")
574
+ print(f" {cyan('sibyl setup --codex-config /custom/config.toml')}")
411
575
  print()
412
576
  return 0
413
577
 
@@ -415,7 +579,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
415
579
  print(dim("Detected:"))
416
580
  for name, w in detected.items():
417
581
  st = w.current_state()
418
- loc = st.get("hermes_home") or st.get("settings_path")
582
+ loc = st.get("hermes_home") or st.get("settings_path") or st.get("config_path")
419
583
  print(f" {w.display_name} at {loc}")
420
584
  print()
421
585
 
@@ -449,11 +613,14 @@ def cmd_setup(args: argparse.Namespace) -> int:
449
613
 
450
614
  # Pre-prompt for fresh adds (interactive only). Already-wired and
451
615
  # existing-other-provider are handled inside wire() itself.
616
+ # State keys per wirer: hermes → memory_provider; claude-code →
617
+ # sibyl_mcp; codex → sibyl_block_present.
452
618
  if (
453
619
  not args.yes
454
620
  and not st.get("wired_with_sibyl")
455
621
  and not st.get("memory_provider")
456
622
  and not st.get("sibyl_mcp")
623
+ and not st.get("sibyl_block_present")
457
624
  ):
458
625
  if name == "hermes":
459
626
  q = f"Set SIBYL as default memory provider in {wirer.display_name}?"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibyl-memory-cli
3
- Version: 0.3.5
3
+ Version: 0.3.7
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
@@ -48,39 +48,37 @@ sibyl health Run the SibylMemoryProvider self-check (schema versi
48
48
  ```bash
49
49
  $ sibyl init
50
50
 
51
- activation · three paths · pick whichever fits your device
51
+ Sibyl Memory Plugin · activation
52
52
 
53
- Session a1b2c3d4…e5f6
54
- Code 1 2 3 4 5 6 (use this in the email panel)
55
- Opening https://auth.sibyllabs.org/a1b2c3d4-…
53
+ Session: a1b2c3d4…e5f6
54
+ Opening: https://sibyllabs.org/plugin/activate?session=a1b2c3d4-…
56
55
 
57
- desktop wallet · email + code · or send USDC from any mobile wallet
58
- this terminal will pick up automatically when you bind.
56
+ Sign in with your wallet in the browser. This terminal will pick up automatically.
59
57
 
60
- ⠹ watching the network for your bind … 9:42 left
58
+ ⠹ waiting for browser activation … 9:42 left
61
59
  ```
62
60
 
63
- The browser opens to a dedicated short-URL auth subdomain (`auth.sibyllabs.org/<session-uuid>`). Pick a wallet from the EIP-6963 picker (Rabby / MetaMask / Coinbase Wallet / Frame / Brave / Rainbow), enter email + 6-digit code, or send a unique sub-cent USDC amount from any mobile wallet on Base. The terminal picks up the moment any of the three paths binds. Credentials are written to `~/.sibyl-memory/credentials.json` at mode 0600.
61
+ The browser opens. Sign a SIWE message with your wallet. The terminal picks up the moment the binding lands. Credentials are written to `~/.sibyl-memory/credentials.json` at mode 0600.
64
62
 
65
63
  ## Upgrade
66
64
 
67
65
  ```bash
68
66
  $ sibyl upgrade
69
67
 
70
- upgrade · lift the 2 MB free-tier cap
68
+ Sibyl Memory Plugin · upgrade
71
69
 
72
70
  Account a1b2c3d4…e5f6
73
71
  Current tier FREE
74
72
  Opening https://sibyllabs.org/plugin/upgrade?session=…
75
73
 
76
- two paths in the browser:
77
- 1. stake $SIBYL on Base (free unlimited if you qualify)
78
- 2. subscribe in USDC (monthly / quarterly / annual)
74
+ Two paths in your browser:
75
+ 1. Stake $SIBYL on Base (free unlimited if you qualify)
76
+ 2. Subscribe in USDC (monthly / quarterly / annual)
79
77
  ```
80
78
 
81
79
  In the browser:
82
- - **Stake** — connect your wallet (browser or Coinbase Smart Wallet), sign to bind, and the page checks your `$SIBYL` balance on Base. If you hold the threshold (default 100,000 $SIBYL liquid+staked, configurable), the local cap lifts.
83
- - **Subscribe** — pick monthly ($29) / quarterly ($79) / annual ($290) USDC, sign the transfer, the server records the subscription. Tier flips immediately.
80
+ - **Stake**: connect your wallet (browser or Coinbase Smart Wallet), sign to bind, and the page checks your `$SIBYL` balance on Base. If you hold the threshold (default 100,000 $SIBYL liquid+staked, configurable), the local cap lifts.
81
+ - **Subscribe**: pick monthly ($29) / quarterly ($79) / annual ($290) USDC, sign the transfer, the server records the subscription. Tier flips immediately.
84
82
 
85
83
  On either path, the CLI sees the tier change, rewrites `credentials.json`, and clears `tier_cache.json` so your next write picks up the new entitlement without delay.
86
84
 
@@ -115,16 +113,14 @@ For internal testing only:
115
113
 
116
114
  ```bash
117
115
  SIBYL_API_BASE=https://staging.api.sibyllabs.org sibyl init
118
- SIBYL_ACTIVATE_BASE=https://staging.auth.sibyllabs.org sibyl init
116
+ SIBYL_ACTIVATE_BASE=https://staging.sibyllabs.org/plugin/activate sibyl init
119
117
  SIBYL_UPGRADE_BASE=https://staging.sibyllabs.org/plugin/upgrade sibyl upgrade
120
118
  ```
121
119
 
122
- `SIBYL_ACTIVATE_BASE` accepts both subdomain form (`https://auth.example.org` → path-based session URL: `https://auth.example.org/<uuid>`) and legacy path form (`https://example.org/plugin/activate` → query-based: `?session=<uuid>`). The CLI auto-detects from the hostname.
123
-
124
120
  ## Security
125
121
 
126
122
  - `credentials.json` is written atomically at mode 0600.
127
- - `session_token` is never printed in full — only a short slice.
123
+ - `session_token` is never printed in full: only a short slice.
128
124
  - No memory content ever transits these endpoints. The CLI never reads `memory.db` content; it only checks file size.
129
125
  - Wallet operations happen in the browser. The CLI sees only the resulting tier change.
130
126
 
@@ -36,7 +36,7 @@ from sibyl_memory_cli.setup import ( # noqa: E402
36
36
  # ----------------------------------------------------------------------
37
37
 
38
38
  def _stub_install_plugin(hermes_home: str):
39
- """Replacement for sibyl_memory_hermes.install_plugin.install — drops a fake
39
+ """Replacement for sibyl_memory_hermes.install_plugin.install: drops a fake
40
40
  adapter file so the wirer sees plugin_installed=True afterwards."""
41
41
  plugin_dir = Path(hermes_home) / "plugins" / "sibyl"
42
42
  plugin_dir.mkdir(parents=True, exist_ok=True)