sibyl-memory-cli 0.1.2__tar.gz → 0.1.4__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.1.2
3
+ Version: 0.1.4
4
4
  Summary: Command-line interface for the Sibyl Memory Plugin. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats.
5
5
  Author-email: "SIBYL, Sibyl Labs LLC" <sibyl@sibyllabs.org>
6
6
  License: MIT
@@ -18,8 +18,9 @@ Classifier: Environment :: Console
18
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
19
  Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
- Requires-Dist: sibyl-memory-client>=0.3.3
22
- Requires-Dist: sibyl-memory-hermes>=0.3.1
21
+ Requires-Dist: sibyl-memory-client>=0.4.0
22
+ Requires-Dist: sibyl-memory-hermes>=0.3.2
23
+ Requires-Dist: pyyaml>=6.0
23
24
  Provides-Extra: dev
24
25
  Requires-Dist: pytest>=7.0; extra == "dev"
25
26
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sibyl-memory-cli"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Command-line interface for the Sibyl Memory Plugin. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats."
9
9
  authors = [{ name = "SIBYL, Sibyl Labs LLC", email = "sibyl@sibyllabs.org" }]
10
10
  license = { text = "MIT" }
@@ -23,8 +23,9 @@ classifiers = [
23
23
  "Topic :: Software Development :: Libraries :: Python Modules",
24
24
  ]
25
25
  dependencies = [
26
- "sibyl-memory-client>=0.3.3",
27
- "sibyl-memory-hermes>=0.3.1",
26
+ "sibyl-memory-client>=0.4.0",
27
+ "sibyl-memory-hermes>=0.3.2",
28
+ "pyyaml>=6.0",
28
29
  ]
29
30
 
30
31
  [project.optional-dependencies]
@@ -629,6 +629,38 @@ def build_parser() -> argparse.ArgumentParser:
629
629
  p_h = sub.add_parser("health", help="Run the provider self-check")
630
630
  p_h.set_defaults(func=cmd_health)
631
631
 
632
+ # v0.1.4: one-command auto-detect-and-wire setup for any agent stack
633
+ from .setup import cmd_setup
634
+ p_setup = sub.add_parser(
635
+ "setup",
636
+ help="Auto-detect Hermes / Claude Code and wire SIBYL as the memory provider",
637
+ )
638
+ p_setup.add_argument(
639
+ "target", nargs="?", choices=list(["hermes", "claude-code"]),
640
+ help="Wire just this framework (default: detect all)",
641
+ )
642
+ p_setup.add_argument(
643
+ "--yes", "-y", action="store_true",
644
+ help="Skip prompts, accept defaults (still respects destructive-default-NO unless --force)",
645
+ )
646
+ p_setup.add_argument(
647
+ "--force", action="store_true",
648
+ help="Overwrite existing non-SIBYL memory provider configs",
649
+ )
650
+ p_setup.add_argument(
651
+ "--dry-run", action="store_true",
652
+ help="Print what would change without writing",
653
+ )
654
+ p_setup.add_argument(
655
+ "--hermes-home", default=None,
656
+ help="Override HERMES_HOME autodetection",
657
+ )
658
+ p_setup.add_argument(
659
+ "--claude-settings", default=None,
660
+ help="Override ~/.claude/settings.json autodetection",
661
+ )
662
+ p_setup.set_defaults(func=cmd_setup)
663
+
632
664
  return p
633
665
 
634
666
 
@@ -0,0 +1,493 @@
1
+ """`sibyl setup`: auto-detect agent frameworks and wire SIBYL as memory provider.
2
+
3
+ Maximum-efficiency onboarding command. Single-command path for the user:
4
+
5
+ pip install sibyl-memory-cli
6
+ sibyl setup # auto-detects Hermes + Claude Code, prompts per stack, wires
7
+
8
+ Two wirers in v0.1.4:
9
+ - HermesWirer: install-plugin + edit $HERMES_HOME/config.yaml (memory.provider)
10
+ - ClaudeCodeWirer: edit ~/.claude/settings.json (mcpServers.sibyl-memory)
11
+
12
+ Each wirer follows the same protocol:
13
+ is_present() -> bool (filesystem + PATH detect)
14
+ current_state() -> dict (configured? wired-with-sibyl? current-value?)
15
+ wire(force, dry_run, prompt_fn) -> WireOutcome
16
+
17
+ Destructive operations (overwriting an existing non-SIBYL config) default to NO
18
+ on the prompt. Fresh adds default to YES. --force overrides destructive guards.
19
+ --yes accepts all defaults (still respects the destructive-default-NO unless
20
+ --force is also passed). --dry-run prints intent without writing.
21
+
22
+ All config edits are atomic: backup to <file>.bak, write to <file>.tmp, rename.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import shutil
30
+ import sys
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Any, Callable, Optional, Union
34
+
35
+ # Color helpers re-imported from cli module via late binding to avoid circular dep.
36
+ # When called via `sibyl setup` they resolve through the cli module's tty detection.
37
+ def _color_fns():
38
+ from .cli import bold, cyan, dim, green, red, yellow
39
+ return bold, cyan, dim, green, red, yellow
40
+
41
+
42
+ # ----------------------------------------------------------------------
43
+ # WireOutcome
44
+ # ----------------------------------------------------------------------
45
+
46
+ @dataclass
47
+ class WireOutcome:
48
+ """Result of a wirer.wire() call. Composable across multiple wirers."""
49
+ name: str
50
+ status: str # 'wired' / 'already' / 'skipped' / 'dry-run' / 'error'
51
+ message: str
52
+ backup_path: Optional[Path] = None
53
+
54
+
55
+ # ----------------------------------------------------------------------
56
+ # Lazy YAML import. Hermes wirer needs it; Claude-only users do not.
57
+ # ----------------------------------------------------------------------
58
+
59
+ def _import_yaml():
60
+ try:
61
+ import yaml
62
+ return yaml
63
+ except ImportError:
64
+ return None
65
+
66
+
67
+ # ----------------------------------------------------------------------
68
+ # HermesWirer
69
+ # ----------------------------------------------------------------------
70
+
71
+ class HermesWirer:
72
+ name = "hermes"
73
+ display_name = "Hermes"
74
+ initial = "h"
75
+
76
+ def __init__(self, *, hermes_home: Optional[Union[str, Path]] = None):
77
+ self.hermes_home = (
78
+ Path(hermes_home).expanduser() if hermes_home
79
+ else self._auto_hermes_home()
80
+ )
81
+ self.config_path = self.hermes_home / "config.yaml"
82
+ self.plugin_dir = self.hermes_home / "plugins" / "sibyl"
83
+
84
+ @staticmethod
85
+ def _auto_hermes_home() -> Path:
86
+ env = os.environ.get("HERMES_HOME")
87
+ if env:
88
+ return Path(env).expanduser()
89
+ return Path.home() / ".hermes"
90
+
91
+ def is_present(self) -> bool:
92
+ # Present if HERMES_HOME exists OR `hermes` binary on PATH
93
+ if self.hermes_home.exists():
94
+ return True
95
+ if shutil.which("hermes"):
96
+ return True
97
+ return False
98
+
99
+ def current_state(self) -> dict:
100
+ config_exists = self.config_path.exists()
101
+ plugin_installed = (self.plugin_dir / "__init__.py").exists()
102
+ memory_provider: Optional[str] = None
103
+ if config_exists:
104
+ yaml = _import_yaml()
105
+ if yaml is not None:
106
+ try:
107
+ raw = self.config_path.read_text(encoding="utf-8")
108
+ cfg = yaml.safe_load(raw) or {}
109
+ if isinstance(cfg, dict):
110
+ mem = cfg.get("memory")
111
+ if isinstance(mem, dict):
112
+ memory_provider = mem.get("provider")
113
+ except Exception:
114
+ pass
115
+ return {
116
+ "hermes_home": str(self.hermes_home),
117
+ "config_path": str(self.config_path),
118
+ "config_exists": config_exists,
119
+ "plugin_installed": plugin_installed,
120
+ "memory_provider": memory_provider,
121
+ "wired_with_sibyl": memory_provider == "sibyl",
122
+ }
123
+
124
+ def wire(self, *, force: bool = False, dry_run: bool = False,
125
+ prompt_fn: Optional[Callable[..., str]] = None) -> WireOutcome:
126
+ state = self.current_state()
127
+ yaml = _import_yaml()
128
+ if yaml is None:
129
+ return WireOutcome(
130
+ self.name, "error",
131
+ "PyYAML not installed. Run `pip install pyyaml` and retry.",
132
+ )
133
+
134
+ # 1. Install plugin if missing
135
+ if not state["plugin_installed"]:
136
+ if dry_run:
137
+ pass # report at the end
138
+ else:
139
+ try:
140
+ self._install_plugin()
141
+ except Exception as e:
142
+ return WireOutcome(
143
+ self.name, "error",
144
+ f"install-plugin failed: {type(e).__name__}: {e}",
145
+ )
146
+
147
+ # 2. Already wired? no-op
148
+ if state["wired_with_sibyl"] and state["plugin_installed"]:
149
+ return WireOutcome(
150
+ self.name, "already",
151
+ f"Hermes already has SIBYL as memory provider in {self.config_path}",
152
+ )
153
+
154
+ # 3. Existing non-SIBYL provider? confirm or refuse
155
+ if state["memory_provider"] and state["memory_provider"] != "sibyl" and not force:
156
+ if prompt_fn is None:
157
+ return WireOutcome(
158
+ self.name, "skipped",
159
+ f"Existing memory.provider '{state['memory_provider']}'. Use --force to overwrite.",
160
+ )
161
+ ans = prompt_fn(
162
+ f"Hermes currently uses '{state['memory_provider']}' as memory provider. Overwrite with SIBYL?",
163
+ default="N",
164
+ )
165
+ if ans != "y":
166
+ return WireOutcome(self.name, "skipped", "Memory provider overwrite declined.")
167
+
168
+ # 4. Dry-run report
169
+ if dry_run:
170
+ actions = []
171
+ if not state["plugin_installed"]:
172
+ actions.append(f"install plugin at {self.plugin_dir}")
173
+ actions.append(f"set memory.provider=sibyl in {self.config_path}")
174
+ return WireOutcome(self.name, "dry-run", "Would: " + "; ".join(actions))
175
+
176
+ # 5. Real write. Backup, then atomic rename.
177
+ backup = self._backup_config()
178
+ try:
179
+ self._write_config_with_sibyl(yaml)
180
+ except Exception as e:
181
+ return WireOutcome(
182
+ self.name, "error",
183
+ f"config write failed: {type(e).__name__}: {e}",
184
+ backup_path=backup,
185
+ )
186
+ return WireOutcome(
187
+ self.name, "wired",
188
+ f"Wired memory.provider=sibyl in {self.config_path}",
189
+ backup_path=backup,
190
+ )
191
+
192
+ def _install_plugin(self) -> None:
193
+ from sibyl_memory_hermes.install_plugin import install
194
+ install(hermes_home=str(self.hermes_home))
195
+
196
+ def _backup_config(self) -> Optional[Path]:
197
+ if not self.config_path.exists():
198
+ return None
199
+ backup = self.config_path.with_suffix(".yaml.bak")
200
+ shutil.copy2(self.config_path, backup)
201
+ return backup
202
+
203
+ def _write_config_with_sibyl(self, yaml) -> None:
204
+ cfg: dict = {}
205
+ if self.config_path.exists():
206
+ raw = self.config_path.read_text(encoding="utf-8")
207
+ loaded = yaml.safe_load(raw)
208
+ if isinstance(loaded, dict):
209
+ cfg = loaded
210
+ if not isinstance(cfg.get("memory"), dict):
211
+ cfg["memory"] = {}
212
+ cfg["memory"]["provider"] = "sibyl"
213
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
214
+ tmp = self.config_path.with_suffix(".yaml.tmp")
215
+ with open(tmp, "w", encoding="utf-8") as f:
216
+ yaml.safe_dump(cfg, f, sort_keys=False, default_flow_style=False)
217
+ os.replace(tmp, self.config_path)
218
+
219
+
220
+ # ----------------------------------------------------------------------
221
+ # ClaudeCodeWirer
222
+ # ----------------------------------------------------------------------
223
+
224
+ class ClaudeCodeWirer:
225
+ name = "claude-code"
226
+ display_name = "Claude Code"
227
+ initial = "c"
228
+
229
+ SIBYL_MCP_BLOCK = {"command": "sibyl-memory-mcp"}
230
+
231
+ def __init__(self, *, settings_path: Optional[Union[str, Path]] = None):
232
+ self.settings_path = (
233
+ Path(settings_path).expanduser() if settings_path
234
+ else Path.home() / ".claude" / "settings.json"
235
+ )
236
+
237
+ def is_present(self) -> bool:
238
+ if self.settings_path.exists():
239
+ return True
240
+ if shutil.which("claude"):
241
+ return True
242
+ return False
243
+
244
+ def current_state(self) -> dict:
245
+ settings_exists = self.settings_path.exists()
246
+ mcp_servers: dict = {}
247
+ sibyl_block: Optional[dict] = None
248
+ if settings_exists:
249
+ try:
250
+ cfg = json.loads(self.settings_path.read_text(encoding="utf-8"))
251
+ if isinstance(cfg, dict):
252
+ raw_servers = cfg.get("mcpServers", {})
253
+ if isinstance(raw_servers, dict):
254
+ mcp_servers = raw_servers
255
+ sibyl_block = mcp_servers.get("sibyl-memory")
256
+ except Exception:
257
+ pass
258
+ return {
259
+ "settings_path": str(self.settings_path),
260
+ "settings_exists": settings_exists,
261
+ "mcp_servers_count": len(mcp_servers),
262
+ "sibyl_mcp": sibyl_block,
263
+ "wired_with_sibyl": sibyl_block == self.SIBYL_MCP_BLOCK,
264
+ }
265
+
266
+ def wire(self, *, force: bool = False, dry_run: bool = False,
267
+ prompt_fn: Optional[Callable[..., str]] = None) -> WireOutcome:
268
+ state = self.current_state()
269
+
270
+ if state["wired_with_sibyl"]:
271
+ return WireOutcome(
272
+ self.name, "already",
273
+ f"Claude Code already has SIBYL Memory MCP server in {self.settings_path}",
274
+ )
275
+
276
+ if state["sibyl_mcp"] and not force:
277
+ if prompt_fn is None:
278
+ return WireOutcome(
279
+ self.name, "skipped",
280
+ "Existing sibyl-memory MCP entry differs. Use --force to overwrite.",
281
+ )
282
+ ans = prompt_fn(
283
+ "Claude Code has 'sibyl-memory' MCP entry but pointing elsewhere. Update?",
284
+ default="N",
285
+ )
286
+ if ans != "y":
287
+ return WireOutcome(self.name, "skipped", "MCP entry update declined.")
288
+
289
+ if dry_run:
290
+ verb = "update" if state["sibyl_mcp"] else "add"
291
+ return WireOutcome(
292
+ self.name, "dry-run",
293
+ f"Would {verb} mcpServers.sibyl-memory in {self.settings_path}",
294
+ )
295
+
296
+ backup = self._backup_settings()
297
+ try:
298
+ self._write_settings_with_sibyl()
299
+ except Exception as e:
300
+ return WireOutcome(
301
+ self.name, "error",
302
+ f"settings write failed: {type(e).__name__}: {e}",
303
+ backup_path=backup,
304
+ )
305
+ return WireOutcome(
306
+ self.name, "wired",
307
+ f"Added SIBYL Memory MCP server to {self.settings_path}",
308
+ backup_path=backup,
309
+ )
310
+
311
+ def _backup_settings(self) -> Optional[Path]:
312
+ if not self.settings_path.exists():
313
+ return None
314
+ backup = self.settings_path.with_suffix(".json.bak")
315
+ shutil.copy2(self.settings_path, backup)
316
+ return backup
317
+
318
+ def _write_settings_with_sibyl(self) -> None:
319
+ cfg: dict = {}
320
+ if self.settings_path.exists():
321
+ try:
322
+ loaded = json.loads(self.settings_path.read_text(encoding="utf-8"))
323
+ if isinstance(loaded, dict):
324
+ cfg = loaded
325
+ except Exception:
326
+ cfg = {}
327
+ if not isinstance(cfg.get("mcpServers"), dict):
328
+ cfg["mcpServers"] = {}
329
+ cfg["mcpServers"]["sibyl-memory"] = self.SIBYL_MCP_BLOCK
330
+ self.settings_path.parent.mkdir(parents=True, exist_ok=True)
331
+ tmp = self.settings_path.with_suffix(".json.tmp")
332
+ tmp.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
333
+ os.replace(tmp, self.settings_path)
334
+
335
+
336
+ # ----------------------------------------------------------------------
337
+ # Registry + dispatch
338
+ # ----------------------------------------------------------------------
339
+
340
+ ALL_WIRERS: dict = {
341
+ "hermes": HermesWirer,
342
+ "claude-code": ClaudeCodeWirer,
343
+ }
344
+
345
+
346
+ def _interactive_prompt(question: str, *, default: str = "Y") -> str:
347
+ """Yes/no prompt. default 'Y' or 'N'. Returns 'y' or 'n'."""
348
+ default_label = "[Y/n]" if default.upper() == "Y" else "[y/N]"
349
+ try:
350
+ ans = input(f"{question} {default_label}: ").strip()
351
+ except EOFError:
352
+ return default.lower()
353
+ if not ans:
354
+ return default.lower()
355
+ return "y" if ans[:1].lower() == "y" else "n"
356
+
357
+
358
+ def _accept_defaults_prompt(question: str, *, default: str = "Y") -> str:
359
+ """Non-interactive prompt. Returns the default. Used with --yes."""
360
+ return default.lower()
361
+
362
+
363
+ def _wirer_kwargs(args: argparse.Namespace, name: str) -> dict:
364
+ kw: dict = {}
365
+ if name == "hermes" and getattr(args, "hermes_home", None):
366
+ kw["hermes_home"] = args.hermes_home
367
+ if name == "claude-code" and getattr(args, "claude_settings", None):
368
+ kw["settings_path"] = args.claude_settings
369
+ return kw
370
+
371
+
372
+ def cmd_setup(args: argparse.Namespace) -> int:
373
+ """`sibyl setup` entry point. Auto-detect, then wire."""
374
+ bold, cyan, dim, green, red, yellow = _color_fns()
375
+
376
+ # Resolve target wirers
377
+ target = getattr(args, "target", None)
378
+ if target:
379
+ if target not in ALL_WIRERS:
380
+ print(red(f"Unknown setup target: {target}"))
381
+ print(f"Available: {', '.join(ALL_WIRERS)}")
382
+ return 1
383
+ wirers: dict = {target: ALL_WIRERS[target](**_wirer_kwargs(args, target))}
384
+ skip_present_check = True # explicit target = wire it even if not detected on PATH
385
+ else:
386
+ wirers = {name: cls(**_wirer_kwargs(args, name)) for name, cls in ALL_WIRERS.items()}
387
+ skip_present_check = False
388
+
389
+ print()
390
+ print(bold("Sibyl Memory Plugin setup"))
391
+ print()
392
+
393
+ # Detection
394
+ if skip_present_check:
395
+ detected = wirers
396
+ else:
397
+ detected = {n: w for n, w in wirers.items() if w.is_present()}
398
+
399
+ if not detected:
400
+ print(yellow("No agent frameworks detected on this machine."))
401
+ print()
402
+ print(dim("Looked for:"))
403
+ for name, w in wirers.items():
404
+ st = w.current_state()
405
+ loc = st.get("hermes_home") or st.get("settings_path")
406
+ print(f" {w.display_name}: {loc}")
407
+ print()
408
+ print(dim("To override detection, point setup at a custom path:"))
409
+ print(f" {cyan('sibyl setup --hermes-home /custom/path')}")
410
+ print(f" {cyan('sibyl setup --claude-settings /custom/settings.json')}")
411
+ print()
412
+ return 0
413
+
414
+ # Detection summary
415
+ print(dim("Detected:"))
416
+ for name, w in detected.items():
417
+ st = w.current_state()
418
+ loc = st.get("hermes_home") or st.get("settings_path")
419
+ print(f" {w.display_name} at {loc}")
420
+ print()
421
+
422
+ # Multi-framework picker
423
+ selected = list(detected.keys())
424
+ if len(detected) > 1 and not args.yes:
425
+ choices = ", ".join(f"[{w.initial}]{w.display_name}" for w in detected.values())
426
+ ans = input(
427
+ f"Wire which? {choices}, [a]ll, [n]one (default: all): "
428
+ ).strip().lower()
429
+ if ans in ("n", "none"):
430
+ print(dim("Skipping all."))
431
+ print()
432
+ return 0
433
+ elif ans in ("", "a", "all"):
434
+ pass
435
+ else:
436
+ picked = [n for n, w in detected.items() if w.initial == ans[:1]]
437
+ if not picked:
438
+ print(red(f"No match for '{ans}'. Aborting."))
439
+ return 1
440
+ selected = picked
441
+
442
+ # Per-stack execution
443
+ outcomes: list = []
444
+ prompt_fn = _accept_defaults_prompt if args.yes else _interactive_prompt
445
+
446
+ for name in selected:
447
+ wirer = detected[name]
448
+ st = wirer.current_state()
449
+
450
+ # Pre-prompt for fresh adds (interactive only). Already-wired and
451
+ # existing-other-provider are handled inside wire() itself.
452
+ if (
453
+ not args.yes
454
+ and not st.get("wired_with_sibyl")
455
+ and not st.get("memory_provider")
456
+ and not st.get("sibyl_mcp")
457
+ ):
458
+ if name == "hermes":
459
+ q = f"Set SIBYL as default memory provider in {wirer.display_name}?"
460
+ else:
461
+ q = f"Add SIBYL Memory as an MCP server in {wirer.display_name}?"
462
+ ans = _interactive_prompt(q, default="Y")
463
+ if ans != "y":
464
+ outcomes.append(WireOutcome(name, "skipped", "Declined by user."))
465
+ continue
466
+
467
+ outcomes.append(
468
+ wirer.wire(force=args.force, dry_run=args.dry_run, prompt_fn=prompt_fn)
469
+ )
470
+
471
+ # Report
472
+ print()
473
+ any_wired = False
474
+ for o in outcomes:
475
+ marker = {
476
+ "wired": green("✓"),
477
+ "already": green("·"),
478
+ "skipped": yellow("·"),
479
+ "dry-run": cyan("→"),
480
+ "error": red("✗"),
481
+ }.get(o.status, "?")
482
+ print(f" {marker} {o.name}: {o.message}")
483
+ if o.backup_path:
484
+ print(f" {dim('backup at')} {o.backup_path}")
485
+ if o.status == "wired":
486
+ any_wired = True
487
+ print()
488
+
489
+ if any_wired:
490
+ print(green("Restart your agent(s) to load the new memory provider."))
491
+ print()
492
+
493
+ return 0 if all(o.status != "error" for o in outcomes) else 2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibyl-memory-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Command-line interface for the Sibyl Memory Plugin. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats.
5
5
  Author-email: "SIBYL, Sibyl Labs LLC" <sibyl@sibyllabs.org>
6
6
  License: MIT
@@ -18,8 +18,9 @@ Classifier: Environment :: Console
18
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
19
  Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
- Requires-Dist: sibyl-memory-client>=0.3.3
22
- Requires-Dist: sibyl-memory-hermes>=0.3.1
21
+ Requires-Dist: sibyl-memory-client>=0.4.0
22
+ Requires-Dist: sibyl-memory-hermes>=0.3.2
23
+ Requires-Dist: pyyaml>=6.0
23
24
  Provides-Extra: dev
24
25
  Requires-Dist: pytest>=7.0; extra == "dev"
25
26
 
@@ -3,9 +3,11 @@ pyproject.toml
3
3
  src/sibyl_memory_cli/__init__.py
4
4
  src/sibyl_memory_cli/_banner.py
5
5
  src/sibyl_memory_cli/cli.py
6
+ src/sibyl_memory_cli/setup.py
6
7
  src/sibyl_memory_cli.egg-info/PKG-INFO
7
8
  src/sibyl_memory_cli.egg-info/SOURCES.txt
8
9
  src/sibyl_memory_cli.egg-info/dependency_links.txt
9
10
  src/sibyl_memory_cli.egg-info/entry_points.txt
10
11
  src/sibyl_memory_cli.egg-info/requires.txt
11
- src/sibyl_memory_cli.egg-info/top_level.txt
12
+ src/sibyl_memory_cli.egg-info/top_level.txt
13
+ tests/test_setup.py
@@ -0,0 +1,6 @@
1
+ sibyl-memory-client>=0.4.0
2
+ sibyl-memory-hermes>=0.3.2
3
+ pyyaml>=6.0
4
+
5
+ [dev]
6
+ pytest>=7.0
@@ -0,0 +1,381 @@
1
+ """Tests for the v0.1.4 `sibyl setup` command.
2
+
3
+ Covers:
4
+ - Hermes wirer: fresh, existing-no-memory, existing-sibyl, existing-other-provider,
5
+ force-overwrite, dry-run, plugin-install side-effect.
6
+ - Claude Code wirer: fresh-no-file, fresh-with-other-mcps, existing-sibyl,
7
+ existing-sibyl-mismatch, force-overwrite, dry-run.
8
+ - Detection: is_present logic for both wirers.
9
+ - Outcomes: WireOutcome status field correctness.
10
+ - Atomic writes + backup files land at the expected paths.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+ from unittest.mock import patch
19
+
20
+ import pytest
21
+
22
+ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
23
+
24
+ from sibyl_memory_cli.setup import ( # noqa: E402
25
+ ALL_WIRERS,
26
+ ClaudeCodeWirer,
27
+ HermesWirer,
28
+ WireOutcome,
29
+ _accept_defaults_prompt,
30
+ _interactive_prompt,
31
+ )
32
+
33
+
34
+ # ----------------------------------------------------------------------
35
+ # Helpers
36
+ # ----------------------------------------------------------------------
37
+
38
+ def _stub_install_plugin(hermes_home: str):
39
+ """Replacement for sibyl_memory_hermes.install_plugin.install — drops a fake
40
+ adapter file so the wirer sees plugin_installed=True afterwards."""
41
+ plugin_dir = Path(hermes_home) / "plugins" / "sibyl"
42
+ plugin_dir.mkdir(parents=True, exist_ok=True)
43
+ (plugin_dir / "__init__.py").write_text("# stub plugin\n")
44
+
45
+
46
+ # ----------------------------------------------------------------------
47
+ # WireOutcome basics
48
+ # ----------------------------------------------------------------------
49
+
50
+ def test_outcome_dataclass_basic():
51
+ o = WireOutcome("hermes", "wired", "test")
52
+ assert o.name == "hermes" and o.status == "wired" and o.backup_path is None
53
+ o2 = WireOutcome("claude-code", "skipped", "no", backup_path=Path("/tmp/x.bak"))
54
+ assert o2.backup_path == Path("/tmp/x.bak")
55
+
56
+
57
+ # ----------------------------------------------------------------------
58
+ # Prompt helpers
59
+ # ----------------------------------------------------------------------
60
+
61
+ def test_interactive_prompt_default_y_empty_input(monkeypatch):
62
+ monkeypatch.setattr("builtins.input", lambda _: "")
63
+ assert _interactive_prompt("Q?", default="Y") == "y"
64
+
65
+
66
+ def test_interactive_prompt_default_n_empty_input(monkeypatch):
67
+ monkeypatch.setattr("builtins.input", lambda _: "")
68
+ assert _interactive_prompt("Q?", default="N") == "n"
69
+
70
+
71
+ def test_interactive_prompt_explicit_y(monkeypatch):
72
+ monkeypatch.setattr("builtins.input", lambda _: "y")
73
+ assert _interactive_prompt("Q?", default="N") == "y"
74
+
75
+
76
+ def test_interactive_prompt_explicit_n(monkeypatch):
77
+ monkeypatch.setattr("builtins.input", lambda _: "no")
78
+ assert _interactive_prompt("Q?", default="Y") == "n"
79
+
80
+
81
+ def test_accept_defaults_prompt_returns_default():
82
+ assert _accept_defaults_prompt("Q?", default="Y") == "y"
83
+ assert _accept_defaults_prompt("Q?", default="N") == "n"
84
+
85
+
86
+ # ----------------------------------------------------------------------
87
+ # HermesWirer
88
+ # ----------------------------------------------------------------------
89
+
90
+ def test_hermes_wirer_auto_home_env(monkeypatch, tmp_path):
91
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path / "custom-hermes"))
92
+ w = HermesWirer()
93
+ assert w.hermes_home == tmp_path / "custom-hermes"
94
+
95
+
96
+ def test_hermes_wirer_auto_home_default(monkeypatch):
97
+ monkeypatch.delenv("HERMES_HOME", raising=False)
98
+ w = HermesWirer()
99
+ assert w.hermes_home == Path.home() / ".hermes"
100
+
101
+
102
+ def test_hermes_is_present_false_when_no_dir_no_bin(monkeypatch, tmp_path):
103
+ monkeypatch.delenv("HERMES_HOME", raising=False)
104
+ w = HermesWirer(hermes_home=tmp_path / "nope")
105
+ with patch("sibyl_memory_cli.setup.shutil.which", return_value=None):
106
+ assert not w.is_present()
107
+
108
+
109
+ def test_hermes_is_present_true_when_dir_exists(tmp_path):
110
+ (tmp_path / "hermes-home").mkdir()
111
+ w = HermesWirer(hermes_home=tmp_path / "hermes-home")
112
+ assert w.is_present()
113
+
114
+
115
+ def test_hermes_state_fresh(tmp_path):
116
+ w = HermesWirer(hermes_home=tmp_path / "hermes-home")
117
+ st = w.current_state()
118
+ assert st["config_exists"] is False
119
+ assert st["plugin_installed"] is False
120
+ assert st["memory_provider"] is None
121
+ assert st["wired_with_sibyl"] is False
122
+
123
+
124
+ def test_hermes_state_existing_sibyl(tmp_path):
125
+ home = tmp_path / "hermes-home"
126
+ home.mkdir()
127
+ (home / "config.yaml").write_text("memory:\n provider: sibyl\n")
128
+ w = HermesWirer(hermes_home=home)
129
+ st = w.current_state()
130
+ assert st["memory_provider"] == "sibyl"
131
+ assert st["wired_with_sibyl"] is True
132
+
133
+
134
+ def test_hermes_state_existing_other_provider(tmp_path):
135
+ home = tmp_path / "hermes-home"
136
+ home.mkdir()
137
+ (home / "config.yaml").write_text("memory:\n provider: mem0\nother: thing\n")
138
+ w = HermesWirer(hermes_home=home)
139
+ st = w.current_state()
140
+ assert st["memory_provider"] == "mem0"
141
+ assert st["wired_with_sibyl"] is False
142
+
143
+
144
+ def test_hermes_wire_fresh_creates_config_and_installs_plugin(tmp_path, monkeypatch):
145
+ home = tmp_path / "hermes-home"
146
+ home.mkdir()
147
+ w = HermesWirer(hermes_home=home)
148
+ # Stub the install_plugin import via the wirer's _install_plugin override
149
+ monkeypatch.setattr(
150
+ HermesWirer, "_install_plugin",
151
+ lambda self: _stub_install_plugin(str(self.hermes_home)),
152
+ )
153
+ outcome = w.wire()
154
+ assert outcome.status == "wired"
155
+ # Config now has memory.provider: sibyl
156
+ import yaml
157
+ cfg = yaml.safe_load((home / "config.yaml").read_text())
158
+ assert cfg == {"memory": {"provider": "sibyl"}}
159
+ # Plugin "installed" (stub created the file)
160
+ assert (home / "plugins" / "sibyl" / "__init__.py").exists()
161
+
162
+
163
+ def test_hermes_wire_existing_sibyl_is_noop(tmp_path, monkeypatch):
164
+ home = tmp_path / "hermes-home"
165
+ home.mkdir()
166
+ (home / "config.yaml").write_text("memory:\n provider: sibyl\n")
167
+ # Also pre-install the plugin so the noop path is true end-to-end
168
+ (home / "plugins" / "sibyl").mkdir(parents=True)
169
+ (home / "plugins" / "sibyl" / "__init__.py").write_text("# stub\n")
170
+ w = HermesWirer(hermes_home=home)
171
+ outcome = w.wire()
172
+ assert outcome.status == "already"
173
+
174
+
175
+ def test_hermes_wire_existing_other_provider_refused_without_force(tmp_path, monkeypatch):
176
+ home = tmp_path / "hermes-home"
177
+ home.mkdir()
178
+ (home / "config.yaml").write_text("memory:\n provider: mem0\n")
179
+ monkeypatch.setattr(
180
+ HermesWirer, "_install_plugin",
181
+ lambda self: _stub_install_plugin(str(self.hermes_home)),
182
+ )
183
+ w = HermesWirer(hermes_home=home)
184
+ # No prompt_fn means non-interactive refusal
185
+ outcome = w.wire()
186
+ assert outcome.status == "skipped"
187
+ # Config UNCHANGED
188
+ assert "mem0" in (home / "config.yaml").read_text()
189
+
190
+
191
+ def test_hermes_wire_existing_other_provider_with_force(tmp_path, monkeypatch):
192
+ home = tmp_path / "hermes-home"
193
+ home.mkdir()
194
+ (home / "config.yaml").write_text("memory:\n provider: mem0\n")
195
+ monkeypatch.setattr(
196
+ HermesWirer, "_install_plugin",
197
+ lambda self: _stub_install_plugin(str(self.hermes_home)),
198
+ )
199
+ w = HermesWirer(hermes_home=home)
200
+ outcome = w.wire(force=True)
201
+ assert outcome.status == "wired"
202
+ import yaml
203
+ cfg = yaml.safe_load((home / "config.yaml").read_text())
204
+ assert cfg["memory"]["provider"] == "sibyl"
205
+ # Backup landed
206
+ assert (home / "config.yaml.bak").exists()
207
+ assert "mem0" in (home / "config.yaml.bak").read_text()
208
+
209
+
210
+ def test_hermes_wire_existing_other_provider_prompt_y_accepts(tmp_path, monkeypatch):
211
+ home = tmp_path / "hermes-home"
212
+ home.mkdir()
213
+ (home / "config.yaml").write_text("memory:\n provider: mem0\n")
214
+ monkeypatch.setattr(
215
+ HermesWirer, "_install_plugin",
216
+ lambda self: _stub_install_plugin(str(self.hermes_home)),
217
+ )
218
+ w = HermesWirer(hermes_home=home)
219
+ outcome = w.wire(prompt_fn=lambda q, *, default: "y")
220
+ assert outcome.status == "wired"
221
+
222
+
223
+ def test_hermes_wire_dry_run_no_writes(tmp_path):
224
+ home = tmp_path / "hermes-home"
225
+ home.mkdir()
226
+ w = HermesWirer(hermes_home=home)
227
+ outcome = w.wire(dry_run=True)
228
+ assert outcome.status == "dry-run"
229
+ assert not (home / "config.yaml").exists()
230
+ assert not (home / "plugins" / "sibyl" / "__init__.py").exists()
231
+
232
+
233
+ def test_hermes_wire_preserves_other_top_level_keys(tmp_path, monkeypatch):
234
+ home = tmp_path / "hermes-home"
235
+ home.mkdir()
236
+ (home / "config.yaml").write_text(
237
+ "model:\n name: gpt-4\ntools:\n - search\n - file\n"
238
+ )
239
+ monkeypatch.setattr(
240
+ HermesWirer, "_install_plugin",
241
+ lambda self: _stub_install_plugin(str(self.hermes_home)),
242
+ )
243
+ w = HermesWirer(hermes_home=home)
244
+ w.wire()
245
+ import yaml
246
+ cfg = yaml.safe_load((home / "config.yaml").read_text())
247
+ assert cfg["model"]["name"] == "gpt-4"
248
+ assert cfg["tools"] == ["search", "file"]
249
+ assert cfg["memory"]["provider"] == "sibyl"
250
+
251
+
252
+ # ----------------------------------------------------------------------
253
+ # ClaudeCodeWirer
254
+ # ----------------------------------------------------------------------
255
+
256
+ def test_claude_is_present_false_when_no_settings_no_bin(monkeypatch, tmp_path):
257
+ w = ClaudeCodeWirer(settings_path=tmp_path / "no.json")
258
+ with patch("sibyl_memory_cli.setup.shutil.which", return_value=None):
259
+ assert not w.is_present()
260
+
261
+
262
+ def test_claude_is_present_true_when_settings_exists(tmp_path):
263
+ p = tmp_path / "settings.json"
264
+ p.write_text("{}")
265
+ w = ClaudeCodeWirer(settings_path=p)
266
+ assert w.is_present()
267
+
268
+
269
+ def test_claude_state_fresh(tmp_path):
270
+ w = ClaudeCodeWirer(settings_path=tmp_path / "settings.json")
271
+ st = w.current_state()
272
+ assert st["settings_exists"] is False
273
+ assert st["mcp_servers_count"] == 0
274
+ assert st["sibyl_mcp"] is None
275
+ assert st["wired_with_sibyl"] is False
276
+
277
+
278
+ def test_claude_state_existing_sibyl(tmp_path):
279
+ p = tmp_path / "settings.json"
280
+ p.write_text(json.dumps({"mcpServers": {"sibyl-memory": {"command": "sibyl-memory-mcp"}}}))
281
+ w = ClaudeCodeWirer(settings_path=p)
282
+ st = w.current_state()
283
+ assert st["wired_with_sibyl"] is True
284
+
285
+
286
+ def test_claude_state_existing_other_mcps_no_sibyl(tmp_path):
287
+ p = tmp_path / "settings.json"
288
+ p.write_text(json.dumps({
289
+ "mcpServers": {"github": {"command": "gh-mcp"}, "filesystem": {"command": "fs-mcp"}}
290
+ }))
291
+ w = ClaudeCodeWirer(settings_path=p)
292
+ st = w.current_state()
293
+ assert st["mcp_servers_count"] == 2
294
+ assert st["sibyl_mcp"] is None
295
+ assert st["wired_with_sibyl"] is False
296
+
297
+
298
+ def test_claude_wire_fresh_no_settings_creates(tmp_path):
299
+ p = tmp_path / "subdir" / "settings.json" # parent doesn't exist yet
300
+ w = ClaudeCodeWirer(settings_path=p)
301
+ outcome = w.wire()
302
+ assert outcome.status == "wired"
303
+ cfg = json.loads(p.read_text())
304
+ assert cfg["mcpServers"]["sibyl-memory"] == {"command": "sibyl-memory-mcp"}
305
+
306
+
307
+ def test_claude_wire_fresh_preserves_other_mcps(tmp_path):
308
+ p = tmp_path / "settings.json"
309
+ p.write_text(json.dumps({
310
+ "mcpServers": {"github": {"command": "gh-mcp"}},
311
+ "theme": "dark",
312
+ }))
313
+ w = ClaudeCodeWirer(settings_path=p)
314
+ outcome = w.wire()
315
+ assert outcome.status == "wired"
316
+ cfg = json.loads(p.read_text())
317
+ assert cfg["mcpServers"]["github"] == {"command": "gh-mcp"}
318
+ assert cfg["mcpServers"]["sibyl-memory"] == {"command": "sibyl-memory-mcp"}
319
+ assert cfg["theme"] == "dark"
320
+ # backup landed
321
+ assert (tmp_path / "settings.json.bak").exists()
322
+
323
+
324
+ def test_claude_wire_existing_sibyl_is_noop(tmp_path):
325
+ p = tmp_path / "settings.json"
326
+ p.write_text(json.dumps({"mcpServers": {"sibyl-memory": {"command": "sibyl-memory-mcp"}}}))
327
+ w = ClaudeCodeWirer(settings_path=p)
328
+ outcome = w.wire()
329
+ assert outcome.status == "already"
330
+ # No backup written for no-op
331
+ assert not (tmp_path / "settings.json.bak").exists()
332
+
333
+
334
+ def test_claude_wire_mismatched_sibyl_refused_without_force(tmp_path):
335
+ p = tmp_path / "settings.json"
336
+ # sibyl-memory key exists but command is different
337
+ p.write_text(json.dumps({"mcpServers": {"sibyl-memory": {"command": "/some/other/path"}}}))
338
+ w = ClaudeCodeWirer(settings_path=p)
339
+ outcome = w.wire()
340
+ assert outcome.status == "skipped"
341
+ # File UNCHANGED
342
+ assert "/some/other/path" in p.read_text()
343
+
344
+
345
+ def test_claude_wire_mismatched_sibyl_with_force(tmp_path):
346
+ p = tmp_path / "settings.json"
347
+ p.write_text(json.dumps({"mcpServers": {"sibyl-memory": {"command": "/some/other/path"}}}))
348
+ w = ClaudeCodeWirer(settings_path=p)
349
+ outcome = w.wire(force=True)
350
+ assert outcome.status == "wired"
351
+ cfg = json.loads(p.read_text())
352
+ assert cfg["mcpServers"]["sibyl-memory"]["command"] == "sibyl-memory-mcp"
353
+
354
+
355
+ def test_claude_wire_dry_run_no_writes(tmp_path):
356
+ p = tmp_path / "settings.json"
357
+ w = ClaudeCodeWirer(settings_path=p)
358
+ outcome = w.wire(dry_run=True)
359
+ assert outcome.status == "dry-run"
360
+ assert not p.exists()
361
+
362
+
363
+ def test_claude_wire_mismatched_dry_run(tmp_path):
364
+ p = tmp_path / "settings.json"
365
+ p.write_text(json.dumps({"mcpServers": {"sibyl-memory": {"command": "/old"}}}))
366
+ w = ClaudeCodeWirer(settings_path=p, )
367
+ outcome = w.wire(dry_run=True, force=True)
368
+ assert outcome.status == "dry-run"
369
+ assert "update" in outcome.message
370
+ # Still no write
371
+ assert "/old" in p.read_text()
372
+
373
+
374
+ # ----------------------------------------------------------------------
375
+ # Registry
376
+ # ----------------------------------------------------------------------
377
+
378
+ def test_registry_has_both_wirers():
379
+ assert set(ALL_WIRERS) == {"hermes", "claude-code"}
380
+ assert ALL_WIRERS["hermes"] is HermesWirer
381
+ assert ALL_WIRERS["claude-code"] is ClaudeCodeWirer
@@ -1,5 +0,0 @@
1
- sibyl-memory-client>=0.3.3
2
- sibyl-memory-hermes>=0.3.1
3
-
4
- [dev]
5
- pytest>=7.0