"""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 ") 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)