From 0e13bb5c2927bb9e858ace4f43ab9f479ba55c61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E9=93=AD=E8=BD=A9?= Date: Thu, 24 Sep 2026 12:07:42 +0800 Subject: [PATCH] fix: synchronize MCP clients on launch --- README.md | 9 +- apps/showcase_ui/package-lock.json | 4 +- apps/showcase_ui/package.json | 2 +- docs/ai-automation.md | 4 +- docs/releases/1.1.2.md | 13 + pyproject.toml | 2 +- scripts/start.ps1 | 15 +- scripts/update_installation.py | 382 ++++++++++++++++++------- start-cn.bat | 3 +- tests/unit/test_installation_update.py | 163 ++++++++++- uv.lock | 2 +- 11 files changed, 475 insertions(+), 124 deletions(-) create mode 100644 docs/releases/1.1.2.md diff --git a/README.md b/README.md index 70c2bab..ac747bd 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,10 @@ cd Artemis-Eapil 常用选项: ```powershell -.\start-cn.bat -SetupOnly # 仅准备 Python 环境并检查已有 MCP 配置 +.\start-cn.bat -SetupOnly # 准备环境并安装/更新已检测客户端的 MCP .\start.bat -Port 8001 # 指定端口 .\start.bat -SkipUpdate # 跳过 Git 源码更新检查 +.\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页 ``` 镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。 @@ -55,13 +56,13 @@ cd Artemis-Eapil ## 让 AI 使用 -环境与模型配置完成后,例如为 Codex 接入 MCP: +Windows 启动脚本会为已检测到的 AI 客户端安装或更新 MCP。未被检测到的客户端,可手动安装,例如 Codex: ```sh uv run artemis mcp --install codex ``` -在客户端重新加载 MCP,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。其他客户端可通过 `uv run artemis mcp --help` 查看接入方式。 +安装或更新后,在客户端重新加载 MCP 或重启客户端,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。其他客户端可通过 `uv run artemis mcp --help` 查看接入方式。 示例指令: @@ -69,7 +70,7 @@ uv run artemis mcp --install codex ## 更新与排错 -Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建和已有 MCP 配置。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。更新后需在 AI 客户端重新加载 MCP。 +Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建并同步已检测客户端的 MCP。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。MCP 配置同步失败会明确停止启动,可使用 `-SkipMcpSync` 仅启动网页。更新后需在 AI 客户端重新加载 MCP。 1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树,解压后再次运行一键脚本即可检查更新。1.1.0 及更早 ZIP 不含 Git 信息,无法自行拉取;需要下载新版源码包重新解压一次,之后即可自动更新。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。 diff --git a/apps/showcase_ui/package-lock.json b/apps/showcase_ui/package-lock.json index 68db408..1858911 100644 --- a/apps/showcase_ui/package-lock.json +++ b/apps/showcase_ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "artemis-showcase-ui", - "version": "1.1.1", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis-showcase-ui", - "version": "1.1.1", + "version": "1.1.2", "dependencies": { "@angular/cdk": "^22.1.5", "@angular/common": "^22.1.4", diff --git a/apps/showcase_ui/package.json b/apps/showcase_ui/package.json index f215c2b..bb3a62f 100644 --- a/apps/showcase_ui/package.json +++ b/apps/showcase_ui/package.json @@ -1,6 +1,6 @@ { "name": "artemis-showcase-ui", - "version": "1.1.1", + "version": "1.1.2", "scripts": { "ng": "ng", "start": "ng serve --proxy-config proxy.conf.json", diff --git a/docs/ai-automation.md b/docs/ai-automation.md index 0d8a51c..33856b4 100644 --- a/docs/ai-automation.md +++ b/docs/ai-automation.md @@ -2,7 +2,7 @@ 源码内的 `skills/artemis-automation/` 是可分发 Skill,不依赖本机 Codex 的私有能力。将整个目录复制到宿主支持的 Skill 目录即可;Codex 为 `$CODEX_HOME/skills/`(未配置时 `~/.codex/skills/`)。仓库包含 Skill,启动程序不会修改用户的全局 Skill 目录。 -先完成项目环境和模型配置,然后在项目目录执行 `uv run artemis mcp --install codex` 安装/更新 MCP 配置。其他客户端将 `codex` 换成对应客户端;`uv run artemis mcp --help` 可查看支持列表。更新后重新加载宿主 MCP;已有配置若设置了工具白名单,需要重新安装配置或加入新增工具。复制 Skill 后按宿主方式重新加载技能。 +Windows 一键脚本会为检测到的客户端自动安装或更新 MCP 配置。未检测到客户端时,在项目目录执行 `uv run artemis mcp --install codex`;其他客户端将 `codex` 换成对应名称,`uv run artemis mcp --help` 可查看支持列表。更新后重新加载宿主 MCP;复制 Skill 后按宿主方式重新加载技能。 可以向 AI 这样下达任务: @@ -36,7 +36,7 @@ Windows 已安装用户再次运行 `start.bat` / `start-cn.bat` 时会检查当 Python 每次按 `uv.lock` 检查并同步缺失/变化的依赖;前端依据源码、资源及包锁文件指纹决定是否重新执行 `npm ci` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。 -MCP 自动刷新只处理已存在且指向当前项目的标准 Python 模块配置,更新解释器路径和已有工具白名单。保留用户环境变量、禁用状态、禁用工具及其他服务;JSON/TOML 无法安全解析、其他项目和自定义启动包装器保持原样。首次修改保留备份,未接入的客户端不会自动安装。源码更新后需在 AI 客户端重新加载 MCP 连接以使用新实现;脚本不会强制重启你的 IDE。 +MCP 同步会识别已安装客户端的配置目录,为缺少 ARTEMIS 条目的客户端安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。无法安全解析配置时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。源码和配置同步后需在 AI 客户端重新加载 MCP 连接或重启客户端,脚本无法替宿主重启已加载的 MCP 进程。 创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。 diff --git a/docs/releases/1.1.2.md b/docs/releases/1.1.2.md new file mode 100644 index 0000000..04c83fa --- /dev/null +++ b/docs/releases/1.1.2.md @@ -0,0 +1,13 @@ +# ARTEMIS-Eapil 1.1.2 + +## 修复 + +- Windows 一键脚本在依赖同步后,为已检测到的 AI 客户端安装或更新 ARTEMIS MCP。旧配置指向其他解压目录时,会迁移到当前目录,并更新 Python、启动参数、环境路径和工具清单。 +- 同步保留已有密钥、禁用状态、其他 MCP 服务及自定义设置。自定义启动命令会提示跳过;配置无法安全解析时明确停止启动,原文件保持不变。仅需网页时可用 `-SkipMcpSync`。 +- 源码升级后会重新载入新版启动脚本再同步 MCP。客户端需重新加载 MCP 或重启,才能让已运行的旧 MCP 进程退出。 + +## 安装 + +下载发布附件 `artemis-eapil-1.1.2-source.zip`,解压后运行 `start.bat` 或国内镜像入口 `start-cn.bat`。附件包含精简 Git 仓库,后续可通过启动脚本检查更新。Gitea 自动生成的“源代码 ZIP”没有 `.git`,不能通过脚本拉取更新。 + +后端默认测试 2170 项通过、6 项跳过;Ruff、质量基线、核心类型检查通过。附件同时提供 SHA-256 校验文件。 diff --git a/pyproject.toml b/pyproject.toml index f49b65f..7b5d99a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ [project] name = "artemis" -version = "1.1.1" +version = "1.1.2" description = "Autonomous AI Assistant for Mobile Automation" authors = [ diff --git a/scripts/start.ps1 b/scripts/start.ps1 index 758f376..91d266f 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -18,6 +18,7 @@ param( [switch]$NoOpen = $false, [switch]$SetupOnly = $false, [switch]$SkipUpdate = $false, + [switch]$SkipMcpSync = $false, [ValidateSet('Official', 'China')][string]$Mirror ) @@ -50,6 +51,7 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) { $reloadArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ScriptDir 'start.ps1'), '-Port', $Port, '-SkipUpdate') if ($NoOpen) { $reloadArgs += '-NoOpen' } if ($SetupOnly) { $reloadArgs += '-SetupOnly' } + if ($SkipMcpSync) { $reloadArgs += '-SkipMcpSync' } if ($Mirror) { $reloadArgs += @('-Mirror', $Mirror) } & powershell.exe @reloadArgs exit $LASTEXITCODE @@ -353,10 +355,15 @@ if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) { Write-Host (Get-LaunchText ' [OK] Created .env from .env.example. Configure your model in the web UI.' ' [完成] 已按模板创建 .env;请在网页中配置模型。') -ForegroundColor Green } } -Write-Host (Get-LaunchText ' [CHECK] Refreshing already-configured MCP clients...' ' [检查] 正在检查并更新已有的 MCP 客户端配置...') -ForegroundColor Cyan -& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp -if ($LASTEXITCODE -ne 0) { - Write-Host (Get-LaunchText ' [WARN] MCP refresh failed. Existing client settings were not replaced; check the errors above.' ' [提示] MCP 配置更新失败,原配置仍保留;请查看上方错误。') -ForegroundColor Yellow +if (-not $SkipMcpSync) { + Write-Host (Get-LaunchText ' [CHECK] Installing/updating MCP for detected AI clients...' ' [检查] 正在为检测到的 AI 客户端安装或更新 MCP...') -ForegroundColor Cyan + & $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp + if ($LASTEXITCODE -ne 0) { + Write-Host (Get-LaunchText ' [ERROR] MCP synchronization failed. Fix the configuration above, or use -SkipMcpSync to run the web UI without MCP.' ' [错误] MCP 同步失败。请修复上方配置;仅需启动网页时可使用 -SkipMcpSync。') -ForegroundColor Red + exit 1 + } +} else { + Write-Host (Get-LaunchText ' [SKIP] MCP synchronization was skipped.' ' [跳过] 已跳过 MCP 同步。') -ForegroundColor Yellow } if ($SetupOnly) { Write-Host (Get-LaunchText ' [OK] Setup complete. Run start.bat to launch Artemis.' ' [完成] 环境已准备好。运行 start-cn.bat 启动 ARTEMIS。') -ForegroundColor Green diff --git a/scripts/update_installation.py b/scripts/update_installation.py index c755536..71cf565 100644 --- a/scripts/update_installation.py +++ b/scripts/update_installation.py @@ -1,4 +1,4 @@ -"""Check an existing checkout and refresh only already-configured local MCP clients.""" +"""Check an existing checkout and synchronize installed local MCP clients.""" from __future__ import annotations @@ -140,10 +140,10 @@ def update_source(root: Path, port: int = 8000) -> int: def _atomic_write(path: Path, text: str) -> None: - if path.read_text(encoding="utf-8") == text: + if path.is_file() and path.read_text(encoding="utf-8") == text: return backup = path.with_name(path.name + ".artemis-update.bak") - if not backup.exists(): + if path.is_file() and not backup.exists(): shutil.copy2(path, backup) fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent) try: @@ -154,87 +154,120 @@ def _atomic_write(path: Path, text: str) -> None: Path(name).unlink(missing_ok=True) -def _belongs_to_project(config: dict, root: Path) -> bool: - if config.get("url"): +def _is_standard_local_artemis(config: dict) -> bool: + if config.get("url") or config.get("type") not in (None, "stdio"): 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() - ) + # Running this launcher selects this checkout, including after a user moves + # a release ZIP to a new directory. Do not take over custom wrappers/SSE. + return args in (["-m", "mcp_server"], ["-m", "mcp_server.server"]) -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 _updated_entry(config: dict, template: dict, root: Path, names: tuple[str, ...]) -> dict: + updated = {**config, "command": template["command"], "args": template["args"]} + if "cwd" in template or "cwd" in config: + updated["cwd"] = str(root) + environment = config.get("env", {}) + if not isinstance(environment, dict): + raise ValueError("The existing Artemis MCP environment is not an object") + updated["env"] = {**environment, **template["env"]} + if "enabled_tools" in template or "enabled_tools" in config: + updated["enabled_tools"] = list(names) + if "tools" in template or "tools" in config: + current_tools = config.get("tools", {}) + if not isinstance(current_tools, dict): + raise ValueError("The existing Artemis MCP tools setting is not an object") + updated["tools"] = {name: {"eager": True, **current_tools.get(name, {})} for name in names} + for key in ("type", "required", "startup_timeout_sec", "connectionTimeoutMs"): + if key in template and key not in updated: + updated[key] = template[key] + return updated def refresh_json( - path: Path, keys: tuple[str, ...], root: Path, python: str, names: tuple[str, ...] -) -> bool: - if not path.is_file(): + path: Path, + keys: tuple[str, ...], + root: Path, + python: str, + names: tuple[str, ...], + template: dict | None = None, + install_missing: bool = False, +) -> bool | None: + if not path.is_file() and not install_missing: return False - raw = path.read_text(encoding="utf-8") + raw = path.read_text(encoding="utf-8") if path.is_file() else "{}" data = json.loads(raw) + if not isinstance(data, dict): + raise ValueError("MCP configuration must be a JSON object") parent = data for key in keys: - parent = parent.get(key, {}) + child = parent.get(key) + if child is None and install_missing: + child = parent[key] = {} + if not isinstance(child, dict): + raise ValueError(f"Invalid MCP configuration section: {key}") + parent = child 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 config is None and install_missing: + if template is None: + raise ValueError("A standard MCP template is required for installation") + updated = template + elif isinstance(config, dict) and _is_standard_local_artemis(config): + if template is None: + template = { + "command": python, + "args": ["-m", "mcp_server"], + "env": {"PYTHONPATH": str(root)}, + } + updated = _updated_entry(config, template, root, names) + elif config is not None and not isinstance(config, dict): + raise ValueError("The Artemis MCP configuration is not an object") + else: + return None if updated == config: return False parent["artemis"] = updated + path.parent.mkdir(parents=True, exist_ok=True) _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 +def _toml_value(value: object) -> str: + if isinstance(value, bool): + return "true" if value else "false" + return json.dumps(value, ensure_ascii=False) + + +def _toml_inline_table(values: dict) -> str: + return "{ " + ", ".join(f"{key} = {_toml_value(value)}" for key, value in values.items()) + " }" + + +def _replace_toml_tables(raw: str, assignments: dict[str, dict]) -> str: + """Update only Artemis TOML tables; preserve every unrelated section and setting.""" lines = raw.splitlines(keepends=True) - inside = False - found = set() - output = [] + output: list[str] = [] + section = "" + found: dict[str, set[str]] = {name: set() for name in assignments} + seen: set[str] = set() - 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) + def append_missing() -> None: + if section in assignments: + for key, value in assignments[section].items(): + if key not in found[section]: + rendered = _toml_inline_table(value) if key == "env" else _toml_value(value) + output.append(f"{key} = {rendered}\n") + found[section].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() + 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: + section = header[1:-1] if header.startswith("[") and header.endswith("]") else "" + seen.add(section) + match = re.match(r"\s*(\w+)\s*=", line) if section in assignments else None + if match and match[1] in assignments[section]: key = match[1] original = line while True: @@ -246,91 +279,236 @@ def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) - 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) + value = assignments[section][key] + rendered = _toml_inline_table(value) if key == "env" else _toml_value(value) + output.append(f"{key} = {rendered}\n") + found[section].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 + append_missing() + for name, values in assignments.items(): + if name not in seen: + if output and not output[-1].endswith("\n"): + output.append("\n") + output.append(f"\n[{name}]\n") + for key, value in values.items(): + rendered = _toml_inline_table(value) if key == "env" else _toml_value(value) + output.append(f"{key} = {rendered}\n") + return "".join(output) -def refresh_mcp(root: Path) -> None: +def refresh_codex( + path: Path, + root: Path, + python: str, + names: tuple[str, ...], + template: dict | None = None, + install_missing: bool = False, +) -> bool | None: + if not path.is_file() and not install_missing: + return False + raw = path.read_text(encoding="utf-8") if path.is_file() else "" + parsed = tomllib.loads(raw) + config = parsed.get("mcp_servers", {}).get("artemis") + if config is None and install_missing: + if template is None: + raise ValueError("A standard MCP template is required for installation") + updated = template + elif isinstance(config, dict) and _is_standard_local_artemis(config): + if template is None: + template = { + "command": python, + "args": ["-m", "mcp_server"], + "cwd": str(root), + "env": {"PYTHONPATH": str(root)}, + "enabled_tools": list(names), + } + updated = _updated_entry(config, template, root, names) + else: + return None + top = { + key: value + for key, value in updated.items() + if key != "env" and (not config or config.get(key) != value) + } + environment = updated["env"] + if config and config.get("env") == environment: + env_updates = {} + else: + env_updates = { + key: value + for key, value in environment.items() + if not config or config.get("env", {}).get(key) != value + } + env_header = "mcp_servers.artemis.env" + has_env_table = re.search(r"(?m)^\s*\[mcp_servers\.artemis\.env\]\s*(?:#.*)?$", raw) is not None + if config and "env" in config and not has_env_table and env_updates: + top["env"] = environment + env_updates = {} + assignments = {"mcp_servers.artemis": top} + if env_updates: + assignments[env_header] = env_updates + if not top and not env_updates: + return False + result = _replace_toml_tables(raw, assignments) + actual = tomllib.loads(result)["mcp_servers"]["artemis"] + if any(actual.get(key) != value for key, value in top.items()) or any( + actual.get("env", {}).get(key) != value for key, value in env_updates.items() + ): + raise ValueError("Could not safely refresh the Artemis MCP table") + path.parent.mkdir(parents=True, exist_ok=True) + _atomic_write(path, result) + return result != raw + + +def refresh_mcp(root: Path) -> int: sys.path.insert(0, str(root)) - from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_vscode_user_dir + from artemis.interfaces.cli.commands.mcp import ( + ARTEMIS_MCP_TOOLS, + _get_config_snippet, + _get_vscode_user_dir, + ) home = Path.home() vscode = _get_vscode_user_dir() appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming")) + cline_store = vscode / "globalStorage/saoudrizwan.claude-dev" + roo_store = vscode / "globalStorage/rooveterinaryinc.roo-cline" 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",)), + (home / ".gemini/config/mcp_config.json", ("mcpServers",), "antigravity", home / ".gemini"), + (home / ".gemini/jetski/mcp_config.json", ("mcpServers",), "jetski", home / ".gemini"), ( - vscode / "globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", + home / ".gemini/antigravity/mcp_config.json", ("mcpServers",), + "antigravity", + home / ".gemini", + ), + (home / ".cursor/mcp.json", ("mcpServers",), "cursor", home / ".cursor"), + ( + home / ".codeium/windsurf/mcp_config.json", + ("mcpServers",), + "windsurf", + home / ".codeium/windsurf", + ), + (home / ".claude.json", ("mcpServers",), "claude", home / ".claude"), + ( + appdata / "Claude/claude_desktop_config.json", + ("mcpServers",), + "claude", + appdata / "Claude", + ), + (vscode / "mcp.json", ("servers",), "vscode", vscode), + (home / ".copilot/mcp-config.json", ("servers",), "vscode", home / ".copilot"), + ( + home / ".cline/data/settings/cline_mcp_settings.json", + ("mcpServers",), + "cline", + home / ".cline", + ), + (home / ".cline/mcp.json", ("mcpServers",), "cline", home / ".cline"), + ( + cline_store / "settings/cline_mcp_settings.json", + ("mcpServers",), + "cline", + cline_store, ), ( - vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json", + roo_store / "settings/mcp_settings.json", ("mcpServers",), + "roo", + roo_store, ), ( - vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json", + roo_store / "settings/cline_mcp_settings.json", ("mcpServers",), + "roo", + roo_store, ), - (root / ".roo/mcp.json", ("mcpServers",)), - (home / ".openclaw/openclaw.json", ("mcp", "servers")), + (root / ".roo/mcp.json", ("mcpServers",), "roo", root / ".roo"), + (home / ".openclaw/openclaw.json", ("mcp", "servers"), "openclaw", home / ".openclaw"), ] - changed = 0 - codex = Path(os.environ.get("CODEX_HOME", home / ".codex")) / "config.toml" - for path, keys in [*paths, (codex, ())]: + codex_home = Path(os.environ.get("CODEX_HOME", home / ".codex")) + paths.append((codex_home / "config.toml", (), "codex", codex_home)) + installed = updated_count = ready = skipped = errors = 0 + for path, keys, client, marker in paths: + if not path.is_file() and not marker.is_dir(): + continue + existed = path.is_file() + template = _get_config_snippet(client, sys.executable, str(root)) + for key in keys: + template = template[key] + template = template["artemis"] if keys else template["mcp_servers"]["artemis"] try: - updated = ( - refresh_json(path, keys, root, sys.executable, ARTEMIS_MCP_TOOLS) + changed = ( + refresh_json( + path, + keys, + root, + sys.executable, + ARTEMIS_MCP_TOOLS, + template=template, + install_missing=True, + ) if keys - else refresh_codex(path, root, sys.executable, ARTEMIS_MCP_TOOLS) + else refresh_codex( + path, + root, + sys.executable, + ARTEMIS_MCP_TOOLS, + template=template, + install_missing=True, + ) ) - if updated: + if changed: print( launch_text( - f"[UPDATED] Existing MCP configuration: {path}", - f"[已更新] 已有 MCP 配置:{path}", + f"[{'UPDATED' if existed else 'INSTALLED'}] MCP configuration: {path}", + f"[{'已更新' if existed else '已安装'}] MCP 配置:{path}", ) ) - changed += 1 + if existed: + updated_count += 1 + else: + installed += 1 + elif changed is None: + print( + launch_text( + f"[SKIP] Custom Artemis MCP entry in {path}; use the manual installer for this client.", + f"[跳过] {path} 使用自定义 ARTEMIS MCP 启动方式;请手动检查此客户端。", + ) + ) + skipped += 1 + else: + ready += 1 except (OSError, ValueError, TypeError, AttributeError): print( launch_text( - f"[WARN] Could not safely refresh {path}; original configuration preserved.", - f"[提示] 无法安全更新 {path};原配置已保留。", + f"[ERROR] Could not safely synchronize {path}; original configuration preserved.", + f"[错误] 无法安全同步 {path};原配置已保留。", ) ) + errors += 1 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。", + f"[CHECK] MCP clients: installed {installed}, updated {updated_count}, already current {ready}, custom skipped {skipped}, errors {errors}.", + f"[检查] MCP 客户端:新装 {installed} 个、更新 {updated_count} 个、已是最新 {ready} 个、自定义跳过 {skipped} 个、失败 {errors} 个。", ) ) print( launch_text( - "[TIP] For a new client run: uv run artemis mcp --install ", - "[提示] 新客户端可执行:uv run artemis mcp --install ", + "[ACTION] Reload or restart your AI client to load the current MCP process and tool schemas.", + "[操作] 请在 AI 客户端重新加载 MCP 或重启客户端,使新版进程和工具签名生效。", ) ) + if not (installed or updated_count or ready or skipped or errors): + print( + launch_text( + "[TIP] No supported client detected; install one with: uv run artemis mcp --install ", + "[提示] 未检测到受支持的客户端;可执行 uv run artemis mcp --install 。", + ) + ) + return 1 if errors else 0 def frontend_fingerprint(root: Path) -> str: @@ -362,7 +540,7 @@ if __name__ == "__main__": if args.action == "source": sys.exit(update_source(args.root, args.port)) elif args.action == "mcp": - refresh_mcp(args.root) + sys.exit(refresh_mcp(args.root)) else: marker = args.root / "apps/showcase_ui/node_modules/.artemis-source-hash" current = frontend_fingerprint(args.root) diff --git a/start-cn.bat b/start-cn.bat index 21f5511..2d6a171 100644 --- a/start-cn.bat +++ b/start-cn.bat @@ -25,8 +25,9 @@ echo -SetupOnly 安装或检查环境,不启动网页 echo -Port 8001 使用指定端口,默认 8000 echo -NoOpen 启动服务后不自动打开浏览器 echo -SkipUpdate 跳过 Git 源码更新检查 +echo -SkipMcpSync 仅启动网页,跳过 MCP 安装和更新 echo -Help 显示本说明 echo. echo 国内镜像设置:config\downloads.json -echo 首次运行需联网;后续运行会检查源码、依赖和已有 MCP 配置。 +echo 首次运行需联网;后续运行会检查源码、依赖,并同步已安装 AI 客户端的 MCP。 endlocal & exit /b 0 diff --git a/tests/unit/test_installation_update.py b/tests/unit/test_installation_update.py index a1e16a3..6da8912 100644 --- a/tests/unit/test_installation_update.py +++ b/tests/unit/test_installation_update.py @@ -120,7 +120,7 @@ command = "leave-alone" assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original -def test_json_only_updates_existing_matching_project(tmp_path): +def test_json_migrates_existing_standard_mcp_to_current_checkout(tmp_path): path = tmp_path / "mcp.json" root = tmp_path / "project" config = { @@ -132,19 +132,170 @@ def test_json_only_updates_existing_matching_project(tmp_path): "autoApprove": [], } path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}})) - assert not update.refresh_json( - path, ("mcpServers",), tmp_path / "different", "new", ("mobile_batch",) - ) - assert update.refresh_json(path, ("mcpServers",), root, "new", ("mobile_batch",)) + moved_root = tmp_path / "new release" + assert update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",)) result = json.loads(path.read_text())["mcpServers"] - assert result["artemis"]["env"] == {"CUSTOM": "preserved"} + assert result["artemis"]["env"]["CUSTOM"] == "preserved" + assert result["artemis"]["env"]["PYTHONPATH"] == str(moved_root) + assert result["artemis"]["cwd"] == str(moved_root) assert result["artemis"]["disabled"] is True assert result["other"] == {"command": "keep"} + assert not update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",)) missing = tmp_path / "not-installed.json" assert not update.refresh_json(missing, ("mcpServers",), root, "new", ()) assert not missing.exists() +def test_mcp_sync_installs_detected_clients_and_migrates_codex(tmp_path, monkeypatch): + from artemis.interfaces.cli.commands import mcp as mcp_command + + home = tmp_path / "home" + home.mkdir() + codex_home = home / ".codex" + codex_home.mkdir() + codex_config = codex_home / "config.toml" + codex_config.write_text( + 'model = "my-model"\n' + "[mcp_servers.artemis]\n" + 'command = "old-python"\n' + 'args = ["-m", "mcp_server.server"]\n' + 'cwd = "C:/old-release"\n' + 'enabled_tools = ["mobile_run_task", "retired_tool"]\n' + "enabled = false\n" + "[mcp_servers.artemis.env]\n" + 'PYTHONPATH = "C:/old-release"\n' + 'OPENAI_API_KEY = "keep-secret"\n', + encoding="utf-8", + ) + cursor_home = home / ".cursor" + cursor_home.mkdir() + (cursor_home / "mcp.json").write_text('{"mcpServers":{"other":{"command":"keep"}}}') + monkeypatch.setattr("pathlib.Path.home", lambda: home) + monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user") + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setenv("APPDATA", str(home / "AppData")) + root = tmp_path / "new release" + assert update.refresh_mcp(root) == 0 + codex = tomllib.loads(codex_config.read_text(encoding="utf-8")) + entry = codex["mcp_servers"]["artemis"] + assert codex["model"] == "my-model" + assert entry["command"] == update.sys.executable + assert entry["cwd"] == str(root) + assert entry["args"] == ["-m", "mcp_server"] + assert entry["enabled"] is False + assert entry["env"]["OPENAI_API_KEY"] == "keep-secret" + assert entry["env"]["PYTHONPATH"] == str(root) + assert entry["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS) + cursor = json.loads((cursor_home / "mcp.json").read_text(encoding="utf-8")) + assert cursor["mcpServers"]["artemis"]["env"]["PYTHONPATH"] == str(root) + assert cursor["mcpServers"]["other"] == {"command": "keep"} + first = codex_config.read_text(encoding="utf-8") + assert update.refresh_mcp(root) == 0 + assert codex_config.read_text(encoding="utf-8") == first + + +def test_mcp_sync_fails_on_invalid_detected_config_without_overwriting(tmp_path, monkeypatch): + from artemis.interfaces.cli.commands import mcp as mcp_command + + home = tmp_path / "home" + cursor_home = home / ".cursor" + cursor_home.mkdir(parents=True) + config = cursor_home / "mcp.json" + config.write_text("{invalid json", encoding="utf-8") + monkeypatch.setattr("pathlib.Path.home", lambda: home) + monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user") + monkeypatch.delenv("CODEX_HOME", raising=False) + assert update.refresh_mcp(tmp_path / "project") == 1 + assert config.read_text(encoding="utf-8") == "{invalid json" + + +def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypatch): + from artemis.interfaces.cli.commands import mcp as mcp_command + + home = tmp_path / "home" + codex_home = home / ".codex" + codex_home.mkdir(parents=True) + monkeypatch.setattr("pathlib.Path.home", lambda: home) + monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user") + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + root = tmp_path / "project" + assert update.refresh_mcp(root) == 0 + config = tomllib.loads((codex_home / "config.toml").read_text(encoding="utf-8")) + assert config["mcp_servers"]["artemis"]["cwd"] == str(root) + assert config["mcp_servers"]["artemis"]["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS) + + +def test_codex_install_preserves_existing_user_tables(tmp_path): + path = tmp_path / "config.toml" + path.write_text( + 'model = "keep"\n[mcp_servers.other]\ncommand = "other-tool"\n', encoding="utf-8" + ) + template = { + "command": "new-python", + "args": ["-m", "mcp_server"], + "cwd": str(tmp_path), + "env": {"PYTHONPATH": str(tmp_path)}, + "enabled_tools": ["mobile_run_task"], + } + assert update.refresh_codex( + path, + tmp_path, + "new-python", + ("mobile_run_task",), + template=template, + install_missing=True, + ) + data = tomllib.loads(path.read_text(encoding="utf-8")) + assert data["model"] == "keep" + assert data["mcp_servers"]["other"]["command"] == "other-tool" + assert data["mcp_servers"]["artemis"]["env"]["PYTHONPATH"] == str(tmp_path) + + +def test_codex_inline_environment_sync_is_idempotent(tmp_path): + path = tmp_path / "config.toml" + path.write_text( + '[mcp_servers.artemis]\ncommand = "old-python"\n' + 'args = ["-m", "mcp_server"]\n' + 'env = { PYTHONPATH = "C:/old", OPENAI_API_KEY = "keep-secret" }\n', + encoding="utf-8", + ) + template = { + "command": "new-python", + "args": ["-m", "mcp_server"], + "cwd": str(tmp_path), + "env": {"PYTHONPATH": str(tmp_path)}, + "enabled_tools": ["mobile_run_task"], + } + assert update.refresh_codex( + path, tmp_path, "new-python", ("mobile_run_task",), template=template + ) + data = tomllib.loads(path.read_text(encoding="utf-8"))["mcp_servers"]["artemis"] + assert data["env"]["OPENAI_API_KEY"] == "keep-secret" + assert data["env"]["PYTHONPATH"] == str(tmp_path) + assert not update.refresh_codex( + path, tmp_path, "new-python", ("mobile_run_task",), template=template + ) + + +def test_custom_mcp_wrapper_is_left_unchanged(tmp_path): + path = tmp_path / "mcp.json" + original = '{"mcpServers":{"artemis":{"command":"uv","args":["run","custom"]}}}' + path.write_text(original, encoding="utf-8") + assert ( + update.refresh_json( + path, + ("mcpServers",), + tmp_path, + "new-python", + ("mobile_run_task",), + install_missing=True, + template={"command": "new-python", "args": ["-m", "mcp_server"], "env": {}}, + ) + is None + ) + assert path.read_text(encoding="utf-8") == original + + def test_frontend_fingerprint_detects_lock_source_and_deleted_assets(tmp_path): ui = tmp_path / "apps/showcase_ui" (ui / "src").mkdir(parents=True) diff --git a/uv.lock b/uv.lock index c3ce3d2..f379fa3 100644 --- a/uv.lock +++ b/uv.lock @@ -206,7 +206,7 @@ wheels = [ [[package]] name = "artemis" -version = "1.1.1" +version = "1.1.2" source = { editable = "." } dependencies = [ { name = "adbutils" },