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.
- monash_study_kit/__init__.py +4 -0
- monash_study_kit/__main__.py +5 -0
- monash_study_kit/browser_login.py +213 -0
- monash_study_kit/cdp.py +188 -0
- monash_study_kit/claude_setup.py +152 -0
- monash_study_kit/cli.py +843 -0
- monash_study_kit/cli_ed.py +238 -0
- monash_study_kit/clients.py +230 -0
- monash_study_kit/content_index.py +324 -0
- monash_study_kit/doctor.py +228 -0
- monash_study_kit/doctor_deep.py +275 -0
- monash_study_kit/edlib.py +551 -0
- monash_study_kit/edquery.py +431 -0
- monash_study_kit/edsync.py +183 -0
- monash_study_kit/features.py +591 -0
- monash_study_kit/help_menu.py +121 -0
- monash_study_kit/htmldom.py +162 -0
- monash_study_kit/jobs.py +233 -0
- monash_study_kit/lesson_reader.py +94 -0
- monash_study_kit/lessons.py +231 -0
- monash_study_kit/mcp_server.py +713 -0
- monash_study_kit/moodlelib.py +485 -0
- monash_study_kit/output.py +73 -0
- monash_study_kit/paths.py +126 -0
- monash_study_kit/recordings.py +312 -0
- monash_study_kit/syncer.py +393 -0
- monash_study_kit/textextract.py +89 -0
- monash_study_kit/todo.py +178 -0
- monash_study_kit/transcribe.py +128 -0
- monash_study_kit/update_check.py +113 -0
- monash_study_kit/webnotes.py +312 -0
- monash_study_kit-1.2.0.dist-info/METADATA +464 -0
- monash_study_kit-1.2.0.dist-info/RECORD +36 -0
- monash_study_kit-1.2.0.dist-info/WHEEL +4 -0
- monash_study_kit-1.2.0.dist-info/entry_points.txt +2 -0
- monash_study_kit-1.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
|
monash_study_kit/cdp.py
ADDED
|
@@ -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
|