Files
Artemis-Eapil/scripts/update_installation.py
T

326 lines
13 KiB
Python

"""Check an existing checkout and refresh only already-configured local MCP clients."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import tomllib
import urllib.request
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1]
def git(root: Path, *args: str, timeout: int = 15) -> str:
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "Never"}
result = subprocess.run(
["git", "-C", str(root), *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=env,
timeout=timeout,
)
if result.returncode:
# Remote diagnostics can contain URLs/userinfo. Keep them out of launcher logs.
raise RuntimeError(
f"git {args[0]} failed (exit {result.returncode}); check network, credentials or repository state in Git."
)
return result.stdout.strip()
def update_source(root: Path, port: int = 8000) -> int:
"""Return 10 after a fast-forward (caller reloads launcher); never overwrite local work."""
try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(f"http://127.0.0.1:{port}/api/status", timeout=2) as response:
status = json.load(response)
if (
status.get("active_tasks")
or status.get("queue")
or (status.get("status") == "running" and status.get("pid"))
):
print(
"[WAIT] Tasks are active. Finish/stop them before updating dependencies or restarting."
)
return 20
except (OSError, ValueError):
pass
if not shutil.which("git"):
print(
"[SKIP] Git is not installed. Continuing with local source; install Git for automatic source updates."
)
return 0
if not (root / ".git").exists():
print(
"[SKIP] This source ZIP has no Git metadata. Use a Git clone for automatic source updates; local files are preserved."
)
return 0
try:
branch = git(root, "symbolic-ref", "--quiet", "--short", "HEAD")
remote = git(root, "config", "--get", f"branch.{branch}.remote")
merge_ref = git(root, "config", "--get", f"branch.{branch}.merge")
if remote == "." or remote.startswith("-") or not merge_ref.startswith("refs/heads/"):
raise RuntimeError(
"No remote tracking branch; configure an upstream before enabling source updates."
)
url = git(root, "remote", "get-url", remote)
parsed = urlsplit(url)
if parsed.scheme in {"http", "https"} and (parsed.username or parsed.password):
raise RuntimeError(
"Use a clean Git remote URL and a credential manager, not embedded credentials."
)
print("[CHECK] Checking the current branch's configured upstream...")
git(root, "fetch", "--no-tags", remote, merge_ref, timeout=45)
target = git(root, "rev-parse", "FETCH_HEAD")
current = git(root, "rev-parse", "HEAD")
if current == target:
print("[OK] Source is up to date.")
return 0
if git(root, "status", "--porcelain"):
print(
"[SKIP] Upstream differs, but local changes exist. Commit/back up your work before updating; continuing locally."
)
return 0
ancestor = git(root, "merge-base", "HEAD", target)
if ancestor != current:
print(
"[SKIP] Local branch is ahead or diverged. No automatic merge/rebase; continuing locally."
)
return 0
git(root, "merge", "--ff-only", target, timeout=30)
print(
f"[UPDATED] Source {current[:12]} -> {target[:12]}. Reloading launcher and checking dependencies."
)
return 10
except (RuntimeError, subprocess.TimeoutExpired) as exc:
print(
f"[WARN] {exc if isinstance(exc, RuntimeError) else 'Git update timed out.'} Continuing with local source."
)
return 0
def _atomic_write(path: Path, text: str) -> None:
if path.read_text(encoding="utf-8") == text:
return
backup = path.with_name(path.name + ".artemis-update.bak")
if not backup.exists():
shutil.copy2(path, backup)
fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream:
stream.write(text)
os.replace(name, path)
finally:
Path(name).unlink(missing_ok=True)
def _belongs_to_project(config: dict, root: Path) -> bool:
if config.get("url"):
return False
args = config.get("args", [])
if args[:2] not in (["-m", "mcp_server"], ["-m", "mcp_server.server"]):
# A hand-written wrapper/uv/SSE command may need custom arguments; leave it alone.
return False
candidates = [config.get("cwd"), config.get("env", {}).get("PYTHONPATH")]
for value in candidates:
if isinstance(value, str) and Path(value).resolve() == root.resolve():
return True
command = config.get("command")
return (
isinstance(command, str)
and Path(command).resolve().parent == (root / ".venv" / "Scripts").resolve()
)
def _tool_updates(config: dict, names: tuple[str, ...]) -> dict:
updates = {}
if "enabled_tools" in config:
updates["enabled_tools"] = list(dict.fromkeys([*config["enabled_tools"], *names]))
if isinstance(config.get("tools"), dict):
updates["tools"] = {**{name: {"eager": True} for name in names}, **config["tools"]}
return updates
def refresh_json(
path: Path, keys: tuple[str, ...], root: Path, python: str, names: tuple[str, ...]
) -> bool:
if not path.is_file():
return False
raw = path.read_text(encoding="utf-8")
data = json.loads(raw)
parent = data
for key in keys:
parent = parent.get(key, {})
config = parent.get("artemis")
if not isinstance(config, dict) or not _belongs_to_project(config, root):
return False
updated = {**config, "command": python, **_tool_updates(config, names)}
if updated == config:
return False
parent["artemis"] = updated
_atomic_write(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
return True
def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -> bool:
if not path.is_file():
return False
raw = path.read_text(encoding="utf-8")
config = tomllib.loads(raw).get("mcp_servers", {}).get("artemis")
if not isinstance(config, dict) or not _belongs_to_project(config, root):
return False
wanted = {"command": python, **_tool_updates(config, names)}
wanted = {key: value for key, value in wanted.items() if config.get(key) != value}
if not wanted:
return False
lines = raw.splitlines(keepends=True)
inside = False
found = set()
output = []
def append_missing():
for key, value in wanted.items():
if key not in found:
output.append(f"{key} = {json.dumps(value, ensure_ascii=False)}\n")
found.add(key)
# Replace complete assignment spans, including multiline arrays, using TOML parsing.
index = 0
while index < len(lines):
line = lines[index]
if line.lstrip().startswith("["):
if inside:
append_missing()
header = re.sub(r"[\s\"']", "", line.split("#", 1)[0]).strip()
inside = header == "[mcp_servers.artemis]"
match = re.match(r"\s*(\w+)\s*=", line) if inside else None
if match and match[1] in wanted:
key = match[1]
original = line
while True:
try:
tomllib.loads(original)
break
except tomllib.TOMLDecodeError:
index += 1
if index >= len(lines):
raise ValueError("Cannot safely parse existing MCP setting")
original += lines[index]
output.append(f"{key} = {json.dumps(wanted[key], ensure_ascii=False)}\n")
found.add(key)
else:
output.append(line)
index += 1
if inside:
append_missing()
updated = "".join(output)
parsed = tomllib.loads(updated)
if any(parsed["mcp_servers"]["artemis"].get(k) != v for k, v in wanted.items()):
raise ValueError("Could not locate the existing Artemis table safely")
_atomic_write(path, updated)
return updated != raw
def refresh_mcp(root: Path) -> None:
sys.path.insert(0, str(root))
from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_vscode_user_dir
home = Path.home()
vscode = _get_vscode_user_dir()
appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming"))
paths = [
(home / ".gemini/config/mcp_config.json", ("mcpServers",)),
(home / ".gemini/jetski/mcp_config.json", ("mcpServers",)),
(home / ".gemini/antigravity/mcp_config.json", ("mcpServers",)),
(home / ".cursor/mcp.json", ("mcpServers",)),
(home / ".codeium/windsurf/mcp_config.json", ("mcpServers",)),
(home / ".claude.json", ("mcpServers",)),
(appdata / "Claude/claude_desktop_config.json", ("mcpServers",)),
(vscode / "mcp.json", ("servers",)),
(home / ".copilot/mcp-config.json", ("servers",)),
(home / ".cline/data/settings/cline_mcp_settings.json", ("mcpServers",)),
(home / ".cline/mcp.json", ("mcpServers",)),
(
vscode / "globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
("mcpServers",),
),
(
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
("mcpServers",),
),
(
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json",
("mcpServers",),
),
(root / ".roo/mcp.json", ("mcpServers",)),
(home / ".openclaw/openclaw.json", ("mcp", "servers")),
]
changed = 0
codex = Path(os.environ.get("CODEX_HOME", home / ".codex")) / "config.toml"
for path, keys in [*paths, (codex, ())]:
try:
updated = (
refresh_json(path, keys, root, sys.executable, ARTEMIS_MCP_TOOLS)
if keys
else refresh_codex(path, root, sys.executable, ARTEMIS_MCP_TOOLS)
)
if updated:
print(f"[UPDATED] Existing MCP configuration: {path}")
changed += 1
except (OSError, ValueError, TypeError, AttributeError):
print(f"[WARN] Could not safely refresh {path}; original configuration preserved.")
print(
f"[OK] MCP source is current; refreshed {changed} existing client configuration(s). Reload the MCP connection in your AI client after a source update."
)
print("[TIP] For a new client run: uv run artemis mcp --install <client>")
def frontend_fingerprint(root: Path) -> str:
ui = root / "apps/showcase_ui"
files = list((ui / "src").rglob("*")) + list((ui / "public").rglob("*"))
files += [
ui / name
for name in (
"package.json",
"package-lock.json",
"angular.json",
"tsconfig.json",
"tsconfig.app.json",
)
]
digest = hashlib.sha256()
for path in sorted(p for p in files if p.is_file()):
digest.update(path.relative_to(ui).as_posix().encode())
digest.update(path.read_bytes())
return digest.hexdigest()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["source", "mcp", "frontend-check", "frontend-mark"])
parser.add_argument("--root", type=Path, default=ROOT)
parser.add_argument("--port", type=int, default=8000)
args = parser.parse_args()
if args.action == "source":
sys.exit(update_source(args.root, args.port))
elif args.action == "mcp":
refresh_mcp(args.root)
else:
marker = args.root / "apps/showcase_ui/node_modules/.artemis-source-hash"
current = frontend_fingerprint(args.root)
if args.action == "frontend-mark":
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text(current, encoding="ascii")
elif not marker.is_file() or marker.read_text(encoding="ascii").strip() != current:
sys.exit(10)