File size: 7,724 Bytes
c5a913d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ce9684e
c5a913d
 
 
ce9684e
 
 
 
c5a913d
ce9684e
 
c5a913d
 
ce9684e
c5a913d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Tests for the bug fix: Claude Code MCP registration via `claude mcp add` (not the
stale settings.json), and Codex auto-wiring its config.toml. The Claude CLI is fully
MOCKED here — no test ever runs the real `claude mcp` against this machine's config."""
import sys
from pathlib import Path

import pytest

from sibyl_memory_cli import setup as S
from sibyl_memory_cli.setup import ClaudeCodeWirer, CodexWirer, ALL_WIRERS


def _with_cli(monkeypatch):
    monkeypatch.setattr(ClaudeCodeWirer, "_claude_cli", staticmethod(lambda: "/usr/bin/claude"))


def _mock_run(monkeypatch, *, get_rc=1, add_rc=0, add_err="boom"):
    calls = []
    state = {"added": False}
    def fake(cmd, *, timeout=20.0):
        calls.append(cmd)
        if cmd[:3] == ["claude", "mcp", "get"]:
            # Model real CLI state: once a successful `add` has run the server is
            # registered, so the post-wire verification `get` succeeds. Before that
            # it returns get_rc (1 = not yet registered).
            return (0, "", "") if state["added"] else (get_rc, "", "")
        if cmd[:3] == ["claude", "mcp", "add"]:
            if add_rc == 0:
                state["added"] = True
            return (add_rc, "", "" if add_rc == 0 else add_err)
        if cmd[:3] == ["claude", "mcp", "remove"]:
            state["added"] = False
            return (0, "", "")
        return (0, "", "")
    monkeypatch.setattr(S, "_run", fake)
    return calls


# ---------------------------------------------------------------- Claude CLI path

def test_claude_wire_uses_mcp_add_user_scope(monkeypatch):
    _with_cli(monkeypatch)
    monkeypatch.setattr(ClaudeCodeWirer, "_ensure_mcp_binary", lambda self, **k: True)
    calls = _mock_run(monkeypatch, get_rc=1, add_rc=0)            # not registered -> add
    out = ClaudeCodeWirer().wire()
    assert out.status == "wired"
    add = [c for c in calls if c[:3] == ["claude", "mcp", "add"]]
    assert len(add) == 1
    c = add[0]
    assert c[:6] == ["claude", "mcp", "add", "--scope", "user", "sibyl-memory"]
    assert c[6] == "--" and c[7].endswith("sibyl-memory-mcp")   # resolved abspath or bare


def test_claude_wire_already_registered_is_noop(monkeypatch):
    _with_cli(monkeypatch)
    monkeypatch.setattr(ClaudeCodeWirer, "_ensure_mcp_binary", lambda self, **k: True)
    calls = _mock_run(monkeypatch, get_rc=0)                      # get -> registered
    out = ClaudeCodeWirer().wire()
    assert out.status == "already"
    assert not [c for c in calls if c[:3] == ["claude", "mcp", "add"]]


def test_claude_wire_force_reregisters(monkeypatch):
    _with_cli(monkeypatch)
    monkeypatch.setattr(ClaudeCodeWirer, "_ensure_mcp_binary", lambda self, **k: True)
    calls = _mock_run(monkeypatch, get_rc=0, add_rc=0)
    out = ClaudeCodeWirer().wire(force=True)
    assert out.status == "wired"
    assert any(c[:3] == ["claude", "mcp", "remove"] for c in calls)
    assert any(c[:3] == ["claude", "mcp", "add"] for c in calls)


def test_claude_wire_add_failure_is_error(monkeypatch):
    _with_cli(monkeypatch)
    monkeypatch.setattr(ClaudeCodeWirer, "_ensure_mcp_binary", lambda self, **k: True)
    _mock_run(monkeypatch, get_rc=1, add_rc=2, add_err="permission denied")
    out = ClaudeCodeWirer().wire()
    assert out.status == "error" and "permission denied" in out.message


def test_claude_wire_dry_run_does_not_add(monkeypatch):
    _with_cli(monkeypatch)
    monkeypatch.setattr(ClaudeCodeWirer, "_ensure_mcp_binary", lambda self, **k: True)
    calls = _mock_run(monkeypatch, get_rc=1)
    out = ClaudeCodeWirer().wire(dry_run=True)
    assert out.status == "dry-run" and "claude mcp add" in out.message
    assert not [c for c in calls if c[:3] == ["claude", "mcp", "add"]]


def test_claude_wire_binary_missing_errors(monkeypatch):
    _with_cli(monkeypatch)
    monkeypatch.setattr(ClaudeCodeWirer, "_ensure_mcp_binary", lambda self, **k: False)
    _mock_run(monkeypatch, get_rc=1)
    out = ClaudeCodeWirer().wire()
    assert out.status == "error" and "not on PATH" in out.message


def test_claude_current_state_reflects_cli(monkeypatch):
    _with_cli(monkeypatch)
    monkeypatch.setattr(ClaudeCodeWirer, "_mcp_binary_found", lambda self: True)
    _mock_run(monkeypatch, get_rc=0)
    st = ClaudeCodeWirer().current_state()
    assert st["claude_cli"] is True and st["cli_registered"] is True and st["wired_with_sibyl"] is True
    _mock_run(monkeypatch, get_rc=1)
    assert ClaudeCodeWirer().current_state()["wired_with_sibyl"] is False


def test_claude_no_cli_falls_back_to_settings(tmp_path):
    # autouse conftest already forces no-CLI -> settings.json path
    p = tmp_path / "settings.json"
    out = ClaudeCodeWirer(settings_path=p).wire()
    assert out.status in ("wired", "error")     # wired if binary present
    if out.status == "wired":
        import json
        assert "sibyl-memory" in json.loads(p.read_text())["mcpServers"]


# ---------------------------------------------------------------- Codex auto-wire

def test_codex_in_registry():
    assert "codex" in ALL_WIRERS and ALL_WIRERS["codex"] is CodexWirer


def test_codex_wire_fresh_creates_config(tmp_path):
    cfg = tmp_path / ".codex" / "config.toml"
    out = CodexWirer(config_path=cfg).wire()
    assert out.status == "wired" and out.backup_path is None
    txt = cfg.read_text()
    assert "[mcp_servers.sibyl_memory]" in txt
    # command is the RESOLVED absolute path (or bare name fallback) — matches
    # codex's own `mcp add` behavior; never connect-fails on spawn PATH.
    cmd_line = [l for l in txt.splitlines() if l.startswith("command = ")][0]
    assert cmd_line.rstrip().endswith('sibyl-memory-mcp"')


def test_codex_wire_appends_and_preserves(tmp_path):
    cfg = tmp_path / "config.toml"
    cfg.write_text('model = "o4"\n[other]\nx = 1\n')
    out = CodexWirer(config_path=cfg).wire()
    assert out.status == "wired" and out.backup_path is not None
    txt = cfg.read_text()
    assert 'model = "o4"' in txt and "[other]" in txt           # preserved
    assert "[mcp_servers.sibyl_memory]" in txt                  # appended
    assert cfg.with_suffix(".toml.bak").exists()


def test_codex_wire_idempotent(tmp_path):
    cfg = tmp_path / "config.toml"; cfg.write_text('model = "o4"\n')
    CodexWirer(config_path=cfg).wire()
    after_first = cfg.read_text()
    out2 = CodexWirer(config_path=cfg).wire()
    assert out2.status == "already" and cfg.read_text() == after_first   # no double-append


def test_codex_wire_dry_run_untouched(tmp_path):
    cfg = tmp_path / "config.toml"; cfg.write_text('model = "o4"\n')
    out = CodexWirer(config_path=cfg).wire(dry_run=True)
    assert out.status == "dry-run" and "[mcp_servers.sibyl_memory]" not in cfg.read_text()


def test_codex_result_is_valid_toml(tmp_path):
    cfg = tmp_path / "config.toml"; cfg.write_text('model = "o4"\nfoo = "bar"\n')
    CodexWirer(config_path=cfg).wire()
    try:
        import tomllib
        parsed = tomllib.loads(cfg.read_text())
        assert parsed["mcp_servers"]["sibyl_memory"]["command"].endswith("sibyl-memory-mcp")
        assert parsed["model"] == "o4"
    except ModuleNotFoundError:
        pytest.skip("tomllib not available (<3.11)")


def test_codex_binary_missing_errors(tmp_path, monkeypatch):
    cfg = tmp_path / "config.toml"; cfg.write_text("model='o4'\n")
    monkeypatch.setattr(CodexWirer, "_mcp_binary_found", lambda self: False)
    monkeypatch.setattr(S.subprocess, "check_call", lambda *a, **k: None)  # pip install no-op
    out = CodexWirer(config_path=cfg).wire()
    assert out.status == "error" and "not on PATH" in out.message
    assert "[mcp_servers.sibyl_memory]" not in cfg.read_text()   # not written on error