From ec5010220bea2de70cb29c87084e811a2b9e32d8 Mon Sep 17 00:00:00 2001 From: somew1nd Date: Tue, 18 Aug 2026 18:40:38 -0700 Subject: [PATCH] feat(mcp): implement double-insurance global-only MCP and testing rules installation - Install MCP configs and Artemis mobile testing rules strictly into user home directories (~), avoiding project workspace pollution - Add double-insurance rules synchronization injecting into both primary global instruction files (e.g. ~/.gemini/GEMINI.md, ~/.claude/CLAUDE.md, ~/.cursorrules, ~/.codeium/windsurf/memories/global_rules.md) and modular rules/ directories - Support JSONC parsing (comments and trailing commas) and automatic .bak backups when merging existing IDE configuration files - Update README and Quick Start documentation to clarify automatic global MCP & rule mounting across all major AI IDEs --- .gitignore | 11 ++ README.md | 12 +- README_CN.md | 12 +- artemis/interfaces/cli/commands/mcp.py | 212 +++++++++++++++++++++++-- mcp_server/README.md | 16 +- scripts/start.ps1 | 6 +- start.sh | 6 +- tests/unit/test_cli.py | 65 +++++++- 8 files changed, 305 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index 6ce825b..cad0729 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,16 @@ dmypy.json # IDEs & Editors .vscode/ .idea/ +.cursor/ +.gemini/ +.claude/ +.windsurf/ +.cline/ +.roo/ +.openclaw/ +.clinerules +.windsurfrules +.roorules *.swp *.swo *~ @@ -80,6 +90,7 @@ scratch/ data_engine.db data_engine.db-journal .ipc_port +.artemis_ipc_port .ls_address .jetski_ls_address diff --git a/README.md b/README.md index 7eec50e..c26406a 100644 --- a/README.md +++ b/README.md @@ -80,17 +80,19 @@ Experience seamless collaboration between **Antigravity** and **ARTEMIS** via na ## ⚡ Quick Start -Ensure an Android device (with **USB Debugging** enabled) or emulator is connected. +Ensure an Android device (with **USB Debugging** enabled) or emulator is connected. The one-click startup script will automatically: +- 🛠️ **Install System Toolchains**: Detect and auto-install ADB, scrcpy, FFmpeg, and Python (`uv`) dependencies. +- 🔌 **Mount Global MCP Server & AI Agent Rules**: Prompt to automatically install global MCP configurations and the **Artemis Mobile Testing Mindset (`rules.md`)** into your AI IDEs (**Antigravity**, **Cursor**, **Claude Code**, **Windsurf**, **VS Code**, **Cline/Roo**, **OpenClaw**). ```bash # 1. Clone repo & navigate to directory git clone https://github.com/google/artemis.git && cd artemis -# 2. One-click launch (automatically installs ADB, scrcpy, FFmpeg, uv runtime, and opens console) +# 2. One-click launch # 🍎 macOS & 🐧 Linux ./start.sh -# 🪟 Windows (CMD / PowerShell) +# 🪟 Windows start.bat ``` @@ -107,10 +109,10 @@ ARTEMIS includes a native **Model Context Protocol (MCP)** server. Connect your ### 1. One-Click Auto Install (Recommended) -Running `./start.sh` or `start.bat` will prompt you to configure MCP for detected IDEs (or you can install/update anytime later manually using the commands below): +Running `./start.sh` or `start.bat` will prompt you to configure global MCP and testing rules for detected IDEs (or you can install/update anytime later manually using the commands below): ```bash -# Auto-install for Antigravity / Jetski: +# Auto-install MCP server & global rules for Antigravity / Jetski: uv run artemis mcp --install antigravity # Or install for all detected AI IDEs (Antigravity, Cursor, Claude Desktop/Code, OpenClaw): diff --git a/README_CN.md b/README_CN.md index d5337ce..0ee438e 100644 --- a/README_CN.md +++ b/README_CN.md @@ -82,17 +82,19 @@ ## ⚡ 快速上手 -确保电脑已连接 Android 实体机(已开启 **USB 调试**)或 Android 模拟器。 +确保电脑已连接 Android 实体机(已开启 **USB 调试**)或 Android 模拟器。一键启动脚本将会自动完成以下配置: +- 🛠️ **安装系统环境依赖**:自动检测并安装 ADB、scrcpy、FFmpeg 与 Python(`uv`)运行时及项目依赖。 +- 🔌 **全局挂载 MCP 服务与测试准则 (Rules)**:主动引导并自动将全局 MCP 服务与 **Artemis 移动端测试思维准则 (`rules.md`)** 挂载至你使用的 AI IDE(支持 **Antigravity**、**Cursor**、**Claude Code**、**Windsurf**、**VS Code**、**Cline/Roo**、**OpenClaw**)。 ```bash # 1. 克隆代码仓库并进入目录 git clone https://github.com/google/artemis.git && cd artemis -# 2. 一键启动(自动检测并安装 ADB、scrcpy、FFmpeg、uv 运行时等全部依赖,并拉起控制台) +# 2. 一键启动 # 🍎 macOS & 🐧 Linux ./start.sh -# 🪟 Windows (CMD / PowerShell) +# 🪟 Windows start.bat ``` @@ -109,10 +111,10 @@ ARTEMIS 内置原生 **Model Context Protocol (MCP)** 服务。只需将以下 ### 1. 一键自动安装到 IDE(推荐) -运行 `./start.sh` 或 `start.bat` 启动脚本时会主动询问是否需要配置当前检测到的 AI IDE(支持跳过并在之后随时手动执行以下命令挂载): +运行 `./start.sh` 或 `start.bat` 启动脚本时会主动询问是否自动挂载全局 MCP 与测试行为准则(支持跳过并在之后随时手动执行以下命令挂载): ```bash -# 一键安装到 Antigravity / Jetski: +# 一键安装全局 MCP 服务与 Rules 到 Antigravity / Jetski: uv run artemis mcp --install antigravity # 或一键安装到所有检测到的 AI IDE(Antigravity、Cursor、Claude Desktop/Code、OpenClaw): diff --git a/artemis/interfaces/cli/commands/mcp.py b/artemis/interfaces/cli/commands/mcp.py index 56d3b66..dfe1981 100644 --- a/artemis/interfaces/cli/commands/mcp.py +++ b/artemis/interfaces/cli/commands/mcp.py @@ -76,18 +76,43 @@ def _get_config_snippet(client: str, python_exe: str, project_root: str) -> dict return {"mcpServers": {"artemis": config_body}} +def _parse_json_lenient(text: str) -> dict: + """Parses JSON or JSONC (JSON with comments / trailing commas) safely.""" + try: + data = json.loads(text) + return data if isinstance(data, dict) else {} + except Exception: + pass + import re + # 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 {} + except Exception: + return {} + + def _merge_json_file(file_path: Path, server_name: str, server_config: dict, key_name: str = "mcpServers") -> bool: """Merges server configuration into a target JSON file without overwriting existing servers.""" try: file_path.parent.mkdir(parents=True, exist_ok=True) data: dict = {} if file_path.exists() and file_path.stat().st_size > 0: - try: - data = json.loads(file_path.read_text(encoding="utf-8")) - if not isinstance(data, dict): - data = {} - except Exception: - data = {} + raw_text = file_path.read_text(encoding="utf-8") + data = _parse_json_lenient(raw_text) + if not data and raw_text.strip(): + # 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 Exception: + pass if key_name not in data or not isinstance(data.get(key_name), dict): data[key_name] = {} @@ -100,8 +125,168 @@ def _merge_json_file(file_path: Path, server_name: str, server_config: dict, key 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: + file_path.parent.mkdir(parents=True, exist_ok=True) + begin_marker = "" + end_marker = "" + new_block = f"{begin_marker}\n{content.strip()}\n{end_marker}\n" + + existing = "" + if file_path.exists(): + try: + existing = file_path.read_text(encoding="utf-8") + except Exception: + existing = "" + + if begin_marker in existing and end_marker in existing: + prefix = existing.split(begin_marker)[0] + suffix = existing.split(end_marker)[1] + updated = f"{prefix.rstrip()}\n\n{new_block}\n{suffix.lstrip()}".strip() + "\n" + else: + updated = f"{existing.rstrip()}\n\n{new_block}".strip() + "\n" + + if existing == updated: + return True + + file_path.write_text(updated, encoding="utf-8") + return True + except Exception as e: + logger.warning(f"Could not inject rules block into {file_path}: {e}") + 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 Exception: + 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 Exception: + 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] = [] + 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 [] + + 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}") + return [] + + targets = ( + ["antigravity", "cursor", "claude", "windsurf", "vscode", "cline", "roo", "openclaw"] + if client == "all" + else [client] + ) + + 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_desktop"): + claude_md = Path.home() / ".claude" / "CLAUDE.md" + global_rule = Path.home() / ".claude" / "rules" / "artemis.md" + if _inject_rules_block(claude_md, raw_rules): + installed_paths.append(str(claude_md)) + if _write_rule_file(global_rule, raw_rules): + installed_paths.append(str(global_rule)) + 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)) + + return installed_paths + + def install_mcp_config(client: str, python_exe: str, project_root: str) -> list[str]: - """Auto-installs/merges ARTEMIS MCP configuration into IDE config files across any OS.""" + """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"] @@ -166,7 +351,14 @@ def install_mcp_config(client: str, python_exe: str, project_root: str) -> list[ if _merge_json_file(openclaw_path, "artemis_mcp", plugin_cfg, key_name="plugins"): installed_paths.append(str(openclaw_path)) - return installed_paths + 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 def mcp_command( @@ -205,7 +397,7 @@ def mcp_command( typer.Option( "--install", "-i", - help="Auto-install and merge ARTEMIS MCP configuration into 'antigravity', 'claude', 'cursor', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', or 'all'.", + help="Auto-install and merge ARTEMIS MCP configuration and testing rules into 'antigravity', 'claude', 'cursor', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', or 'all'.", ), ] = None, generate_config: Annotated[ @@ -243,7 +435,7 @@ def mcp_command( ) raise typer.Exit(1) installed_paths = install_mcp_config(client, python_exe, project_root) - console.print("[bold green]✔ Successfully installed ARTEMIS MCP server configuration to:[/bold green]") + console.print("[bold green]✔ Successfully installed ARTEMIS MCP server configuration & rules to:[/bold green]") for path in installed_paths: console.print(f" • [cyan]{path}[/cyan]") console.print("\n[dim]Please restart or reload your IDE window to activate the Artemis MCP tools.[/dim]") diff --git a/mcp_server/README.md b/mcp_server/README.md index ed884f5..ae1309e 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -41,11 +41,17 @@ The included [`rules.md`](./rules.md) file contains the **Mobile Testing Mindset 3. **Latency & Timing Compensation**: Clarifies the difference between AI exploratory latency (e.g., model decision intervals) and the deterministic timing requirements of final test code. 4. **"Dynamic-First, Coordinate-Fallback" Locator Pattern**: Teaches the AI to prioritize dynamic UI locators (Resource IDs, OCR text, semantics) for layout resilience, while implementing absolute coordinate fallbacks for maximum execution reliability. -### How to Apply `rules.md` in Your IDE -* **Antigravity**: Add or copy the contents of `rules.md` into your Workspace Rules, Global Rules settings, or agent instructions. -* **Claude Code**: Copy or include the contents of `rules.md` in your project's `CLAUDE.md` file (or reference it directly in your instructions). -* **Cursor**: Copy the contents of `rules.md` into your `.cursorrules` or create a new rule file in `.cursor/rules/artemis.mdc`. -* **Windsurf / OpenClaw**: Add the rules to your global/workspace rules or agent system prompts so the assistant always follows verified mobile testing principles. +### How Global Rules & MCP are Installed +When you run `uv run artemis mcp --install all` (or target a specific IDE like `--install cursor`, `--install claude`, etc.), the installer **automatically synchronizes both global MCP server configuration and global testing rules** into the corresponding user home directories (without creating project-level rule files in your workspace): +* **Antigravity / Jetski**: Automatically installs global rules to `~/.gemini/rules/artemis.md`. +* **Cursor**: Automatically installs global YAML-frontmatter rule file at `~/.cursor/rules/artemis.mdc`. +* **Claude Code / Desktop**: Automatically installs global rules to `~/.claude/rules/artemis.md`. +* **Windsurf**: Automatically installs global rules to `~/.codeium/windsurf/rules/artemis.md`. +* **VS Code**: Automatically installs global rules to `~/.vscode/rules/artemis.md`. +* **Cline / Roo Code**: Automatically installs global rules to `~/.cline/rules/artemis.md` and `~/.roo/rules/artemis.md`. +* **OpenClaw**: Automatically installs global rules to `~/.openclaw/rules/artemis.md`. + +*(Note: If you prefer manual configuration, you can also copy the contents of [`rules.md`](./rules.md) into your IDE's system prompt or global rules settings.)* ## 🛠️ MCP Tools Overview diff --git a/scripts/start.ps1 b/scripts/start.ps1 index fced28c..2041d44 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -132,11 +132,11 @@ if ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Path $ShowcaseIndexAlt1)) # 7. Optionally configure MCP for detected AI IDEs Write-Host "" -Write-Host " 🔌 Would you like to configure ARTEMIS MCP for your AI IDEs?" -ForegroundColor Cyan +Write-Host " 🔌 Would you like to configure ARTEMIS MCP & testing rules for your AI IDEs?" -ForegroundColor Cyan Write-Host " (Supported: Antigravity, Cursor, Claude Code/Desktop, OpenClaw, Windsurf)" $installMcp = "Y" if ([Console]::IsInputRedirected -eq $false) { - $response = Read-Host " Install MCP configuration now? [Y/n]" + $response = Read-Host " Install MCP configuration & rules now? [Y/n]" if ($response -ne "") { $installMcp = $response } @@ -145,7 +145,7 @@ if ([Console]::IsInputRedirected -eq $false) { } if ($installMcp -match "^[Yy]") { - Write-Host " ✔ Installing MCP server configuration..." -ForegroundColor Green + Write-Host " ✔ Installing MCP server configuration & testing rules..." -ForegroundColor Green uv run artemis mcp --install all 2>$null | Out-Null Write-Host " 💡 Tip: You can update or re-install anytime with: uv run artemis mcp --install all" -ForegroundColor Cyan } else { diff --git a/start.sh b/start.sh index e775656..18396c2 100755 --- a/start.sh +++ b/start.sh @@ -155,17 +155,17 @@ fi # 7. Optionally configure MCP for detected AI IDEs echo "" -echo -e " ${CYAN}🔌 Would you like to configure ARTEMIS MCP for your AI IDEs?${NC}" +echo -e " ${CYAN}🔌 Would you like to configure ARTEMIS MCP & testing rules for your AI IDEs?${NC}" echo -e " (Supported: Antigravity, Cursor, Claude Code/Desktop, OpenClaw, Windsurf)" if [ -t 0 ]; then - read -r -p " Install MCP configuration now? [Y/n]: " INSTALL_MCP + read -r -p " Install MCP configuration & rules now? [Y/n]: " INSTALL_MCP INSTALL_MCP=${INSTALL_MCP:-Y} else INSTALL_MCP="n" fi if [[ "${INSTALL_MCP}" =~ ^[Yy]$ ]]; then - echo -e " ${GREEN}✔ Installing MCP server configuration...${NC}" + echo -e " ${GREEN}✔ Installing MCP server configuration & testing rules...${NC}" uv run artemis mcp --install all >/dev/null 2>&1 || true echo -e " ${CYAN}💡 Tip: You can update or re-install anytime with: ${BOLD}uv run artemis mcp --install all${NC}" else diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 16f4445..e13ba12 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -103,11 +103,12 @@ def test_cli_mcp_generate_config_all(): def test_cli_mcp_install_antigravity(tmp_path, monkeypatch): - """Verify 'artemis mcp --install antigravity' writes configuration into target file.""" + """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" in result.output + 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 @@ -116,16 +117,72 @@ def test_cli_mcp_install_antigravity(tmp_path, monkeypatch): assert "mobile_run_task" in data["mcpServers"]["artemis"]["tools"] assert "PYTHONPATH" in data["mcpServers"]["artemis"]["env"] + # 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 to all supported IDE locations.""" + """Verify 'artemis mcp --install all' installs configs and global rules to all supported IDE locations.""" 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", "all"]) assert result.exit_code == 0 - assert "Successfully installed ARTEMIS MCP server configuration" in result.output + 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() + # 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() + assert (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() + + +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") +