monash-study-kit 1.2.0__py3-none-any.whl

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.
@@ -0,0 +1,4 @@
1
+ """Monash 学习助手:把 Moodle 和 Ed 接进 Claude。"""
2
+ # 发新版就改这里(pyproject 从这里读):朋友那边的更新提示比的就是 main 上的这个号。
3
+ # 修 bug 加最后一位,加功能加中间一位。CI 会检查改了 src/ 的 PR 有没有改它。
4
+ __version__ = "1.2.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,213 @@
1
+ """专用登录窗口:用单独的浏览器配置目录打开 Moodle,登录完只从这个窗口里取 Moodle 的 cookie。
2
+
3
+ 为什么这样做:
4
+ * Monash 走 Okta SAML + MFA,命令行没法自己登录,只能让人在真浏览器里登。
5
+ * 不去读日常 Chrome 的 cookie:那要钥匙串里的解密密钥,能解开所有网站的登录状态。
6
+ 这里用的是 BROWSER_PROFILE 这个独立目录,里面只有你在这个窗口里登录过的东西。
7
+ * 独立目录会记住 Okta 的“保持登录”,所以下次过期时可以先在后台(无界面)试着自动登录,
8
+ Okta 那边还认你的话就不用再弹窗;不认才打开窗口让你登。
9
+
10
+ 浏览器:Chrome、Edge、Brave、Chromium 都行(Windows 自带 Edge)。`monash config browser <路径>` 可以指定。
11
+ Chrome 136 起不许对默认配置目录开远程调试,独立目录不受影响。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ import urllib.parse
21
+ from pathlib import Path
22
+
23
+ from .cdp import CDP, PipeCDP, WebSocketClosed
24
+ from .moodlelib import BASE_URL, HOST, MoodleAuthError, MoodleClient, save_cookies
25
+ from .paths import BROWSER, BROWSER_PROFILE, ensure_private_dir
26
+
27
+ KEEP = ("MoodleSession", "MOODLEID", "AWSALB", "AWSELB") # 只留 Moodle 自己要的,别存一堆统计 cookie
28
+
29
+
30
+ class LoginFailed(RuntimeError):
31
+ pass
32
+
33
+
34
+ def find_browser() -> str | None:
35
+ if BROWSER:
36
+ return BROWSER
37
+ if sys.platform == "darwin":
38
+ for app, exe in (("Google Chrome", "Google Chrome"), ("Microsoft Edge", "Microsoft Edge"),
39
+ ("Brave Browser", "Brave Browser"), ("Chromium", "Chromium")):
40
+ for root in (Path("/Applications"), Path.home() / "Applications"):
41
+ p = root / f"{app}.app" / "Contents" / "MacOS" / exe
42
+ if p.exists():
43
+ return str(p)
44
+ return None
45
+ if os.name == "nt":
46
+ roots = [os.environ.get(k) for k in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA")]
47
+ for rel in (r"Google\Chrome\Application\chrome.exe", r"Microsoft\Edge\Application\msedge.exe",
48
+ r"BraveSoftware\Brave-Browser\Application\brave.exe"):
49
+ for root in filter(None, roots):
50
+ p = Path(root) / rel
51
+ if p.exists():
52
+ return str(p)
53
+ return None
54
+ for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
55
+ "microsoft-edge", "brave-browser"):
56
+ if shutil.which(name):
57
+ return shutil.which(name)
58
+ return None
59
+
60
+
61
+ class Browser:
62
+ """一个用 BROWSER_PROFILE 启动、能用 CDP 控制的浏览器(POSIX 走管道,Windows 走本机端口)。"""
63
+
64
+ def __init__(self, url: str, headless: bool = False):
65
+ exe = find_browser()
66
+ if not exe:
67
+ raise LoginFailed("没找到 Chrome / Edge / Brave。装一个 Chrome,或者用 monash config browser <路径> 指定浏览器程序")
68
+ ensure_private_dir(BROWSER_PROFILE)
69
+ flags = ["--no-first-run", "--no-default-browser-check", "--disable-sync",
70
+ "--window-size=1100,860", "--new-window", url]
71
+ if headless:
72
+ flags.insert(0, "--headless=new")
73
+ if os.name != "nt":
74
+ # 不开端口:DevTools 走管道,登录窗口开着的时候本机别的程序也连不上它
75
+ self.cdp = PipeCDP([exe, f"--user-data-dir={BROWSER_PROFILE}", "--remote-debugging-pipe", *flags])
76
+ self.proc = self.cdp.proc
77
+ try:
78
+ self.cdp.call("Browser.getVersion", timeout=30)
79
+ except (OSError, RuntimeError, TimeoutError) as e:
80
+ self.proc.kill()
81
+ raise LoginFailed("浏览器没能启动(如果这个专用窗口已经开着,先把它关掉再试)") from e
82
+ return
83
+ # Windows:随机端口,端口号写在专用配置目录里(只有自己能读)
84
+ port_file = BROWSER_PROFILE / "DevToolsActivePort"
85
+ port_file.unlink(missing_ok=True)
86
+ self.proc = subprocess.Popen([exe, f"--user-data-dir={BROWSER_PROFILE}", "--remote-debugging-port=0", *flags],
87
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
88
+ deadline = time.monotonic() + 30
89
+ while time.monotonic() < deadline:
90
+ if port_file.exists():
91
+ lines = port_file.read_text(encoding="utf-8").split()
92
+ if len(lines) >= 2:
93
+ self.cdp = CDP(f"ws://127.0.0.1:{lines[0]}{lines[1]}")
94
+ return
95
+ if self.proc.poll() is not None:
96
+ break
97
+ time.sleep(0.2)
98
+ self.proc.kill()
99
+ raise LoginFailed("浏览器没能启动(如果这个专用窗口已经开着,先把它关掉再试)")
100
+
101
+ def cookies(self) -> dict[str, str]:
102
+ got = self.cdp.call("Storage.getCookies").get("cookies", [])
103
+ return {c["name"]: c["value"] for c in got
104
+ if _domain_matches(c.get("domain", "")) and c["name"].startswith(KEEP)}
105
+
106
+ def page_urls(self) -> list[str]:
107
+ infos = self.cdp.call("Target.getTargets").get("targetInfos", [])
108
+ return [t.get("url", "") for t in infos if t.get("type") == "page"]
109
+
110
+ def close(self) -> None:
111
+ try:
112
+ self.cdp.call("Browser.close", timeout=5)
113
+ except Exception: # noqa: BLE001
114
+ pass
115
+ try:
116
+ self.cdp.close()
117
+ except Exception: # noqa: BLE001
118
+ pass
119
+ try:
120
+ self.proc.wait(timeout=10)
121
+ except subprocess.TimeoutExpired:
122
+ self.proc.kill()
123
+
124
+
125
+ def _domain_matches(domain: str) -> bool:
126
+ """cookie 的 domain 是 Moodle 本站或它的上级域名。按点分段比,免得 "ash.edu" 也算进 monash.edu。"""
127
+ d = domain.lstrip(".").lower()
128
+ return bool(d) and (HOST == d or HOST.endswith("." + d))
129
+
130
+
131
+ def _on_moodle(urls: list[str]) -> bool:
132
+ """有页面停在 Moodle 本站、而且不是登录页,大概登录完了,值得验证一下。"""
133
+ for u in urls:
134
+ p = urllib.parse.urlsplit(u)
135
+ if p.hostname == HOST and not p.path.startswith(("/login", "/auth")):
136
+ return True
137
+ return False
138
+
139
+
140
+ def _on_okta(urls: list[str]) -> bool:
141
+ return any((urllib.parse.urlsplit(u).hostname or "").endswith("okta.com") for u in urls)
142
+
143
+
144
+ def login_moodle(interactive: bool = True, timeout: float = 300, log=print) -> dict:
145
+ """登录 Moodle 并保存 cookie。返回 {"userid", "time_remaining"}。
146
+
147
+ interactive=False:无界面试一次,靠专用目录里记住的 Okta 登录状态自动登进去;停在 Okta
148
+ 登录页超过 20 秒就算失败(调用方再决定要不要弹窗)。
149
+ """
150
+ browser = Browser(f"{BASE_URL}/my/", headless=not interactive)
151
+ if interactive:
152
+ log("已打开登录窗口:在里面登录 Monash(Okta + MFA),登录成功后窗口会自动关闭。")
153
+ started = time.monotonic()
154
+ tried: dict[str, float] = {} # 会话值 → 上次验证的时间;登录前的匿名会话也叫 MoodleSession
155
+ try:
156
+ while time.monotonic() - started < timeout:
157
+ time.sleep(1.5)
158
+ try:
159
+ urls = browser.page_urls()
160
+ jar = browser.cookies()
161
+ except (WebSocketClosed, OSError):
162
+ raise LoginFailed("登录窗口被关掉了,没有完成登录") from None
163
+ if not interactive and _on_okta(urls) and time.monotonic() - started > 20:
164
+ raise LoginFailed("自动登录不行(Okta 要重新输入密码或 MFA)")
165
+ session = jar.get("MoodleSession")
166
+ if not session or not _on_moodle(urls) or time.monotonic() - tried.get(session, -99) < 6:
167
+ continue
168
+ tried[session] = time.monotonic()
169
+ client = MoodleClient(dict(jar), persist=False)
170
+ try:
171
+ left = client.time_remaining()
172
+ except MoodleAuthError:
173
+ continue
174
+ save_cookies(client.jar)
175
+ _save_calendar_url(client, log)
176
+ return {"userid": client.userid, "time_remaining": left}
177
+ raise LoginFailed("等了太久还没登录成功" if interactive else "自动登录超时")
178
+ finally:
179
+ browser.close()
180
+
181
+
182
+ def _save_calendar_url(client: MoodleClient, log) -> None:
183
+ """顺手存下 iCal 订阅链接:以后会话过期了,截止日期还能靠它拿到。"""
184
+ from . import features as F
185
+ if F.load_calendar_url():
186
+ return
187
+ try:
188
+ F.calendar_url(client)
189
+ except Exception as e: # noqa: BLE001
190
+ log(f"(日历订阅链接没拿到:{e})")
191
+
192
+
193
+ def ensure_login(log=print, allow_window: bool = True) -> dict:
194
+ """先无界面试自动登录(专用窗口以前登录过才试),不行再开窗口。"""
195
+ if (BROWSER_PROFILE / "Default").exists():
196
+ try:
197
+ return login_moodle(interactive=False, timeout=45, log=log)
198
+ except LoginFailed as e:
199
+ if not allow_window:
200
+ raise
201
+ log(f"{e},打开登录窗口…")
202
+ elif not allow_window:
203
+ raise LoginFailed("还没在登录窗口里登录过,没法自动登录")
204
+ return login_moodle(interactive=True, log=log)
205
+
206
+
207
+ def session_ok() -> bool:
208
+ try:
209
+ MoodleClient().time_remaining()
210
+ return True
211
+ except (MoodleAuthError, OSError):
212
+ return False
213
+
@@ -0,0 +1,188 @@
1
+ """和 Chrome DevTools 协议(CDP)说话用的最小 WebSocket 客户端。
2
+
3
+ 标准库没有 WebSocket,这里只实现 CDP 用得到的部分:握手、发文本帧(客户端必须加掩码)、
4
+ 收文本帧(含分片)、回 ping。不发 Origin 头,因为 Chrome 只对带 Origin 的连接要求
5
+ --remote-allow-origins,不带就放行。只连 127.0.0.1。
6
+
7
+ macOS 和 Linux 上不开端口,用 PipeCDP:DevTools 走 fd 3/4 两根管道(--remote-debugging-pipe),
8
+ 本机别的程序和别的用户都连不上。Windows 没有 fork/exec 时挪 fd 的办法,只能用端口。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import json
14
+ import os
15
+ import socket
16
+ import struct
17
+ import subprocess
18
+ import time
19
+ import urllib.parse
20
+
21
+
22
+ class WebSocketClosed(ConnectionError):
23
+ """浏览器关了(或者用户把登录窗口关了)。"""
24
+
25
+
26
+ class WebSocket:
27
+ def __init__(self, url: str, timeout: float = 10):
28
+ u = urllib.parse.urlsplit(url)
29
+ if u.scheme != "ws" or u.hostname not in ("127.0.0.1", "localhost"):
30
+ raise ValueError("只连本机的 DevTools")
31
+ self.sock = socket.create_connection((u.hostname, u.port or 80), timeout=timeout)
32
+ key = base64.b64encode(os.urandom(16)).decode()
33
+ path = u.path + (f"?{u.query}" if u.query else "")
34
+ self.sock.sendall((f"GET {path} HTTP/1.1\r\nHost: {u.hostname}:{u.port}\r\n"
35
+ "Upgrade: websocket\r\nConnection: Upgrade\r\n"
36
+ f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
37
+ head = b""
38
+ while b"\r\n\r\n" not in head:
39
+ chunk = self.sock.recv(4096)
40
+ if not chunk:
41
+ raise WebSocketClosed("握手时连接断了")
42
+ head += chunk
43
+ head, self._buf = head.split(b"\r\n\r\n", 1)
44
+ if b" 101 " not in head.split(b"\r\n", 1)[0]:
45
+ raise ConnectionError(f"DevTools 拒绝了 WebSocket:{head[:200]!r}")
46
+
47
+ def _read(self, n: int) -> bytes:
48
+ while len(self._buf) < n:
49
+ chunk = self.sock.recv(65536)
50
+ if not chunk:
51
+ raise WebSocketClosed("浏览器已关闭")
52
+ self._buf += chunk
53
+ out, self._buf = self._buf[:n], self._buf[n:]
54
+ return out
55
+
56
+ def _send_frame(self, opcode: int, payload: bytes) -> None:
57
+ head = bytes([0x80 | opcode])
58
+ n = len(payload)
59
+ if n < 126:
60
+ head += bytes([0x80 | n])
61
+ elif n < 1 << 16:
62
+ head += bytes([0x80 | 126]) + struct.pack(">H", n)
63
+ else:
64
+ head += bytes([0x80 | 127]) + struct.pack(">Q", n)
65
+ mask = os.urandom(4)
66
+ body = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
67
+ self.sock.sendall(head + mask + body)
68
+
69
+ def send(self, text: str) -> None:
70
+ self._send_frame(0x1, text.encode("utf-8"))
71
+
72
+ def recv(self) -> str:
73
+ parts: list[bytes] = []
74
+ while True:
75
+ b0, b1 = self._read(2)
76
+ opcode, n = b0 & 0x0F, b1 & 0x7F
77
+ if n == 126:
78
+ n = struct.unpack(">H", self._read(2))[0]
79
+ elif n == 127:
80
+ n = struct.unpack(">Q", self._read(8))[0]
81
+ mask = self._read(4) if b1 & 0x80 else None
82
+ data = self._read(n)
83
+ if mask:
84
+ data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
85
+ if opcode == 0x8:
86
+ raise WebSocketClosed("浏览器关闭了连接")
87
+ if opcode == 0x9:
88
+ self._send_frame(0xA, data)
89
+ continue
90
+ if opcode in (0x1, 0x0):
91
+ parts.append(data)
92
+ if b0 & 0x80:
93
+ return b"".join(parts).decode("utf-8", "replace")
94
+
95
+ def close(self) -> None:
96
+ try:
97
+ self._send_frame(0x8, b"")
98
+ except OSError:
99
+ pass
100
+ self.sock.close()
101
+
102
+
103
+ class CDP:
104
+ """浏览器级的 CDP 会话:call() 发命令等结果,中间收到的事件丢掉。"""
105
+
106
+ def __init__(self, ws_url: str):
107
+ self.ws = WebSocket(ws_url)
108
+ self._id = 0
109
+
110
+ def call(self, method: str, params: dict | None = None, timeout: float = 15) -> dict:
111
+ self._id += 1
112
+ mid = self._id
113
+ self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
114
+ deadline = time.monotonic() + timeout
115
+ while time.monotonic() < deadline:
116
+ msg = json.loads(self.ws.recv())
117
+ if msg.get("id") == mid:
118
+ if "error" in msg:
119
+ raise RuntimeError(f"{method}: {msg['error'].get('message')}")
120
+ return msg.get("result") or {}
121
+ raise TimeoutError(f"{method} 没有回应")
122
+
123
+ def close(self) -> None:
124
+ self.ws.close()
125
+
126
+
127
+ class PipeCDP:
128
+ """--remote-debugging-pipe 上的 CDP:浏览器从 fd 3 读命令、往 fd 4 写回复,消息以 \\0 结尾。只用于 POSIX。"""
129
+
130
+ def __init__(self, argv: list[str]):
131
+ to_child_r, self._to_child = os.pipe() # 我们写 → 浏览器从 fd 3 读
132
+ self._from_child, from_child_w = os.pipe() # 浏览器写 fd 4 → 我们读
133
+
134
+ def wire(): # 子进程里、exec 之前
135
+ # 先挪到别的号上再 dup2:管道本身可能就是 fd 3/4,dup2(3, 3) 什么都不做,
136
+ # close-on-exec 标志还在,exec 时浏览器那头就被关了
137
+ a, b = os.dup(to_child_r), os.dup(from_child_w)
138
+ os.dup2(a, 3)
139
+ os.dup2(b, 4)
140
+ os.set_inheritable(3, True)
141
+ os.set_inheritable(4, True)
142
+
143
+ self.proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
144
+ stderr=subprocess.DEVNULL, preexec_fn=wire, close_fds=False)
145
+ os.close(to_child_r)
146
+ os.close(from_child_w)
147
+ os.set_blocking(self._from_child, False)
148
+ self._buf = b""
149
+ self._id = 0
150
+
151
+ def call(self, method: str, params: dict | None = None, timeout: float = 15) -> dict:
152
+ self._id += 1
153
+ mid = self._id
154
+ try:
155
+ os.write(self._to_child, json.dumps({"id": mid, "method": method, "params": params or {}}).encode() + b"\0")
156
+ except OSError as e:
157
+ raise WebSocketClosed("浏览器已经关掉了") from e
158
+ deadline = time.monotonic() + timeout
159
+ while time.monotonic() < deadline:
160
+ while b"\0" in self._buf:
161
+ raw, self._buf = self._buf.split(b"\0", 1)
162
+ try:
163
+ msg = json.loads(raw)
164
+ except ValueError:
165
+ continue
166
+ if msg.get("id") == mid:
167
+ if "error" in msg:
168
+ raise RuntimeError(f"{method}: {msg['error'].get('message')}")
169
+ return msg.get("result") or {}
170
+ try:
171
+ chunk = os.read(self._from_child, 1 << 16)
172
+ except BlockingIOError:
173
+ if self.proc.poll() is not None:
174
+ raise WebSocketClosed("浏览器已经关掉了") from None
175
+ time.sleep(0.05)
176
+ continue
177
+ if not chunk:
178
+ raise WebSocketClosed("浏览器已经关掉了")
179
+ self._buf += chunk
180
+ raise TimeoutError(f"{method} 没有回应")
181
+
182
+ def close(self) -> None:
183
+ for fd in (self._to_child, self._from_child):
184
+ try:
185
+ os.close(fd)
186
+ except OSError:
187
+ pass
188
+
@@ -0,0 +1,152 @@
1
+ """把 monash MCP 加进 Claude Desktop 和 Claude Code。
2
+
3
+ Claude Desktop 的坑:App 运行时把配置文件攥在内存里,退出时整份写回。开着 App 改
4
+ claude_desktop_config.json,一退出就被覆盖掉。所以必须先完全退出 App(macOS 上我们自动退,
5
+ Windows 上请用户从托盘图标退出),确认进程没了再改。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+
17
+ SERVER_NAME = "monash"
18
+
19
+
20
+ def server_command() -> list[str]:
21
+ """启动 MCP 的命令:用装好的 monash 程序的绝对路径,Claude 启动它时不依赖 PATH。"""
22
+ exe = shutil.which("monash")
23
+ if exe:
24
+ return [str(Path(exe).resolve()), "mcp"]
25
+ return [sys.executable, "-m", "monash_study_kit", "mcp"]
26
+
27
+
28
+ # ---------------------------------------------------------------- Claude Desktop
29
+
30
+ def desktop_config_candidates() -> list[Path]:
31
+ if sys.platform == "darwin":
32
+ return [Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"]
33
+ if os.name == "nt":
34
+ out = []
35
+ if os.environ.get("APPDATA"):
36
+ out.append(Path(os.environ["APPDATA"]) / "Claude" / "claude_desktop_config.json")
37
+ # 从 Microsoft Store / MSIX 装的版本,配置在包的虚拟化目录里
38
+ pkgs = Path(os.environ.get("LOCALAPPDATA", "")) / "Packages"
39
+ if pkgs.exists():
40
+ for p in pkgs.glob("Claude_*"):
41
+ out.append(p / "LocalCache" / "Roaming" / "Claude" / "claude_desktop_config.json")
42
+ return out
43
+ return [Path.home() / ".config" / "Claude" / "claude_desktop_config.json"]
44
+
45
+
46
+ def desktop_config_path() -> Path | None:
47
+ cands = desktop_config_candidates()
48
+ for p in cands:
49
+ if p.exists() or p.parent.exists():
50
+ return p
51
+ return None
52
+
53
+
54
+ def desktop_running() -> bool:
55
+ if sys.platform == "darwin":
56
+ return subprocess.run(["pgrep", "-xq", "Claude"]).returncode == 0
57
+ if os.name == "nt":
58
+ r = subprocess.run(["tasklist", "/FI", "IMAGENAME eq Claude.exe", "/NH"], capture_output=True, text=True)
59
+ return "claude.exe" in r.stdout.lower()
60
+ return subprocess.run(["pgrep", "-x", "claude-desktop"], capture_output=True).returncode == 0
61
+
62
+
63
+ def quit_desktop(wait: int = 60) -> bool:
64
+ """退出 Claude Desktop 并等进程结束。返回是否已经没在运行。"""
65
+ if not desktop_running():
66
+ return True
67
+ if sys.platform == "darwin":
68
+ subprocess.run(["osascript", "-e", 'quit app "Claude"'], capture_output=True)
69
+ else:
70
+ return False # Windows 上 Claude 会缩到托盘,让用户自己退出更可靠
71
+ for _ in range(wait):
72
+ if not desktop_running():
73
+ return True
74
+ time.sleep(1)
75
+ return False
76
+
77
+
78
+ def write_desktop_config(path: Path, command: list[str]) -> Path | None:
79
+ """加上(或更新)mcpServers.monash,其他内容原样保留。返回备份文件路径。"""
80
+ cfg, backup = {}, None
81
+ if path.exists():
82
+ raw = path.read_text(encoding="utf-8")
83
+ cfg = json.loads(raw) if raw.strip() else {}
84
+ backup = path.with_name(path.name + f".bak-{time.strftime('%Y%m%d%H%M%S')}")
85
+ backup.write_text(raw, encoding="utf-8")
86
+ cfg.setdefault("mcpServers", {})[SERVER_NAME] = {"command": command[0], "args": command[1:]}
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ tmp = path.with_name(path.name + ".tmp")
89
+ tmp.write_text(json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
90
+ os.replace(tmp, path)
91
+ return backup
92
+
93
+
94
+ def remove_from_desktop_config(path: Path) -> bool:
95
+ if not path.exists():
96
+ return False
97
+ cfg = json.loads(path.read_text(encoding="utf-8") or "{}")
98
+ if SERVER_NAME not in cfg.get("mcpServers", {}):
99
+ return False
100
+ del cfg["mcpServers"][SERVER_NAME]
101
+ path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
102
+ return True
103
+
104
+
105
+ def open_desktop() -> None:
106
+ if sys.platform == "darwin":
107
+ subprocess.run(["open", "-a", "Claude"], capture_output=True)
108
+
109
+
110
+ def install_desktop(ask=input, log=print) -> bool:
111
+ path = desktop_config_path()
112
+ if path is None:
113
+ log("没找到 Claude Desktop(没装,或者从没打开过)。装好并打开一次后再运行 monash setup claude")
114
+ return False
115
+ if desktop_running():
116
+ if sys.platform == "darwin":
117
+ if ask("要先退出 Claude Desktop 才能改它的配置(开着改会被覆盖)。现在退出吗?[Y/n] ").strip().lower() not in ("", "y", "yes"):
118
+ log("跳过了 Claude Desktop。之后可以运行 monash setup claude")
119
+ return False
120
+ if not quit_desktop():
121
+ log("Claude 60 秒内没退出。手动按 ⌘Q 退出后再运行 monash setup claude")
122
+ return False
123
+ else:
124
+ log("请完全退出 Claude Desktop:右下角托盘里的 Claude 图标 → 右键 → Quit(只关窗口不够)。")
125
+ for _ in range(3):
126
+ ask("退出后按回车继续…")
127
+ if not desktop_running():
128
+ break
129
+ else:
130
+ log("Claude 还在运行,先跳过。退出后再运行 monash setup claude")
131
+ return False
132
+ backup = write_desktop_config(path, server_command())
133
+ log(f"已把 monash 加进 Claude Desktop({path})" + (f",原配置备份在 {backup.name}" if backup else ""))
134
+ open_desktop()
135
+ log("重新打开 Claude Desktop 后,在聊天输入框的“+ → Connectors”里能看到 monash。")
136
+ return True
137
+
138
+
139
+ # ---------------------------------------------------------------- Claude Code
140
+
141
+ def install_code(log=print) -> bool:
142
+ claude = shutil.which("claude")
143
+ if not claude:
144
+ return False
145
+ cmd = server_command()
146
+ subprocess.run([claude, "mcp", "remove", "-s", "user", SERVER_NAME], capture_output=True)
147
+ r = subprocess.run([claude, "mcp", "add", "-s", "user", SERVER_NAME, "--", *cmd], capture_output=True, text=True)
148
+ if r.returncode != 0:
149
+ log(f"加进 Claude Code 失败:{(r.stderr or r.stdout).strip()}")
150
+ return False
151
+ log("已把 monash 加进 Claude Code(所有项目都能用)")
152
+ return True