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/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