Package a clean shallow main checkout with credential-free Gitea remote, localize the China launcher, and set the new-install Responses endpoint.
374 lines
15 KiB
Python
374 lines
15 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 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 <client>",
|
|
"[提示] 新客户端可执行: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)
|