| """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 ( |
| ALL_WIRERS, |
| ClaudeCodeWirer, |
| HermesWirer, |
| WireOutcome, |
| _accept_defaults_prompt, |
| _interactive_prompt, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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") |
|
|
|
|
| |
| |
| |
|
|
| 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") |
|
|
|
|
| |
| |
| |
|
|
| 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" |
|
|
|
|
| |
| |
| |
|
|
| 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) |
| |
| monkeypatch.setattr( |
| HermesWirer, "_install_plugin", |
| lambda self: _stub_install_plugin(str(self.hermes_home)), |
| ) |
| outcome = w.wire() |
| assert outcome.status == "wired" |
| |
| import yaml |
| cfg = yaml.safe_load((home / "config.yaml").read_text()) |
| assert cfg == {"memory": {"provider": "sibyl"}} |
| |
| 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") |
| |
| (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) |
| |
| outcome = w.wire() |
| assert outcome.status == "skipped" |
| |
| 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" |
| |
| 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" |
|
|
|
|
| |
| |
| |
|
|
| 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" |
| 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" |
| |
| 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" |
| |
| assert not (tmp_path / "settings.json.bak").exists() |
|
|
|
|
| def test_claude_wire_mismatched_sibyl_refused_without_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() |
| assert outcome.status == "skipped" |
| |
| 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 |
| |
| assert "/old" in p.read_text() |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|