From c96935b47b1e631deccdf2c66d0892681af0a382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E9=93=AD=E8=BD=A9?= Date: Thu, 8 Oct 2026 18:01:15 +0800 Subject: [PATCH] fix: scope MCP configuration to Codex and stabilize Windows launchers --- .gitattributes | 2 + README.md | 8 +- artemis/interfaces/cli/commands/mcp.py | 630 +++---------------------- docs/ai-automation.md | 4 +- scripts/start.ps1 | 4 +- scripts/update_installation.py | 251 +++------- skills/artemis-automation/SKILL.md | 2 + start-cn.bat | 4 +- start.bat | 2 +- tests/unit/test_cli.py | 328 ++----------- tests/unit/test_installation_update.py | 133 +++--- tests/unit/test_source_archive.py | 20 + tests/unit/test_windows_bootstrap.py | 27 ++ 13 files changed, 290 insertions(+), 1125 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2b23ba4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# cmd.exe requires CRLF for batch scripts, especially UTF-8 Chinese text. +*.bat text eol=crlf diff --git a/README.md b/README.md index b722bb8..0fadcd9 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ cd Artemis-Eapil 常用选项: ```powershell -.\start-cn.bat -SetupOnly # 准备环境并安装/更新已检测客户端的 MCP +.\start-cn.bat -SetupOnly # 准备环境并安装/更新 Codex MCP .\start.bat -Port 8001 # 指定端口 .\start.bat -SkipUpdate # 跳过 Git 源码更新检查 .\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页 @@ -58,13 +58,13 @@ cd Artemis-Eapil ## 让 AI 使用 -Windows 启动脚本会为已检测到的 AI 客户端安装或更新 MCP。未被检测到的客户端,可手动安装,例如 Codex: +目前仅支持为 Codex 配置 MCP。Windows 启动脚本检测 Codex 配置目录并安装或更新 ARTEMIS;也可手动安装: ```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` 查看接入方式。 +安装或更新后,在 Codex 中重新加载 MCP 或重启 Codex,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。可用 `uv run artemis mcp --generate-config codex` 查看配置模板。 示例指令: @@ -72,7 +72,7 @@ uv run artemis mcp --install codex ## 更新与排错 -Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建并同步已检测客户端的 MCP。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。MCP 配置同步失败会明确停止启动,可使用 `-SkipMcpSync` 仅启动网页。更新后需在 AI 客户端重新加载 MCP。 +Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建并同步 Codex MCP。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。Codex 配置同步失败会明确停止启动,可使用 `-SkipMcpSync` 仅启动网页。更新后需在 Codex 中重新加载 MCP。 1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树,解压后再次运行一键脚本即可检查更新。1.1.0 及更早 ZIP 不含 Git 信息,无法自行拉取;需要下载新版源码包重新解压一次,之后即可自动更新。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。 diff --git a/artemis/interfaces/cli/commands/mcp.py b/artemis/interfaces/cli/commands/mcp.py index efaffde..e7d3566 100644 --- a/artemis/interfaces/cli/commands/mcp.py +++ b/artemis/interfaces/cli/commands/mcp.py @@ -51,212 +51,28 @@ ARTEMIS_MCP_TOOLS = ( ) -def _eager_tools_config() -> dict[str, dict[str, bool]]: - """Return the eager-loading extension used by clients that support it.""" - return {tool_name: {"eager": True} for tool_name in ARTEMIS_MCP_TOOLS} - - -def _get_vscode_user_dir() -> Path: - """Returns the platform-specific VS Code user configuration directory.""" - if sys.platform == "darwin": - return Path.home() / "Library" / "Application Support" / "Code" / "User" - elif sys.platform == "win32": - appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming") - return Path(appdata) / "Code" / "User" - else: - return Path.home() / ".config" / "Code" / "User" - - def _get_config_snippet(client: str, python_exe: str, project_root: str) -> dict: - """Generates MCP configuration dictionary for the specified client.""" - config_body = { - "command": python_exe, - "args": ["-m", "mcp_server"], - "env": { - "PYTHONUNBUFFERED": "1", - "PYTHONPATH": project_root, - "ARTEMIS_DESKTOP_NOTIFY": "true", - }, + """Generate the supported Codex MCP configuration.""" + if client != "codex": + raise ValueError("Only Codex MCP configuration is supported") + return { + "mcp_servers": { + "artemis": { + "command": python_exe, + "args": ["-m", "mcp_server"], + "cwd": project_root, + "env": { + "PYTHONUNBUFFERED": "1", + "PYTHONPATH": project_root, + "ARTEMIS_DESKTOP_NOTIFY": "true", + }, + "enabled": True, + "required": True, + "startup_timeout_sec": 120, + "enabled_tools": list(ARTEMIS_MCP_TOOLS), + } + } } - config_with_cwd = {**config_body, "cwd": project_root} - - if client == "antigravity": - return { - "mcpServers": { - "artemis": { - **config_with_cwd, - # This is the documented way to expose every tool. Avoid - # proprietary eager fields in the current config so a - # strict parser can always load the server. - "disabledTools": [], - } - } - } - elif client == "jetski": - return { - "mcpServers": { - "artemis": { - **config_with_cwd, - "disabledTools": [], - # Retain the legacy Jetski eager extension only in its - # legacy config file. Current Antigravity gets the strict, - # documented configuration above as a safe fallback. - "tools": _eager_tools_config(), - } - } - } - elif client == "openclaw": - return { - "mcp": { - "servers": { - "artemis": { - **config_with_cwd, - "enabled": True, - "connectionTimeoutMs": 120000, - } - } - } - } - elif client == "codex": - return { - "mcp_servers": { - "artemis": { - **config_with_cwd, - # Codex has no eager/deferred MCP setting. Requiring the - # server makes it initialize during host startup, while an - # explicit allow-list guarantees all Artemis tools are - # registered. The host may still present schemas through - # tool search to control context size. - "enabled": True, - "required": True, - "startup_timeout_sec": 120, - "enabled_tools": list(ARTEMIS_MCP_TOOLS), - } - } - } - elif client == "vscode": - return {"servers": {"artemis": {**config_with_cwd, "type": "stdio"}}} - elif client in ("claude", "claude_code", "claude_desktop"): - return {"mcpServers": {"artemis": {**config_body, "type": "stdio"}}} - elif client == "cline": - return { - "mcpServers": { - "artemis": { - **config_body, - "disabled": False, - "autoApprove": [], - "timeout": 120, - } - } - } - elif client in ("roo", "roo_code"): - return { - "mcpServers": { - "artemis": { - **config_body, - "disabled": False, - "alwaysAllow": [], - "timeout": 120, - } - } - } - else: # cursor, windsurf, generic - return {"mcpServers": {"artemis": config_body}} - - -def _parse_json_lenient(text: str) -> dict | None: - """Parses JSON or JSONC (JSON with comments / trailing commas) safely. - - Returns ``None`` when the text cannot be parsed as a JSON object, so callers - can tell a genuinely empty ``{}`` file apart from a corrupt one. - """ - try: - data = json.loads(text) - return data if isinstance(data, dict) else None - except ValueError: - # Not strict JSON; fall through to the lenient JSONC cleanup below. - pass - # Remove // single-line comments - cleaned = re.sub(r"//.*", "", text) - # Remove /* ... */ multi-line comments - cleaned = re.sub(r"/\*.*?\*/", "", cleaned, flags=re.DOTALL) - # Remove trailing commas before } or ] - cleaned = re.sub(r",\s*([}\]])", r"\1", cleaned) - try: - data = json.loads(cleaned) - return data if isinstance(data, dict) else None - except Exception: - return None - - -def _merge_json_file( - file_path: Path, - server_name: str, - server_config: dict, - key_name: str | tuple[str, ...] = "mcpServers", - remove_paths: tuple[tuple[str, ...], ...] = (), - strict_parse: bool = False, -) -> bool: - """Merges server configuration into a target JSON file without overwriting existing servers. - - With ``strict_parse=True``, an existing non-empty file that cannot be parsed - aborts the merge instead of being replaced. Use this for files that hold - state beyond MCP config (e.g. ~/.claude.json), where a degraded rewrite - would destroy unrelated data. - """ - try: - file_path.parent.mkdir(parents=True, exist_ok=True) - data: dict = {} - if file_path.exists() and file_path.stat().st_size > 0: - raw_text = file_path.read_text(encoding="utf-8") - parsed = _parse_json_lenient(raw_text) - data = parsed or {} - if parsed is None and raw_text.strip(): - if strict_parse: - logger.warning( - f"Existing JSON in {file_path} could not be parsed; refusing to rewrite it. " - "Fix or remove the file, then re-run the install." - ) - return False - # Backup unparseable existing file to prevent accidental loss - backup_path = file_path.with_suffix(file_path.suffix + ".bak") - try: - backup_path.write_text(raw_text, encoding="utf-8") - logger.warning( - f"Existing JSON in {file_path} could not be parsed; backup created at {backup_path}" - ) - except OSError as exc: - logger.warning( - f"Existing JSON in {file_path} could not be parsed AND the backup to" - f" {backup_path} failed ({exc}); refusing to rewrite it." - " Fix or remove the file, then re-run the install." - ) - return False - - for remove_path in remove_paths: - parent = data - for part in remove_path[:-1]: - child = parent.get(part) - if not isinstance(child, dict): - parent = {} - break - parent = child - if parent: - parent.pop(remove_path[-1], None) - - key_path = (key_name,) if isinstance(key_name, str) else key_name - target = data - for part in key_path: - if not isinstance(target.get(part), dict): - target[part] = {} - target = target[part] - - target[server_name] = server_config - file_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") - return True - except Exception as e: - logger.warning(f"Could not update MCP config file {file_path}: {e}") - return False def _codex_toml_block(server_config: dict) -> str: @@ -302,7 +118,9 @@ def _merge_codex_toml(file_path: Path, server_config: dict) -> bool: managed_pattern = re.compile( rf"(?ms)^{re.escape(begin_marker)}\n.*?^{re.escape(end_marker)}\n?" ) - updated = managed_pattern.sub(managed_block, existing, count=1) + # Windows paths contain backslashes; a replacement string would + # interpret them as regex escapes on a second installation. + updated = managed_pattern.sub(lambda _: managed_block, existing, count=1) else: # Remove an older, unmanaged Artemis table before adding the managed block. kept_lines: list[str] = [] @@ -342,36 +160,6 @@ def _merge_codex_toml(file_path: Path, server_config: dict) -> bool: return False -def _remove_rules_block(file_path: Path) -> bool: - """Removes a previously injected managed ARTEMIS block from a rules file. - - Used to migrate clients that load both their main rules file and a - standalone rule file (e.g. Claude Code loads ~/.claude/CLAUDE.md AND - ~/.claude/rules/*.md), where keeping the injected block would duplicate - the rules in the model's context. If the file contains nothing but the - managed block, the file is deleted entirely. - """ - try: - if not file_path.exists(): - return True - begin_marker = "" - end_marker = "" - existing = file_path.read_text(encoding="utf-8") - if begin_marker not in existing or end_marker not in existing: - return True - prefix = existing.split(begin_marker)[0] - suffix = existing.split(end_marker)[1] - remainder = f"{prefix.rstrip()}\n\n{suffix.lstrip()}".strip() - if remainder: - file_path.write_text(remainder + "\n", encoding="utf-8") - else: - file_path.unlink() - return True - except Exception as e: - logger.warning(f"Could not remove rules block from {file_path}: {e}") - return False - - def _inject_rules_block(file_path: Path, content: str) -> bool: """Injects or updates a managed ARTEMIS block in a global rules file without overwriting user content.""" try: @@ -404,297 +192,36 @@ def _inject_rules_block(file_path: Path, content: str) -> bool: return False -def _write_rule_file(file_path: Path, content: str) -> bool: - """Writes or overwrites a standalone rule file.""" - try: - file_path.parent.mkdir(parents=True, exist_ok=True) - new_text = content.strip() + "\n" - if file_path.exists(): - try: - if file_path.read_text(encoding="utf-8") == new_text: - return True - except (OSError, UnicodeDecodeError): - # Unreadable existing file: skip the no-op check and rewrite it. - pass - file_path.write_text(new_text, encoding="utf-8") - return True - except Exception as e: - logger.warning(f"Could not write rule file {file_path}: {e}") - return False - - -def _write_cursor_mdc(file_path: Path, content: str) -> bool: - """Writes a Cursor .mdc rule file with required YAML frontmatter.""" - try: - file_path.parent.mkdir(parents=True, exist_ok=True) - mdc_header = ( - "---\n" - "description: Artemis Mobile Testing Mindset & Rules\n" - "globs: **/*\n" - "alwaysApply: true\n" - "---\n\n" - ) - new_text = mdc_header + content.strip() + "\n" - if file_path.exists(): - try: - if file_path.read_text(encoding="utf-8") == new_text: - return True - except (OSError, UnicodeDecodeError): - # Unreadable existing file: skip the no-op check and rewrite it. - pass - file_path.write_text(new_text, encoding="utf-8") - return True - except Exception as e: - logger.warning(f"Could not write Cursor rule file {file_path}: {e}") - return False - - def install_rules(client: str, project_root: str) -> list[str]: - """Auto-installs ARTEMIS testing rules globally into user IDE rule directories across any OS.""" - installed_paths: list[str] = [] + """Install testing rules only into the Codex instructions file.""" + if client != "codex": + raise ValueError("Only Codex MCP configuration is supported") rules_src = Path(project_root) / "mcp_server" / "rules.md" if not rules_src.exists(): - alt_src = Path(__file__).resolve().parents[4] / "mcp_server" / "rules.md" - if alt_src.exists(): - rules_src = alt_src - else: - logger.warning(f"Could not locate rules.md at {rules_src}") - return [] - + rules_src = Path(__file__).resolve().parents[4] / "mcp_server" / "rules.md" try: raw_rules = rules_src.read_text(encoding="utf-8").strip() - except Exception as e: - logger.warning(f"Could not read rules file {rules_src}: {e}") + except OSError: + logger.warning(f"Could not read rules file {rules_src}") return [] - - targets = ( - [ - "antigravity", - "cursor", - "claude", - "windsurf", - "vscode", - "cline", - "roo", - "openclaw", - "codex", - ] - if client == "all" - else [client] + codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex")) + override_file = codex_home / "AGENTS.override.md" + agents_file = ( + override_file + if override_file.exists() and override_file.read_text(encoding="utf-8").strip() + else codex_home / "AGENTS.md" ) - - for target in targets: - if target in ("antigravity", "jetski"): - gemini_md = Path.home() / ".gemini" / "GEMINI.md" - global_rule = Path.home() / ".gemini" / "rules" / "artemis.md" - if _inject_rules_block(gemini_md, raw_rules): - installed_paths.append(str(gemini_md)) - if _write_rule_file(global_rule, raw_rules): - installed_paths.append(str(global_rule)) - elif target == "cursor": - cursor_rules_file = Path.home() / ".cursorrules" - global_mdc = Path.home() / ".cursor" / "rules" / "artemis.mdc" - if _inject_rules_block(cursor_rules_file, raw_rules): - installed_paths.append(str(cursor_rules_file)) - if _write_cursor_mdc(global_mdc, raw_rules): - installed_paths.append(str(global_mdc)) - elif target in ("claude", "claude_code"): - # Claude Code loads BOTH ~/.claude/CLAUDE.md and ~/.claude/rules/*.md - # into context every session, so install the rules in exactly one - # place (the standalone rule file) and migrate away any block a - # previous version injected into CLAUDE.md. - claude_md = Path.home() / ".claude" / "CLAUDE.md" - global_rule = Path.home() / ".claude" / "rules" / "artemis.md" - _remove_rules_block(claude_md) - if _write_rule_file(global_rule, raw_rules): - installed_paths.append(str(global_rule)) - elif target == "claude_desktop": - # Claude Desktop only reads ~/.claude/CLAUDE.md (not rules/*.md), so - # the rules block stays injected there for this target. - claude_md = Path.home() / ".claude" / "CLAUDE.md" - if _inject_rules_block(claude_md, raw_rules): - installed_paths.append(str(claude_md)) - elif target == "windsurf": - global_rule = Path.home() / ".codeium" / "windsurf" / "rules" / "artemis.md" - global_mem = Path.home() / ".codeium" / "windsurf" / "memories" / "global_rules.md" - if _inject_rules_block(global_mem, raw_rules): - installed_paths.append(str(global_mem)) - if _write_rule_file(global_rule, raw_rules): - installed_paths.append(str(global_rule)) - elif target == "vscode": - global_rule = Path.home() / ".vscode" / "rules" / "artemis.md" - user_rule = _get_vscode_user_dir() / "rules" / "artemis.md" - if _write_rule_file(global_rule, raw_rules): - installed_paths.append(str(global_rule)) - if _write_rule_file(user_rule, raw_rules): - installed_paths.append(str(user_rule)) - elif target == "cline": - clinerules = Path.home() / ".clinerules" - global_rule = Path.home() / ".cline" / "rules" / "artemis.md" - if _inject_rules_block(clinerules, raw_rules): - installed_paths.append(str(clinerules)) - if _write_rule_file(global_rule, raw_rules): - installed_paths.append(str(global_rule)) - elif target in ("roo", "roo_code"): - roorules = Path.home() / ".roorules" - global_rule = Path.home() / ".roo" / "rules" / "artemis.md" - if _inject_rules_block(roorules, raw_rules): - installed_paths.append(str(roorules)) - if _write_rule_file(global_rule, raw_rules): - installed_paths.append(str(global_rule)) - elif target == "openclaw": - openclaw_md = Path.home() / ".openclaw" / "OPENCLAW.md" - global_rule = Path.home() / ".openclaw" / "rules" / "artemis.md" - if _inject_rules_block(openclaw_md, raw_rules): - installed_paths.append(str(openclaw_md)) - if _write_rule_file(global_rule, raw_rules): - installed_paths.append(str(global_rule)) - elif target == "codex": - codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex")) - override_file = codex_home / "AGENTS.override.md" - agents_file = ( - override_file - if override_file.exists() and override_file.read_text(encoding="utf-8").strip() - else codex_home / "AGENTS.md" - ) - if _inject_rules_block(agents_file, raw_rules): - installed_paths.append(str(agents_file)) - - return installed_paths + return [str(agents_file)] if _inject_rules_block(agents_file, raw_rules) else [] def install_mcp_config(client: str, python_exe: str, project_root: str) -> list[str]: - """Auto-installs/merges ARTEMIS MCP configuration and testing rules into IDE config files across any OS.""" - installed_paths: list[str] = [] - targets = ( - [ - "antigravity", - "cursor", - "claude", - "windsurf", - "vscode", - "cline", - "roo", - "openclaw", - "codex", - ] - if client == "all" - else [client] - ) - - for target in targets: - snippet = _get_config_snippet(target, python_exe, project_root) - if target in ("antigravity", "jetski"): - current_server_cfg = _get_config_snippet("antigravity", python_exe, project_root)[ - "mcpServers" - ]["artemis"] - legacy_server_cfg = _get_config_snippet("jetski", python_exe, project_root)[ - "mcpServers" - ]["artemis"] - jetski_path = Path.home() / ".gemini" / "jetski" / "mcp_config.json" - antigravity_legacy_path = Path.home() / ".gemini" / "antigravity" / "mcp_config.json" - config_path = Path.home() / ".gemini" / "config" / "mcp_config.json" - if _merge_json_file(jetski_path, "artemis", legacy_server_cfg): - installed_paths.append(str(jetski_path)) - if _merge_json_file(antigravity_legacy_path, "artemis", current_server_cfg): - installed_paths.append(str(antigravity_legacy_path)) - if _merge_json_file(config_path, "artemis", current_server_cfg): - installed_paths.append(str(config_path)) - elif target in ("claude", "claude_code", "claude_desktop"): - server_cfg = snippet["mcpServers"]["artemis"] - if sys.platform == "darwin": - claude_path = ( - Path.home() - / "Library" - / "Application Support" - / "Claude" - / "claude_desktop_config.json" - ) - elif sys.platform == "win32": - appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming") - claude_path = Path(appdata) / "Claude" / "claude_desktop_config.json" - else: - claude_path = Path.home() / ".config" / "Claude" / "claude_desktop_config.json" - if _merge_json_file(claude_path, "artemis", server_cfg): - installed_paths.append(str(claude_path)) - - # Also install to Claude Code CLI global config (~/.claude.json). - # This file holds Claude Code state well beyond MCP config, so a - # parse failure must abort the merge rather than rewrite the file. - claude_code_path = Path.home() / ".claude.json" - if _merge_json_file(claude_code_path, "artemis", server_cfg, strict_parse=True): - installed_paths.append(str(claude_code_path)) - elif target == "cursor": - server_cfg = snippet["mcpServers"]["artemis"] - cursor_path = Path.home() / ".cursor" / "mcp.json" - if _merge_json_file(cursor_path, "artemis", server_cfg): - installed_paths.append(str(cursor_path)) - elif target == "windsurf": - server_cfg = snippet["mcpServers"]["artemis"] - windsurf_path = Path.home() / ".codeium" / "windsurf" / "mcp_config.json" - if _merge_json_file(windsurf_path, "artemis", server_cfg): - installed_paths.append(str(windsurf_path)) - elif target == "vscode": - server_cfg = snippet["servers"]["artemis"] - vscode_path = _get_vscode_user_dir() / "mcp.json" - copilot_path = Path.home() / ".copilot" / "mcp-config.json" - if _merge_json_file(vscode_path, "artemis", server_cfg, key_name="servers"): - installed_paths.append(str(vscode_path)) - if _merge_json_file(copilot_path, "artemis", server_cfg, key_name="servers"): - installed_paths.append(str(copilot_path)) - elif target == "cline": - server_cfg = snippet["mcpServers"]["artemis"] - cline_paths = ( - Path.home() / ".cline" / "data" / "settings" / "cline_mcp_settings.json", - Path.home() / ".cline" / "mcp.json", - _get_vscode_user_dir() - / "globalStorage" - / "saoudrizwan.claude-dev" - / "settings" - / "cline_mcp_settings.json", - ) - for cline_path in cline_paths: - if _merge_json_file(cline_path, "artemis", server_cfg): - installed_paths.append(str(cline_path)) - elif target in ("roo", "roo_code"): - server_cfg = snippet["mcpServers"]["artemis"] - roo_settings_dir = ( - _get_vscode_user_dir() / "globalStorage" / "rooveterinaryinc.roo-cline" / "settings" - ) - roo_paths = ( - roo_settings_dir / "mcp_settings.json", - roo_settings_dir / "cline_mcp_settings.json", - Path(project_root) / ".roo" / "mcp.json", - ) - for roo_path in roo_paths: - if _merge_json_file(roo_path, "artemis", server_cfg): - installed_paths.append(str(roo_path)) - elif target == "openclaw": - server_cfg = snippet["mcp"]["servers"]["artemis"] - openclaw_path = Path.home() / ".openclaw" / "openclaw.json" - if _merge_json_file( - openclaw_path, - "artemis", - server_cfg, - key_name=("mcp", "servers"), - remove_paths=(("plugins", "artemis_mcp"),), - ): - installed_paths.append(str(openclaw_path)) - elif target == "codex": - server_cfg = snippet["mcp_servers"]["artemis"] - codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex")) - codex_path = codex_home / "config.toml" - if _merge_codex_toml(codex_path, server_cfg): - installed_paths.append(str(codex_path)) - - rules_paths = install_rules(client, project_root) - installed_paths.extend(rules_paths) - - unique_paths: list[str] = [] - for p in installed_paths: - if p not in unique_paths: - unique_paths.append(p) - return unique_paths + """Install ARTEMIS configuration and rules only for Codex.""" + server_cfg = _get_config_snippet(client, python_exe, project_root)["mcp_servers"]["artemis"] + codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex")) + codex_path = codex_home / "config.toml" + if not _merge_codex_toml(codex_path, server_cfg): + raise typer.Exit(1) + return [str(codex_path), *install_rules(client, project_root)] def mcp_command( @@ -733,7 +260,7 @@ def mcp_command( typer.Option( "--install", "-i", - help="Auto-install and merge ARTEMIS MCP configuration and testing rules into 'antigravity', 'claude', 'cursor', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.", + help="Install ARTEMIS MCP configuration and testing rules for Codex: --install codex.", ), ] = None, generate_config: Annotated[ @@ -741,7 +268,7 @@ def mcp_command( typer.Option( "--generate-config", "-g", - help="Output ready-to-use MCP configuration for 'antigravity', 'cursor', 'claude', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.", + help="Output ready-to-use Codex TOML configuration: --generate-config codex.", ), ] = None, ) -> None: @@ -751,24 +278,9 @@ def mcp_command( if install_config: client = install_config.lower() - if client not in ( - "antigravity", - "jetski", - "claude", - "claude_code", - "claude_desktop", - "cursor", - "windsurf", - "vscode", - "cline", - "roo", - "roo_code", - "openclaw", - "codex", - "all", - ): + if client != "codex": console.print( - f"[bold red]Unsupported install target: '{install_config}'. Use 'antigravity', 'claude', 'cursor', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.[/bold red]" + "[bold red]目前仅支持 Codex MCP 配置,请使用 --install codex。[/bold red]" ) raise typer.Exit(1) installed_paths = install_mcp_config(client, python_exe, project_root) @@ -784,46 +296,14 @@ def mcp_command( if generate_config: client = generate_config.lower() - if client == "all": - all_configs = { - "antigravity (~/.gemini/jetski/mcp_config.json)": _get_config_snippet( - "antigravity", python_exe, project_root - ), - "cursor (.cursor/mcp.json)": _get_config_snippet( - "cursor", python_exe, project_root - ), - "claude (claude_desktop_config.json & ~/.claude.json)": _get_config_snippet( - "claude", python_exe, project_root - ), - "windsurf (~/.codeium/windsurf/mcp_config.json)": _get_config_snippet( - "windsurf", python_exe, project_root - ), - "vscode (mcp.json)": _get_config_snippet("vscode", python_exe, project_root), - "cline (cline_mcp_settings.json)": _get_config_snippet( - "cline", python_exe, project_root - ), - "roo (cline_mcp_settings.json)": _get_config_snippet( - "roo", python_exe, project_root - ), - "openclaw (openclaw.json)": _get_config_snippet( - "openclaw", python_exe, project_root - ), - "codex (~/.codex/config.toml)": _get_config_snippet( - "codex", python_exe, project_root - ), - } - json_str = json.dumps(all_configs, indent=2) - syntax_language = "json" - elif client == "codex": - server_cfg = _get_config_snippet("codex", python_exe, project_root)["mcp_servers"][ - "artemis" - ] - json_str = _codex_toml_block(server_cfg) - syntax_language = "toml" - else: - snippet = _get_config_snippet(client, python_exe, project_root) - json_str = json.dumps(snippet, indent=2) - syntax_language = "json" + if client != "codex": + console.print( + "[bold red]目前仅支持 Codex MCP 配置,请使用 --generate-config codex。[/bold red]" + ) + raise typer.Exit(1) + server_cfg = _get_config_snippet(client, python_exe, project_root)["mcp_servers"]["artemis"] + json_str = _codex_toml_block(server_cfg) + syntax_language = "toml" syntax = Syntax(json_str, syntax_language, theme="monokai", line_numbers=False) console.print(f"[bold cyan]MCP Configuration for {client.upper()}:[/bold cyan]") diff --git a/docs/ai-automation.md b/docs/ai-automation.md index 03c0e69..6188530 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 目录。 -Windows 一键脚本会为检测到的客户端自动安装或更新 MCP 配置。未检测到客户端时,在项目目录执行 `uv run artemis mcp --install codex`;其他客户端将 `codex` 换成对应名称,`uv run artemis mcp --help` 可查看支持列表。更新后重新加载宿主 MCP;复制 Skill 后按宿主方式重新加载技能。 +目前仅支持 Codex MCP 配置。Windows 一键脚本检测并同步 Codex;未检测到配置目录时,在项目目录执行 `uv run artemis mcp --install codex`。配置模板使用 `uv run artemis mcp --generate-config codex` 查看;其他名称和 `all` 不再支持。更新后重新加载 Codex MCP;复制 Skill 后重新加载技能。 可以向 AI 这样下达任务: @@ -36,7 +36,7 @@ Windows 已安装用户再次运行 `start.bat` / `start-cn.bat` 时会检查当 Python 每次按 `uv.lock` 检查并同步缺失/变化的依赖;前端依据源码、资源及包锁文件指纹决定是否重新执行 `npm ci` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。 -MCP 同步会识别已安装客户端的配置目录,为缺少 ARTEMIS 条目的客户端安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。无法安全解析配置时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。源码和配置同步后需在 AI 客户端重新加载 MCP 连接或重启客户端,脚本无法替宿主重启已加载的 MCP 进程。 +MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当前用户的 `~/.codex/config.toml`。日志会显示实际路径,以及新装、更新、已是最新、未检测到或自定义跳过的结果。检测到目录但缺少 ARTEMIS 条目时安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。其他客户端的配置和规则文件不读取、不修改,其格式错误不会阻止启动。Codex 配置无法安全解析时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。同步后需在 Codex 中重新加载 MCP 或重启 Codex,脚本无法替宿主重启已加载的 MCP 进程。 创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。 diff --git a/scripts/start.ps1 b/scripts/start.ps1 index 91d266f..bb98e5c 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -356,10 +356,10 @@ if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) { } } if (-not $SkipMcpSync) { - Write-Host (Get-LaunchText ' [CHECK] Installing/updating MCP for detected AI clients...' ' [检查] 正在为检测到的 AI 客户端安装或更新 MCP...') -ForegroundColor Cyan + Write-Host (Get-LaunchText ' [CHECK] Installing/updating Codex MCP...' ' [检查] 正在安装或更新 Codex 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 + Write-Host (Get-LaunchText ' [ERROR] Codex MCP synchronization failed. Fix the configuration above, or use -SkipMcpSync to run the web UI without MCP.' ' [错误] Codex MCP 同步失败。请修复上方配置;仅需启动网页时可使用 -SkipMcpSync。') -ForegroundColor Red exit 1 } } else { diff --git a/scripts/update_installation.py b/scripts/update_installation.py index 71cf565..0db6d7e 100644 --- a/scripts/update_installation.py +++ b/scripts/update_installation.py @@ -1,4 +1,4 @@ -"""Check an existing checkout and synchronize installed local MCP clients.""" +"""Check an existing checkout and synchronize the local Codex MCP configuration.""" from __future__ import annotations @@ -184,54 +184,6 @@ def _updated_entry(config: dict, template: dict, root: Path, names: tuple[str, . return updated -def refresh_json( - 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") 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: - 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 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 _toml_value(value: object) -> str: if isinstance(value, bool): return "true" if value else "false" @@ -363,152 +315,73 @@ def refresh_codex( def refresh_mcp(root: Path) -> int: + """Synchronize only Codex; never inspect or modify other clients.""" sys.path.insert(0, str(root)) - from artemis.interfaces.cli.commands.mcp import ( - ARTEMIS_MCP_TOOLS, - _get_config_snippet, - _get_vscode_user_dir, - ) + from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_config_snippet - 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",), "antigravity", home / ".gemini"), - (home / ".gemini/jetski/mcp_config.json", ("mcpServers",), "jetski", home / ".gemini"), - ( - 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, - ), - ( - roo_store / "settings/mcp_settings.json", - ("mcpServers",), - "roo", - roo_store, - ), - ( - roo_store / "settings/cline_mcp_settings.json", - ("mcpServers",), - "roo", - roo_store, - ), - (root / ".roo/mcp.json", ("mcpServers",), "roo", root / ".roo"), - (home / ".openclaw/openclaw.json", ("mcp", "servers"), "openclaw", home / ".openclaw"), - ] - 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: - 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, - template=template, - install_missing=True, - ) - ) - if changed: - print( - launch_text( - f"[{'UPDATED' if existed else 'INSTALLED'}] MCP configuration: {path}", - f"[{'已更新' if existed else '已安装'}] MCP 配置:{path}", - ) - ) - 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"[ERROR] Could not safely synchronize {path}; original configuration preserved.", - f"[错误] 无法安全同步 {path};原配置已保留。", - ) - ) - errors += 1 - print( - launch_text( - 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( - "[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): + codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex")) + path = codex_home / "config.toml" + print(launch_text(f"[CHECK] Codex MCP configuration: {path}", f"[检查] Codex MCP 配置:{path}")) + if not path.is_file() and not codex_home.is_dir(): print( launch_text( - "[TIP] No supported client detected; install one with: uv run artemis mcp --install ", - "[提示] 未检测到受支持的客户端;可执行 uv run artemis mcp --install 。", + "[SKIP] Codex configuration directory not found. Install with: uv run artemis mcp --install codex", + "[跳过] 未发现 Codex 配置目录。可执行 uv run artemis mcp --install codex 安装。", ) ) - return 1 if errors else 0 + return 0 + existed = path.is_file() + template = _get_config_snippet("codex", sys.executable, str(root))["mcp_servers"]["artemis"] + try: + changed = refresh_codex( + path, root, sys.executable, ARTEMIS_MCP_TOOLS, template=template, install_missing=True + ) + except (OSError, ValueError, TypeError, AttributeError) as exc: + if isinstance(exc, OSError): + reason = launch_text( + "Cannot read/write the configuration; check file permissions.", + "无法读写配置,请检查文件权限。", + ) + else: + reason = launch_text( + "Invalid TOML or Artemis configuration structure; check the file in Codex.", + "TOML 格式或 ARTEMIS 配置结构无效,请在 Codex 中检查配置文件。", + ) + print( + launch_text( + f"[ERROR] Cannot safely synchronize {path}; original preserved. {reason}", + f"[错误] 无法安全同步 {path};原配置已保留。{reason}", + ) + ) + return 1 + if changed: + print( + launch_text( + f"[{'UPDATED' if existed else 'INSTALLED'}] Codex MCP: {path}", + f"[{'已更新' if existed else '已安装'}] Codex MCP:{path}", + ) + ) + elif changed is None: + print( + launch_text( + "[SKIP] Custom Codex Artemis MCP command preserved; check it manually.", + "[跳过] Codex 使用自定义 ARTEMIS MCP 启动方式,已保留,请手动检查。", + ) + ) + return 0 + else: + print( + launch_text( + "[OK] Codex Artemis MCP is already current.", "[完成] Codex ARTEMIS MCP 已是最新。" + ) + ) + print( + launch_text( + "[ACTION] Reload MCP or restart Codex to activate the updated tools.", + "[操作] 请在 Codex 中重新加载 MCP 或重启 Codex,使新版进程和工具签名生效。", + ) + ) + return 0 def frontend_fingerprint(root: Path) -> str: diff --git a/skills/artemis-automation/SKILL.md b/skills/artemis-automation/SKILL.md index 53ff98b..2a3a9ed 100644 --- a/skills/artemis-automation/SKILL.md +++ b/skills/artemis-automation/SKILL.md @@ -5,6 +5,8 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语 # ARTEMIS 自动化 +当前仅提供 Codex MCP 配置安装与更新:`uv run artemis mcp --install codex`;模板使用 `uv run artemis mcp --generate-config codex` 查看。启动日志会打印实际 Codex 配置路径,更新后重载 MCP。 + 使用项目提供的 MCP 工具操作 Android。报告由 ARTEMIS 自己生成,不要求宿主 AI 具备 Codex 私有工具、Word 或 Excel 插件。该 Skill 不提供桌面/浏览器自动化,也不提供历史动作脚本回放。 ## 从需求到执行 diff --git a/start-cn.bat b/start-cn.bat index 2d6a171..f1560b1 100644 --- a/start-cn.bat +++ b/start-cn.bat @@ -25,9 +25,9 @@ echo -SetupOnly 安装或检查环境,不启动网页 echo -Port 8001 使用指定端口,默认 8000 echo -NoOpen 启动服务后不自动打开浏览器 echo -SkipUpdate 跳过 Git 源码更新检查 -echo -SkipMcpSync 仅启动网页,跳过 MCP 安装和更新 +echo -SkipMcpSync 仅启动网页,跳过 Codex MCP 安装和更新 echo -Help 显示本说明 echo. echo 国内镜像设置:config\downloads.json -echo 首次运行需联网;后续运行会检查源码、依赖,并同步已安装 AI 客户端的 MCP。 +echo 首次运行需联网;后续运行会检查源码、依赖,并同步 Codex MCP 配置。 endlocal & exit /b 0 diff --git a/start.bat b/start.bat index bf33a11..980bbdd 100644 --- a/start.bat +++ b/start.bat @@ -24,7 +24,7 @@ if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (echo ARTEMIS 手机自动化测试 echo ====================================================== echo. -rem First run installs uv/Python/.venv; later runs check Git, dependencies and existing MCP clients. +rem First run installs uv/Python/.venv; later runs check Git, dependencies and Codex MCP. rem Use -SkipUpdate to skip the Git source check and run local code. rem Use start.bat -SetupOnly to prepare Python without launching the UI. rem Use start-cn.bat or start.bat -Mirror China for China download mirrors. diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 81b6c28..f1d8063 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -14,6 +14,8 @@ """Unit tests for ARTEMIS Unified CLI application.""" +import pytest + from typer.testing import CliRunner from artemis.interfaces.cli.main import app @@ -169,56 +171,41 @@ def test_cli_mcp_help(): assert "--generate-config" in result.output -def test_cli_mcp_generate_config(): - """Verify 'artemis mcp --generate-config cursor' produces valid configuration.""" - result = runner.invoke(app, ["mcp", "--generate-config", "cursor"]) - assert result.exit_code == 0 - assert "mcpServers" in result.output - assert "mcp_server" in result.output - - -def test_cli_mcp_generate_config_antigravity(): - """Verify current Antigravity uses only documented, load-safe fields.""" - from artemis.interfaces.cli.commands.mcp import _get_config_snippet - - result = runner.invoke(app, ["mcp", "--generate-config", "antigravity"]) - assert result.exit_code == 0 - assert "mcpServers" in result.output - server_config = _get_config_snippet("antigravity", "python", "/project")["mcpServers"][ - "artemis" - ] - assert server_config["disabledTools"] == [] - assert "tools" not in server_config - - legacy_config = _get_config_snippet("jetski", "python", "/project")["mcpServers"]["artemis"] - assert set(legacy_config["tools"]) == { - "mobile_run_task", - "mobile_manage_task", - "mobile_get_device_state", - "mobile_inspect_trace", - "mobile_diagnose", - "mobile_workspace", - "mobile_config", - "mobile_task_file", - "mobile_batch", - "mobile_search_history", - "mobile_export_report", - } - assert all(tool["eager"] is True for tool in legacy_config["tools"].values()) - - -def test_cli_mcp_generate_config_all(): - """Verify 'artemis mcp --generate-config all' includes every supported client.""" - result = runner.invoke(app, ["mcp", "--generate-config", "all"]) - assert result.exit_code == 0 - assert "antigravity" in result.output - assert "cursor" in result.output - assert "windsurf" in result.output - assert "claude" in result.output - assert "vscode" in result.output - assert "cline" in result.output - assert "roo" in result.output - assert "codex" in result.output +@pytest.mark.parametrize("option", ["--install", "--generate-config"]) +@pytest.mark.parametrize( + "client", + [ + "cursor", + "vscode", + "claude", + "claude_code", + "claude_desktop", + "antigravity", + "jetski", + "windsurf", + "cline", + "roo", + "roo_code", + "openclaw", + "all", + ], +) +def test_cli_mcp_rejects_other_clients_without_modifying_files( + tmp_path, monkeypatch, option, client +): + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / ".codex")) + monkeypatch.setenv("APPDATA", str(tmp_path / "AppData/Roaming")) + monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) + sentinel = tmp_path / ".cursor/mcp.json" + sentinel.parent.mkdir() + sentinel.write_bytes(b"{invalid json with user data}") + before = {p.relative_to(tmp_path): p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} + result = runner.invoke(app, ["mcp", option, client]) + assert result.exit_code == 1 + assert "Codex" in result.output + after = {p.relative_to(tmp_path): p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} + assert after == before def test_cli_mcp_generate_config_codex(): @@ -251,165 +238,6 @@ def test_cli_mcp_generate_config_codex(): ] -def test_cli_mcp_install_antigravity(tmp_path, monkeypatch): - """Verify 'artemis mcp --install antigravity' writes configuration and global rules into target files.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - result = runner.invoke(app, ["mcp", "--install", "antigravity"]) - assert result.exit_code == 0 - assert "Successfully installed ARTEMIS MCP server configuration & rules" in result.output - jetski_file = tmp_path / ".gemini" / "jetski" / "mcp_config.json" - assert jetski_file.exists() - import json - - data = json.loads(jetski_file.read_text()) - assert "artemis" in data["mcpServers"] - assert "mobile_run_task" in data["mcpServers"]["artemis"]["tools"] - assert "PYTHONPATH" in data["mcpServers"]["artemis"]["env"] - - current_file = tmp_path / ".gemini" / "config" / "mcp_config.json" - current_data = json.loads(current_file.read_text()) - assert current_data["mcpServers"]["artemis"]["disabledTools"] == [] - assert "tools" not in current_data["mcpServers"]["artemis"] - - # Verify global rule file installed - gemini_md = tmp_path / ".gemini" / "GEMINI.md" - assert gemini_md.exists() - assert "Mobile Testing Mindset" in gemini_md.read_text(encoding="utf-8") - rule_file = tmp_path / ".gemini" / "rules" / "artemis.md" - assert rule_file.exists() - assert "Mobile Testing Mindset" in rule_file.read_text(encoding="utf-8") - - -def test_cli_mcp_install_all(tmp_path, monkeypatch): - """Verify 'artemis mcp --install all' installs configs and global rules to all supported IDE locations.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - result = runner.invoke(app, ["mcp", "--install", "all"]) - assert result.exit_code == 0 - assert "Successfully installed ARTEMIS MCP server configuration & rules" in result.output - assert (tmp_path / ".cursor" / "mcp.json").exists() - assert (tmp_path / ".codeium" / "windsurf" / "mcp_config.json").exists() - assert (tmp_path / ".openclaw" / "openclaw.json").exists() - assert (tmp_path / ".claude.json").exists() - assert (tmp_path / ".codex" / "config.toml").exists() - - import json - - cursor_data = json.loads((tmp_path / ".cursor" / "mcp.json").read_text()) - assert cursor_data["mcpServers"]["artemis"]["command"] - - windsurf_data = json.loads((tmp_path / ".codeium" / "windsurf" / "mcp_config.json").read_text()) - assert windsurf_data["mcpServers"]["artemis"]["command"] - - claude_data = json.loads((tmp_path / ".claude.json").read_text()) - assert claude_data["mcpServers"]["artemis"]["type"] == "stdio" - - from artemis.interfaces.cli.commands.mcp import _get_vscode_user_dir - - vscode_data = json.loads((_get_vscode_user_dir() / "mcp.json").read_text()) - assert vscode_data["servers"]["artemis"]["type"] == "stdio" - assert "mcpServers" not in vscode_data - - copilot_data = json.loads((tmp_path / ".copilot" / "mcp-config.json").read_text()) - assert copilot_data["servers"]["artemis"]["type"] == "stdio" - - openclaw_data = json.loads((tmp_path / ".openclaw" / "openclaw.json").read_text()) - assert openclaw_data["mcp"]["servers"]["artemis"]["enabled"] is True - - cline_data = json.loads( - (tmp_path / ".cline" / "data" / "settings" / "cline_mcp_settings.json").read_text() - ) - assert cline_data["mcpServers"]["artemis"]["disabled"] is False - - roo_data = json.loads((tmp_path / ".roo" / "mcp.json").read_text()) - assert roo_data["mcpServers"]["artemis"]["disabled"] is False - - # Verify global rule files installed across IDEs - assert (tmp_path / ".gemini" / "GEMINI.md").exists() - assert (tmp_path / ".gemini" / "rules" / "artemis.md").exists() - assert (tmp_path / ".cursorrules").exists() - assert (tmp_path / ".cursor" / "rules" / "artemis.mdc").exists() - # Claude Code loads both CLAUDE.md and rules/*.md, so rules are installed - # only as the standalone rule file to avoid duplicated context. - assert not (tmp_path / ".claude" / "CLAUDE.md").exists() - assert (tmp_path / ".claude" / "rules" / "artemis.md").exists() - assert (tmp_path / ".codeium" / "windsurf" / "memories" / "global_rules.md").exists() - assert (tmp_path / ".codeium" / "windsurf" / "rules" / "artemis.md").exists() - assert (tmp_path / ".vscode" / "rules" / "artemis.md").exists() - assert (tmp_path / ".clinerules").exists() - assert (tmp_path / ".cline" / "rules" / "artemis.md").exists() - assert (tmp_path / ".roorules").exists() - assert (tmp_path / ".roo" / "rules" / "artemis.md").exists() - assert (tmp_path / ".openclaw" / "OPENCLAW.md").exists() - assert (tmp_path / ".openclaw" / "rules" / "artemis.md").exists() - assert (tmp_path / ".codex" / "AGENTS.md").exists() - - -def test_cli_mcp_install_claude_migrates_legacy_claude_md(tmp_path, monkeypatch): - """Verify the claude target removes a previously injected CLAUDE.md block while preserving user content.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - - claude_dir = tmp_path / ".claude" - claude_dir.mkdir(parents=True) - claude_md = claude_dir / "CLAUDE.md" - claude_md.write_text( - "# My own instructions\n\n" - "\nold injected rules\n" - "\n", - encoding="utf-8", - ) - - result = runner.invoke(app, ["mcp", "--install", "claude"]) - assert result.exit_code == 0 - - remaining = claude_md.read_text(encoding="utf-8") - assert "My own instructions" in remaining - assert "ARTEMIS MOBILE TESTING RULES" not in remaining - rule_file = claude_dir / "rules" / "artemis.md" - assert rule_file.exists() - assert "Mobile Testing Mindset" in rule_file.read_text(encoding="utf-8") - - -def test_cli_mcp_install_claude_deletes_block_only_claude_md(tmp_path, monkeypatch): - """Verify a CLAUDE.md consisting solely of the injected block is deleted outright.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - - claude_dir = tmp_path / ".claude" - claude_dir.mkdir(parents=True) - claude_md = claude_dir / "CLAUDE.md" - claude_md.write_text( - "\nold injected rules\n" - "\n", - encoding="utf-8", - ) - - result = runner.invoke(app, ["mcp", "--install", "claude"]) - assert result.exit_code == 0 - assert not claude_md.exists() - assert (claude_dir / "rules" / "artemis.md").exists() - - -def test_cli_mcp_install_claude_refuses_unparseable_claude_json(tmp_path, monkeypatch): - """Verify an unparseable ~/.claude.json is left untouched instead of being rewritten.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - - corrupt = "{ this is not json at all" - claude_json = tmp_path / ".claude.json" - claude_json.write_text(corrupt, encoding="utf-8") - - result = runner.invoke(app, ["mcp", "--install", "claude"]) - assert result.exit_code == 0 - assert claude_json.read_text(encoding="utf-8") == corrupt - - def test_cli_mcp_install_codex_preserves_config_and_is_idempotent(tmp_path, monkeypatch): """Verify Codex TOML merging preserves other servers and updates only Artemis.""" monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) @@ -461,88 +289,6 @@ def test_cli_mcp_install_codex_preserves_config_and_is_idempotent(tmp_path, monk assert agents_text.count("") == 1 -def test_cli_mcp_install_jsonc_and_backup(tmp_path, monkeypatch): - """Verify merging configuration preserves valid JSONC and creates .bak for unparseable JSON.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - - cursor_dir = tmp_path / ".cursor" - cursor_dir.mkdir(parents=True, exist_ok=True) - mcp_json = cursor_dir / "mcp.json" - - # 1. Valid JSONC with comments and trailing comma - mcp_json.write_text( - '{\n // My existing server\n "mcpServers": {\n "test": {"command": "echo"},\n }\n}', - encoding="utf-8", - ) - result = runner.invoke(app, ["mcp", "--install", "cursor"]) - assert result.exit_code == 0 - import json - - data = json.loads(mcp_json.read_text(encoding="utf-8")) - assert "test" in data["mcpServers"] - assert "artemis" in data["mcpServers"] - - # 2. Corrupt / unparseable file triggers backup - mcp_json.write_text("INVALID JSON DATA {{{{", encoding="utf-8") - result2 = runner.invoke(app, ["mcp", "--install", "cursor"]) - assert result2.exit_code == 0 - backup_file = cursor_dir / "mcp.json.bak" - assert backup_file.exists() - assert "INVALID JSON DATA" in backup_file.read_text(encoding="utf-8") - - -def test_cli_mcp_install_refuses_rewrite_when_backup_fails(tmp_path, monkeypatch): - """Backup failure for an unparseable config must abort the rewrite instead of destroying it.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - - cursor_dir = tmp_path / ".cursor" - cursor_dir.mkdir(parents=True, exist_ok=True) - mcp_json = cursor_dir / "mcp.json" - original = "INVALID JSON DATA {{{{" - mcp_json.write_text(original, encoding="utf-8") - - import pathlib - - real_write_text = pathlib.Path.write_text - - def failing_bak_write(self, *args, **kwargs): - if str(self).endswith(".bak"): - raise OSError("disk full") - return real_write_text(self, *args, **kwargs) - - monkeypatch.setattr("pathlib.Path.write_text", failing_bak_write) - - runner.invoke(app, ["mcp", "--install", "cursor"]) - - assert mcp_json.read_text(encoding="utf-8") == original - assert not (cursor_dir / "mcp.json.bak").exists() - - -def test_cli_mcp_install_openclaw_migrates_legacy_plugin_shape(tmp_path, monkeypatch): - """Verify reinstalling OpenClaw replaces the obsolete plugin wrapper with mcp.servers.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - config_path = tmp_path / ".openclaw" / "openclaw.json" - config_path.parent.mkdir(parents=True) - config_path.write_text( - '{"plugins":{"artemis_mcp":{"enabled":true},"keep":{"enabled":true}}}', - encoding="utf-8", - ) - - result = runner.invoke(app, ["mcp", "--install", "openclaw"]) - assert result.exit_code == 0 - - import json - - data = json.loads(config_path.read_text(encoding="utf-8")) - assert "artemis_mcp" not in data["plugins"] - assert data["plugins"]["keep"]["enabled"] is True - assert data["mcp"]["servers"]["artemis"]["enabled"] is True - assert data["mcp"]["servers"]["artemis"]["command"] - - def test_cli_restart_help(): """Verify 'artemis restart --help' displays lifecycle options.""" result = runner.invoke(app, ["restart", "--help"]) diff --git a/tests/unit/test_installation_update.py b/tests/unit/test_installation_update.py index 6da8912..c306d2f 100644 --- a/tests/unit/test_installation_update.py +++ b/tests/unit/test_installation_update.py @@ -120,33 +120,7 @@ command = "leave-alone" assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original -def test_json_migrates_existing_standard_mcp_to_current_checkout(tmp_path): - path = tmp_path / "mcp.json" - root = tmp_path / "project" - config = { - "cwd": str(root), - "command": "old", - "args": ["-m", "mcp_server"], - "disabled": True, - "env": {"CUSTOM": "preserved"}, - "autoApprove": [], - } - path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}})) - 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"]["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): +def test_mcp_sync_updates_only_codex_and_preserves_other_clients(tmp_path, monkeypatch): from artemis.interfaces.cli.commands import mcp as mcp_command home = tmp_path / "home" @@ -170,8 +144,11 @@ def test_mcp_sync_installs_detected_clients_and_migrates_codex(tmp_path, monkeyp cursor_home = home / ".cursor" cursor_home.mkdir() (cursor_home / "mcp.json").write_text('{"mcpServers":{"other":{"command":"keep"}}}') + vscode_home = home / "AppData/Code/User" + vscode_home.mkdir(parents=True) + vscode_config = vscode_home / "mcp.json" + vscode_config.write_bytes(b"{invalid VS Code JSONC}") 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" @@ -187,26 +164,31 @@ def test_mcp_sync_installs_detected_clients_and_migrates_codex(tmp_path, monkeyp 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 "artemis" not in cursor["mcpServers"] assert cursor["mcpServers"]["other"] == {"command": "keep"} + assert vscode_config.read_bytes() == b"{invalid VS Code JSONC}" + assert not vscode_config.with_name("mcp.json.artemis-update.bak").exists() 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 - +def test_mcp_sync_fails_on_invalid_codex_config_without_overwriting(tmp_path, monkeypatch, capsys): 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") + codex_home = home / ".codex" + codex_home.mkdir(parents=True) + config = codex_home / "config.toml" + original = b'model = "keep-secret"\n[mcp_servers.artemis\n' + config.write_bytes(original) 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) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) assert update.refresh_mcp(tmp_path / "project") == 1 - assert config.read_text(encoding="utf-8") == "{invalid json" + assert config.read_bytes() == original + assert not config.with_name(config.name + ".artemis-update.bak").exists() + output = capsys.readouterr().out + assert str(config) in output + assert "Invalid TOML" in output + assert "keep-secret" not in output def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypatch): @@ -216,7 +198,6 @@ def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypa 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 @@ -225,6 +206,59 @@ def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypa assert config["mcp_servers"]["artemis"]["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS) +@pytest.mark.parametrize("codex_home_setting", [None, "", "custom"]) +def test_codex_sync_reports_actual_path_and_ready_state( + tmp_path, monkeypatch, capsys, codex_home_setting +): + home = tmp_path / "home" + codex_home = home / ".codex" if codex_home_setting != "custom" else tmp_path / "custom Codex" + codex_home.mkdir(parents=True) + monkeypatch.setattr("pathlib.Path.home", lambda: home) + monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", "zh-CN") + if codex_home_setting is None: + monkeypatch.delenv("CODEX_HOME", raising=False) + else: + monkeypatch.setenv("CODEX_HOME", str(codex_home) if codex_home_setting == "custom" else "") + root = tmp_path / "project" + assert update.refresh_mcp(root) == 0 + first = capsys.readouterr().out + assert str(codex_home / "config.toml") in first + assert "已安装" in first + assert update.refresh_mcp(root) == 0 + second = capsys.readouterr().out + assert str(codex_home / "config.toml") in second + assert "已是最新" in second + if codex_home_setting == "custom": + assert not (home / ".codex").exists() + + +def test_codex_sync_reports_missing_directory_without_touching_other_clients( + tmp_path, monkeypatch, capsys +): + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + monkeypatch.delenv("CODEX_HOME", raising=False) + monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", "zh-CN") + assert update.refresh_mcp(tmp_path / "project") == 0 + output = capsys.readouterr().out + assert str(tmp_path / ".codex/config.toml") in output + assert "未发现 Codex 配置目录" in output + assert "--install codex" in output + assert not (tmp_path / ".codex").exists() + + +def test_custom_codex_command_is_preserved_and_reported(tmp_path, monkeypatch, capsys): + codex_home = tmp_path / ".codex" + codex_home.mkdir() + config = codex_home / "config.toml" + original = '[mcp_servers.artemis]\ncommand = "custom"\nargs = ["wrapper"]\n' + config.write_text(original, encoding="utf-8") + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", "zh-CN") + assert update.refresh_mcp(tmp_path / "project") == 0 + assert config.read_text(encoding="utf-8") == original + assert "自定义" in capsys.readouterr().out + + def test_codex_install_preserves_existing_user_tables(tmp_path): path = tmp_path / "config.toml" path.write_text( @@ -277,25 +311,6 @@ def test_codex_inline_environment_sync_is_idempotent(tmp_path): ) -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/tests/unit/test_source_archive.py b/tests/unit/test_source_archive.py index eb3b7a3..967b923 100644 --- a/tests/unit/test_source_archive.py +++ b/tests/unit/test_source_archive.py @@ -132,3 +132,23 @@ def test_packaging_rejects_uncommitted_changes_and_credentials(repository, tmp_p ) with pytest.raises(ValueError, match="credential-free HTTPS"): module.package_source(root, tmp_path / "source.zip") + + +def test_archive_checks_out_chinese_batch_with_crlf_and_clean_git(repository, tmp_path): + root, _, _, _ = repository + project_root = Path(__file__).resolve().parents[2] + (root / ".gitattributes").write_bytes((project_root / ".gitattributes").read_bytes()) + # The Git blob uses LF; the archive must contain the CRLF working-tree form. + content = (project_root / "start-cn.bat").read_bytes().replace(b"\r\n", b"\n") + (root / "start-cn.bat").write_bytes(content) + git(root, "add", "--", ".gitattributes", "start-cn.bat") + git(root, "commit", "-m", "Windows launcher attributes") + archive_path = tmp_path / "source.zip" + module.package_source(root, archive_path) + with zipfile.ZipFile(archive_path) as archive: + packed = archive.read("artemis/start-cn.bat") + assert b"\r\n" in packed + assert b"\n" not in packed.replace(b"\r\n", b"") + assert packed.decode("utf-8").startswith("@echo off\r\n") + archive.extractall(tmp_path / "unpacked") + assert git(tmp_path / "unpacked/artemis", "status", "--porcelain") == "" diff --git a/tests/unit/test_windows_bootstrap.py b/tests/unit/test_windows_bootstrap.py index bdd85fc..37511de 100644 --- a/tests/unit/test_windows_bootstrap.py +++ b/tests/unit/test_windows_bootstrap.py @@ -146,3 +146,30 @@ def test_batch_preserves_exit_code_and_handles_space_and_exclamation_paths(tmp_p timeout=30, ) assert result.returncode == exit_code, result.stdout + result.stderr + + +@pytest.mark.parametrize("exit_code", [0, 7]) +def test_chinese_batch_prints_complete_lines_and_preserves_exit_code(tmp_path, exit_code): + root = tmp_path / "source with spaces!" + root.mkdir() + shutil.copy2(ROOT / "start-cn.bat", root / "start-cn.bat") + (root / "start.bat").write_bytes( + f'@echo off\r\nif not "%~1"=="-Mirror" exit /b 19\r\n' + f'if not "%~2"=="China" exit /b 20\r\nexit /b {exit_code}\r\n'.encode("ascii") + ) + result = subprocess.run( + ["cmd.exe", "/d", "/c", "call", str(root / "start-cn.bat")], + capture_output=True, + timeout=15, + ) + assert result.returncode == exit_code, result.stdout + result.stderr + assert not result.stderr, result.stderr + assert "镜像地址和下载超时可在" in result.stdout.decode("utf-8") + help_result = subprocess.run( + ["cmd.exe", "/d", "/c", "call", str(root / "start-cn.bat"), "-Help"], + capture_output=True, + timeout=15, + ) + assert help_result.returncode == 0 + assert not help_result.stderr + assert "Codex MCP" in help_result.stdout.decode("utf-8")