Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6a3ee2eb0 | ||
|
|
49873e7f05 | ||
|
|
f826bdf3b0 | ||
|
|
c96935b47b |
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
# cmd.exe requires CRLF for batch scripts, especially UTF-8 Chinese text.
|
||||||
|
*.bat text eol=crlf
|
||||||
@@ -29,7 +29,7 @@ cd Artemis-Eapil
|
|||||||
常用选项:
|
常用选项:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\start-cn.bat -SetupOnly # 准备环境并安装/更新已检测客户端的 MCP
|
.\start-cn.bat -SetupOnly # 准备环境并安装/更新 Codex MCP
|
||||||
.\start.bat -Port 8001 # 指定端口
|
.\start.bat -Port 8001 # 指定端口
|
||||||
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
|
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
|
||||||
.\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页
|
.\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页
|
||||||
@@ -58,13 +58,13 @@ cd Artemis-Eapil
|
|||||||
|
|
||||||
## 让 AI 使用
|
## 让 AI 使用
|
||||||
|
|
||||||
Windows 启动脚本会为已检测到的 AI 客户端安装或更新 MCP。未被检测到的客户端,可手动安装,例如 Codex:
|
目前仅支持为 Codex 配置 MCP。Windows 启动脚本检测 Codex 配置目录并安装或更新 ARTEMIS;也可手动安装:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv run artemis mcp --install codex
|
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`。
|
1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树,解压后再次运行一键脚本即可检查更新。1.1.0 及更早 ZIP 不含 Git 信息,无法自行拉取;需要下载新版源码包重新解压一次,之后即可自动更新。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.3.1",
|
"version": "1.3.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.3.1",
|
"version": "1.3.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.5",
|
"@angular/cdk": "^22.1.5",
|
||||||
"@angular/common": "^22.1.4",
|
"@angular/common": "^22.1.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.3.1",
|
"version": "1.3.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
|||||||
@@ -51,82 +51,21 @@ 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:
|
def _get_config_snippet(client: str, python_exe: str, project_root: str) -> dict:
|
||||||
"""Generates MCP configuration dictionary for the specified client."""
|
"""Generate the supported Codex MCP configuration."""
|
||||||
config_body = {
|
if client != "codex":
|
||||||
|
raise ValueError("Only Codex MCP configuration is supported")
|
||||||
|
return {
|
||||||
|
"mcp_servers": {
|
||||||
|
"artemis": {
|
||||||
"command": python_exe,
|
"command": python_exe,
|
||||||
"args": ["-m", "mcp_server"],
|
"args": ["-m", "mcp_server"],
|
||||||
|
"cwd": project_root,
|
||||||
"env": {
|
"env": {
|
||||||
"PYTHONUNBUFFERED": "1",
|
"PYTHONUNBUFFERED": "1",
|
||||||
"PYTHONPATH": project_root,
|
"PYTHONPATH": project_root,
|
||||||
"ARTEMIS_DESKTOP_NOTIFY": "true",
|
"ARTEMIS_DESKTOP_NOTIFY": "true",
|
||||||
},
|
},
|
||||||
}
|
|
||||||
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,
|
"enabled": True,
|
||||||
"required": True,
|
"required": True,
|
||||||
"startup_timeout_sec": 120,
|
"startup_timeout_sec": 120,
|
||||||
@@ -134,129 +73,6 @@ def _get_config_snippet(client: str, python_exe: str, project_root: str) -> dict
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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:
|
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(
|
managed_pattern = re.compile(
|
||||||
rf"(?ms)^{re.escape(begin_marker)}\n.*?^{re.escape(end_marker)}\n?"
|
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:
|
else:
|
||||||
# Remove an older, unmanaged Artemis table before adding the managed block.
|
# Remove an older, unmanaged Artemis table before adding the managed block.
|
||||||
kept_lines: list[str] = []
|
kept_lines: list[str] = []
|
||||||
@@ -342,36 +160,6 @@ def _merge_codex_toml(file_path: Path, server_config: dict) -> bool:
|
|||||||
return False
|
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 = "<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->"
|
|
||||||
end_marker = "<!-- END ARTEMIS MOBILE TESTING RULES -->"
|
|
||||||
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:
|
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."""
|
"""Injects or updates a managed ARTEMIS block in a global rules file without overwriting user content."""
|
||||||
try:
|
try:
|
||||||
@@ -404,152 +192,18 @@ def _inject_rules_block(file_path: Path, content: str) -> bool:
|
|||||||
return False
|
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]:
|
def install_rules(client: str, project_root: str) -> list[str]:
|
||||||
"""Auto-installs ARTEMIS testing rules globally into user IDE rule directories across any OS."""
|
"""Install testing rules only into the Codex instructions file."""
|
||||||
installed_paths: list[str] = []
|
if client != "codex":
|
||||||
|
raise ValueError("Only Codex MCP configuration is supported")
|
||||||
rules_src = Path(project_root) / "mcp_server" / "rules.md"
|
rules_src = Path(project_root) / "mcp_server" / "rules.md"
|
||||||
if not rules_src.exists():
|
if not rules_src.exists():
|
||||||
alt_src = Path(__file__).resolve().parents[4] / "mcp_server" / "rules.md"
|
rules_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:
|
try:
|
||||||
raw_rules = rules_src.read_text(encoding="utf-8").strip()
|
raw_rules = rules_src.read_text(encoding="utf-8").strip()
|
||||||
except Exception as e:
|
except OSError:
|
||||||
logger.warning(f"Could not read rules file {rules_src}: {e}")
|
logger.warning(f"Could not read rules file {rules_src}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
targets = (
|
|
||||||
[
|
|
||||||
"antigravity",
|
|
||||||
"cursor",
|
|
||||||
"claude",
|
|
||||||
"windsurf",
|
|
||||||
"vscode",
|
|
||||||
"cline",
|
|
||||||
"roo",
|
|
||||||
"openclaw",
|
|
||||||
"codex",
|
|
||||||
]
|
|
||||||
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 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"))
|
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||||
override_file = codex_home / "AGENTS.override.md"
|
override_file = codex_home / "AGENTS.override.md"
|
||||||
agents_file = (
|
agents_file = (
|
||||||
@@ -557,144 +211,17 @@ def install_rules(client: str, project_root: str) -> list[str]:
|
|||||||
if override_file.exists() and override_file.read_text(encoding="utf-8").strip()
|
if override_file.exists() and override_file.read_text(encoding="utf-8").strip()
|
||||||
else codex_home / "AGENTS.md"
|
else codex_home / "AGENTS.md"
|
||||||
)
|
)
|
||||||
if _inject_rules_block(agents_file, raw_rules):
|
return [str(agents_file)] if _inject_rules_block(agents_file, raw_rules) else []
|
||||||
installed_paths.append(str(agents_file))
|
|
||||||
|
|
||||||
return installed_paths
|
|
||||||
|
|
||||||
|
|
||||||
def install_mcp_config(client: str, python_exe: str, project_root: str) -> list[str]:
|
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."""
|
"""Install ARTEMIS configuration and rules only for Codex."""
|
||||||
installed_paths: list[str] = []
|
server_cfg = _get_config_snippet(client, python_exe, project_root)["mcp_servers"]["artemis"]
|
||||||
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_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||||
codex_path = codex_home / "config.toml"
|
codex_path = codex_home / "config.toml"
|
||||||
if _merge_codex_toml(codex_path, server_cfg):
|
if not _merge_codex_toml(codex_path, server_cfg):
|
||||||
installed_paths.append(str(codex_path))
|
raise typer.Exit(1)
|
||||||
|
return [str(codex_path), *install_rules(client, project_root)]
|
||||||
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(
|
def mcp_command(
|
||||||
@@ -733,7 +260,7 @@ def mcp_command(
|
|||||||
typer.Option(
|
typer.Option(
|
||||||
"--install",
|
"--install",
|
||||||
"-i",
|
"-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,
|
] = None,
|
||||||
generate_config: Annotated[
|
generate_config: Annotated[
|
||||||
@@ -741,7 +268,7 @@ def mcp_command(
|
|||||||
typer.Option(
|
typer.Option(
|
||||||
"--generate-config",
|
"--generate-config",
|
||||||
"-g",
|
"-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,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -751,24 +278,9 @@ def mcp_command(
|
|||||||
|
|
||||||
if install_config:
|
if install_config:
|
||||||
client = install_config.lower()
|
client = install_config.lower()
|
||||||
if client not in (
|
if client != "codex":
|
||||||
"antigravity",
|
|
||||||
"jetski",
|
|
||||||
"claude",
|
|
||||||
"claude_code",
|
|
||||||
"claude_desktop",
|
|
||||||
"cursor",
|
|
||||||
"windsurf",
|
|
||||||
"vscode",
|
|
||||||
"cline",
|
|
||||||
"roo",
|
|
||||||
"roo_code",
|
|
||||||
"openclaw",
|
|
||||||
"codex",
|
|
||||||
"all",
|
|
||||||
):
|
|
||||||
console.print(
|
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)
|
raise typer.Exit(1)
|
||||||
installed_paths = install_mcp_config(client, python_exe, project_root)
|
installed_paths = install_mcp_config(client, python_exe, project_root)
|
||||||
@@ -784,46 +296,14 @@ def mcp_command(
|
|||||||
|
|
||||||
if generate_config:
|
if generate_config:
|
||||||
client = generate_config.lower()
|
client = generate_config.lower()
|
||||||
if client == "all":
|
if client != "codex":
|
||||||
all_configs = {
|
console.print(
|
||||||
"antigravity (~/.gemini/jetski/mcp_config.json)": _get_config_snippet(
|
"[bold red]目前仅支持 Codex MCP 配置,请使用 --generate-config codex。[/bold red]"
|
||||||
"antigravity", python_exe, project_root
|
)
|
||||||
),
|
raise typer.Exit(1)
|
||||||
"cursor (.cursor/mcp.json)": _get_config_snippet(
|
server_cfg = _get_config_snippet(client, python_exe, project_root)["mcp_servers"]["artemis"]
|
||||||
"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)
|
json_str = _codex_toml_block(server_cfg)
|
||||||
syntax_language = "toml"
|
syntax_language = "toml"
|
||||||
else:
|
|
||||||
snippet = _get_config_snippet(client, python_exe, project_root)
|
|
||||||
json_str = json.dumps(snippet, indent=2)
|
|
||||||
syntax_language = "json"
|
|
||||||
|
|
||||||
syntax = Syntax(json_str, syntax_language, theme="monokai", line_numbers=False)
|
syntax = Syntax(json_str, syntax_language, theme="monokai", line_numbers=False)
|
||||||
console.print(f"[bold cyan]MCP Configuration for {client.upper()}:[/bold cyan]")
|
console.print(f"[bold cyan]MCP Configuration for {client.upper()}:[/bold cyan]")
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from artemis.clients.screen_client_factory import create_screen_client
|
|||||||
from artemis.context import ArtemisContext, DeviceContext, DevicePlatform
|
from artemis.context import ArtemisContext, DeviceContext, DevicePlatform
|
||||||
from artemis.controllers.unified_controller import UnifiedMobileController
|
from artemis.controllers.unified_controller import UnifiedMobileController
|
||||||
from artemis.platform import platform
|
from artemis.platform import platform
|
||||||
|
from artemis.runtime.adb_endpoint import AdbEndpoint
|
||||||
from artemis.utils.app_launch_utils import launch_app_with_retries
|
from artemis.utils.app_launch_utils import launch_app_with_retries
|
||||||
|
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ def _get_controller(device_serial: str | None = None):
|
|||||||
port_str = os.environ.get("ADB_PORT", "5037")
|
port_str = os.environ.get("ADB_PORT", "5037")
|
||||||
port = int(port_str) if port_str.isdigit() else 5037
|
port = int(port_str) if port_str.isdigit() else 5037
|
||||||
if "ADB_SERVER_SOCKET" not in os.environ and (host != "localhost" or port != 5037):
|
if "ADB_SERVER_SOCKET" not in os.environ and (host != "localhost" or port != 5037):
|
||||||
os.environ["ADB_SERVER_SOCKET"] = f"tcp:{host}:{port}"
|
os.environ["ADB_SERVER_SOCKET"] = AdbEndpoint.create(host, port).client_socket
|
||||||
adb = AdbClient(host=host, port=port)
|
adb = AdbClient(host=host, port=port)
|
||||||
|
|
||||||
devices = adb.device_list()
|
devices = adb.device_list()
|
||||||
|
|||||||
@@ -92,6 +92,19 @@ class AdbEndpoint:
|
|||||||
def mode(self) -> str:
|
def mode(self) -> str:
|
||||||
return "local" if self.is_local_default else "remote"
|
return "local" if self.is_local_default else "remote"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_host(self) -> str:
|
||||||
|
"""ADB only permits daemon auto-start for the literal host 'localhost'.
|
||||||
|
|
||||||
|
Keep forwarded/custom-port endpoints explicit, even on loopback, so a
|
||||||
|
missing tunnel never starts a different local daemon at that address.
|
||||||
|
"""
|
||||||
|
return "localhost" if self.is_local_default else self.host
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_socket(self) -> str:
|
||||||
|
return f"tcp:localhost:{self.port}" if self.is_local_default else self.socket
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
**asdict(self),
|
**asdict(self),
|
||||||
@@ -108,7 +121,7 @@ class AdbEndpoint:
|
|||||||
target = environment if environment is not None else os.environ
|
target = environment if environment is not None else os.environ
|
||||||
target["ADB_HOST"] = self.host
|
target["ADB_HOST"] = self.host
|
||||||
target["ADB_PORT"] = str(self.port)
|
target["ADB_PORT"] = str(self.port)
|
||||||
target["ADB_SERVER_SOCKET"] = self.socket
|
target["ADB_SERVER_SOCKET"] = self.client_socket
|
||||||
target[ADB_ENDPOINT_ID_ENV] = self.identity
|
target[ADB_ENDPOINT_ID_ENV] = self.identity
|
||||||
return target
|
return target
|
||||||
|
|
||||||
@@ -145,7 +158,7 @@ class AdbSession:
|
|||||||
return [
|
return [
|
||||||
self.adb_path,
|
self.adb_path,
|
||||||
"-H",
|
"-H",
|
||||||
self.endpoint.host,
|
self.endpoint.client_host,
|
||||||
"-P",
|
"-P",
|
||||||
str(self.endpoint.port),
|
str(self.endpoint.port),
|
||||||
*arguments,
|
*arguments,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
|
||||||
from artemis.runtime.device_lock import DeviceExecutionLock
|
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||||
from artemis.toolchain import toolchain
|
from artemis.toolchain import toolchain
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
@@ -242,23 +243,29 @@ class DevicePool:
|
|||||||
|
|
||||||
async def _start_adb_server(self, timeout: float) -> None:
|
async def _start_adb_server(self, timeout: float) -> None:
|
||||||
"""Best-effort bounded `adb start-server` so later queries hit a warm daemon."""
|
"""Best-effort bounded `adb start-server` so later queries hit a warm daemon."""
|
||||||
|
endpoint = current_adb_endpoint()
|
||||||
|
if not endpoint.is_local_default:
|
||||||
|
return
|
||||||
adb = self._resolve_adb()
|
adb = self._resolve_adb()
|
||||||
if not adb:
|
if not adb:
|
||||||
return
|
return
|
||||||
proc = None
|
proc = None
|
||||||
try:
|
try:
|
||||||
clean_env = os.environ.copy()
|
session = AdbSession(endpoint, adb_path=adb)
|
||||||
clean_env.pop("ADB_SERVER_SOCKET", None)
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
adb,
|
*session.command(["start-server"]),
|
||||||
"start-server",
|
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
env=clean_env,
|
env=session.environment(),
|
||||||
|
)
|
||||||
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
output = (stderr or stdout).decode(errors="replace").strip()
|
||||||
|
logger.warning(
|
||||||
|
f"Local ADB server startup failed (exit {proc.returncode}): {output}"
|
||||||
)
|
)
|
||||||
await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug(f"adb start-server warm-up did not complete: {exc}")
|
logger.warning(f"Local ADB server startup did not complete: {exc}")
|
||||||
if proc is not None:
|
if proc is not None:
|
||||||
try:
|
try:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
源码内的 `skills/artemis-automation/` 是可分发 Skill,不依赖本机 Codex 的私有能力。将整个目录复制到宿主支持的 Skill 目录即可;Codex 为 `$CODEX_HOME/skills/`(未配置时 `~/.codex/skills/`)。仓库包含 Skill,启动程序不会修改用户的全局 Skill 目录。
|
源码内的 `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 这样下达任务:
|
可以向 AI 这样下达任务:
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ Windows 已安装用户再次运行 `start.bat` / `start-cn.bat` 时会检查当
|
|||||||
|
|
||||||
Python 每次按 `uv.lock` 检查并同步缺失/变化的依赖;前端依据源码、资源及包锁文件指纹决定是否重新执行 `npm ci` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。
|
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)。
|
创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# ARTEMIS-Eapil 1.3.2
|
||||||
|
|
||||||
|
## 更新与修复
|
||||||
|
|
||||||
|
- MCP 配置安装与更新仅支持 Codex。一键启动不再检查或修改 VS Code、Cursor 等其他客户端的配置,其配置错误不会阻止网页启动。手动 CLI 仅接受 `--install codex` 和 `--generate-config codex`,不再支持其他客户端或 `all`。
|
||||||
|
- 启动日志明确显示实际检查的 Codex 配置路径,以及安装、更新、已是最新、未检测到或自定义跳过的结果。未设置或空值的 `CODEX_HOME` 使用当前用户的 `.codex` 目录;保留已有自定义路径设置。
|
||||||
|
- 修复 Windows 手动重复安装 Codex MCP 时,路径反斜杠被误当成正则替换符的问题。配置写入失败会返回失败状态。
|
||||||
|
- 固定批处理脚本为 CRLF 换行,修复中文启动脚本在部分源码包中将中文截断并当成命令执行的问题。
|
||||||
|
- 同步 README、AI 接入说明及随源码分发的 Skill。
|
||||||
|
|
||||||
|
## 安装与升级
|
||||||
|
|
||||||
|
下载附件 `artemis-eapil-1.3.2-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件包含可继续更新的 Git 仓库,不包含虚拟环境、构建产物、密钥和任务记录。请使用发布附件,Gitea 自动生成的“源代码”压缩包不含 Git 元数据。
|
||||||
|
|
||||||
|
已有安装可通过启动脚本更新。更新后重启服务、刷新网页,并重新加载 Codex MCP。未检测到 Codex 配置目录时,可运行 `uv run artemis mcp --install codex` 手动安装。仅需网页时使用 `-SkipMcpSync`。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
默认 Python 测试范围分批验证共 2267 项通过,6 项跳过,8 项按标记排除;Ruff、质量基线、核心类型检查和 Git 差异检查通过。已验证 Windows 中文启动与帮助输出、Codex 默认及自定义目录识别、重复安装、其他客户端配置保留,以及源码包 CRLF 和干净 Git 状态。本次没有修改前端交互,也未运行真实 Android 任务。
|
||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "artemis"
|
name = "artemis"
|
||||||
version = "1.3.1"
|
version = "1.3.3"
|
||||||
description = "Autonomous AI Assistant for Mobile Automation"
|
description = "Autonomous AI Assistant for Mobile Automation"
|
||||||
|
|
||||||
authors = [
|
authors = [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Copyright 2026 Google LLC
|
# Copyright 2026 Google LLC
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -91,12 +91,23 @@ function Update-EnvironmentPath {
|
|||||||
|
|
||||||
function Start-LocalAdbServer {
|
function Start-LocalAdbServer {
|
||||||
if (Test-CommandExists "adb") {
|
if (Test-CommandExists "adb") {
|
||||||
|
$adbErrorPreference = $ErrorActionPreference
|
||||||
try {
|
try {
|
||||||
$origSocket = $env:ADB_SERVER_SOCKET
|
# Windows PowerShell 5.1 wraps native stderr in ErrorRecord objects.
|
||||||
Remove-Item Env:\ADB_SERVER_SOCKET -ErrorAction SilentlyContinue
|
# ADB prints normal daemon-start messages there, so inspect its exit code.
|
||||||
& adb start-server 2>$null | Out-Null
|
$ErrorActionPreference = "Continue"
|
||||||
if ($origSocket) { $env:ADB_SERVER_SOCKET = $origSocket }
|
# Explicit options ignore inherited remote-server environment settings.
|
||||||
} catch {}
|
$adbOutput = & adb -H localhost -P 5037 start-server 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host " [警告] 本机 ADB 服务启动失败(退出码 $LASTEXITCODE)。" -ForegroundColor Yellow
|
||||||
|
Write-Host ($adbOutput | Out-String).Trim() -ForegroundColor Yellow
|
||||||
|
Write-Host " [操作] 请检查 ADB 安装、端口占用或安全软件拦截;本机设备暂不可用。" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " [警告] 本机 ADB 服务启动异常:$_" -ForegroundColor Yellow
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $adbErrorPreference
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-7
@@ -126,12 +126,23 @@ function Update-EnvironmentPath {
|
|||||||
|
|
||||||
function Start-LocalAdbServer {
|
function Start-LocalAdbServer {
|
||||||
if (Test-CommandExists "adb") {
|
if (Test-CommandExists "adb") {
|
||||||
|
$adbErrorPreference = $ErrorActionPreference
|
||||||
try {
|
try {
|
||||||
$origSocket = $env:ADB_SERVER_SOCKET
|
# Windows PowerShell 5.1 wraps native stderr in ErrorRecord objects.
|
||||||
Remove-Item Env:\ADB_SERVER_SOCKET -ErrorAction SilentlyContinue
|
# ADB prints normal daemon-start messages there, so inspect its exit code.
|
||||||
& adb start-server 2>$null | Out-Null
|
$ErrorActionPreference = "Continue"
|
||||||
if ($origSocket) { $env:ADB_SERVER_SOCKET = $origSocket }
|
# Explicit options ignore inherited remote-server environment settings.
|
||||||
} catch {}
|
$adbOutput = & adb -H localhost -P 5037 start-server 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host " [警告] 本机 ADB 服务启动失败(退出码 $LASTEXITCODE)。" -ForegroundColor Yellow
|
||||||
|
Write-Host ($adbOutput | Out-String).Trim() -ForegroundColor Yellow
|
||||||
|
Write-Host " [操作] 请检查 ADB 安装、端口占用或安全软件拦截;本机设备暂不可用。" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " [警告] 本机 ADB 服务启动异常:$_" -ForegroundColor Yellow
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $adbErrorPreference
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,10 +367,10 @@ if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (-not $SkipMcpSync) {
|
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
|
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
|
||||||
if ($LASTEXITCODE -ne 0) {
|
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
|
exit 1
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+42
-169
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -184,54 +184,6 @@ def _updated_entry(config: dict, template: dict, root: Path, names: tuple[str, .
|
|||||||
return updated
|
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:
|
def _toml_value(value: object) -> str:
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return "true" if value else "false"
|
return "true" if value else "false"
|
||||||
@@ -363,152 +315,73 @@ def refresh_codex(
|
|||||||
|
|
||||||
|
|
||||||
def refresh_mcp(root: Path) -> int:
|
def refresh_mcp(root: Path) -> int:
|
||||||
|
"""Synchronize only Codex; never inspect or modify other clients."""
|
||||||
sys.path.insert(0, str(root))
|
sys.path.insert(0, str(root))
|
||||||
from artemis.interfaces.cli.commands.mcp import (
|
from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_config_snippet
|
||||||
ARTEMIS_MCP_TOOLS,
|
|
||||||
_get_config_snippet,
|
|
||||||
_get_vscode_user_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
home = Path.home()
|
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||||
vscode = _get_vscode_user_dir()
|
path = codex_home / "config.toml"
|
||||||
appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming"))
|
print(launch_text(f"[CHECK] Codex MCP configuration: {path}", f"[检查] Codex MCP 配置:{path}"))
|
||||||
cline_store = vscode / "globalStorage/saoudrizwan.claude-dev"
|
if not path.is_file() and not codex_home.is_dir():
|
||||||
roo_store = vscode / "globalStorage/rooveterinaryinc.roo-cline"
|
print(
|
||||||
paths = [
|
launch_text(
|
||||||
(home / ".gemini/config/mcp_config.json", ("mcpServers",), "antigravity", home / ".gemini"),
|
"[SKIP] Codex configuration directory not found. Install with: uv run artemis mcp --install codex",
|
||||||
(home / ".gemini/jetski/mcp_config.json", ("mcpServers",), "jetski", home / ".gemini"),
|
"[跳过] 未发现 Codex 配置目录。可执行 uv run artemis mcp --install codex 安装。",
|
||||||
(
|
)
|
||||||
home / ".gemini/antigravity/mcp_config.json",
|
)
|
||||||
("mcpServers",),
|
return 0
|
||||||
"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()
|
existed = path.is_file()
|
||||||
template = _get_config_snippet(client, sys.executable, str(root))
|
template = _get_config_snippet("codex", sys.executable, str(root))["mcp_servers"]["artemis"]
|
||||||
for key in keys:
|
|
||||||
template = template[key]
|
|
||||||
template = template["artemis"] if keys else template["mcp_servers"]["artemis"]
|
|
||||||
try:
|
try:
|
||||||
changed = (
|
changed = refresh_codex(
|
||||||
refresh_json(
|
path, root, sys.executable, ARTEMIS_MCP_TOOLS, template=template, install_missing=True
|
||||||
path,
|
|
||||||
keys,
|
|
||||||
root,
|
|
||||||
sys.executable,
|
|
||||||
ARTEMIS_MCP_TOOLS,
|
|
||||||
template=template,
|
|
||||||
install_missing=True,
|
|
||||||
)
|
)
|
||||||
if keys
|
except (OSError, ValueError, TypeError, AttributeError) as exc:
|
||||||
else refresh_codex(
|
if isinstance(exc, OSError):
|
||||||
path,
|
reason = launch_text(
|
||||||
root,
|
"Cannot read/write the configuration; check file permissions.",
|
||||||
sys.executable,
|
"无法读写配置,请检查文件权限。",
|
||||||
ARTEMIS_MCP_TOOLS,
|
)
|
||||||
template=template,
|
else:
|
||||||
install_missing=True,
|
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:
|
if changed:
|
||||||
print(
|
print(
|
||||||
launch_text(
|
launch_text(
|
||||||
f"[{'UPDATED' if existed else 'INSTALLED'}] MCP configuration: {path}",
|
f"[{'UPDATED' if existed else 'INSTALLED'}] Codex MCP: {path}",
|
||||||
f"[{'已更新' if existed else '已安装'}] MCP 配置:{path}",
|
f"[{'已更新' if existed else '已安装'}] Codex MCP:{path}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if existed:
|
|
||||||
updated_count += 1
|
|
||||||
else:
|
|
||||||
installed += 1
|
|
||||||
elif changed is None:
|
elif changed is None:
|
||||||
print(
|
print(
|
||||||
launch_text(
|
launch_text(
|
||||||
f"[SKIP] Custom Artemis MCP entry in {path}; use the manual installer for this client.",
|
"[SKIP] Custom Codex Artemis MCP command preserved; check it manually.",
|
||||||
f"[跳过] {path} 使用自定义 ARTEMIS MCP 启动方式;请手动检查此客户端。",
|
"[跳过] Codex 使用自定义 ARTEMIS MCP 启动方式,已保留,请手动检查。",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
skipped += 1
|
return 0
|
||||||
else:
|
else:
|
||||||
ready += 1
|
|
||||||
except (OSError, ValueError, TypeError, AttributeError):
|
|
||||||
print(
|
print(
|
||||||
launch_text(
|
launch_text(
|
||||||
f"[ERROR] Could not safely synchronize {path}; original configuration preserved.",
|
"[OK] Codex Artemis MCP is already current.", "[完成] Codex ARTEMIS MCP 已是最新。"
|
||||||
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(
|
print(
|
||||||
launch_text(
|
launch_text(
|
||||||
"[ACTION] Reload or restart your AI client to load the current MCP process and tool schemas.",
|
"[ACTION] Reload MCP or restart Codex to activate the updated tools.",
|
||||||
"[操作] 请在 AI 客户端重新加载 MCP 或重启客户端,使新版进程和工具签名生效。",
|
"[操作] 请在 Codex 中重新加载 MCP 或重启 Codex,使新版进程和工具签名生效。",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not (installed or updated_count or ready or skipped or errors):
|
return 0
|
||||||
print(
|
|
||||||
launch_text(
|
|
||||||
"[TIP] No supported client detected; install one with: uv run artemis mcp --install <client>",
|
|
||||||
"[提示] 未检测到受支持的客户端;可执行 uv run artemis mcp --install <client>。",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return 1 if errors else 0
|
|
||||||
|
|
||||||
|
|
||||||
def frontend_fingerprint(root: Path) -> str:
|
def frontend_fingerprint(root: Path) -> str:
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
|||||||
|
|
||||||
# ARTEMIS 自动化
|
# 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 不提供桌面/浏览器自动化,也不提供历史动作脚本回放。
|
使用项目提供的 MCP 工具操作 Android。报告由 ARTEMIS 自己生成,不要求宿主 AI 具备 Codex 私有工具、Word 或 Excel 插件。该 Skill 不提供桌面/浏览器自动化,也不提供历史动作脚本回放。
|
||||||
|
|
||||||
## 从需求到执行
|
## 从需求到执行
|
||||||
|
|||||||
+2
-2
@@ -25,9 +25,9 @@ echo -SetupOnly 安装或检查环境,不启动网页
|
|||||||
echo -Port 8001 使用指定端口,默认 8000
|
echo -Port 8001 使用指定端口,默认 8000
|
||||||
echo -NoOpen 启动服务后不自动打开浏览器
|
echo -NoOpen 启动服务后不自动打开浏览器
|
||||||
echo -SkipUpdate 跳过 Git 源码更新检查
|
echo -SkipUpdate 跳过 Git 源码更新检查
|
||||||
echo -SkipMcpSync 仅启动网页,跳过 MCP 安装和更新
|
echo -SkipMcpSync 仅启动网页,跳过 Codex MCP 安装和更新
|
||||||
echo -Help 显示本说明
|
echo -Help 显示本说明
|
||||||
echo.
|
echo.
|
||||||
echo 国内镜像设置:config\downloads.json
|
echo 国内镜像设置:config\downloads.json
|
||||||
echo 首次运行需联网;后续运行会检查源码、依赖,并同步已安装 AI 客户端的 MCP。
|
echo 首次运行需联网;后续运行会检查源码、依赖,并同步 Codex MCP 配置。
|
||||||
endlocal & exit /b 0
|
endlocal & exit /b 0
|
||||||
@@ -24,7 +24,7 @@ if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (echo ARTEMIS 手机自动化测试
|
|||||||
echo ======================================================
|
echo ======================================================
|
||||||
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 -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.bat -SetupOnly to prepare Python without launching the UI.
|
||||||
rem Use start-cn.bat or start.bat -Mirror China for China download mirrors.
|
rem Use start-cn.bat or start.bat -Mirror China for China download mirrors.
|
||||||
|
|||||||
@@ -323,7 +323,9 @@ async def test_queue_worker_cmd_construction():
|
|||||||
endpoint = enqueue_result["tasks"][0]["adb_endpoint"]
|
endpoint = enqueue_result["tasks"][0]["adb_endpoint"]
|
||||||
assert executed_kwargs[0]["env"]["ADB_HOST"] == endpoint["host"]
|
assert executed_kwargs[0]["env"]["ADB_HOST"] == endpoint["host"]
|
||||||
assert executed_kwargs[0]["env"]["ADB_PORT"] == str(endpoint["port"])
|
assert executed_kwargs[0]["env"]["ADB_PORT"] == str(endpoint["port"])
|
||||||
assert executed_kwargs[0]["env"]["ADB_SERVER_SOCKET"] == endpoint["socket"]
|
assert executed_kwargs[0]["env"]["ADB_SERVER_SOCKET"] == (
|
||||||
|
"tcp:localhost:5037" if endpoint["is_local_default"] else endpoint["socket"]
|
||||||
|
)
|
||||||
assert executed_kwargs[0]["env"]["ARTEMIS_ADB_ENDPOINT_ID"] == endpoint["identity"]
|
assert executed_kwargs[0]["env"]["ARTEMIS_ADB_ENDPOINT_ID"] == endpoint["identity"]
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
assert executed_kwargs[0]["creationflags"] == (
|
assert executed_kwargs[0]["creationflags"] == (
|
||||||
|
|||||||
@@ -70,6 +70,34 @@ def test_adb_session_builds_explicit_endpoint_command_and_environment():
|
|||||||
assert environment[ADB_ENDPOINT_ID_ENV] == endpoint.identity
|
assert environment[ADB_ENDPOINT_ID_ENV] == endpoint.identity
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "::1"])
|
||||||
|
def test_local_endpoint_allows_adb_auto_start_without_changing_task_identity(host):
|
||||||
|
endpoint = AdbServerEndpoint(host, 5037)
|
||||||
|
session = AdbSession(endpoint, adb_path="adb")
|
||||||
|
inherited = {
|
||||||
|
"ADB_SERVER_SOCKET": "tcp:remote.example:5040",
|
||||||
|
"ANDROID_ADB_SERVER_ADDRESS": "remote.example",
|
||||||
|
"ANDROID_ADB_SERVER_PORT": "5040",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert session.command(["devices"]) == ["adb", "-H", "localhost", "-P", "5037", "devices"]
|
||||||
|
environment = session.environment(inherited)
|
||||||
|
assert environment["ADB_SERVER_SOCKET"] == "tcp:localhost:5037"
|
||||||
|
assert environment["ADB_HOST"] == host
|
||||||
|
assert environment[ADB_ENDPOINT_ID_ENV] == endpoint.identity
|
||||||
|
assert inherited["ADB_SERVER_SOCKET"] == "tcp:remote.example:5040"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("host", "port"), [("127.0.0.1", 5038), ("::1", 5038), ("server.example", 5037)]
|
||||||
|
)
|
||||||
|
def test_forwarded_endpoint_never_uses_local_daemon_auto_start(host, port):
|
||||||
|
endpoint = AdbServerEndpoint(host, port)
|
||||||
|
session = AdbSession(endpoint, adb_path="adb")
|
||||||
|
assert session.command(["devices"])[2] == host
|
||||||
|
assert session.environment({})["ADB_SERVER_SOCKET"] == endpoint.socket
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_connect_activates_forwarded_server_after_success(monkeypatch):
|
async def test_connect_activates_forwarded_server_after_success(monkeypatch):
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
@@ -253,7 +281,7 @@ async def test_use_local_server_restores_standard_endpoint(monkeypatch):
|
|||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["endpoint"] == AdbServerEndpoint("127.0.0.1", 5037).to_dict()
|
assert result["endpoint"] == AdbServerEndpoint("127.0.0.1", 5037).to_dict()
|
||||||
assert settings.ADB_PORT == 5037
|
assert settings.ADB_PORT == 5037
|
||||||
assert os.environ["ADB_SERVER_SOCKET"] == "tcp:127.0.0.1:5037"
|
assert os.environ["ADB_SERVER_SOCKET"] == "tcp:localhost:5037"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import pytest
|
|||||||
|
|
||||||
from artemis.runtime.device_lock import DeviceExecutionLock
|
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||||
from artemis.runtime.device_pool import DevicePool, DeviceStatus
|
from artemis.runtime.device_pool import DevicePool, DeviceStatus
|
||||||
|
from artemis.config import settings
|
||||||
|
|
||||||
device_pool_module = importlib.import_module("artemis.runtime.device_pool")
|
device_pool_module = importlib.import_module("artemis.runtime.device_pool")
|
||||||
|
|
||||||
@@ -245,6 +246,45 @@ def test_warm_up_without_adb_returns_false(monkeypatch):
|
|||||||
assert asyncio.run(pool.warm_up_async(settle_timeout=0.0)) is False
|
assert asyncio.run(pool.warm_up_async(settle_timeout=0.0)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_adb_warmup_does_not_start_local_server_for_remote_endpoint(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "ADB_HOST", "127.0.0.1")
|
||||||
|
monkeypatch.setattr(settings, "ADB_PORT", 5038)
|
||||||
|
|
||||||
|
async def unexpected_spawn(*args, **kwargs):
|
||||||
|
pytest.fail("A forwarded endpoint must not start a local ADB daemon")
|
||||||
|
|
||||||
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", unexpected_spawn)
|
||||||
|
asyncio.run(DevicePool(adb_path="adb")._start_adb_server(timeout=1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_adb_warmup_reports_failed_start_with_local_command(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "ADB_HOST", "127.0.0.1")
|
||||||
|
monkeypatch.setattr(settings, "ADB_PORT", 5037)
|
||||||
|
monkeypatch.setenv("ADB_SERVER_SOCKET", "tcp:remote.example:5040")
|
||||||
|
captured = {}
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
class FailedProcess:
|
||||||
|
returncode = 1
|
||||||
|
|
||||||
|
async def communicate(self):
|
||||||
|
return b"", b"cannot bind listener"
|
||||||
|
|
||||||
|
async def spawn(*args, **kwargs):
|
||||||
|
captured["command"] = args
|
||||||
|
captured["env"] = kwargs["env"]
|
||||||
|
return FailedProcess()
|
||||||
|
|
||||||
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn)
|
||||||
|
monkeypatch.setattr(device_pool_module.logger, "warning", warnings.append)
|
||||||
|
asyncio.run(DevicePool(adb_path="adb")._start_adb_server(timeout=1.0))
|
||||||
|
assert captured["command"] == ("adb", "-H", "localhost", "-P", "5037", "start-server")
|
||||||
|
assert captured["env"]["ADB_SERVER_SOCKET"] == "tcp:localhost:5037"
|
||||||
|
assert len(warnings) == 1
|
||||||
|
assert "exit 1" in warnings[0]
|
||||||
|
assert "cannot bind listener" in warnings[0]
|
||||||
|
|
||||||
|
|
||||||
def test_try_list_devices_distinguishes_failure_from_empty(monkeypatch):
|
def test_try_list_devices_distinguishes_failure_from_empty(monkeypatch):
|
||||||
pool = DevicePool()
|
pool = DevicePool()
|
||||||
monkeypatch.setattr(pool, "_query_adb_devices_sync", lambda timeout=None: None)
|
monkeypatch.setattr(pool, "_query_adb_devices_sync", lambda timeout=None: None)
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Run just the ADB startup functions with fake commands; never touch host ADB."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not shutil.which("powershell.exe"), reason="Windows PowerShell required")
|
||||||
|
@pytest.mark.parametrize("script_name", ["start.ps1", "install_deps.ps1"])
|
||||||
|
@pytest.mark.parametrize("outcome", ["success", "failure", "exception"])
|
||||||
|
def test_adb_startup_reports_failure_and_preserves_server_environment(
|
||||||
|
tmp_path, script_name, outcome
|
||||||
|
):
|
||||||
|
harness = tmp_path / "adb-startup.ps1"
|
||||||
|
harness.write_text(
|
||||||
|
"""
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
$tokens = $null
|
||||||
|
$parseErrors = $null
|
||||||
|
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
|
||||||
|
$env:ADB_TEST_SCRIPT, [ref]$tokens, [ref]$parseErrors)
|
||||||
|
if ($parseErrors.Count) { throw ($parseErrors | Out-String) }
|
||||||
|
$function = $ast.Find({ param($node)
|
||||||
|
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
|
||||||
|
$node.Name -eq 'Start-LocalAdbServer'
|
||||||
|
}, $true)
|
||||||
|
Invoke-Expression $function.Extent.Text
|
||||||
|
function Test-CommandExists { return $true }
|
||||||
|
function adb {
|
||||||
|
Write-Host ('ADB_ARGS=' + ($args -join '|'))
|
||||||
|
if ($env:ADB_TEST_OUTCOME -eq 'exception') { throw 'fixture launch error' }
|
||||||
|
Write-Error 'fixture stderr' -ErrorAction Continue
|
||||||
|
$global:LASTEXITCODE = $(if ($env:ADB_TEST_OUTCOME -eq 'failure') { 7 } else { 0 })
|
||||||
|
}
|
||||||
|
Start-LocalAdbServer
|
||||||
|
if ($env:ADB_SERVER_SOCKET -ne 'tcp:remote.example:5040') { throw 'Socket was changed' }
|
||||||
|
if ($env:ANDROID_ADB_SERVER_ADDRESS -ne 'remote.example') { throw 'Host was changed' }
|
||||||
|
if ($env:ANDROID_ADB_SERVER_PORT -ne '5040') { throw 'Port was changed' }
|
||||||
|
if ($ErrorActionPreference -ne 'Stop') { throw 'Error preference was changed' }
|
||||||
|
Write-Host 'HARNESS_OK'
|
||||||
|
""",
|
||||||
|
encoding="utf-8-sig",
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(harness)],
|
||||||
|
env={
|
||||||
|
**os.environ,
|
||||||
|
"ADB_TEST_SCRIPT": str(ROOT / "scripts" / script_name),
|
||||||
|
"ADB_TEST_OUTCOME": outcome,
|
||||||
|
"ADB_SERVER_SOCKET": "tcp:remote.example:5040",
|
||||||
|
"ANDROID_ADB_SERVER_ADDRESS": "remote.example",
|
||||||
|
"ANDROID_ADB_SERVER_PORT": "5040",
|
||||||
|
},
|
||||||
|
capture_output=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
assert "ADB_ARGS=-H|localhost|-P|5037|start-server" in result.stdout
|
||||||
|
assert "HARNESS_OK" in result.stdout
|
||||||
|
if outcome == "success":
|
||||||
|
assert "[警告]" not in result.stdout
|
||||||
|
elif outcome == "failure":
|
||||||
|
assert "退出码 7" in result.stdout
|
||||||
|
assert "fixture stderr" in result.stdout
|
||||||
|
else:
|
||||||
|
assert "启动异常" in result.stdout
|
||||||
|
assert "fixture launch error" in result.stdout
|
||||||
+37
-291
@@ -14,6 +14,8 @@
|
|||||||
|
|
||||||
"""Unit tests for ARTEMIS Unified CLI application."""
|
"""Unit tests for ARTEMIS Unified CLI application."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from artemis.interfaces.cli.main import app
|
from artemis.interfaces.cli.main import app
|
||||||
|
|
||||||
@@ -169,56 +171,41 @@ def test_cli_mcp_help():
|
|||||||
assert "--generate-config" in result.output
|
assert "--generate-config" in result.output
|
||||||
|
|
||||||
|
|
||||||
def test_cli_mcp_generate_config():
|
@pytest.mark.parametrize("option", ["--install", "--generate-config"])
|
||||||
"""Verify 'artemis mcp --generate-config cursor' produces valid configuration."""
|
@pytest.mark.parametrize(
|
||||||
result = runner.invoke(app, ["mcp", "--generate-config", "cursor"])
|
"client",
|
||||||
assert result.exit_code == 0
|
[
|
||||||
assert "mcpServers" in result.output
|
"cursor",
|
||||||
assert "mcp_server" in result.output
|
"vscode",
|
||||||
|
"claude",
|
||||||
|
"claude_code",
|
||||||
def test_cli_mcp_generate_config_antigravity():
|
"claude_desktop",
|
||||||
"""Verify current Antigravity uses only documented, load-safe fields."""
|
"antigravity",
|
||||||
from artemis.interfaces.cli.commands.mcp import _get_config_snippet
|
"jetski",
|
||||||
|
"windsurf",
|
||||||
result = runner.invoke(app, ["mcp", "--generate-config", "antigravity"])
|
"cline",
|
||||||
assert result.exit_code == 0
|
"roo",
|
||||||
assert "mcpServers" in result.output
|
"roo_code",
|
||||||
server_config = _get_config_snippet("antigravity", "python", "/project")["mcpServers"][
|
"openclaw",
|
||||||
"artemis"
|
"all",
|
||||||
]
|
],
|
||||||
assert server_config["disabledTools"] == []
|
)
|
||||||
assert "tools" not in server_config
|
def test_cli_mcp_rejects_other_clients_without_modifying_files(
|
||||||
|
tmp_path, monkeypatch, option, client
|
||||||
legacy_config = _get_config_snippet("jetski", "python", "/project")["mcpServers"]["artemis"]
|
):
|
||||||
assert set(legacy_config["tools"]) == {
|
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||||
"mobile_run_task",
|
monkeypatch.setenv("CODEX_HOME", str(tmp_path / ".codex"))
|
||||||
"mobile_manage_task",
|
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData/Roaming"))
|
||||||
"mobile_get_device_state",
|
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
|
||||||
"mobile_inspect_trace",
|
sentinel = tmp_path / ".cursor/mcp.json"
|
||||||
"mobile_diagnose",
|
sentinel.parent.mkdir()
|
||||||
"mobile_workspace",
|
sentinel.write_bytes(b"{invalid json with user data}")
|
||||||
"mobile_config",
|
before = {p.relative_to(tmp_path): p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()}
|
||||||
"mobile_task_file",
|
result = runner.invoke(app, ["mcp", option, client])
|
||||||
"mobile_batch",
|
assert result.exit_code == 1
|
||||||
"mobile_search_history",
|
assert "Codex" in result.output
|
||||||
"mobile_export_report",
|
after = {p.relative_to(tmp_path): p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()}
|
||||||
}
|
assert after == before
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_mcp_generate_config_codex():
|
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"
|
|
||||||
"<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->\nold injected rules\n"
|
|
||||||
"<!-- END ARTEMIS MOBILE TESTING RULES -->\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(
|
|
||||||
"<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->\nold injected rules\n"
|
|
||||||
"<!-- END ARTEMIS MOBILE TESTING RULES -->\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):
|
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."""
|
"""Verify Codex TOML merging preserves other servers and updates only Artemis."""
|
||||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
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("<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->") == 1
|
assert agents_text.count("<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->") == 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():
|
def test_cli_restart_help():
|
||||||
"""Verify 'artemis restart --help' displays lifecycle options."""
|
"""Verify 'artemis restart --help' displays lifecycle options."""
|
||||||
result = runner.invoke(app, ["restart", "--help"])
|
result = runner.invoke(app, ["restart", "--help"])
|
||||||
|
|||||||
@@ -120,33 +120,7 @@ command = "leave-alone"
|
|||||||
assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original
|
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):
|
def test_mcp_sync_updates_only_codex_and_preserves_other_clients(tmp_path, monkeypatch):
|
||||||
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):
|
|
||||||
from artemis.interfaces.cli.commands import mcp as mcp_command
|
from artemis.interfaces.cli.commands import mcp as mcp_command
|
||||||
|
|
||||||
home = tmp_path / "home"
|
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 = home / ".cursor"
|
||||||
cursor_home.mkdir()
|
cursor_home.mkdir()
|
||||||
(cursor_home / "mcp.json").write_text('{"mcpServers":{"other":{"command":"keep"}}}')
|
(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("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("CODEX_HOME", str(codex_home))
|
||||||
monkeypatch.setenv("APPDATA", str(home / "AppData"))
|
monkeypatch.setenv("APPDATA", str(home / "AppData"))
|
||||||
root = tmp_path / "new release"
|
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["env"]["PYTHONPATH"] == str(root)
|
||||||
assert entry["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
|
assert entry["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
|
||||||
cursor = json.loads((cursor_home / "mcp.json").read_text(encoding="utf-8"))
|
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 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")
|
first = codex_config.read_text(encoding="utf-8")
|
||||||
assert update.refresh_mcp(root) == 0
|
assert update.refresh_mcp(root) == 0
|
||||||
assert codex_config.read_text(encoding="utf-8") == first
|
assert codex_config.read_text(encoding="utf-8") == first
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_sync_fails_on_invalid_detected_config_without_overwriting(tmp_path, monkeypatch):
|
def test_mcp_sync_fails_on_invalid_codex_config_without_overwriting(tmp_path, monkeypatch, capsys):
|
||||||
from artemis.interfaces.cli.commands import mcp as mcp_command
|
|
||||||
|
|
||||||
home = tmp_path / "home"
|
home = tmp_path / "home"
|
||||||
cursor_home = home / ".cursor"
|
codex_home = home / ".codex"
|
||||||
cursor_home.mkdir(parents=True)
|
codex_home.mkdir(parents=True)
|
||||||
config = cursor_home / "mcp.json"
|
config = codex_home / "config.toml"
|
||||||
config.write_text("{invalid json", encoding="utf-8")
|
original = b'model = "keep-secret"\n[mcp_servers.artemis\n'
|
||||||
|
config.write_bytes(original)
|
||||||
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
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.delenv("CODEX_HOME", raising=False)
|
|
||||||
assert update.refresh_mcp(tmp_path / "project") == 1
|
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):
|
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 = home / ".codex"
|
||||||
codex_home.mkdir(parents=True)
|
codex_home.mkdir(parents=True)
|
||||||
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
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("CODEX_HOME", str(codex_home))
|
||||||
root = tmp_path / "project"
|
root = tmp_path / "project"
|
||||||
assert update.refresh_mcp(root) == 0
|
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)
|
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):
|
def test_codex_install_preserves_existing_user_tables(tmp_path):
|
||||||
path = tmp_path / "config.toml"
|
path = tmp_path / "config.toml"
|
||||||
path.write_text(
|
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):
|
def test_frontend_fingerprint_detects_lock_source_and_deleted_assets(tmp_path):
|
||||||
ui = tmp_path / "apps/showcase_ui"
|
ui = tmp_path / "apps/showcase_ui"
|
||||||
(ui / "src").mkdir(parents=True)
|
(ui / "src").mkdir(parents=True)
|
||||||
|
|||||||
@@ -132,3 +132,23 @@ def test_packaging_rejects_uncommitted_changes_and_credentials(repository, tmp_p
|
|||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="credential-free HTTPS"):
|
with pytest.raises(ValueError, match="credential-free HTTPS"):
|
||||||
module.package_source(root, tmp_path / "source.zip")
|
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") == ""
|
||||||
@@ -146,3 +146,30 @@ def test_batch_preserves_exit_code_and_handles_space_and_exclamation_paths(tmp_p
|
|||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
assert result.returncode == exit_code, result.stdout + result.stderr
|
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")
|
||||||
Reference in new issue
Block a user