File size: 13,631 Bytes
2cf7040
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b2101ae
2cf7040
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c5a913d
 
2cf7040
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
"""Tests for the v0.1.4 `sibyl setup` command.

Covers:
- Hermes wirer: fresh, existing-no-memory, existing-sibyl, existing-other-provider,
  force-overwrite, dry-run, plugin-install side-effect.
- Claude Code wirer: fresh-no-file, fresh-with-other-mcps, existing-sibyl,
  existing-sibyl-mismatch, force-overwrite, dry-run.
- Detection: is_present logic for both wirers.
- Outcomes: WireOutcome status field correctness.
- Atomic writes + backup files land at the expected paths.
"""
from __future__ import annotations

import json
import os
import sys
from pathlib import Path
from unittest.mock import patch

import pytest

sys.path.insert(0, str(Path(__file__).parent.parent / "src"))

from sibyl_memory_cli.setup import (  # noqa: E402
    ALL_WIRERS,
    ClaudeCodeWirer,
    HermesWirer,
    WireOutcome,
    _accept_defaults_prompt,
    _interactive_prompt,
)


# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------

def _stub_install_plugin(hermes_home: str):
    """Replacement for sibyl_memory_hermes.install_plugin.install: drops a fake
    adapter file so the wirer sees plugin_installed=True afterwards."""
    plugin_dir = Path(hermes_home) / "plugins" / "sibyl"
    plugin_dir.mkdir(parents=True, exist_ok=True)
    (plugin_dir / "__init__.py").write_text("# stub plugin\n")


# ----------------------------------------------------------------------
# WireOutcome basics
# ----------------------------------------------------------------------

def test_outcome_dataclass_basic():
    o = WireOutcome("hermes", "wired", "test")
    assert o.name == "hermes" and o.status == "wired" and o.backup_path is None
    o2 = WireOutcome("claude-code", "skipped", "no", backup_path=Path("/tmp/x.bak"))
    assert o2.backup_path == Path("/tmp/x.bak")


# ----------------------------------------------------------------------
# Prompt helpers
# ----------------------------------------------------------------------

def test_interactive_prompt_default_y_empty_input(monkeypatch):
    monkeypatch.setattr("builtins.input", lambda _: "")
    assert _interactive_prompt("Q?", default="Y") == "y"


def test_interactive_prompt_default_n_empty_input(monkeypatch):
    monkeypatch.setattr("builtins.input", lambda _: "")
    assert _interactive_prompt("Q?", default="N") == "n"


def test_interactive_prompt_explicit_y(monkeypatch):
    monkeypatch.setattr("builtins.input", lambda _: "y")
    assert _interactive_prompt("Q?", default="N") == "y"


def test_interactive_prompt_explicit_n(monkeypatch):
    monkeypatch.setattr("builtins.input", lambda _: "no")
    assert _interactive_prompt("Q?", default="Y") == "n"


def test_accept_defaults_prompt_returns_default():
    assert _accept_defaults_prompt("Q?", default="Y") == "y"
    assert _accept_defaults_prompt("Q?", default="N") == "n"


# ----------------------------------------------------------------------
# HermesWirer
# ----------------------------------------------------------------------

def test_hermes_wirer_auto_home_env(monkeypatch, tmp_path):
    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "custom-hermes"))
    w = HermesWirer()
    assert w.hermes_home == tmp_path / "custom-hermes"


def test_hermes_wirer_auto_home_default(monkeypatch):
    monkeypatch.delenv("HERMES_HOME", raising=False)
    w = HermesWirer()
    assert w.hermes_home == Path.home() / ".hermes"


def test_hermes_is_present_false_when_no_dir_no_bin(monkeypatch, tmp_path):
    monkeypatch.delenv("HERMES_HOME", raising=False)
    w = HermesWirer(hermes_home=tmp_path / "nope")
    with patch("sibyl_memory_cli.setup.shutil.which", return_value=None):
        assert not w.is_present()


def test_hermes_is_present_true_when_dir_exists(tmp_path):
    (tmp_path / "hermes-home").mkdir()
    w = HermesWirer(hermes_home=tmp_path / "hermes-home")
    assert w.is_present()


def test_hermes_state_fresh(tmp_path):
    w = HermesWirer(hermes_home=tmp_path / "hermes-home")
    st = w.current_state()
    assert st["config_exists"] is False
    assert st["plugin_installed"] is False
    assert st["memory_provider"] is None
    assert st["wired_with_sibyl"] is False


def test_hermes_state_existing_sibyl(tmp_path):
    home = tmp_path / "hermes-home"
    home.mkdir()
    (home / "config.yaml").write_text("memory:\n  provider: sibyl\n")
    w = HermesWirer(hermes_home=home)
    st = w.current_state()
    assert st["memory_provider"] == "sibyl"
    assert st["wired_with_sibyl"] is True


def test_hermes_state_existing_other_provider(tmp_path):
    home = tmp_path / "hermes-home"
    home.mkdir()
    (home / "config.yaml").write_text("memory:\n  provider: mem0\nother: thing\n")
    w = HermesWirer(hermes_home=home)
    st = w.current_state()
    assert st["memory_provider"] == "mem0"
    assert st["wired_with_sibyl"] is False


def test_hermes_wire_fresh_creates_config_and_installs_plugin(tmp_path, monkeypatch):
    home = tmp_path / "hermes-home"
    home.mkdir()
    w = HermesWirer(hermes_home=home)
    # Stub the install_plugin import via the wirer's _install_plugin override
    monkeypatch.setattr(
        HermesWirer, "_install_plugin",
        lambda self: _stub_install_plugin(str(self.hermes_home)),
    )
    outcome = w.wire()
    assert outcome.status == "wired"
    # Config now has memory.provider: sibyl
    import yaml
    cfg = yaml.safe_load((home / "config.yaml").read_text())
    assert cfg == {"memory": {"provider": "sibyl"}}
    # Plugin "installed" (stub created the file)
    assert (home / "plugins" / "sibyl" / "__init__.py").exists()


def test_hermes_wire_existing_sibyl_is_noop(tmp_path, monkeypatch):
    home = tmp_path / "hermes-home"
    home.mkdir()
    (home / "config.yaml").write_text("memory:\n  provider: sibyl\n")
    # Also pre-install the plugin so the noop path is true end-to-end
    (home / "plugins" / "sibyl").mkdir(parents=True)
    (home / "plugins" / "sibyl" / "__init__.py").write_text("# stub\n")
    w = HermesWirer(hermes_home=home)
    outcome = w.wire()
    assert outcome.status == "already"


def test_hermes_wire_existing_other_provider_refused_without_force(tmp_path, monkeypatch):
    home = tmp_path / "hermes-home"
    home.mkdir()
    (home / "config.yaml").write_text("memory:\n  provider: mem0\n")
    monkeypatch.setattr(
        HermesWirer, "_install_plugin",
        lambda self: _stub_install_plugin(str(self.hermes_home)),
    )
    w = HermesWirer(hermes_home=home)
    # No prompt_fn means non-interactive refusal
    outcome = w.wire()
    assert outcome.status == "skipped"
    # Config UNCHANGED
    assert "mem0" in (home / "config.yaml").read_text()


def test_hermes_wire_existing_other_provider_with_force(tmp_path, monkeypatch):
    home = tmp_path / "hermes-home"
    home.mkdir()
    (home / "config.yaml").write_text("memory:\n  provider: mem0\n")
    monkeypatch.setattr(
        HermesWirer, "_install_plugin",
        lambda self: _stub_install_plugin(str(self.hermes_home)),
    )
    w = HermesWirer(hermes_home=home)
    outcome = w.wire(force=True)
    assert outcome.status == "wired"
    import yaml
    cfg = yaml.safe_load((home / "config.yaml").read_text())
    assert cfg["memory"]["provider"] == "sibyl"
    # Backup landed
    assert (home / "config.yaml.bak").exists()
    assert "mem0" in (home / "config.yaml.bak").read_text()


def test_hermes_wire_existing_other_provider_prompt_y_accepts(tmp_path, monkeypatch):
    home = tmp_path / "hermes-home"
    home.mkdir()
    (home / "config.yaml").write_text("memory:\n  provider: mem0\n")
    monkeypatch.setattr(
        HermesWirer, "_install_plugin",
        lambda self: _stub_install_plugin(str(self.hermes_home)),
    )
    w = HermesWirer(hermes_home=home)
    outcome = w.wire(prompt_fn=lambda q, *, default: "y")
    assert outcome.status == "wired"


def test_hermes_wire_dry_run_no_writes(tmp_path):
    home = tmp_path / "hermes-home"
    home.mkdir()
    w = HermesWirer(hermes_home=home)
    outcome = w.wire(dry_run=True)
    assert outcome.status == "dry-run"
    assert not (home / "config.yaml").exists()
    assert not (home / "plugins" / "sibyl" / "__init__.py").exists()


def test_hermes_wire_preserves_other_top_level_keys(tmp_path, monkeypatch):
    home = tmp_path / "hermes-home"
    home.mkdir()
    (home / "config.yaml").write_text(
        "model:\n  name: gpt-4\ntools:\n  - search\n  - file\n"
    )
    monkeypatch.setattr(
        HermesWirer, "_install_plugin",
        lambda self: _stub_install_plugin(str(self.hermes_home)),
    )
    w = HermesWirer(hermes_home=home)
    w.wire()
    import yaml
    cfg = yaml.safe_load((home / "config.yaml").read_text())
    assert cfg["model"]["name"] == "gpt-4"
    assert cfg["tools"] == ["search", "file"]
    assert cfg["memory"]["provider"] == "sibyl"


# ----------------------------------------------------------------------
# ClaudeCodeWirer
# ----------------------------------------------------------------------

def test_claude_is_present_false_when_no_settings_no_bin(monkeypatch, tmp_path):
    w = ClaudeCodeWirer(settings_path=tmp_path / "no.json")
    with patch("sibyl_memory_cli.setup.shutil.which", return_value=None):
        assert not w.is_present()


def test_claude_is_present_true_when_settings_exists(tmp_path):
    p = tmp_path / "settings.json"
    p.write_text("{}")
    w = ClaudeCodeWirer(settings_path=p)
    assert w.is_present()


def test_claude_state_fresh(tmp_path):
    w = ClaudeCodeWirer(settings_path=tmp_path / "settings.json")
    st = w.current_state()
    assert st["settings_exists"] is False
    assert st["mcp_servers_count"] == 0
    assert st["sibyl_mcp"] is None
    assert st["wired_with_sibyl"] is False


def test_claude_state_existing_sibyl(tmp_path):
    p = tmp_path / "settings.json"
    p.write_text(json.dumps({"mcpServers": {"sibyl-memory": {"command": "sibyl-memory-mcp"}}}))
    w = ClaudeCodeWirer(settings_path=p)
    st = w.current_state()
    assert st["wired_with_sibyl"] is True


def test_claude_state_existing_other_mcps_no_sibyl(tmp_path):
    p = tmp_path / "settings.json"
    p.write_text(json.dumps({
        "mcpServers": {"github": {"command": "gh-mcp"}, "filesystem": {"command": "fs-mcp"}}
    }))
    w = ClaudeCodeWirer(settings_path=p)
    st = w.current_state()
    assert st["mcp_servers_count"] == 2
    assert st["sibyl_mcp"] is None
    assert st["wired_with_sibyl"] is False


def test_claude_wire_fresh_no_settings_creates(tmp_path):
    p = tmp_path / "subdir" / "settings.json"  # parent doesn't exist yet
    w = ClaudeCodeWirer(settings_path=p)
    outcome = w.wire()
    assert outcome.status == "wired"
    cfg = json.loads(p.read_text())
    assert cfg["mcpServers"]["sibyl-memory"] == {"command": "sibyl-memory-mcp"}


def test_claude_wire_fresh_preserves_other_mcps(tmp_path):
    p = tmp_path / "settings.json"
    p.write_text(json.dumps({
        "mcpServers": {"github": {"command": "gh-mcp"}},
        "theme": "dark",
    }))
    w = ClaudeCodeWirer(settings_path=p)
    outcome = w.wire()
    assert outcome.status == "wired"
    cfg = json.loads(p.read_text())
    assert cfg["mcpServers"]["github"] == {"command": "gh-mcp"}
    assert cfg["mcpServers"]["sibyl-memory"] == {"command": "sibyl-memory-mcp"}
    assert cfg["theme"] == "dark"
    # backup landed
    assert (tmp_path / "settings.json.bak").exists()


def test_claude_wire_existing_sibyl_is_noop(tmp_path):
    p = tmp_path / "settings.json"
    p.write_text(json.dumps({"mcpServers": {"sibyl-memory": {"command": "sibyl-memory-mcp"}}}))
    w = ClaudeCodeWirer(settings_path=p)
    outcome = w.wire()
    assert outcome.status == "already"
    # No backup written for no-op
    assert not (tmp_path / "settings.json.bak").exists()


def test_claude_wire_mismatched_sibyl_refused_without_force(tmp_path):
    p = tmp_path / "settings.json"
    # sibyl-memory key exists but command is different
    p.write_text(json.dumps({"mcpServers": {"sibyl-memory": {"command": "/some/other/path"}}}))
    w = ClaudeCodeWirer(settings_path=p)
    outcome = w.wire()
    assert outcome.status == "skipped"
    # File UNCHANGED
    assert "/some/other/path" in p.read_text()


def test_claude_wire_mismatched_sibyl_with_force(tmp_path):
    p = tmp_path / "settings.json"
    p.write_text(json.dumps({"mcpServers": {"sibyl-memory": {"command": "/some/other/path"}}}))
    w = ClaudeCodeWirer(settings_path=p)
    outcome = w.wire(force=True)
    assert outcome.status == "wired"
    cfg = json.loads(p.read_text())
    assert cfg["mcpServers"]["sibyl-memory"]["command"] == "sibyl-memory-mcp"


def test_claude_wire_dry_run_no_writes(tmp_path):
    p = tmp_path / "settings.json"
    w = ClaudeCodeWirer(settings_path=p)
    outcome = w.wire(dry_run=True)
    assert outcome.status == "dry-run"
    assert not p.exists()


def test_claude_wire_mismatched_dry_run(tmp_path):
    p = tmp_path / "settings.json"
    p.write_text(json.dumps({"mcpServers": {"sibyl-memory": {"command": "/old"}}}))
    w = ClaudeCodeWirer(settings_path=p, )
    outcome = w.wire(dry_run=True, force=True)
    assert outcome.status == "dry-run"
    assert "update" in outcome.message
    # Still no write
    assert "/old" in p.read_text()


# ----------------------------------------------------------------------
# Registry
# ----------------------------------------------------------------------

def test_registry_has_all_wirers():
    assert set(ALL_WIRERS) == {"hermes", "claude-code", "codex"}
    assert ALL_WIRERS["hermes"] is HermesWirer
    assert ALL_WIRERS["claude-code"] is ClaudeCodeWirer