sibyllabs's picture
release: sibyl-memory-cli 0.3.9 (guided `sibyl migrate` + Codex + Claude MCP fix)
c5a913d
"""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