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.
- {sibyl_memory_cli-0.1.2 → sibyl_memory_cli-0.1.4}/PKG-INFO +4 -3
- {sibyl_memory_cli-0.1.2 → sibyl_memory_cli-0.1.4}/pyproject.toml +4 -3
- {sibyl_memory_cli-0.1.2 → sibyl_memory_cli-0.1.4}/src/sibyl_memory_cli/cli.py +32 -0
- sibyl_memory_cli-0.1.4/src/sibyl_memory_cli/setup.py +493 -0
- {sibyl_memory_cli-0.1.2 → sibyl_memory_cli-0.1.4}/src/sibyl_memory_cli.egg-info/PKG-INFO +4 -3
- {sibyl_memory_cli-0.1.2 → sibyl_memory_cli-0.1.4}/src/sibyl_memory_cli.egg-info/SOURCES.txt +3 -1
- sibyl_memory_cli-0.1.4/src/sibyl_memory_cli.egg-info/requires.txt +6 -0
- sibyl_memory_cli-0.1.4/tests/test_setup.py +381 -0
- sibyl_memory_cli-0.1.2/src/sibyl_memory_cli.egg-info/requires.txt +0 -5
- {sibyl_memory_cli-0.1.2 → sibyl_memory_cli-0.1.4}/README.md +0 -0
- {sibyl_memory_cli-0.1.2 → sibyl_memory_cli-0.1.4}/setup.cfg +0 -0
- {sibyl_memory_cli-0.1.2 → sibyl_memory_cli-0.1.4}/src/sibyl_memory_cli/__init__.py +0 -0
- {sibyl_memory_cli-0.1.2 → sibyl_memory_cli-0.1.4}/src/sibyl_memory_cli/_banner.py +0 -0
- {sibyl_memory_cli-0.1.2 → sibyl_memory_cli-0.1.4}/src/sibyl_memory_cli.egg-info/dependency_links.txt +0 -0
- {sibyl_memory_cli-0.1.2 → sibyl_memory_cli-0.1.4}/src/sibyl_memory_cli.egg-info/entry_points.txt +0 -0
- {sibyl_memory_cli-0.1.2 → sibyl_memory_cli-0.1.4}/src/sibyl_memory_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sibyl-memory-cli
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
22
|
-
Requires-Dist: sibyl-memory-hermes>=0.3.
|
|
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.
|
|
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.
|
|
27
|
-
"sibyl-memory-hermes>=0.3.
|
|
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.
|
|
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.
|
|
22
|
-
Requires-Dist: sibyl-memory-hermes>=0.3.
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sibyl_memory_cli-0.1.2 → sibyl_memory_cli-0.1.4}/src/sibyl_memory_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{sibyl_memory_cli-0.1.2 → sibyl_memory_cli-0.1.4}/src/sibyl_memory_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{sibyl_memory_cli-0.1.2 → sibyl_memory_cli-0.1.4}/src/sibyl_memory_cli.egg-info/top_level.txt
RENAMED
|
File without changes
|