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,238 @@
|
|
|
1
|
+
"""monash ed …:查本地的 Ed 库(终端里是文本,管道/重定向时是 JSON,可用 --json/--text 强制)。
|
|
2
|
+
|
|
3
|
+
monash ed new # 上次看过之后的新帖/新回复
|
|
4
|
+
monash ed threads FIT2102 --since 7d --unanswered
|
|
5
|
+
monash ed search FIT2102 assignment deadline
|
|
6
|
+
monash ed show FIT2102#42 [--live] # 正文 + 全部回复;--live 先从 Ed 重抓这一帖
|
|
7
|
+
monash ed following # 有未读新回复的帖子(我发的/关注的排前面)
|
|
8
|
+
monash ed lessons FIT2109 --module "Week 3"
|
|
9
|
+
monash ed quiz FIT2109 # Lessons 里的测验题(复习用;Ed 不公开答案)
|
|
10
|
+
monash ed read FIT2109 "W3 Pre-Class" # 整节 lesson 的正文(Markdown,按页序,附测验题)
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
from . import edlib, edquery
|
|
19
|
+
from .edquery import STATE_FILTERS
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _want_json(args) -> bool:
|
|
23
|
+
if getattr(args, "json", False):
|
|
24
|
+
return True
|
|
25
|
+
if getattr(args, "text", False):
|
|
26
|
+
return False
|
|
27
|
+
return not sys.stdout.isatty()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _emit(args, data, render) -> int:
|
|
31
|
+
print(json.dumps(data, ensure_ascii=False, indent=2) if _want_json(args) else render(data))
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _when(iso: str | None) -> str:
|
|
36
|
+
if not iso:
|
|
37
|
+
return "-"
|
|
38
|
+
try:
|
|
39
|
+
return datetime.fromisoformat(iso).astimezone().strftime("%m-%d %H:%M")
|
|
40
|
+
except ValueError:
|
|
41
|
+
return iso[:16]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _thread_line(t: dict) -> str:
|
|
45
|
+
flag = ("📌" if t["is_pinned"] else "") + ("🔒" if t["is_private"] else "")
|
|
46
|
+
if t["type"] == "question":
|
|
47
|
+
flag += "✅" if t["is_staff_answered"] or t["is_answered"] else "❓"
|
|
48
|
+
elif t["type"] == "announcement":
|
|
49
|
+
flag += "📣"
|
|
50
|
+
if t.get("is_starred"):
|
|
51
|
+
flag += "★"
|
|
52
|
+
unread = f" (+{t['new_reply_count']} 未读)" if t.get("new_reply_count") else ""
|
|
53
|
+
return f"{t['ref']:<14} {_when(t['created_at'])} {flag:<3} 💬{t['reply_count']:<3} {t['title']}{unread}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _render_threads(rows: list[dict]) -> str:
|
|
57
|
+
if not rows:
|
|
58
|
+
return "(没有匹配的帖子)"
|
|
59
|
+
out = []
|
|
60
|
+
for t in rows:
|
|
61
|
+
out.append(_thread_line(t))
|
|
62
|
+
if t.get("snippet"):
|
|
63
|
+
out.append(f"{'':<16}{t['snippet']}")
|
|
64
|
+
return "\n".join(out)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _render_replies(t: dict, width: int = 160) -> list[str]:
|
|
68
|
+
out = []
|
|
69
|
+
for r in t["new_replies"]:
|
|
70
|
+
who = r["author"] + ("(教师/助教)" if r["is_staff"] else "")
|
|
71
|
+
text = " ".join((r["text"] or "").split())
|
|
72
|
+
out.append(f"{'':<16}↳ {who} {_when(r['created_at'])}:{text[:width]}{'…' if len(text) > width else ''}")
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_courses(args):
|
|
77
|
+
rows = edquery.courses(edlib.db_connect())
|
|
78
|
+
return _emit(args, rows, lambda rs: "\n".join(
|
|
79
|
+
f"{r['id']:>7} {'★' if r['tracked'] else ' '} {r['code']:<16} {r['threads']:>4} 帖 "
|
|
80
|
+
f"最新 {_when(r['latest'])} {r['name']}" for r in rs) or "(还没同步过 Ed:monash sync)")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_threads(args):
|
|
84
|
+
state = [k for k in STATE_FILTERS if getattr(args, k, False)]
|
|
85
|
+
rows = edquery.list_threads(edlib.db_connect(), args.course, args.since, args.category, args.type,
|
|
86
|
+
args.unanswered, args.limit, args.offset, state)
|
|
87
|
+
return _emit(args, rows, _render_threads)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_search(args):
|
|
91
|
+
conn = edlib.db_connect()
|
|
92
|
+
course, words = args.course, list(args.words)
|
|
93
|
+
if course:
|
|
94
|
+
try: # 第一个词是课号就当课程,否则当搜索词
|
|
95
|
+
edquery.resolve_course(conn, course)
|
|
96
|
+
except edquery.NotFound:
|
|
97
|
+
words.insert(0, course)
|
|
98
|
+
course = None
|
|
99
|
+
rows = edquery.search(conn, " ".join(words), course, args.since, args.category, args.type, args.limit)
|
|
100
|
+
return _emit(args, rows, _render_threads)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_show(args):
|
|
104
|
+
from . import edsync
|
|
105
|
+
conn = edlib.db_connect()
|
|
106
|
+
if args.live:
|
|
107
|
+
try:
|
|
108
|
+
tid = edquery.resolve_thread(conn, args.thread)
|
|
109
|
+
except edquery.NotFound:
|
|
110
|
+
if not str(args.thread).isdigit():
|
|
111
|
+
raise
|
|
112
|
+
tid = int(args.thread)
|
|
113
|
+
try:
|
|
114
|
+
edsync.refresh_thread(conn, tid)
|
|
115
|
+
except edlib.EdForbidden:
|
|
116
|
+
print("[注意] Ed 上已经看不到这个帖子了(被删除或改成私密),下面是库里的旧副本。", file=sys.stderr)
|
|
117
|
+
args.thread = str(tid)
|
|
118
|
+
return _emit(args, edquery.get_thread(conn, args.thread), edquery.thread_markdown)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _render_new(d: dict) -> str:
|
|
122
|
+
out = [f"自 {_when(d['since'])} 以来(最近一次同步 {_when(d['last_sync'])})"]
|
|
123
|
+
if d["new_threads"]:
|
|
124
|
+
out += ["", f"## 新帖 {len(d['new_threads'])}", _render_threads(d["new_threads"])]
|
|
125
|
+
if d["threads_with_new_replies"]:
|
|
126
|
+
out += ["", f"## 有新回复 {len(d['threads_with_new_replies'])}"]
|
|
127
|
+
for t in d["threads_with_new_replies"]:
|
|
128
|
+
out.append(_thread_line(t))
|
|
129
|
+
out += _render_replies(t, 140)
|
|
130
|
+
if len(out) == 1:
|
|
131
|
+
out.append("没有新动态。")
|
|
132
|
+
return "\n".join(out)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_new(args):
|
|
136
|
+
d = edquery.new_activity(edlib.db_connect(), args.since, args.course, cursor="cli",
|
|
137
|
+
advance=not args.peek, limit=args.limit)
|
|
138
|
+
return _emit(args, d, _render_new)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _render_following(rows: list[dict]) -> str:
|
|
142
|
+
if not rows:
|
|
143
|
+
return "(没有未读的新回复)"
|
|
144
|
+
out = []
|
|
145
|
+
for t in rows:
|
|
146
|
+
who = ",".join(w for w, on in (("我发的", t["is_mine"]), ("关注", t["is_watched"]),
|
|
147
|
+
("收藏", t["is_starred"])) if on)
|
|
148
|
+
out += ["", _thread_line(t) + (f" [{who}]" if who else "")] + _render_replies(t)
|
|
149
|
+
return "\n".join(out).lstrip("\n")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cmd_following(args):
|
|
153
|
+
return _emit(args, edquery.following(edlib.db_connect(), args.course, args.limit), _render_following)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _render_lessons(rows: list[dict]) -> str:
|
|
157
|
+
if not rows:
|
|
158
|
+
return "(没有课程内容;先 monash sync)"
|
|
159
|
+
out, last = [], None
|
|
160
|
+
mark = {"completed": "✅", "attempted": "◐", "unattempted": "○"}
|
|
161
|
+
for x in rows:
|
|
162
|
+
if x["module"] != last:
|
|
163
|
+
out += ([""] if out else []) + [f"## {x['module'] or '(未分组)'}"]
|
|
164
|
+
last = x["module"]
|
|
165
|
+
due = f" 截止 {_when(x['due_at'])}" if x["due_at"] else ""
|
|
166
|
+
out.append(f"{mark.get(x['status'], '·')} {x['title']} [{x['type']}]{due}")
|
|
167
|
+
for f in x["files"]:
|
|
168
|
+
where = f["file_url"] or f["url"] or "(正文已存到本地,monash grep 能搜到)"
|
|
169
|
+
out.append(f" {'📄' if f['type'] == 'pdf' else '🔗' if f['type'] == 'webpage' else '📝'} "
|
|
170
|
+
f"{f['title'] or f['type']} {where}")
|
|
171
|
+
return "\n".join(out)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cmd_lessons(args):
|
|
175
|
+
return _emit(args, edquery.lessons(edlib.db_connect(), args.course, args.module, args.status), _render_lessons)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_read(args):
|
|
179
|
+
from . import lesson_reader
|
|
180
|
+
d = lesson_reader.lesson_markdown(edlib.db_connect(), args.course, args.lesson)
|
|
181
|
+
return _emit(args, d, lambda x: x["markdown"])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cmd_quiz(args):
|
|
185
|
+
return _emit(args, edquery.quizzes(edlib.db_connect(), args.course, args.module, args.status),
|
|
186
|
+
edquery.quiz_markdown)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def add_commands(sub) -> None:
|
|
190
|
+
def out(name, fn, **kw):
|
|
191
|
+
p = sub.add_parser(name, **kw)
|
|
192
|
+
g = p.add_mutually_exclusive_group()
|
|
193
|
+
g.add_argument("--json", action="store_true", help="输出 JSON(管道里默认就是)")
|
|
194
|
+
g.add_argument("--text", action="store_true", help="输出文本(终端里默认就是)")
|
|
195
|
+
p.set_defaults(fn=fn)
|
|
196
|
+
return p
|
|
197
|
+
|
|
198
|
+
def filters(p):
|
|
199
|
+
p.add_argument("--since", help="只看这之后发的:7d / 12h / 2w / 2026-09-01")
|
|
200
|
+
p.add_argument("--category", help="分类(或子分类)包含这个词")
|
|
201
|
+
p.add_argument("--type", choices=["question", "post", "announcement"])
|
|
202
|
+
p.add_argument("--limit", type=int, default=20)
|
|
203
|
+
|
|
204
|
+
out("courses", cmd_courses, help="本地库里的课程")
|
|
205
|
+
p = out("threads", cmd_threads, help="列帖子(最新在前)")
|
|
206
|
+
p.add_argument("course", nargs="?", help="课号;不给就是全部跟踪的课")
|
|
207
|
+
filters(p)
|
|
208
|
+
p.add_argument("--unanswered", action="store_true", help="只看还没人回答的提问")
|
|
209
|
+
p.add_argument("--starred", action="store_true", help="只看我收藏的")
|
|
210
|
+
p.add_argument("--watching", action="store_true", help="只看我关注的")
|
|
211
|
+
p.add_argument("--mine", action="store_true", help="只看我发的")
|
|
212
|
+
p.add_argument("--unseen", action="store_true", help="只看还没点开过的")
|
|
213
|
+
p.add_argument("--unread-replies", dest="unread_replies", action="store_true", help="只看有新回复的")
|
|
214
|
+
p.add_argument("--offset", type=int, default=0)
|
|
215
|
+
p = out("search", cmd_search, help="搜标题、正文和回复(每个词都要出现)")
|
|
216
|
+
p.add_argument("course", help="课号(可省略:第一个词不是课号时就当搜索词)")
|
|
217
|
+
p.add_argument("words", nargs="*")
|
|
218
|
+
filters(p)
|
|
219
|
+
p = out("show", cmd_show, help="读一个帖子:正文 + 全部回复")
|
|
220
|
+
p.add_argument("thread", help="FIT2102#42、帖子 id 或 Ed 链接")
|
|
221
|
+
p.add_argument("--live", action="store_true", help="先从 Ed 重抓这一帖")
|
|
222
|
+
p = out("new", cmd_new, help="上次看过之后的新帖和新回复")
|
|
223
|
+
p.add_argument("course", nargs="?")
|
|
224
|
+
p.add_argument("--since", help="不用游标,看这之后的:7d / 2026-09-20")
|
|
225
|
+
p.add_argument("--peek", action="store_true", help="看完不推进游标")
|
|
226
|
+
p.add_argument("--limit", type=int, default=50)
|
|
227
|
+
p = out("following", cmd_following, help="有未读新回复的帖子")
|
|
228
|
+
p.add_argument("course", nargs="?")
|
|
229
|
+
p.add_argument("--limit", type=int, default=30)
|
|
230
|
+
p = out("read", cmd_read, help="整节 lesson 的正文:文字页、阅读网页、PDF 按页序拼成 Markdown,附测验题")
|
|
231
|
+
p.add_argument("course")
|
|
232
|
+
p.add_argument("lesson", help="lesson id 或标题的一部分,如 'W3 Pre-Class'")
|
|
233
|
+
for name, fn, h in (("lessons", cmd_lessons, "课程内容(Lessons)和课件"),
|
|
234
|
+
("quiz", cmd_quiz, "Lessons 里的测验题(题面+选项)")):
|
|
235
|
+
p = out(name, fn, help=h)
|
|
236
|
+
p.add_argument("course")
|
|
237
|
+
p.add_argument("--module", help="模块名的一部分,如 'Week 3'")
|
|
238
|
+
p.add_argument("--status", help="completed / attempted / unattempted / all")
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""把 monash MCP 接进 Claude 以外的 AI 软件:Codex、Cursor、VS Code、Windsurf、Gemini CLI。
|
|
2
|
+
|
|
3
|
+
都是本地 stdio MCP,只是每家配置文件的位置和格式不一样。原则:
|
|
4
|
+
- 只改 monash 这一项,文件里别的内容原样保留,改之前备份。
|
|
5
|
+
- 软件自己有 `xxx mcp add` 命令的(Codex),优先用它;没有或失败了再直接改文件。
|
|
6
|
+
- 文件不是合法 JSON(比如带注释)就不动它,打印出要加的那段让用户自己贴。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
from .claude_setup import SERVER_NAME, server_command
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Client:
|
|
26
|
+
key: str
|
|
27
|
+
name: str
|
|
28
|
+
path: Callable[[], Path]
|
|
29
|
+
fmt: str # "json":mcpServers;"vscode":servers + type;"toml":Codex
|
|
30
|
+
after: str # 接好以后要做什么才能用上
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _vscode_user_dir() -> Path:
|
|
34
|
+
if sys.platform == "darwin":
|
|
35
|
+
return Path.home() / "Library" / "Application Support" / "Code" / "User"
|
|
36
|
+
if os.name == "nt":
|
|
37
|
+
return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / "Code" / "User"
|
|
38
|
+
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "Code" / "User"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _codex_home() -> Path:
|
|
42
|
+
return Path(os.environ["CODEX_HOME"]) if os.environ.get("CODEX_HOME") else Path.home() / ".codex"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
CLIENTS: dict[str, Client] = {c.key: c for c in [
|
|
46
|
+
Client("codex", "Codex", lambda: _codex_home() / "config.toml", "toml",
|
|
47
|
+
"新开一个 Codex 会话,输入 /mcp 能看到 monash。"),
|
|
48
|
+
Client("cursor", "Cursor", lambda: Path.home() / ".cursor" / "mcp.json", "json",
|
|
49
|
+
"重启 Cursor(或 Cmd/Ctrl+Shift+P → Developer: Reload Window),在 Settings → MCP 里能看到 monash。"),
|
|
50
|
+
Client("vscode", "VS Code", lambda: _vscode_user_dir() / "mcp.json", "vscode",
|
|
51
|
+
"在 VS Code 的 Copilot Chat 里切到 Agent 模式,工具列表里能看到 monash。第一次用时 VS Code 会问你是否信任它。"),
|
|
52
|
+
Client("windsurf", "Windsurf", lambda: Path.home() / ".codeium" / "windsurf" / "mcp_config.json", "json",
|
|
53
|
+
"重启 Windsurf,或在 Cascade 面板的 MCP 图标里点刷新。"),
|
|
54
|
+
Client("gemini", "Gemini CLI", lambda: Path.home() / ".gemini" / "settings.json", "json",
|
|
55
|
+
"重新打开 gemini,输入 /mcp 能看到 monash。"),
|
|
56
|
+
]}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def entry(fmt: str, command: list[str]) -> dict:
|
|
60
|
+
e = {"command": command[0], "args": command[1:]}
|
|
61
|
+
return {"type": "stdio", **e} if fmt == "vscode" else e
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def snippet(client: Client, command: list[str]) -> str:
|
|
65
|
+
"""要加进配置文件的那一段,给 --print 和改不了文件时用。"""
|
|
66
|
+
if client.fmt == "toml":
|
|
67
|
+
return _toml_table(command)
|
|
68
|
+
key = "servers" if client.fmt == "vscode" else "mcpServers"
|
|
69
|
+
return json.dumps({key: {SERVER_NAME: entry(client.fmt, command)}}, ensure_ascii=False, indent=2)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def detected(client: Client) -> bool:
|
|
73
|
+
"""配置文件或它所在的文件夹在,就当这个软件装过。"""
|
|
74
|
+
p = client.path()
|
|
75
|
+
return p.exists() or p.parent.exists()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _backup(path: Path, raw: str) -> Path:
|
|
79
|
+
backup = path.with_name(path.name + f".bak-{time.strftime('%Y%m%d%H%M%S')}")
|
|
80
|
+
backup.write_text(raw, encoding="utf-8")
|
|
81
|
+
return backup
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
85
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
87
|
+
tmp.write_text(text, encoding="utf-8")
|
|
88
|
+
os.replace(tmp, path)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------- JSON(Cursor、VS Code、Windsurf、Gemini CLI)
|
|
92
|
+
|
|
93
|
+
def write_json(path: Path, fmt: str, command: list[str]) -> Path | None:
|
|
94
|
+
"""加上(或更新)monash 这一项,其他内容原样保留。返回备份路径。文件不是合法 JSON 时抛 ValueError。"""
|
|
95
|
+
cfg, backup = {}, None
|
|
96
|
+
if path.exists():
|
|
97
|
+
raw = path.read_text(encoding="utf-8")
|
|
98
|
+
cfg = json.loads(raw) if raw.strip() else {}
|
|
99
|
+
if not isinstance(cfg, dict):
|
|
100
|
+
raise ValueError(f"{path} 不是一个 JSON 对象")
|
|
101
|
+
backup = _backup(path, raw)
|
|
102
|
+
key = "servers" if fmt == "vscode" else "mcpServers"
|
|
103
|
+
cfg.setdefault(key, {})[SERVER_NAME] = entry(fmt, command)
|
|
104
|
+
_atomic_write(path, json.dumps(cfg, ensure_ascii=False, indent=2) + "\n")
|
|
105
|
+
return backup
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def remove_json(path: Path, fmt: str) -> bool:
|
|
109
|
+
if not path.exists():
|
|
110
|
+
return False
|
|
111
|
+
try:
|
|
112
|
+
cfg = json.loads(path.read_text(encoding="utf-8") or "{}")
|
|
113
|
+
except ValueError:
|
|
114
|
+
return False
|
|
115
|
+
key = "servers" if fmt == "vscode" else "mcpServers"
|
|
116
|
+
if not isinstance(cfg, dict) or SERVER_NAME not in (cfg.get(key) or {}):
|
|
117
|
+
return False
|
|
118
|
+
del cfg[key][SERVER_NAME]
|
|
119
|
+
_atomic_write(path, json.dumps(cfg, ensure_ascii=False, indent=2) + "\n")
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------- TOML(Codex)
|
|
124
|
+
# Python 3.10 没有 tomllib,这里只按行处理 [mcp_servers.monash] 这一张表,文件其他部分一个字不动。
|
|
125
|
+
|
|
126
|
+
_HEADER = re.compile(r"^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _toml_str(s: str) -> str:
|
|
130
|
+
return json.dumps(s, ensure_ascii=False) # JSON 字符串的转义规则在这里和 TOML basic string 一样
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _toml_table(command: list[str]) -> str:
|
|
134
|
+
args = ", ".join(_toml_str(a) for a in command[1:])
|
|
135
|
+
return f"[mcp_servers.{SERVER_NAME}]\ncommand = {_toml_str(command[0])}\nargs = [{args}]\n"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _strip_table(text: str) -> tuple[str, bool]:
|
|
139
|
+
"""去掉 [mcp_servers.monash] 和它的子表,返回 (剩下的内容, 有没有去掉东西)。"""
|
|
140
|
+
ours = f"mcp_servers.{SERVER_NAME}"
|
|
141
|
+
out, skipping, found = [], False, False
|
|
142
|
+
for line in text.splitlines(keepends=True):
|
|
143
|
+
m = _HEADER.match(line)
|
|
144
|
+
if m:
|
|
145
|
+
name = m.group(1).replace('"', "").replace(" ", "")
|
|
146
|
+
skipping = name == ours or name.startswith(ours + ".")
|
|
147
|
+
found = found or skipping
|
|
148
|
+
if not skipping:
|
|
149
|
+
out.append(line)
|
|
150
|
+
return "".join(out), found
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def write_toml(path: Path, command: list[str]) -> Path | None:
|
|
154
|
+
raw = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
155
|
+
backup = _backup(path, raw) if path.exists() else None
|
|
156
|
+
rest, _ = _strip_table(raw)
|
|
157
|
+
rest = rest.rstrip("\n")
|
|
158
|
+
_atomic_write(path, (rest + "\n\n" if rest else "") + _toml_table(command))
|
|
159
|
+
return backup
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def remove_toml(path: Path) -> bool:
|
|
163
|
+
if not path.exists():
|
|
164
|
+
return False
|
|
165
|
+
rest, found = _strip_table(path.read_text(encoding="utf-8"))
|
|
166
|
+
if found:
|
|
167
|
+
_atomic_write(path, rest.rstrip("\n") + "\n" if rest.strip() else "")
|
|
168
|
+
return found
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------- 对外
|
|
172
|
+
|
|
173
|
+
def _codex_cli(command: list[str]) -> bool:
|
|
174
|
+
codex = shutil.which("codex")
|
|
175
|
+
if not codex:
|
|
176
|
+
return False
|
|
177
|
+
subprocess.run([codex, "mcp", "remove", SERVER_NAME], capture_output=True)
|
|
178
|
+
r = subprocess.run([codex, "mcp", "add", SERVER_NAME, "--", *command], capture_output=True, text=True)
|
|
179
|
+
return r.returncode == 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def connect(key: str, log=print) -> bool:
|
|
183
|
+
client = CLIENTS[key]
|
|
184
|
+
command = server_command()
|
|
185
|
+
path = client.path()
|
|
186
|
+
try:
|
|
187
|
+
if client.fmt == "toml":
|
|
188
|
+
if _codex_cli(command):
|
|
189
|
+
log(f"已用 codex mcp add 把 monash 加进 {client.name}")
|
|
190
|
+
log(client.after)
|
|
191
|
+
return True
|
|
192
|
+
backup = write_toml(path, command)
|
|
193
|
+
else:
|
|
194
|
+
backup = write_json(path, client.fmt, command)
|
|
195
|
+
except (ValueError, OSError) as e:
|
|
196
|
+
log(f"没能自动改 {path}:{e}")
|
|
197
|
+
log("请打开这个文件,把下面这段合并进去(已有的内容别删):\n")
|
|
198
|
+
log(snippet(client, command))
|
|
199
|
+
return False
|
|
200
|
+
log(f"已把 monash 加进 {client.name}({path})" + (f",原配置备份在 {backup.name}" if backup else ""))
|
|
201
|
+
log(client.after)
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def disconnect(key: str) -> bool:
|
|
206
|
+
client = CLIENTS[key]
|
|
207
|
+
path = client.path()
|
|
208
|
+
if client.fmt == "toml":
|
|
209
|
+
codex = shutil.which("codex")
|
|
210
|
+
if codex and subprocess.run([codex, "mcp", "remove", SERVER_NAME], capture_output=True).returncode == 0:
|
|
211
|
+
remove_toml(path)
|
|
212
|
+
return True
|
|
213
|
+
return remove_toml(path)
|
|
214
|
+
return remove_json(path, client.fmt)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def is_connected(key: str) -> bool:
|
|
218
|
+
client = CLIENTS[key]
|
|
219
|
+
path = client.path()
|
|
220
|
+
if not path.exists():
|
|
221
|
+
return False
|
|
222
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
223
|
+
if client.fmt == "toml":
|
|
224
|
+
return _strip_table(text)[1]
|
|
225
|
+
try:
|
|
226
|
+
cfg = json.loads(text or "{}")
|
|
227
|
+
except ValueError:
|
|
228
|
+
return False
|
|
229
|
+
key_name = "servers" if client.fmt == "vscode" else "mcpServers"
|
|
230
|
+
return isinstance(cfg, dict) and SERVER_NAME in (cfg.get(key_name) or {})
|