exegete 0.14.1a0.dev1__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.
- exegete/__init__.py +16 -0
- exegete/coder_comparison.py +208 -0
- exegete/cursors.py +218 -0
- exegete/database.py +12621 -0
- exegete/env_settings.py +174 -0
- exegete/memo_privacy.py +215 -0
- exegete/names.py +68 -0
- exegete/new_project.py +1027 -0
- exegete/preview_tokens.py +613 -0
- exegete/project_settings.py +1082 -0
- exegete/pseudonymise.py +2796 -0
- exegete/refi_export.py +645 -0
- exegete/server.py +18899 -0
- exegete/sessions.py +1153 -0
- exegete/state_folder.py +360 -0
- exegete/transition.py +1049 -0
- exegete-0.14.1a0.dev1.dist-info/METADATA +515 -0
- exegete-0.14.1a0.dev1.dist-info/RECORD +24 -0
- exegete-0.14.1a0.dev1.dist-info/WHEEL +5 -0
- exegete-0.14.1a0.dev1.dist-info/entry_points.txt +2 -0
- exegete-0.14.1a0.dev1.dist-info/licenses/COPYING.LESSER +165 -0
- exegete-0.14.1a0.dev1.dist-info/licenses/NOTICE +773 -0
- exegete-0.14.1a0.dev1.dist-info/licenses/legal/GPL-3.0.txt +674 -0
- exegete-0.14.1a0.dev1.dist-info/top_level.txt +1 -0
exegete/transition.py
ADDED
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
2
|
+
"""`exegete --check-transition`: what the move from qualcoder-mcp left.
|
|
3
|
+
|
|
4
|
+
Read-only by default: it prints, plainly, what the change of name left
|
|
5
|
+
behind on this computer and the steps that tidy it, numbered in the
|
|
6
|
+
order to take them, and exits 0 when nothing is left.
|
|
7
|
+
|
|
8
|
+
- Exegete itself, first, when the old `qualcoder-mcp` package lives in
|
|
9
|
+
an environment with no `exegete` command yet (a uv tool or pipx
|
|
10
|
+
environment, or one holding 0.14.0 or earlier): a host's entry is
|
|
11
|
+
never pointed at a command that does not exist, and the old package
|
|
12
|
+
is never removed while an entry still needs it;
|
|
13
|
+
- an entry in a host's configuration (Claude Desktop, Claude Code,
|
|
14
|
+
LM Studio, Codex) that still starts the old command, with the entry
|
|
15
|
+
to use instead (the files are only read, never changed);
|
|
16
|
+
- the old package, with the command that removes it for the way it
|
|
17
|
+
was installed (pip, uv, uv tool, pipx, a copy of the source);
|
|
18
|
+
- the link left at ~/.qualcoder_mcp, and whether it can safely go: it
|
|
19
|
+
leads to ~/.exegete, no program started as qualcoder-mcp is running,
|
|
20
|
+
and neither a qualcoder-mcp older than 0.14.1 nor a host entry that
|
|
21
|
+
starts the old command is left;
|
|
22
|
+
- Claude Desktop's log files under the extension's earlier name;
|
|
23
|
+
- the earlier default projects folder, searched as the project listing
|
|
24
|
+
searches (three levels down); never moved or emptied, and never
|
|
25
|
+
offered for removal while anything is in it.
|
|
26
|
+
|
|
27
|
+
What the researcher pastes (commands, entries) carries full paths,
|
|
28
|
+
quoted for the shell or the file it goes into; `~` shortens paths in
|
|
29
|
+
prose only. Names and paths read from files and folders are shown as
|
|
30
|
+
JSON strings, so that none can break or disguise a line of the report.
|
|
31
|
+
|
|
32
|
+
`--tidy` removes only what this program owns and can show to be unused:
|
|
33
|
+
the link (never a folder), and the old log files only when
|
|
34
|
+
`--tidy-old-logs` is given as well. Everything else is a step printed
|
|
35
|
+
for the user. Projects, backups, the AI coder name files in projects and
|
|
36
|
+
every host's configuration are never touched. It never moves the state
|
|
37
|
+
folder and never makes one.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import shlex
|
|
44
|
+
import shutil
|
|
45
|
+
import subprocess
|
|
46
|
+
import sys
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Callable, Dict, Iterable, List, Optional, Tuple
|
|
49
|
+
|
|
50
|
+
from . import env_settings, names, state_folder
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
import tomllib
|
|
54
|
+
except ModuleNotFoundError: # Python 3.10
|
|
55
|
+
tomllib = None
|
|
56
|
+
|
|
57
|
+
# The larger host files (Claude Code's ~/.claude.json) can be several MB;
|
|
58
|
+
# anything past this is not read.
|
|
59
|
+
CONFIG_READ_MAX_BYTES = 32 * 1024 * 1024
|
|
60
|
+
OLD_LOG = re.compile(r"mcp-server-qualcoder-mcp\d*\.log")
|
|
61
|
+
# The old command as a word or at the end of a path, bare or pinned
|
|
62
|
+
# (`qualcoder-mcp==0.14.0`, `qualcoder-mcp@0.14.0`, with extras or not).
|
|
63
|
+
_OLD_ARG = re.compile(r"(^|[\\/])qualcoder-mcp(\.exe)?(?:\[[^\]]*\])?"
|
|
64
|
+
r"(?:(?:===|==|~=|!=|>=|<=|<|>|@)\S*)?$", re.I)
|
|
65
|
+
_OLD_MODULE = "qualcoder_mcp.server"
|
|
66
|
+
_NEW_MODULE = "exegete.server"
|
|
67
|
+
# From this release on, the old name's package only points to Exegete:
|
|
68
|
+
# its command runs Exegete, which never uses the link.
|
|
69
|
+
POINTER_SINCE = (0, 14, 1)
|
|
70
|
+
# The project listing's depth: a project up to three levels down.
|
|
71
|
+
PROJECT_DEPTH = 3
|
|
72
|
+
SHOWN_MAX = 10
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Finding:
|
|
76
|
+
"""One thing the change left behind (or kept on purpose): what it
|
|
77
|
+
is, what to do, the commands or entry lines to paste (each printed
|
|
78
|
+
on a line of its own), and what follows them."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, what: str, step: str, left: bool = True,
|
|
81
|
+
tidy: Optional[Callable[[], Tuple[bool, str]]] = None,
|
|
82
|
+
is_log: bool = False, commands: Iterable[str] = (),
|
|
83
|
+
after: str = ""):
|
|
84
|
+
self.is_log = is_log
|
|
85
|
+
self.what, self.step, self.left, self.tidy = what, step, left, tidy
|
|
86
|
+
self.commands, self.after = list(commands), after
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Showing names and paths, and writing commands to paste
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _escape(char: str, toml: bool = False) -> str:
|
|
94
|
+
code = ord(char)
|
|
95
|
+
if code <= 0xFFFF:
|
|
96
|
+
return "\\u%04x" % code
|
|
97
|
+
if toml:
|
|
98
|
+
return "\\U%08x" % code
|
|
99
|
+
code -= 0x10000
|
|
100
|
+
return "\\u%04x\\u%04x" % (0xD800 + (code >> 10), 0xDC00 + (code & 0x3FF))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def quoted(text: str, toml: bool = False) -> str:
|
|
104
|
+
"""`text` as a JSON string (a TOML one with `toml`) with every
|
|
105
|
+
character that is not printable escaped (line breaks, terminal
|
|
106
|
+
controls, direction marks): a name read from a file or a folder can
|
|
107
|
+
then never break a line of the report or disguise itself."""
|
|
108
|
+
body = json.dumps(str(text), ensure_ascii=False)
|
|
109
|
+
return "".join(c if c.isprintable() else _escape(c, toml) for c in body)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _place(path, home: Path) -> str:
|
|
113
|
+
"""A path for prose: in the home folder as ~/..., quoted."""
|
|
114
|
+
text, base = str(path), str(home).rstrip("\\/")
|
|
115
|
+
if base and text == base:
|
|
116
|
+
text = "~"
|
|
117
|
+
elif base and text.startswith(base + os.sep):
|
|
118
|
+
text = "~" + text[len(base):]
|
|
119
|
+
return quoted(text)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _sh_word(word: str) -> str:
|
|
123
|
+
"""One word for sh, bash or zsh: shlex's quoting, or ANSI-C quoting
|
|
124
|
+
for a word with characters that cannot be shown as they are."""
|
|
125
|
+
if word.isprintable():
|
|
126
|
+
return shlex.quote(word)
|
|
127
|
+
out = []
|
|
128
|
+
for c in word:
|
|
129
|
+
if c in "\\'":
|
|
130
|
+
out.append("\\" + c)
|
|
131
|
+
elif c.isprintable():
|
|
132
|
+
out.append(c)
|
|
133
|
+
elif ord(c) < 0x80:
|
|
134
|
+
out.append("\\x%02x" % ord(c))
|
|
135
|
+
else:
|
|
136
|
+
out.append("\\U%08x" % ord(c))
|
|
137
|
+
return "$'" + "".join(out) + "'"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_PS_PLAIN = re.compile(r"[A-Za-z0-9_./:\\=+-]+")
|
|
141
|
+
_PS_SINGLE = "'‘’‚‛" # PowerShell reads all as '
|
|
142
|
+
_PS_DOUBLE = '"“”„'
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _ps_word(word: str) -> str:
|
|
146
|
+
"""One word for PowerShell: single quotes (every kind PowerShell
|
|
147
|
+
reads as one doubled), or double quotes with escapes for a word
|
|
148
|
+
with characters that cannot be shown as they are."""
|
|
149
|
+
if _PS_PLAIN.fullmatch(word):
|
|
150
|
+
return word
|
|
151
|
+
if word.isprintable():
|
|
152
|
+
return "'" + "".join(c * 2 if c in _PS_SINGLE else c
|
|
153
|
+
for c in word) + "'"
|
|
154
|
+
out = []
|
|
155
|
+
for c in word:
|
|
156
|
+
if c in "`$" + _PS_DOUBLE:
|
|
157
|
+
out.append("`" + c)
|
|
158
|
+
elif c.isprintable():
|
|
159
|
+
out.append(c)
|
|
160
|
+
else:
|
|
161
|
+
out.append("$([char]::ConvertFromUtf32(0x%x))" % ord(c))
|
|
162
|
+
return '"' + "".join(out) + '"'
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def command_line(argv: List[str], windows: Optional[bool] = None) -> str:
|
|
166
|
+
"""A command to paste, with full paths: for PowerShell on Windows
|
|
167
|
+
(where a quoted program needs `&` in front), else for sh, bash and
|
|
168
|
+
zsh."""
|
|
169
|
+
windows = os.name == "nt" if windows is None else windows
|
|
170
|
+
if not windows:
|
|
171
|
+
return " ".join(_sh_word(word) for word in argv)
|
|
172
|
+
words = [_ps_word(word) for word in argv]
|
|
173
|
+
return ("& " if words[0] != argv[0] else "") + " ".join(words)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _shell(windows: Optional[bool] = None) -> str:
|
|
177
|
+
windows = os.name == "nt" if windows is None else windows
|
|
178
|
+
return "In PowerShell" if windows else "In a terminal"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _listed(items: List[str]) -> str:
|
|
182
|
+
shown = ", ".join(quoted(item) for item in items[:SHOWN_MAX])
|
|
183
|
+
more = len(items) - SHOWN_MAX
|
|
184
|
+
return shown + (f" and {more} more" if more > 0 else "")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Where things live, from the home folder alone (no environment is read)
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
# The folder Claude Desktop keeps its files and logs in: the app's name
|
|
192
|
+
# without its second word.
|
|
193
|
+
_APP_FOLDER = "Claude Desktop".split()[0]
|
|
194
|
+
|
|
195
|
+
def host_config_files(home: Path) -> List[Tuple[str, Path]]:
|
|
196
|
+
appdata = home / "AppData" / "Roaming"
|
|
197
|
+
return [
|
|
198
|
+
("Claude Desktop", home / "Library" / "Application Support" /
|
|
199
|
+
_APP_FOLDER / "claude_desktop_config.json"),
|
|
200
|
+
("Claude Desktop", appdata / _APP_FOLDER /
|
|
201
|
+
"claude_desktop_config.json"),
|
|
202
|
+
("Claude Desktop", home / ".config" / _APP_FOLDER /
|
|
203
|
+
"claude_desktop_config.json"),
|
|
204
|
+
("Claude Code", home / ".claude.json"),
|
|
205
|
+
("LM Studio", home / ".lmstudio" / "mcp.json"),
|
|
206
|
+
("Codex", home / ".codex" / "config.toml"),
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def claude_log_folders(home: Path) -> List[Path]:
|
|
211
|
+
return [home / "Library" / "Logs" / _APP_FOLDER,
|
|
212
|
+
home / "AppData" / "Roaming" / _APP_FOLDER / "logs",
|
|
213
|
+
home / ".config" / _APP_FOLDER / "logs"]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def tool_folders(home: Path) -> List[Tuple[str, Path]]:
|
|
217
|
+
"""Where uv tool and pipx keep a tool's own environment by default."""
|
|
218
|
+
return [
|
|
219
|
+
("uv tool", home / ".local" / "share" / "uv" / "tools"),
|
|
220
|
+
("uv tool", home / "AppData" / "Roaming" / "uv" / "data" / "tools"),
|
|
221
|
+
("pipx", home / ".local" / "pipx" / "venvs"),
|
|
222
|
+
("pipx", home / ".local" / "share" / "pipx" / "venvs"),
|
|
223
|
+
("pipx", home / "Library" / "Application Support" / "pipx" /
|
|
224
|
+
"venvs"),
|
|
225
|
+
("pipx", home / "AppData" / "Local" / "pipx" / "pipx" / "venvs"),
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def tool_command_folder(home: Path) -> Path:
|
|
230
|
+
"""Where uv tool and pipx put a tool's commands by default."""
|
|
231
|
+
return home / ".local" / "bin"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# The old package, wherever it is installed
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def _dist_info(env_root: Path, dist: str = "qualcoder_mcp") -> Optional[Path]:
|
|
239
|
+
"""The `<dist>-*.dist-info` folder in an environment, if any."""
|
|
240
|
+
for pattern in (f"lib/python*/site-packages/{dist}-*.dist-info",
|
|
241
|
+
f"Lib/site-packages/{dist}-*.dist-info"):
|
|
242
|
+
try:
|
|
243
|
+
for found in sorted(env_root.glob(pattern)):
|
|
244
|
+
if found.is_dir():
|
|
245
|
+
return found
|
|
246
|
+
except (OSError, ValueError):
|
|
247
|
+
return None
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _python_of(env_root: Path) -> Path:
|
|
252
|
+
windows = (env_root / "Scripts").is_dir()
|
|
253
|
+
return (env_root / "Scripts" / "python.exe" if windows
|
|
254
|
+
else env_root / "bin" / "python")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _tool_kind(env_root: Path) -> Optional[str]:
|
|
258
|
+
parts = [p.lower() for p in env_root.parts]
|
|
259
|
+
if any(parts[i:i + 2] == ["uv", "tools"] or
|
|
260
|
+
parts[i:i + 3] == ["uv", "data", "tools"]
|
|
261
|
+
for i in range(len(parts))):
|
|
262
|
+
return "uv tool"
|
|
263
|
+
if "pipx" in parts:
|
|
264
|
+
return "pipx"
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def is_pointer(version: str) -> bool:
|
|
269
|
+
"""Whether this version of the old name's package only points to
|
|
270
|
+
Exegete (0.14.1 on); anything unreadable counts as older."""
|
|
271
|
+
match = re.match(r"(\d+)\.(\d+)(?:\.(\d+))?", version)
|
|
272
|
+
if match is None:
|
|
273
|
+
return False
|
|
274
|
+
release = (int(match[1]), int(match[2]), int(match[3] or 0))
|
|
275
|
+
return release >= POINTER_SINCE
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _is_file(path: Path) -> bool:
|
|
279
|
+
try:
|
|
280
|
+
return path.is_file()
|
|
281
|
+
except (OSError, ValueError):
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _command_at(path: Path) -> bool:
|
|
286
|
+
"""Whether a command exists at `path` (with `.exe` on Windows)."""
|
|
287
|
+
return _is_file(path) or (os.name == "nt" and
|
|
288
|
+
_is_file(path.with_name(path.name + ".exe")))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _inside(path: str, root: Path) -> bool:
|
|
292
|
+
try:
|
|
293
|
+
real = Path(os.path.realpath(path))
|
|
294
|
+
top = Path(os.path.realpath(root))
|
|
295
|
+
except (OSError, ValueError):
|
|
296
|
+
return False
|
|
297
|
+
return real == top or top in real.parents
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class OldInstall:
|
|
301
|
+
"""The old package in one environment, and how it was installed."""
|
|
302
|
+
|
|
303
|
+
def __init__(self, root: Path, info: Path, home: Path):
|
|
304
|
+
self.root, self.info = root, info
|
|
305
|
+
self.version = info.name[:-len(".dist-info")].partition("-")[2]
|
|
306
|
+
self.pointer = is_pointer(self.version)
|
|
307
|
+
self.tool = _tool_kind(root)
|
|
308
|
+
self.python = _python_of(root)
|
|
309
|
+
try:
|
|
310
|
+
self.installer = (info / "INSTALLER").read_text(
|
|
311
|
+
encoding="utf-8").strip()
|
|
312
|
+
except (OSError, ValueError):
|
|
313
|
+
self.installer = "pip"
|
|
314
|
+
self.editable = False
|
|
315
|
+
try:
|
|
316
|
+
direct = json.loads((info / "direct_url.json").read_text(
|
|
317
|
+
encoding="utf-8"))
|
|
318
|
+
self.editable = bool(direct.get("dir_info", {}).get("editable"))
|
|
319
|
+
except Exception: # a malformed file: no
|
|
320
|
+
pass
|
|
321
|
+
# folders where the old command is linked from this environment
|
|
322
|
+
self.command_folders: List[Path] = []
|
|
323
|
+
self.needs_exegete = False
|
|
324
|
+
|
|
325
|
+
def pip(self, *args: str) -> List[str]:
|
|
326
|
+
if self.installer == "uv":
|
|
327
|
+
return ["uv", "pip", args[0], "--python", str(self.python),
|
|
328
|
+
*args[1:]]
|
|
329
|
+
return [str(self.python), "-m", "pip", *args]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
# Host configurations (read only)
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
def _read_text(path: Path) -> Optional[str]:
|
|
337
|
+
try:
|
|
338
|
+
if path.stat().st_size > CONFIG_READ_MAX_BYTES:
|
|
339
|
+
return None
|
|
340
|
+
return path.read_text(encoding="utf-8-sig")
|
|
341
|
+
except (OSError, ValueError):
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _read_json(path: Path) -> Optional[dict]:
|
|
346
|
+
text = _read_text(path)
|
|
347
|
+
if text is None:
|
|
348
|
+
return None
|
|
349
|
+
try:
|
|
350
|
+
data = json.loads(text)
|
|
351
|
+
except Exception:
|
|
352
|
+
# The whole class, as project_settings reads its file: a deeply
|
|
353
|
+
# nested file makes CPython's parser raise RecursionError, which
|
|
354
|
+
# is not a ValueError, and a host's file is not ours to trust.
|
|
355
|
+
return None
|
|
356
|
+
return data if isinstance(data, dict) else None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _toml_servers(text: str) -> Dict[str, dict]:
|
|
360
|
+
"""Codex's [mcp_servers.NAME] tables: tomllib, or on Python 3.10 a
|
|
361
|
+
reading of the `command` and `args` lines alone."""
|
|
362
|
+
if tomllib is not None:
|
|
363
|
+
try:
|
|
364
|
+
data = tomllib.loads(text)
|
|
365
|
+
except Exception: # RecursionError too
|
|
366
|
+
return {}
|
|
367
|
+
servers = data.get("mcp_servers")
|
|
368
|
+
return servers if isinstance(servers, dict) else {}
|
|
369
|
+
servers: Dict[str, dict] = {}
|
|
370
|
+
current = None
|
|
371
|
+
for line in text.splitlines():
|
|
372
|
+
head = re.match(r'\s*\[mcp_servers\.("?)([^"\]]+)\1\]\s*$', line)
|
|
373
|
+
if head:
|
|
374
|
+
current = servers.setdefault(head.group(2), {})
|
|
375
|
+
continue
|
|
376
|
+
if line.strip().startswith("["):
|
|
377
|
+
current = None
|
|
378
|
+
continue
|
|
379
|
+
if current is None:
|
|
380
|
+
continue
|
|
381
|
+
pair = re.match(r'\s*(command|args)\s*=\s*(.+)$', line)
|
|
382
|
+
if pair:
|
|
383
|
+
try:
|
|
384
|
+
current[pair.group(1)] = json.loads(pair.group(2))
|
|
385
|
+
except Exception:
|
|
386
|
+
pass
|
|
387
|
+
return servers
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _servers(label: str, path: Path) -> List[Tuple[str, dict]]:
|
|
391
|
+
"""(entry name, entry) for every MCP server entry in a host's file;
|
|
392
|
+
none when the file cannot be read or is not shaped as expected."""
|
|
393
|
+
if label == "Codex":
|
|
394
|
+
text = _read_text(path)
|
|
395
|
+
if text is None:
|
|
396
|
+
return []
|
|
397
|
+
return [(str(n), e) for n, e in _toml_servers(text).items()
|
|
398
|
+
if isinstance(e, dict)]
|
|
399
|
+
data = _read_json(path)
|
|
400
|
+
if data is None:
|
|
401
|
+
return []
|
|
402
|
+
tables = [data.get("mcpServers")]
|
|
403
|
+
projects = data.get("projects")
|
|
404
|
+
if label == "Claude Code" and isinstance(projects, dict):
|
|
405
|
+
tables += [project.get("mcpServers") for project in projects.values()
|
|
406
|
+
if isinstance(project, dict)]
|
|
407
|
+
found: List[Tuple[str, dict]] = []
|
|
408
|
+
for table in tables:
|
|
409
|
+
if isinstance(table, dict):
|
|
410
|
+
found += [(str(n), e) for n, e in table.items()
|
|
411
|
+
if isinstance(e, dict)]
|
|
412
|
+
return found
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _renamed(value: str) -> str:
|
|
416
|
+
"""The old command or module renamed; a pin to an old version goes
|
|
417
|
+
(Exegete's first release is 0.14.1)."""
|
|
418
|
+
if value == _OLD_MODULE:
|
|
419
|
+
return _NEW_MODULE
|
|
420
|
+
match = _OLD_ARG.search(value)
|
|
421
|
+
if match:
|
|
422
|
+
return value[:match.start()] + match.group(1) + names.COMMAND + \
|
|
423
|
+
(match.group(2) or "")
|
|
424
|
+
return value
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
_FOLDER_OPTIONS = ("--directory", "--project", "--cwd", "-C")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _is_folder(path: str) -> bool:
|
|
431
|
+
try:
|
|
432
|
+
return os.path.isdir(path)
|
|
433
|
+
except (OSError, ValueError):
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def old_entry(entry: dict) -> Optional[Tuple[str, List[str]]]:
|
|
438
|
+
"""The entry to use instead, when this one starts the old command."""
|
|
439
|
+
command = entry.get("command")
|
|
440
|
+
args = entry.get("args") or []
|
|
441
|
+
if not isinstance(command, str) or not isinstance(args, list) or \
|
|
442
|
+
not all(isinstance(a, str) for a in args):
|
|
443
|
+
return None
|
|
444
|
+
new_args, previous = [], command
|
|
445
|
+
for arg in args:
|
|
446
|
+
# a folder named qualcoder-mcp (a clone, uv's --directory) keeps
|
|
447
|
+
# its name: only the command and the module change
|
|
448
|
+
folder = previous in _FOLDER_OPTIONS or _is_folder(arg)
|
|
449
|
+
new_args.append(arg if folder else _renamed(arg))
|
|
450
|
+
previous = arg
|
|
451
|
+
new_command = _renamed(command)
|
|
452
|
+
if new_command == command and new_args == args:
|
|
453
|
+
return None
|
|
454
|
+
return new_command, new_args
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class OldEntry:
|
|
458
|
+
"""A host's entry that still starts the old command."""
|
|
459
|
+
|
|
460
|
+
def __init__(self, label: str, path: Path, name: str, entry: dict,
|
|
461
|
+
command: str, args: List[str]):
|
|
462
|
+
self.label, self.path, self.name = label, path, name
|
|
463
|
+
self.old_command = entry["command"]
|
|
464
|
+
self.old_args = list(entry.get("args") or [])
|
|
465
|
+
self.command, self.args = command, args
|
|
466
|
+
env = entry.get("env") if isinstance(entry.get("env"), dict) else {}
|
|
467
|
+
self.earlier = sorted(str(k) for k in env if str(k).startswith(
|
|
468
|
+
("QUALCODER_MCP_", "QUALCODER_PROJECT_PATH")))
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def old_entries(home: Path) -> List[OldEntry]:
|
|
472
|
+
found = []
|
|
473
|
+
for label, path in host_config_files(home):
|
|
474
|
+
if not _is_file(path):
|
|
475
|
+
continue
|
|
476
|
+
for name, entry in _servers(label, path):
|
|
477
|
+
new = old_entry(entry)
|
|
478
|
+
if new is not None:
|
|
479
|
+
found.append(OldEntry(label, path, name, entry, *new))
|
|
480
|
+
return found
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _as_path(command: str, home: Path) -> Optional[str]:
|
|
484
|
+
"""A command given as a path, in full (`~` read as the home folder,
|
|
485
|
+
as a host that expands it would); None for a bare name."""
|
|
486
|
+
if command[:2] in ("~/", "~\\"):
|
|
487
|
+
return str(home / command[2:])
|
|
488
|
+
rooted = os.path.isabs(command) or command.startswith(("/", "\\"))
|
|
489
|
+
return command if rooted else None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _word(text: str) -> str:
|
|
493
|
+
"""A short value read from a file (a version, a setting's name) as it
|
|
494
|
+
is when plain, else quoted."""
|
|
495
|
+
return text if re.fullmatch(r"[A-Za-z0-9_.+!-]+", text) else quoted(text)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ---------------------------------------------------------------------------
|
|
499
|
+
# The old package: install Exegete first, change the entries, remove it
|
|
500
|
+
# ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
def old_installs(home: Path, prefix: Optional[Path] = None,
|
|
503
|
+
which: Callable[[str], Optional[str]] = shutil.which,
|
|
504
|
+
entries: Iterable[OldEntry] = ()) -> List[OldInstall]:
|
|
505
|
+
"""The old package in this environment, behind the old command on the
|
|
506
|
+
PATH or in a host's entry, and in uv's and pipx's tool folders."""
|
|
507
|
+
roots: List[Path] = [Path(sys.prefix if prefix is None else prefix)]
|
|
508
|
+
commands: List[str] = []
|
|
509
|
+
found = which(names.OLD_COMMAND)
|
|
510
|
+
if found:
|
|
511
|
+
commands.append(found)
|
|
512
|
+
for entry in entries:
|
|
513
|
+
path = _as_path(entry.old_command, home)
|
|
514
|
+
if path is not None:
|
|
515
|
+
commands.append(path)
|
|
516
|
+
for command in commands:
|
|
517
|
+
roots.append(Path(command).parent.parent)
|
|
518
|
+
try:
|
|
519
|
+
roots.append(Path(os.path.realpath(command)).parent.parent)
|
|
520
|
+
except (OSError, ValueError):
|
|
521
|
+
pass
|
|
522
|
+
for _, folder in tool_folders(home):
|
|
523
|
+
roots.append(folder / names.OLD_DISTRIBUTION)
|
|
524
|
+
installs, seen = [], set()
|
|
525
|
+
for root in roots:
|
|
526
|
+
try:
|
|
527
|
+
key = os.path.normcase(os.path.realpath(root))
|
|
528
|
+
except (OSError, ValueError):
|
|
529
|
+
continue
|
|
530
|
+
if key in seen:
|
|
531
|
+
continue
|
|
532
|
+
seen.add(key)
|
|
533
|
+
info = _dist_info(root)
|
|
534
|
+
if info is None:
|
|
535
|
+
continue
|
|
536
|
+
install = OldInstall(root, info, home)
|
|
537
|
+
if install.tool:
|
|
538
|
+
# the old command linked from outside the tool's environment
|
|
539
|
+
install.command_folders = [
|
|
540
|
+
Path(c).parent for c in commands
|
|
541
|
+
if _inside(c, root) and not _inside(str(Path(c).parent),
|
|
542
|
+
root)]
|
|
543
|
+
folders = install.command_folders + [tool_command_folder(home)]
|
|
544
|
+
install.needs_exegete = not (
|
|
545
|
+
any(_dist_info(tools / names.DISTRIBUTION, "exegete")
|
|
546
|
+
for _, tools in tool_folders(home)) or
|
|
547
|
+
any(_command_at(f / names.COMMAND) for f in folders))
|
|
548
|
+
else:
|
|
549
|
+
install.needs_exegete = _dist_info(root, "exegete") is None
|
|
550
|
+
installs.append(install)
|
|
551
|
+
return installs
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _install_finding(install: OldInstall, home: Path) -> Finding:
|
|
555
|
+
place, version = _place(install.root, home), _word(install.version)
|
|
556
|
+
if install.tool:
|
|
557
|
+
argv = (["uv", "tool", "install", names.DISTRIBUTION]
|
|
558
|
+
if install.tool == "uv tool"
|
|
559
|
+
else ["pipx", "install", names.DISTRIBUTION])
|
|
560
|
+
return Finding(
|
|
561
|
+
f"The old package, qualcoder-mcp {version}, is installed with "
|
|
562
|
+
f"{install.tool}, in {place}, and Exegete is not: "
|
|
563
|
+
f"{install.tool} puts only a package's own commands on the "
|
|
564
|
+
f"PATH, so there is no exegete command yet.",
|
|
565
|
+
f"This comes first. {_shell()}:",
|
|
566
|
+
commands=[command_line(argv)],
|
|
567
|
+
after="It installs the exegete command that the steps below "
|
|
568
|
+
"point your hosts at. Until it has, change nothing else: "
|
|
569
|
+
"removing the old package first would leave no server.")
|
|
570
|
+
if install.editable:
|
|
571
|
+
return Finding(
|
|
572
|
+
f"{place} has the old package, qualcoder-mcp {version}, "
|
|
573
|
+
f"installed from a copy of the source, and not Exegete.",
|
|
574
|
+
f"This comes first: update your copy of the source. "
|
|
575
|
+
f"{_shell()}, in its folder:",
|
|
576
|
+
commands=["git pull",
|
|
577
|
+
command_line(install.pip("install", "-e", "."))])
|
|
578
|
+
return Finding(
|
|
579
|
+
f"{place} has the old package, qualcoder-mcp {version}, and not "
|
|
580
|
+
f"Exegete.",
|
|
581
|
+
f"This comes first. {_shell()}:",
|
|
582
|
+
commands=[command_line(install.pip("install", names.DISTRIBUTION))],
|
|
583
|
+
after="It installs Exegete in that environment, beside the old "
|
|
584
|
+
"package, which keeps working meanwhile.")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _removal_finding(install: OldInstall, home: Path) -> Finding:
|
|
588
|
+
if install.tool == "uv tool":
|
|
589
|
+
commands = [command_line(["uv", "tool", "uninstall",
|
|
590
|
+
names.OLD_DISTRIBUTION])]
|
|
591
|
+
elif install.tool == "pipx":
|
|
592
|
+
commands = [command_line(["pipx", "uninstall",
|
|
593
|
+
names.OLD_DISTRIBUTION])]
|
|
594
|
+
else:
|
|
595
|
+
commands = [command_line(install.pip("uninstall",
|
|
596
|
+
names.OLD_DISTRIBUTION))]
|
|
597
|
+
if install.editable:
|
|
598
|
+
commands.append(command_line(install.pip("install", "-e", ".")))
|
|
599
|
+
where = (f"{_shell()}, in the folder of your copy of the source:"
|
|
600
|
+
if install.editable and not install.tool else f"{_shell()}:")
|
|
601
|
+
return Finding(
|
|
602
|
+
f"The old package, qualcoder-mcp {_word(install.version)}, is still "
|
|
603
|
+
f"installed in {_place(install.root, home)}.",
|
|
604
|
+
f"Once no host entry starts it any more (the steps above), remove "
|
|
605
|
+
f"it. {where}",
|
|
606
|
+
commands=commands)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _same_root(a: Path, b: Path) -> bool:
|
|
610
|
+
try:
|
|
611
|
+
return os.path.normcase(os.path.realpath(a)) == \
|
|
612
|
+
os.path.normcase(os.path.realpath(b))
|
|
613
|
+
except (OSError, ValueError):
|
|
614
|
+
return False
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _provides(install: OldInstall, old_command: str, home: Path) -> bool:
|
|
618
|
+
"""Whether installing Exegete for `install` puts the new command
|
|
619
|
+
beside `old_command`: the old command is in its environment, or it is
|
|
620
|
+
a tool's and sits where the tool's commands go (on Windows uv copies
|
|
621
|
+
them there rather than linking them)."""
|
|
622
|
+
if _inside(old_command, install.root):
|
|
623
|
+
return True
|
|
624
|
+
folder = Path(old_command).parent
|
|
625
|
+
return bool(install.tool) and any(
|
|
626
|
+
_same_root(folder, f) for f in
|
|
627
|
+
install.command_folders + [tool_command_folder(home)])
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _readiness(entry: OldEntry, home: Path,
|
|
631
|
+
which: Callable[[str], Optional[str]],
|
|
632
|
+
installs: List[OldInstall]) -> str:
|
|
633
|
+
"""'' when the entry's new command is at hand (or cannot be judged
|
|
634
|
+
from here), else what has to come first."""
|
|
635
|
+
after_install = ("Make this change only after the step above that "
|
|
636
|
+
"installs Exegete: until then "
|
|
637
|
+
f"{quoted(entry.command)} does not exist.")
|
|
638
|
+
keep = ("or leave this entry as it is for now (the old way of "
|
|
639
|
+
"starting keeps working until v1.0).")
|
|
640
|
+
if entry.command != entry.old_command:
|
|
641
|
+
new = _as_path(entry.command, home)
|
|
642
|
+
old = _as_path(entry.old_command, home)
|
|
643
|
+
if new is None:
|
|
644
|
+
old = which(entry.old_command)
|
|
645
|
+
if which(entry.command) or (
|
|
646
|
+
old and _command_at(Path(old).parent / entry.command)):
|
|
647
|
+
return ""
|
|
648
|
+
elif _command_at(Path(new)):
|
|
649
|
+
return ""
|
|
650
|
+
if old and any(i.needs_exegete and _provides(i, old, home)
|
|
651
|
+
for i in installs):
|
|
652
|
+
return after_install
|
|
653
|
+
return (f"{quoted(entry.command)} does not exist yet: install "
|
|
654
|
+
f"Exegete where the old command is installed first, {keep}")
|
|
655
|
+
if _NEW_MODULE in entry.args:
|
|
656
|
+
python = _as_path(entry.command, home) or which(entry.command)
|
|
657
|
+
if python is None:
|
|
658
|
+
return ""
|
|
659
|
+
root = Path(python).parent.parent
|
|
660
|
+
if _dist_info(root, "exegete") is not None:
|
|
661
|
+
return ""
|
|
662
|
+
if any(i.needs_exegete and _same_root(i.root, root)
|
|
663
|
+
for i in installs):
|
|
664
|
+
return after_install
|
|
665
|
+
install = command_line([python, "-m", "pip", "install",
|
|
666
|
+
names.DISTRIBUTION])
|
|
667
|
+
return (f"The Python it starts has no Exegete yet: install it "
|
|
668
|
+
f"there first ({_shell()}: {install}), {keep}")
|
|
669
|
+
for option, value in zip(entry.args, entry.args[1:]):
|
|
670
|
+
if option in ("--directory", "--project"):
|
|
671
|
+
folder = Path(_as_path(value, home) or value)
|
|
672
|
+
if not _is_file(folder / "src" / "exegete" / "server.py"):
|
|
673
|
+
return (f"The copy of the source in {_place(value, home)} "
|
|
674
|
+
f"has no Exegete yet: update it first (git pull in "
|
|
675
|
+
f"that folder), {keep}")
|
|
676
|
+
return ""
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _entry_finding(entry: OldEntry, home: Path,
|
|
680
|
+
which: Callable[[str], Optional[str]],
|
|
681
|
+
installs: List[OldInstall]) -> Finding:
|
|
682
|
+
toml = entry.label == "Codex"
|
|
683
|
+
command = quoted(entry.command, toml)
|
|
684
|
+
args = "[" + ", ".join(quoted(a, toml) for a in entry.args) + "]"
|
|
685
|
+
lines = ([f"command = {command}", f"args = {args}"] if toml else
|
|
686
|
+
[f'"command": {command},', f'"args": {args}'])
|
|
687
|
+
first = _readiness(entry, home, which, installs)
|
|
688
|
+
after = (f"Keep the rest of the entry as it is, save the file and "
|
|
689
|
+
f"reopen {entry.label}.")
|
|
690
|
+
known = [(k, _new_spelling(k)) for k in entry.earlier
|
|
691
|
+
if _new_spelling(k) is not None]
|
|
692
|
+
if known:
|
|
693
|
+
after += (" Its settings may keep their earlier spellings until "
|
|
694
|
+
"v1.0, or take the new ones: " + ", ".join(
|
|
695
|
+
f"{k} becomes {n}" for k, n in known) + ".")
|
|
696
|
+
return Finding(
|
|
697
|
+
f"{entry.label}'s entry {quoted(entry.name)} still starts the old "
|
|
698
|
+
f"command.",
|
|
699
|
+
(first + " " if first else "") +
|
|
700
|
+
f"Quit {entry.label}, then in {_place(entry.path, home)} change "
|
|
701
|
+
f"the entry {quoted(entry.name)} (keep its name) to start:",
|
|
702
|
+
commands=lines, after=after)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _new_spelling(old: str) -> Optional[str]:
|
|
706
|
+
for new, earlier in names.SETTINGS.values():
|
|
707
|
+
if earlier == old:
|
|
708
|
+
return new
|
|
709
|
+
return None
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
# ---------------------------------------------------------------------------
|
|
713
|
+
# Programs still started as qualcoder-mcp
|
|
714
|
+
# ---------------------------------------------------------------------------
|
|
715
|
+
|
|
716
|
+
def windows_powershell() -> Optional[str]:
|
|
717
|
+
"""Windows PowerShell's full path in the system folder, or None when
|
|
718
|
+
it is not there. Never a bare name: Windows looks a bare name up in
|
|
719
|
+
the current folder before the system folders, so a powershell.exe in
|
|
720
|
+
the folder the check is run from would be run instead."""
|
|
721
|
+
path = os.path.join(env_settings.windows_system_root(), "System32",
|
|
722
|
+
"WindowsPowerShell", "v1.0", "powershell.exe")
|
|
723
|
+
return path if os.path.isfile(path) else None
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
# ps by its full path, never a name looked up on the PATH (which could
|
|
727
|
+
# lead to another program that says nothing runs).
|
|
728
|
+
POSIX_PS = ("/bin/ps", "/usr/bin/ps")
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def posix_ps() -> Optional[str]:
|
|
732
|
+
for path in POSIX_PS:
|
|
733
|
+
if os.path.isfile(path):
|
|
734
|
+
return path
|
|
735
|
+
return None
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _process_table() -> Optional[List[Tuple[int, int, str]]]:
|
|
739
|
+
"""(process id, parent's id, command line) for every process, or None
|
|
740
|
+
when it cannot be read (then nothing is removed)."""
|
|
741
|
+
if os.name == "nt":
|
|
742
|
+
powershell = windows_powershell()
|
|
743
|
+
if powershell is None:
|
|
744
|
+
return None
|
|
745
|
+
cmd = [powershell, "-NoProfile", "-Command",
|
|
746
|
+
"Get-CimInstance Win32_Process | ForEach-Object "
|
|
747
|
+
"{ \"$($_.ProcessId) $($_.ParentProcessId) "
|
|
748
|
+
"$($_.CommandLine)\" }"]
|
|
749
|
+
else:
|
|
750
|
+
ps = posix_ps()
|
|
751
|
+
if ps is None:
|
|
752
|
+
return None
|
|
753
|
+
cmd = [ps, "-axo", "pid=,ppid=,args="]
|
|
754
|
+
try:
|
|
755
|
+
done = subprocess.run(cmd, capture_output=True, timeout=20,
|
|
756
|
+
check=False)
|
|
757
|
+
except (OSError, subprocess.SubprocessError):
|
|
758
|
+
return None
|
|
759
|
+
if done.returncode != 0:
|
|
760
|
+
return None
|
|
761
|
+
rows = []
|
|
762
|
+
for row in done.stdout.decode("utf-8", errors="replace").splitlines():
|
|
763
|
+
parts = row.strip().split(None, 2)
|
|
764
|
+
if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit():
|
|
765
|
+
rows.append((int(parts[0]), int(parts[1]),
|
|
766
|
+
parts[2] if len(parts) > 2 else ""))
|
|
767
|
+
return rows
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _own_ids() -> Tuple[int, int]:
|
|
771
|
+
"""This process's id and its parent's."""
|
|
772
|
+
return os.getpid(), os.getppid()
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _listing() -> Optional[List[str]]:
|
|
776
|
+
"""Every other process's command line, or None when it cannot be
|
|
777
|
+
read. The check itself and the programs that started it (uvx, pip's
|
|
778
|
+
launcher on Windows, the shell) are left out: they are not an older
|
|
779
|
+
copy of the server, whatever their command line says."""
|
|
780
|
+
rows = _process_table()
|
|
781
|
+
if rows is None:
|
|
782
|
+
return None
|
|
783
|
+
parent = {pid: ppid for pid, ppid, _ in rows}
|
|
784
|
+
own, current = _own_ids()
|
|
785
|
+
mine = {own, current}
|
|
786
|
+
for _ in range(256): # a loop in the table ends
|
|
787
|
+
current = parent.get(current)
|
|
788
|
+
if not current or current in mine:
|
|
789
|
+
break
|
|
790
|
+
mine.add(current)
|
|
791
|
+
return [args for pid, _, args in rows
|
|
792
|
+
if pid not in mine and "--check-transition" not in args.split()]
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def started_as_old(lines: Iterable[str]) -> List[str]:
|
|
796
|
+
"""The command lines that run the old command or module."""
|
|
797
|
+
hits = []
|
|
798
|
+
for line in lines:
|
|
799
|
+
words = [w.strip("\"'") for w in line.split()]
|
|
800
|
+
if any(_OLD_ARG.search(w) for w in words) or _OLD_MODULE in words:
|
|
801
|
+
hits.append(line[:200])
|
|
802
|
+
return hits
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
# ---------------------------------------------------------------------------
|
|
806
|
+
# The state folder's link, the old logs and the old projects folder
|
|
807
|
+
# ---------------------------------------------------------------------------
|
|
808
|
+
|
|
809
|
+
def _remove_link(link: Path, home: Path) -> Tuple[bool, str]:
|
|
810
|
+
"""Remove the link (a junction on Windows) and never what it leads to;
|
|
811
|
+
refuse anything that is not a link to ~/.exegete by then."""
|
|
812
|
+
new = link.with_name(names.STATE_FOLDER)
|
|
813
|
+
shown, shown_new = _place(link, home), _place(new, home)
|
|
814
|
+
if not state_folder.is_link(link) or \
|
|
815
|
+
not state_folder.same_folder(link, new):
|
|
816
|
+
return False, f"Left {shown}: it is no longer a link to {shown_new}."
|
|
817
|
+
try:
|
|
818
|
+
if os.name == "nt" and not os.path.islink(link):
|
|
819
|
+
os.rmdir(link) # a junction: the link alone
|
|
820
|
+
else:
|
|
821
|
+
os.unlink(link)
|
|
822
|
+
except OSError as error:
|
|
823
|
+
return False, f"Could not remove {shown} ({type(error).__name__})."
|
|
824
|
+
return True, f"Removed the link {shown}; {shown_new} is untouched."
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def state_link(home: Path, busy: Optional[List[str]],
|
|
828
|
+
holders: Iterable[str] = ()) -> List[Finding]:
|
|
829
|
+
"""`holders`: what could still start an older copy that uses the link
|
|
830
|
+
(an old package below 0.14.1, a host's entry for the old command)."""
|
|
831
|
+
old = state_folder.old_path(home)
|
|
832
|
+
new = state_folder.new_path(home)
|
|
833
|
+
shown, shown_new = _place(old, home), _place(new, home)
|
|
834
|
+
holders = list(holders)
|
|
835
|
+
if not os.path.lexists(old):
|
|
836
|
+
return []
|
|
837
|
+
if not state_folder.is_link(old) and not os.path.lexists(new):
|
|
838
|
+
return [Finding(
|
|
839
|
+
f"{shown} has not been moved to {shown_new} yet.",
|
|
840
|
+
"Start Exegete once through your AI host: its first start moves "
|
|
841
|
+
"the folder whole and leaves a link under the old name. Then "
|
|
842
|
+
"run the check again.")]
|
|
843
|
+
if not state_folder.is_link(old):
|
|
844
|
+
return [Finding(
|
|
845
|
+
f"{shown} is a folder of its own, beside {shown_new}: an older "
|
|
846
|
+
f"copy of the server (0.14 or earlier) made it, with a secret "
|
|
847
|
+
f"key of its own, which Exegete does not use.",
|
|
848
|
+
"Quit or update that older copy first. Keeping the folder changes "
|
|
849
|
+
"nothing for Exegete; removing it removes that copy's secret, "
|
|
850
|
+
"sessions and privacy run records, so it is yours to decide. "
|
|
851
|
+
"This check never removes a folder.")]
|
|
852
|
+
if not state_folder.same_folder(old, new) or not new.is_dir():
|
|
853
|
+
return [Finding(
|
|
854
|
+
f"{shown} is a link that does not lead to {shown_new}.",
|
|
855
|
+
"See where it leads before removing it yourself; this check "
|
|
856
|
+
"removes only a link to Exegete's own folder.")]
|
|
857
|
+
if busy is None:
|
|
858
|
+
return [Finding(
|
|
859
|
+
f"{shown} is the link the move left, leading to {shown_new}.",
|
|
860
|
+
"Whether a program started as qualcoder-mcp is still running "
|
|
861
|
+
"could not be read, so the link stays; quit every AI host, "
|
|
862
|
+
"then run the check again.")]
|
|
863
|
+
if busy:
|
|
864
|
+
return [Finding(
|
|
865
|
+
f"{shown} is the link the move left, leading to {shown_new}; a "
|
|
866
|
+
f"program started as qualcoder-mcp is running and may still "
|
|
867
|
+
f"use it.",
|
|
868
|
+
"Quit it (and change its entry, above) first, then run "
|
|
869
|
+
"`exegete --check-transition --tidy`.")]
|
|
870
|
+
if holders:
|
|
871
|
+
return [Finding(
|
|
872
|
+
f"{shown} is the link the move left, leading to {shown_new}. "
|
|
873
|
+
f"Nothing started as qualcoder-mcp is running, but an older "
|
|
874
|
+
f"copy could still be started: " + "; ".join(holders) + ".",
|
|
875
|
+
"The link stays until the steps above are done: an older copy "
|
|
876
|
+
"started without it would make a folder of its own, with a "
|
|
877
|
+
"second secret key. Then run `exegete --check-transition "
|
|
878
|
+
"--tidy`.")]
|
|
879
|
+
return [Finding(
|
|
880
|
+
f"{shown} is the link the move left, leading to {shown_new}, and "
|
|
881
|
+
f"nothing started as qualcoder-mcp is running or left to start.",
|
|
882
|
+
"`exegete --check-transition --tidy` removes the link (only the "
|
|
883
|
+
"link: the folder it leads to is untouched).",
|
|
884
|
+
tidy=lambda: _remove_link(old, home))]
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def old_logs(home: Path, busy: Optional[List[str]]) -> List[Finding]:
|
|
888
|
+
findings = []
|
|
889
|
+
for folder in claude_log_folders(home):
|
|
890
|
+
try:
|
|
891
|
+
entries = sorted(folder.iterdir())
|
|
892
|
+
except OSError:
|
|
893
|
+
continue
|
|
894
|
+
logs = [p for p in entries if OLD_LOG.fullmatch(p.name)
|
|
895
|
+
and p.is_file() and not p.is_symlink()]
|
|
896
|
+
if not logs:
|
|
897
|
+
continue
|
|
898
|
+
if busy is None or busy:
|
|
899
|
+
step = ("They may still be written while a program started as "
|
|
900
|
+
"qualcoder-mcp runs; quit it and run the check again.")
|
|
901
|
+
tidy = None
|
|
902
|
+
else:
|
|
903
|
+
step = ("Claude Desktop no longer writes them (the extension "
|
|
904
|
+
"logs as Exegete). `exegete --check-transition --tidy "
|
|
905
|
+
"--tidy-old-logs` removes them; they are only logs.")
|
|
906
|
+
tidy = (lambda logs=logs: _remove_logs(logs))
|
|
907
|
+
findings.append(Finding(
|
|
908
|
+
f"Claude Desktop's logs under the extension's earlier name, in "
|
|
909
|
+
f"{_place(folder, home)}: {', '.join(p.name for p in logs)}.",
|
|
910
|
+
step, tidy=tidy, is_log=True))
|
|
911
|
+
return findings
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _remove_logs(logs: List[Path]) -> Tuple[bool, str]:
|
|
915
|
+
removed = []
|
|
916
|
+
for path in logs:
|
|
917
|
+
try:
|
|
918
|
+
if OLD_LOG.fullmatch(path.name) and path.is_file() and \
|
|
919
|
+
not path.is_symlink():
|
|
920
|
+
path.unlink()
|
|
921
|
+
removed.append(path.name)
|
|
922
|
+
except OSError:
|
|
923
|
+
continue
|
|
924
|
+
return (len(removed) == len(logs),
|
|
925
|
+
f"Removed the old logs: {', '.join(removed) or 'none'}.")
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _projects_in(folder: Path) -> Tuple[List[str], List[str]]:
|
|
929
|
+
"""(projects, backups) in `folder`, as paths relative to it: every
|
|
930
|
+
`*.qda` up to three levels down, as list_available_projects counts
|
|
931
|
+
depth; a project folder is not searched inside."""
|
|
932
|
+
projects: List[str] = []
|
|
933
|
+
backups: List[str] = []
|
|
934
|
+
for root, folders, files in os.walk(folder):
|
|
935
|
+
relative = Path(root).relative_to(folder)
|
|
936
|
+
for name in sorted(folders + files):
|
|
937
|
+
if name.lower().endswith(".qda"):
|
|
938
|
+
stem = name[:-4]
|
|
939
|
+
(backups if "_backup_" in stem or "_BKUP_" in stem
|
|
940
|
+
else projects).append(str(relative / name))
|
|
941
|
+
folders[:] = sorted(
|
|
942
|
+
f for f in folders if not f.lower().endswith(".qda")
|
|
943
|
+
and len(relative.parts) + 1 < PROJECT_DEPTH)
|
|
944
|
+
return projects, backups
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def old_projects_folder(home: Path) -> List[Finding]:
|
|
948
|
+
folder = home / "Documents" / names.OLD_WORKSPACE_FOLDER
|
|
949
|
+
try:
|
|
950
|
+
with os.scandir(folder) as entries:
|
|
951
|
+
top = sorted(e.name for e in entries)
|
|
952
|
+
except OSError:
|
|
953
|
+
return []
|
|
954
|
+
shown = _place(folder, home)
|
|
955
|
+
if not top:
|
|
956
|
+
return [Finding(
|
|
957
|
+
f"{shown}, the earlier projects folder, is empty.",
|
|
958
|
+
"You may remove it yourself if you no longer want it; this check "
|
|
959
|
+
"never removes a folder.")]
|
|
960
|
+
projects, backups = _projects_in(folder)
|
|
961
|
+
others = [name for name in top if not name.lower().endswith(".qda")]
|
|
962
|
+
held = []
|
|
963
|
+
if projects:
|
|
964
|
+
held.append(f"{len(projects)} project(s), up to three folders "
|
|
965
|
+
f"down: {_listed(projects)}")
|
|
966
|
+
if backups:
|
|
967
|
+
held.append(f"{len(backups)} backup(s): {_listed(backups)}")
|
|
968
|
+
if others:
|
|
969
|
+
held.append(("and at its top level " if held else "") +
|
|
970
|
+
f"{_listed(others)}")
|
|
971
|
+
if projects or backups:
|
|
972
|
+
what = (f"{shown}, the earlier projects folder, holds " +
|
|
973
|
+
"; ".join(held) + ".")
|
|
974
|
+
step = ("Nothing to do: Exegete never moves or empties it, and "
|
|
975
|
+
"list_available_projects still searches it, as part of "
|
|
976
|
+
"Documents. To keep working there, set EXEGETE_WORKSPACE "
|
|
977
|
+
"to it.")
|
|
978
|
+
else:
|
|
979
|
+
what = (f"{shown}, the earlier projects folder, holds no project "
|
|
980
|
+
f"within three folders of it, but it is not empty: "
|
|
981
|
+
f"{_listed(others)}.")
|
|
982
|
+
step = ("Nothing to do: look through what is there before you "
|
|
983
|
+
"decide whether to keep it.")
|
|
984
|
+
return [Finding(what, step + " This check never removes a folder.",
|
|
985
|
+
left=False)]
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
# ---------------------------------------------------------------------------
|
|
989
|
+
# The check
|
|
990
|
+
# ---------------------------------------------------------------------------
|
|
991
|
+
|
|
992
|
+
HEADING = "Moving from qualcoder-mcp to Exegete: what the change left behind"
|
|
993
|
+
NOTHING_LEFT = ("Nothing is left behind: the move from qualcoder-mcp to "
|
|
994
|
+
"Exegete is complete on this computer.")
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def check(home: Optional[Path] = None, prefix: Optional[Path] = None,
|
|
998
|
+
which: Callable[[str], Optional[str]] = shutil.which,
|
|
999
|
+
listing: Callable[[], Optional[List[str]]] = _listing
|
|
1000
|
+
) -> List[Finding]:
|
|
1001
|
+
"""Everything, read-only, in the order to take the steps: Exegete
|
|
1002
|
+
where it is missing, then the hosts' entries, then the old package,
|
|
1003
|
+
then the link, the logs and the projects folder."""
|
|
1004
|
+
home = Path.home() if home is None else Path(home)
|
|
1005
|
+
lines = listing()
|
|
1006
|
+
busy = None if lines is None else started_as_old(lines)
|
|
1007
|
+
entries = old_entries(home)
|
|
1008
|
+
installs = old_installs(home, prefix, which, entries)
|
|
1009
|
+
holders = [f"qualcoder-mcp {_word(i.version)} in {_place(i.root, home)}"
|
|
1010
|
+
for i in installs if not i.pointer]
|
|
1011
|
+
holders += [f"{e.label}'s entry {quoted(e.name)}" for e in entries]
|
|
1012
|
+
return ([_install_finding(i, home) for i in installs
|
|
1013
|
+
if i.needs_exegete] +
|
|
1014
|
+
[_entry_finding(e, home, which, installs) for e in entries] +
|
|
1015
|
+
[_removal_finding(i, home) for i in installs] +
|
|
1016
|
+
state_link(home, busy, holders) + old_logs(home, busy) +
|
|
1017
|
+
old_projects_folder(home))
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def run(tidy: bool = False, tidy_old_logs: bool = False,
|
|
1021
|
+
out=None, **where) -> int:
|
|
1022
|
+
"""Print the check (and, with `tidy`, what it removed); the exit code
|
|
1023
|
+
is 0 when nothing is left behind, else 1."""
|
|
1024
|
+
out = sys.stdout if out is None else out
|
|
1025
|
+
findings = check(**where)
|
|
1026
|
+
print(HEADING, file=out)
|
|
1027
|
+
print("", file=out)
|
|
1028
|
+
if not any(f.left for f in findings):
|
|
1029
|
+
print(NOTHING_LEFT, file=out)
|
|
1030
|
+
left = 0
|
|
1031
|
+
for number, finding in enumerate(findings, 1):
|
|
1032
|
+
print(f"{number}. {finding.what}", file=out)
|
|
1033
|
+
print(f" What to do: {finding.step}", file=out)
|
|
1034
|
+
for line in finding.commands:
|
|
1035
|
+
print(f" {line}", file=out)
|
|
1036
|
+
if finding.after:
|
|
1037
|
+
print(f" {finding.after}", file=out)
|
|
1038
|
+
if tidy and finding.tidy is not None and \
|
|
1039
|
+
(tidy_old_logs or not finding.is_log):
|
|
1040
|
+
done, text = finding.tidy()
|
|
1041
|
+
print(f" {'Done' if done else 'Not done'}: {text}", file=out)
|
|
1042
|
+
if done:
|
|
1043
|
+
continue
|
|
1044
|
+
if finding.left:
|
|
1045
|
+
left += 1
|
|
1046
|
+
print("", file=out)
|
|
1047
|
+
print("Read-only unless --tidy was given; projects, backups and hosts' "
|
|
1048
|
+
"configuration files are never changed.", file=out)
|
|
1049
|
+
return 0 if left == 0 else 1
|