"""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 launch_text(english: str, chinese: str) -> str: return chinese if os.environ.get("ARTEMIS_LAUNCH_LANG") == "zh-CN" else english 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( launch_text( "[WAIT] Tasks are active. Finish/stop them before updating dependencies or restarting.", "[等待] 有任务正在执行或排队。请先处理任务,再更新依赖或重启。", ) ) return 20 except (OSError, ValueError): pass if not shutil.which("git"): print( launch_text( "[SKIP] Git is not installed. Continuing with local source; install Git for automatic source updates.", "[跳过] 未安装 Git,继续使用当前源码;安装 Git 后可自动检查更新。", ) ) return 0 if not (root / ".git").exists(): print( launch_text( "[SKIP] This older source ZIP has no Git metadata. Use a new release ZIP or Git clone for automatic updates; local files are preserved.", "[跳过] 旧版源码包不含 Git 信息。请使用新版源码包或 Git 克隆;当前文件保持不变。", ) ) 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( launch_text( "[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(launch_text("[OK] Source is up to date.", "[完成] 源码已是最新版。")) return 0 if git(root, "status", "--porcelain"): print( launch_text( "[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( launch_text( "[SKIP] Local branch is ahead or diverged. No automatic merge/rebase; continuing locally.", "[跳过] 本地分支领先或分叉,不自动合并;继续使用本地源码。", ) ) return 0 git(root, "merge", "--ff-only", target, timeout=30) print( launch_text( f"[UPDATED] Source {current[:12]} -> {target[:12]}. Reloading launcher and checking dependencies.", f"[已更新] 源码 {current[:12]} -> {target[:12]},重新载入启动脚本并检查依赖。", ) ) return 10 except (RuntimeError, subprocess.TimeoutExpired) as exc: print( launch_text( f"[WARN] {exc if isinstance(exc, RuntimeError) else 'Git update timed out.'} Continuing with local source.", "[提示] Git 更新失败或超时;请检查网络、凭据和仓库状态。继续使用当前源码。", ) ) 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( launch_text( f"[UPDATED] Existing MCP configuration: {path}", f"[已更新] 已有 MCP 配置:{path}", ) ) changed += 1 except (OSError, ValueError, TypeError, AttributeError): print( launch_text( f"[WARN] Could not safely refresh {path}; original configuration preserved.", f"[提示] 无法安全更新 {path};原配置已保留。", ) ) print( launch_text( f"[OK] MCP source is current; refreshed {changed} existing client configuration(s). Reload the MCP connection in your AI client after a source update.", f"[完成] MCP 源码已就绪;更新了 {changed} 个已有客户端配置。源码升级后请在 AI 客户端重新加载 MCP。", ) ) print( launch_text( "[TIP] For a new client run: uv run artemis mcp --install ", "[提示] 新客户端可执行: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)